@grifhinz/logics-manager 2.0.0

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,2211 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from collections import Counter
6
+ from datetime import datetime, timedelta, timezone
7
+ import re
8
+ from pathlib import Path
9
+ import subprocess
10
+ from shutil import which
11
+ from typing import Any
12
+
13
+ from .config import ConfigError, find_repo_root, load_repo_config
14
+ from .doctor import doctor_payload
15
+ from .lint import lint_payload
16
+
17
+
18
+ DEFAULT_HYBRID_AUDIT_LOG = "logics/.cache/hybrid_assist_audit.jsonl"
19
+ DEFAULT_HYBRID_MEASUREMENT_LOG = "logics/.cache/hybrid_assist_measurements.jsonl"
20
+ DEFAULT_HYBRID_ROI_RECENT_LIMIT = 8
21
+ DEFAULT_HYBRID_ROI_WINDOW_DAYS = 14
22
+ DEFAULT_ESTIMATED_REMOTE_TOKENS_PER_LOCAL_RUN = 1200
23
+
24
+
25
+ CLAUDE_BRIDGE_VARIANTS: tuple[dict[str, object], ...] = (
26
+ {
27
+ "id": "hybrid-assist",
28
+ "title": "Logics Assist",
29
+ "command_path": ".claude/commands/logics-assist.md",
30
+ "agent_path": ".claude/agents/logics-hybrid-delivery-assistant.md",
31
+ "fallback_prompt": "Use $logics-hybrid-delivery-assistant for commit-all, summaries, next-step, triage, handoff, or split-suggestion requests.",
32
+ },
33
+ {
34
+ "id": "request-draft",
35
+ "title": "Logics Request Draft",
36
+ "command_path": ".claude/commands/logics-request-draft.md",
37
+ "agent_path": ".claude/agents/logics-request-draft.md",
38
+ "fallback_prompt": "Use $logics-hybrid-delivery-assistant for bounded request-draft proposals from a short intent; keep the output proposal-only and do not create files directly.",
39
+ "prompt_override": "Use $logics-hybrid-delivery-assistant for bounded request-draft proposals from a short intent; keep the output proposal-only and do not create files directly.",
40
+ "reviewer_nudge": "Validate the generated Needs and Context blocks before promoting them into a real request doc or committing follow-up work.",
41
+ },
42
+ {
43
+ "id": "spec-first-pass",
44
+ "title": "Logics Spec First Pass",
45
+ "command_path": ".claude/commands/logics-spec-first-pass.md",
46
+ "agent_path": ".claude/agents/logics-spec-first-pass.md",
47
+ "fallback_prompt": "Use $logics-hybrid-delivery-assistant for bounded spec-first-pass outlines from a backlog item; keep the output proposal-only and operator-reviewed.",
48
+ "prompt_override": "Use $logics-hybrid-delivery-assistant for bounded spec-first-pass outlines from a backlog item; keep the output proposal-only and operator-reviewed.",
49
+ "reviewer_nudge": "Validate the proposed spec sections, constraints, and open questions before turning them into a real spec file.",
50
+ },
51
+ {
52
+ "id": "backlog-groom",
53
+ "title": "Logics Backlog Groom",
54
+ "command_path": ".claude/commands/logics-backlog-groom.md",
55
+ "agent_path": ".claude/agents/logics-backlog-groom.md",
56
+ "fallback_prompt": "Use $logics-hybrid-delivery-assistant for bounded backlog-groom proposals from a request doc; keep the output proposal-only and reviewable.",
57
+ "prompt_override": "Use $logics-hybrid-delivery-assistant for bounded backlog-groom proposals from a request doc; keep the output proposal-only and reviewable.",
58
+ "reviewer_nudge": "Validate the scoped title, complexity, and acceptance-criteria proposal before creating or committing a backlog item.",
59
+ },
60
+ )
61
+
62
+ ASSIST_FLOW_DEFAULTS: dict[str, dict[str, object]] = {
63
+ "context-pack": {"mode": "summary-only", "profile": "normal", "include_graph": False, "include_registry": False, "include_doctor": False},
64
+ "request-draft": {"mode": "summary-only", "profile": "normal", "include_graph": False, "include_registry": False, "include_doctor": False},
65
+ "next-step": {"mode": "diff-first", "profile": "deep", "include_graph": True, "include_registry": True, "include_doctor": True},
66
+ "diff-risk": {"mode": "diff-first", "profile": "tiny", "include_graph": False, "include_registry": False, "include_doctor": False},
67
+ "commit-plan": {"mode": "summary-only", "profile": "normal", "include_graph": False, "include_registry": False, "include_doctor": False},
68
+ }
69
+
70
+
71
+ def _get_nested(config: dict[str, object], *keys: str, default: object) -> object:
72
+ current: object = config
73
+ for key in keys:
74
+ if not isinstance(current, dict):
75
+ return default
76
+ current = current.get(key, default)
77
+ return default if current is None else current
78
+
79
+
80
+ def _hybrid_audit_log(config: dict[str, object]) -> str:
81
+ return str(_get_nested(config, "hybrid_assist", "audit_log", default=DEFAULT_HYBRID_AUDIT_LOG))
82
+
83
+
84
+ def _hybrid_measurement_log(config: dict[str, object]) -> str:
85
+ return str(_get_nested(config, "hybrid_assist", "measurement_log", default=DEFAULT_HYBRID_MEASUREMENT_LOG))
86
+
87
+
88
+ def _repo_path(repo_root: Path, value: str | None, default: str) -> Path:
89
+ return (repo_root / (value or default)).resolve()
90
+
91
+
92
+ def _parse_package_version(repo_root: Path) -> str:
93
+ package_json = repo_root / "package.json"
94
+ if not package_json.is_file():
95
+ return "1.0.0"
96
+ try:
97
+ payload = json.loads(package_json.read_text(encoding="utf-8"))
98
+ except Exception:
99
+ return "1.0.0"
100
+ version = payload.get("version") if isinstance(payload, dict) else None
101
+ return str(version).strip() if version else "1.0.0"
102
+
103
+
104
+ def _slugify(text: str) -> str:
105
+ cleaned = re.sub(r"[^a-z0-9]+", "_", text.lower())
106
+ return cleaned.strip("_") or "request"
107
+
108
+
109
+ def _title_from_request_intent(intent: str) -> str:
110
+ cleaned = " ".join(intent.split()).strip()
111
+ cleaned = re.sub(r"^(draft|create|add|write|prepare)\s+(a|an)?\s*request\s*(for|about)?\s*", "", cleaned, flags=re.IGNORECASE)
112
+ cleaned = cleaned.strip(" .:-")
113
+ if not cleaned:
114
+ return "Request draft"
115
+ return cleaned[:1].upper() + cleaned[1:120]
116
+
117
+
118
+ def _next_request_ref(repo_root: Path, title: str) -> str:
119
+ directory = repo_root / "logics" / "request"
120
+ highest = 0
121
+ if directory.is_dir():
122
+ for path in directory.glob("req_*.md"):
123
+ match = re.match(r"^req_(\d{3})_", path.stem)
124
+ if match:
125
+ highest = max(highest, int(match.group(1)))
126
+ return f"req_{highest + 1:03d}_{_slugify(title)}"
127
+
128
+
129
+ def _build_request_draft(repo_root: Path, *, intent: str) -> dict[str, object]:
130
+ title = _title_from_request_intent(intent)
131
+ ref = _next_request_ref(repo_root, title)
132
+ from_version = _parse_package_version(repo_root)
133
+ needs = [f"Deliver {title.lower()}"]
134
+ context = [
135
+ "Draft generated locally by logics-manager.",
136
+ "No manual skills bootstrap or bridge editing is required.",
137
+ ]
138
+ acceptance = [
139
+ f"AC1: The request clearly states the bounded need for {title.lower()}.",
140
+ "AC2: Scope boundaries and operator impact are explicit.",
141
+ "AC3: The request is ready to be promoted into a backlog slice.",
142
+ ]
143
+ content = "\n".join(
144
+ [
145
+ f"## {ref} - {title}",
146
+ f"> From version: {from_version}",
147
+ "> Schema version: 1.0",
148
+ "> Status: Draft",
149
+ "> Understanding: 90%",
150
+ "> Confidence: 85%",
151
+ "> Complexity: Medium",
152
+ "> Theme: Operator workflow",
153
+ "> Reminder: Update status/understanding/confidence and linked backlog/task references when you edit this doc.",
154
+ "",
155
+ "# Needs",
156
+ *[f"- {item}" for item in needs],
157
+ "",
158
+ "# Context",
159
+ *[f"- {item}" for item in context],
160
+ "",
161
+ "# Acceptance criteria",
162
+ *[f"- {item}" for item in acceptance],
163
+ "",
164
+ "# Definition of Ready (DoR)",
165
+ "- [ ] Problem statement is explicit and user impact is clear.",
166
+ "- [ ] Scope boundaries (in/out) are explicit.",
167
+ "- [ ] Acceptance criteria are testable.",
168
+ "- [ ] Dependencies and known risks are listed.",
169
+ "",
170
+ "# Companion docs",
171
+ "- Product brief(s): (none yet)",
172
+ "- Architecture decision(s): (none yet)",
173
+ "",
174
+ "# AI Context",
175
+ f"- Summary: Draft a bounded request for {title.lower()}.",
176
+ "- Keywords: request-draft, logics-manager, python runtime, bundled CLI",
177
+ "- Use when: You need a new bounded request doc for the Logics workflow.",
178
+ "- Skip when: The work already has an existing request or should go straight to a backlog slice.",
179
+ "",
180
+ "# Backlog",
181
+ "- none",
182
+ "",
183
+ ]
184
+ ).rstrip() + "\n"
185
+ return {
186
+ "ref": ref,
187
+ "title": title,
188
+ "from_version": from_version,
189
+ "path": f"logics/request/{ref}.md",
190
+ "content": content,
191
+ "needs": needs,
192
+ "context": context,
193
+ "acceptance": acceptance,
194
+ }
195
+
196
+
197
+ def _section_lines(lines: list[str], heading: str) -> list[str]:
198
+ start_idx = None
199
+ target = heading.strip().lower()
200
+ for idx, line in enumerate(lines):
201
+ if line.startswith("# ") and line[2:].strip().lower() == target:
202
+ start_idx = idx + 1
203
+ break
204
+ if start_idx is None:
205
+ return []
206
+ out: list[str] = []
207
+ for idx in range(start_idx, len(lines)):
208
+ line = lines[idx]
209
+ if line.startswith("# "):
210
+ break
211
+ out.append(line)
212
+ return out
213
+
214
+
215
+ def _bullet_values(lines: list[str]) -> list[str]:
216
+ values: list[str] = []
217
+ for line in lines:
218
+ stripped = line.strip()
219
+ if stripped.startswith("- "):
220
+ value = stripped[2:].strip()
221
+ if value:
222
+ values.append(value)
223
+ return values
224
+
225
+
226
+ def _extract_title_from_doc(path: Path) -> str:
227
+ for line in path.read_text(encoding="utf-8").splitlines():
228
+ if line.startswith("## "):
229
+ payload = line.removeprefix("## ").strip()
230
+ if " - " in payload:
231
+ return payload.split(" - ", 1)[1].strip()
232
+ return payload
233
+ return path.stem
234
+
235
+
236
+ def _next_spec_ref(repo_root: Path, title: str) -> str:
237
+ directory = repo_root / "logics" / "specs"
238
+ highest = 0
239
+ if directory.is_dir():
240
+ for path in directory.glob("spec_*.md"):
241
+ match = re.match(r"^spec_(\d{3})_", path.stem)
242
+ if match:
243
+ highest = max(highest, int(match.group(1)))
244
+ return f"spec_{highest + 1:03d}_{_slugify(title)}"
245
+
246
+
247
+ def _next_backlog_ref(repo_root: Path, title: str) -> str:
248
+ directory = repo_root / "logics" / "backlog"
249
+ highest = 0
250
+ if directory.is_dir():
251
+ for path in directory.glob("item_*.md"):
252
+ match = re.match(r"^item_(\d{3})_", path.stem)
253
+ if match:
254
+ highest = max(highest, int(match.group(1)))
255
+ return f"item_{highest + 1:03d}_{_slugify(title)}"
256
+
257
+
258
+ def _split_backlog_problem(lines: list[str]) -> list[str]:
259
+ return _bullet_values(_section_lines(lines, "Problem"))
260
+
261
+
262
+ def _split_request_acceptance(lines: list[str]) -> list[str]:
263
+ return _bullet_values(_section_lines(lines, "Acceptance criteria"))
264
+
265
+
266
+ def _append_section_bullets(path: Path, heading: str, bullets: list[str], *, dry_run: bool) -> None:
267
+ if dry_run:
268
+ return
269
+ lines = path.read_text(encoding="utf-8").splitlines()
270
+ start_idx = None
271
+ for idx, line in enumerate(lines):
272
+ if line.startswith("# ") and line[2:].strip().lower() == heading.strip().lower():
273
+ start_idx = idx + 1
274
+ break
275
+ if start_idx is None:
276
+ lines.extend(["", f"# {heading}", *[f"- {bullet}" for bullet in bullets]])
277
+ else:
278
+ insert_at = start_idx
279
+ while insert_at < len(lines) and lines[insert_at].strip().startswith("- "):
280
+ insert_at += 1
281
+ existing = {line.strip() for line in lines[start_idx:insert_at] if line.strip().startswith("- ")}
282
+ for bullet in bullets:
283
+ rendered = f"- {bullet}"
284
+ if rendered not in existing:
285
+ lines.insert(insert_at, rendered)
286
+ insert_at += 1
287
+ path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
288
+
289
+
290
+ def _build_spec_first_pass(repo_root: Path, backlog_ref: str) -> dict[str, object]:
291
+ backlog_path = _resolve_workflow_doc(repo_root, backlog_ref)
292
+ if backlog_path is None:
293
+ raise SystemExit(f"Unknown backlog ref `{backlog_ref}`.")
294
+ if backlog_path.parent.name != "backlog":
295
+ raise SystemExit(f"`spec-first-pass` requires a backlog ref. Got `{backlog_ref}`.")
296
+ lines = backlog_path.read_text(encoding="utf-8").splitlines()
297
+ title = _extract_title_from_doc(backlog_path)
298
+ spec_title = f"{title} first-pass spec"
299
+ ref = _next_spec_ref(repo_root, spec_title)
300
+ problem = _bullet_values(_section_lines(lines, "Problem"))
301
+ acceptance = _bullet_values(_section_lines(lines, "Acceptance criteria"))
302
+ summary = problem[0] if problem else f"Derive a first-pass spec for {title.lower()}."
303
+ goals = [
304
+ f"Capture the bounded delivery scope for {title.lower()}.",
305
+ "Keep the spec proposal-only and concise.",
306
+ ]
307
+ non_goals = [
308
+ "Do not add implementation details that belong in a task.",
309
+ ]
310
+ use_cases = [
311
+ f"Operators need a concise spec for `{backlog_ref}` before implementation starts.",
312
+ ]
313
+ reqs = [
314
+ f"Summarize the bounded scope of `{backlog_ref}`.",
315
+ "Translate backlog acceptance criteria into a short functional spec.",
316
+ ]
317
+ acs = acceptance or [
318
+ "AC1: The outline stays bounded and proposal-only.",
319
+ "AC2: The spec highlights the core user-facing behavior.",
320
+ ]
321
+ validation = [
322
+ f"Check the backlog item `{backlog_ref}` and ensure the spec follows it closely.",
323
+ "Run `python3 -m logics_manager lint --require-status` after saving the spec.",
324
+ ]
325
+ questions = [
326
+ "Which acceptance criterion needs the deepest traceability?",
327
+ ]
328
+ content = "\n".join(
329
+ [
330
+ f"## {ref} - {spec_title}",
331
+ f"> From version: {_parse_package_version(repo_root)}",
332
+ "> Understanding: 90%",
333
+ "> Confidence: 85%",
334
+ "",
335
+ "# Overview",
336
+ summary,
337
+ "",
338
+ "# Goals",
339
+ *[f"- {item}" for item in goals],
340
+ "",
341
+ "# Non-goals",
342
+ *[f"- {item}" for item in non_goals],
343
+ "",
344
+ "# Users & use cases",
345
+ *[f"- {item}" for item in use_cases],
346
+ "",
347
+ "# Scope",
348
+ "- In:",
349
+ f" - Deliver a spec for `{backlog_ref}` that stays bounded.",
350
+ "- Out:",
351
+ " - Implementation details and unrelated sibling slices.",
352
+ "",
353
+ "# Requirements",
354
+ *[f"- {item}" for item in reqs],
355
+ "",
356
+ "# Acceptance criteria",
357
+ *[f"- {item}" for item in acs],
358
+ "",
359
+ "# Validation / test plan",
360
+ *[f"- {item}" for item in validation],
361
+ "",
362
+ "# Open questions",
363
+ *[f"- {item}" for item in questions],
364
+ "",
365
+ "# Backlog",
366
+ f"- source backlog: `{backlog_ref}`",
367
+ "",
368
+ ]
369
+ ).rstrip() + "\n"
370
+ return {
371
+ "ref": ref,
372
+ "title": spec_title,
373
+ "path": f"logics/specs/{ref}.md",
374
+ "backlog_ref": backlog_ref,
375
+ "backlog_path": backlog_path.relative_to(repo_root).as_posix(),
376
+ "content": content,
377
+ "overview": summary,
378
+ "goals": goals,
379
+ "acceptance": acs,
380
+ "validation": validation,
381
+ }
382
+
383
+
384
+ def _build_backlog_groom(repo_root: Path, request_ref: str) -> dict[str, object]:
385
+ request_path = _resolve_workflow_doc(repo_root, request_ref)
386
+ if request_path is None:
387
+ raise SystemExit(f"Unknown request ref `{request_ref}`.")
388
+ if request_path.parent.name != "request":
389
+ raise SystemExit(f"`backlog-groom` requires a request ref. Got `{request_ref}`.")
390
+
391
+ lines = request_path.read_text(encoding="utf-8").splitlines()
392
+ title = _extract_title_from_doc(request_path)
393
+ backlog_title = title
394
+ ref = _next_backlog_ref(repo_root, backlog_title)
395
+ problem = _split_backlog_problem(lines)
396
+ acceptance = _split_request_acceptance(lines)
397
+ complexity = "High" if len(acceptance) >= 4 or "runtime" in title.lower() or "plugin" in title.lower() else "Medium"
398
+ theme = "Operator workflow and runtime integration"
399
+ scope_in = [
400
+ "one coherent delivery slice from the source request",
401
+ ]
402
+ scope_out = [
403
+ "unrelated sibling slices that should stay in separate backlog items instead of widening this doc",
404
+ ]
405
+ decision_product = "Not needed"
406
+ decision_architecture = "Not needed"
407
+ product_brief = "logics/product/prod_009_logics_cli_as_the_primary_operator_surface_and_unified_runtime_api.md"
408
+ content = "\n".join(
409
+ [
410
+ f"## {ref} - {backlog_title}",
411
+ f"> From version: {_parse_package_version(repo_root)}",
412
+ "> Schema version: 1.0",
413
+ "> Status: Ready",
414
+ "> Understanding: 90%",
415
+ "> Confidence: 85%",
416
+ "> Progress: 0%",
417
+ f"> Complexity: {complexity}",
418
+ f"> Theme: {theme}",
419
+ "> Reminder: Update status/understanding/confidence/progress and linked request/task references when you edit this doc.",
420
+ "",
421
+ "# Problem",
422
+ *(problem or [f"Deliver the bounded slice for {backlog_title} without widening scope."]),
423
+ "",
424
+ "# Scope",
425
+ "- In:",
426
+ *[f" - {item}" for item in scope_in],
427
+ "- Out:",
428
+ *[f" - {item}" for item in scope_out],
429
+ "",
430
+ "# Acceptance criteria",
431
+ *[f"- {item}" for item in acceptance or [
432
+ "AC1: The backlog slice stays bounded and reviewable.",
433
+ "AC2: The backlog slice preserves the request's core acceptance criteria.",
434
+ ]],
435
+ "",
436
+ "# AC Traceability",
437
+ *(f"- request-AC{idx + 1} -> This backlog slice. Proof: {item}" for idx, item in enumerate(acceptance or ["The request remains bounded and reviewable."])),
438
+ "",
439
+ "# Decision framing",
440
+ f"- Product framing: {decision_product}",
441
+ "- Product signals: (none detected)",
442
+ "- Product follow-up: No product brief follow-up is expected based on current signals.",
443
+ f"- Architecture framing: {decision_architecture}",
444
+ "- Architecture signals: (none detected)",
445
+ "- Architecture follow-up: No architecture decision follow-up is expected based on current signals.",
446
+ "",
447
+ "# Links",
448
+ f"- Product brief(s): `{product_brief}`",
449
+ "- Architecture decision(s): (none yet)",
450
+ f"- Request: `logics/request/{request_ref}.md`",
451
+ "- Primary task(s): (none yet)",
452
+ "",
453
+ "# AI Context",
454
+ f"- Summary: {backlog_title}",
455
+ f"- Keywords: backlog-groom, request, {backlog_title.lower()}, bounded slice",
456
+ f"- Use when: Use when implementing or reviewing the delivery slice for {backlog_title}.",
457
+ "- Skip when: Skip when the change is unrelated to this delivery slice or its linked request.",
458
+ "",
459
+ "# Priority",
460
+ "- Impact:",
461
+ "- Urgency:",
462
+ "",
463
+ "# Notes",
464
+ f"- Hybrid rationale: Derived from request `{request_ref}` and kept bounded to one coherent delivery slice.",
465
+ f"- Source file: `logics/request/{request_ref}.md`.",
466
+ "- Generated locally by logics-manager.",
467
+ "",
468
+ ]
469
+ ).rstrip() + "\n"
470
+ return {
471
+ "ref": ref,
472
+ "title": backlog_title,
473
+ "path": f"logics/backlog/{ref}.md",
474
+ "request_ref": request_ref,
475
+ "request_path": request_path.relative_to(repo_root).as_posix(),
476
+ "content": content,
477
+ "problem": problem,
478
+ "acceptance": acceptance or [
479
+ "AC1: The backlog slice stays bounded and reviewable.",
480
+ "AC2: The backlog slice preserves the request's core acceptance criteria.",
481
+ ],
482
+ "complexity": complexity,
483
+ }
484
+
485
+
486
+ def _load_jsonl_records(path: Path) -> tuple[list[dict[str, Any]], int]:
487
+ if not path.is_file():
488
+ return [], 0
489
+ records: list[dict[str, Any]] = []
490
+ invalid_lines = 0
491
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
492
+ line = raw_line.strip()
493
+ if not line:
494
+ continue
495
+ try:
496
+ payload = json.loads(line)
497
+ except json.JSONDecodeError:
498
+ invalid_lines += 1
499
+ continue
500
+ if isinstance(payload, dict):
501
+ records.append(payload)
502
+ else:
503
+ invalid_lines += 1
504
+ return records, invalid_lines
505
+
506
+
507
+ def _parse_recorded_at(value: Any) -> datetime | None:
508
+ if not isinstance(value, str) or not value.strip():
509
+ return None
510
+ normalized = value.strip()
511
+ if normalized.endswith("Z"):
512
+ normalized = normalized[:-1] + "+00:00"
513
+ try:
514
+ parsed = datetime.fromisoformat(normalized)
515
+ except ValueError:
516
+ return None
517
+ if parsed.tzinfo is None:
518
+ return parsed.replace(tzinfo=timezone.utc)
519
+ return parsed.astimezone(timezone.utc)
520
+
521
+
522
+ def _round_rate(numerator: int, denominator: int) -> float:
523
+ if denominator <= 0:
524
+ return 0.0
525
+ return round(numerator / denominator, 4)
526
+
527
+
528
+ def _normalize_reason_label(value: Any, fallback: str = "unspecified") -> str:
529
+ text = "" if value is None else str(value).strip()
530
+ return text or fallback
531
+
532
+
533
+ def _stringify_scalar(value: Any) -> str:
534
+ if value is None:
535
+ return ""
536
+ if isinstance(value, bool):
537
+ return "true" if value else "false"
538
+ return str(value).strip()
539
+
540
+
541
+ def _summarize_validated_payload(payload: dict[str, Any]) -> str:
542
+ for key in ("summary", "title", "subject", "overall", "classification", "risk"):
543
+ text = _stringify_scalar(payload.get(key))
544
+ if text:
545
+ return " ".join(text.split())[:240]
546
+ if isinstance(payload.get("decision"), dict):
547
+ decision = payload["decision"]
548
+ action = _stringify_scalar(decision.get("action"))
549
+ target = _stringify_scalar(decision.get("target_ref"))
550
+ confidence = decision.get("confidence")
551
+ parts = [part for part in (action, target) if part]
552
+ if confidence is not None:
553
+ parts.append(f"confidence {confidence}")
554
+ if parts:
555
+ return "Decision: " + ", ".join(parts)
556
+ return json.dumps(payload, sort_keys=True)[:240]
557
+
558
+
559
+ def _build_validated_excerpt(payload: Any) -> dict[str, Any] | None:
560
+ if not isinstance(payload, dict):
561
+ return None
562
+ excerpt: dict[str, Any] = {}
563
+ for key in ("summary", "title", "subject", "overall", "classification", "risk", "target_ref"):
564
+ value = payload.get(key)
565
+ if value not in (None, "", [], {}):
566
+ excerpt[key] = value
567
+ if isinstance(payload.get("decision"), dict):
568
+ decision = payload["decision"]
569
+ excerpt["decision"] = {
570
+ "action": decision.get("action"),
571
+ "target_ref": decision.get("target_ref"),
572
+ "confidence": decision.get("confidence"),
573
+ }
574
+ return excerpt or None
575
+
576
+
577
+ def _fallback_triggered(record: dict[str, Any]) -> bool:
578
+ requested = _stringify_scalar(record.get("backend_requested") or record.get("requested_backend"))
579
+ used = _stringify_scalar(record.get("backend_used") or record.get("selected_backend"))
580
+ return used == "codex" and requested in {"auto", "ollama", "openai", "gemini"}
581
+
582
+
583
+ def _measurement_review_recommended(record: dict[str, Any]) -> bool:
584
+ if bool(record.get("review_recommended")):
585
+ return True
586
+ confidence = record.get("confidence")
587
+ return isinstance(confidence, (int, float)) and float(confidence) < 0.7
588
+
589
+
590
+ def _audit_review_recommended(record: dict[str, Any]) -> bool:
591
+ if bool(record.get("review_recommended")):
592
+ return True
593
+ if record.get("result_status") == "degraded":
594
+ return True
595
+ if record.get("degraded_reasons"):
596
+ return True
597
+ validated_payload = record.get("validated_payload")
598
+ if isinstance(validated_payload, dict):
599
+ confidence = validated_payload.get("confidence")
600
+ if isinstance(confidence, (int, float)) and float(confidence) < 0.7:
601
+ return True
602
+ decision = validated_payload.get("decision")
603
+ if isinstance(decision, dict):
604
+ decision_confidence = decision.get("confidence")
605
+ if isinstance(decision_confidence, (int, float)) and float(decision_confidence) < 0.7:
606
+ return True
607
+ return False
608
+
609
+
610
+ def _execution_path_label(requested_backend: str, used_backend: str) -> str:
611
+ if used_backend == "ollama":
612
+ return "local"
613
+ if used_backend in {"openai", "gemini"}:
614
+ return "remote"
615
+ if used_backend == "deterministic":
616
+ return "deterministic"
617
+ if used_backend == "codex" and requested_backend in {"auto", "ollama", "openai", "gemini"}:
618
+ return "fallback"
619
+ if used_backend == "codex":
620
+ return "codex-direct"
621
+ return "unknown"
622
+
623
+
624
+ def _git_changed_paths(repo_root: Path) -> list[str]:
625
+ try:
626
+ completed = subprocess.run(
627
+ ["git", "diff", "--name-only", "--relative=."],
628
+ cwd=repo_root,
629
+ stdout=subprocess.PIPE,
630
+ stderr=subprocess.PIPE,
631
+ text=True,
632
+ check=False,
633
+ )
634
+ except OSError:
635
+ return []
636
+ if completed.returncode != 0:
637
+ return []
638
+ return [line.strip() for line in completed.stdout.splitlines() if line.strip()]
639
+
640
+
641
+ def _is_low_risk_generated_path(path: str) -> bool:
642
+ normalized = path.strip().replace("\\", "/")
643
+ filename = normalized.rsplit("/", 1)[-1]
644
+ lowered = normalized.lower()
645
+ return (
646
+ filename in {"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "Cargo.lock", "Pipfile.lock", "poetry.lock", "composer.lock"}
647
+ or ".generated." in lowered
648
+ or lowered.endswith(".snap")
649
+ or lowered.startswith("dist/")
650
+ or lowered.startswith("build/")
651
+ )
652
+
653
+
654
+ def _is_schema_or_migration_path(path: str) -> bool:
655
+ lowered = path.strip().replace("\\", "/").lower()
656
+ return (
657
+ "/migrations/" in lowered
658
+ or lowered.startswith("migrations/")
659
+ or "/migration/" in lowered
660
+ or lowered.startswith("migration/")
661
+ or lowered.endswith("schema.prisma")
662
+ or lowered.endswith("schema.sql")
663
+ or lowered.endswith("/schema.ts")
664
+ or lowered.endswith("/schema.js")
665
+ or "/db/schema" in lowered
666
+ or "/alembic/" in lowered
667
+ )
668
+
669
+
670
+ def _classify_diff_risk(changed_paths: list[str]) -> dict[str, object]:
671
+ if not changed_paths:
672
+ return {
673
+ "risk": "low",
674
+ "summary": "Deterministic pre-classifier marked the empty diff as low risk.",
675
+ "drivers": ["No changed paths were detected in the working tree."],
676
+ "confidence": 0.97,
677
+ "rationale": "An empty diff does not require AI classification.",
678
+ "classification_reason": "empty-diff",
679
+ }
680
+ if any(_is_schema_or_migration_path(path) for path in changed_paths):
681
+ return {
682
+ "risk": "high",
683
+ "summary": "Deterministic pre-classifier escalated the diff because schema or migration files changed.",
684
+ "drivers": ["The change surface includes schema or migration files that require careful review."],
685
+ "confidence": 0.95,
686
+ "rationale": "Schema and migration changes are treated as high risk without an AI round-trip.",
687
+ "classification_reason": "schema-or-migration",
688
+ }
689
+ if all(_is_low_risk_generated_path(path) for path in changed_paths):
690
+ return {
691
+ "risk": "low",
692
+ "summary": "Deterministic pre-classifier marked the diff as low risk because it only touches lock or generated files.",
693
+ "drivers": ["Only lock-file or generated-artifact paths changed."],
694
+ "confidence": 0.94,
695
+ "rationale": "Lock-file-only and generated-only diffs are handled deterministically before any AI dispatch.",
696
+ "classification_reason": "lock-or-generated-only",
697
+ }
698
+ return {
699
+ "risk": "medium",
700
+ "summary": "Deterministic pre-classifier marked the diff as medium risk because it includes general source edits.",
701
+ "drivers": ["The diff includes non-generated source paths.", "No schema or migration paths were detected."],
702
+ "confidence": 0.78,
703
+ "rationale": "General source edits stay bounded but still deserve a review pass.",
704
+ "classification_reason": "mixed-source",
705
+ }
706
+
707
+
708
+ def _render_diff_risk_text(payload: dict[str, object]) -> str:
709
+ lines = [
710
+ f"Diff risk: {payload['risk']}",
711
+ f"- summary: {payload['summary']}",
712
+ f"- confidence: {payload['confidence']}",
713
+ f"- changed paths: {len(payload['changed_paths'])}",
714
+ ]
715
+ for driver in payload["drivers"]:
716
+ lines.append(f"- {driver}")
717
+ return "\n".join(lines)
718
+
719
+
720
+ def _summarize_commit_scope(changed_paths: list[str]) -> tuple[str, str]:
721
+ if not changed_paths:
722
+ return "root", "No changes detected; nothing to commit."
723
+ if any(path.startswith("src/") for path in changed_paths):
724
+ return "plugin", "Plugin surface changes detected."
725
+ if any(path.startswith("logics_manager/") for path in changed_paths):
726
+ return "python-runtime", "Native Logics manager changes detected."
727
+ if any(path.startswith("logics/") for path in changed_paths):
728
+ return "docs", "Workflow documentation changes detected."
729
+ return "misc", "Mixed repository changes detected."
730
+
731
+
732
+ def _build_commit_plan(changed_paths: list[str]) -> dict[str, object]:
733
+ scope, rationale = _summarize_commit_scope(changed_paths)
734
+ risk = _classify_diff_risk(changed_paths)
735
+ subject = {
736
+ "root": "chore: no changes",
737
+ "plugin": "feat: update plugin runtime wiring",
738
+ "python-runtime": "feat: extend native logics-manager runtime",
739
+ "docs": "docs: update Logics workflow documentation",
740
+ "misc": "chore: update repository changes",
741
+ }.get(scope, "chore: update repository changes")
742
+ body_lines = [
743
+ f"- scope: {scope}",
744
+ f"- changed paths: {len(changed_paths)}",
745
+ f"- risk: {risk['risk']}",
746
+ f"- rationale: {rationale}",
747
+ ]
748
+ if changed_paths:
749
+ body_lines.append("- paths:")
750
+ body_lines.extend(f" - {path}" for path in changed_paths[:8])
751
+ if len(changed_paths) > 8:
752
+ body_lines.append(f" - ... and {len(changed_paths) - 8} more")
753
+ return {
754
+ "subject": subject,
755
+ "body": "\n".join(body_lines),
756
+ "scope": scope,
757
+ "confidence": 0.82 if changed_paths else 1.0,
758
+ "rationale": rationale,
759
+ "risk": risk["risk"],
760
+ "changed_paths": changed_paths,
761
+ "review_recommended": risk["risk"] != "low" or len(changed_paths) > 6,
762
+ }
763
+
764
+
765
+ def _build_changed_surface_summary(changed_paths: list[str]) -> dict[str, object]:
766
+ category_counter: Counter[str] = Counter()
767
+ for path in changed_paths:
768
+ normalized = path.replace("\\", "/")
769
+ if normalized.startswith("src/"):
770
+ category_counter["plugin"] += 1
771
+ elif normalized.startswith("logics_manager/"):
772
+ category_counter["python-runtime"] += 1
773
+ elif normalized.startswith("logics/"):
774
+ category_counter["workflow-docs"] += 1
775
+ elif normalized.startswith("tests/") or "/tests/" in normalized or normalized.startswith("python_tests/"):
776
+ category_counter["tests"] += 1
777
+ elif normalized.endswith(".md"):
778
+ category_counter["docs"] += 1
779
+ else:
780
+ category_counter["other"] += 1
781
+ primary = category_counter.most_common(1)[0][0] if category_counter else "clean"
782
+ summary = {
783
+ "clean": "No changed surface was detected.",
784
+ "plugin": "The plugin surface is the dominant change area.",
785
+ "python-runtime": "The native Python runtime is the dominant change area.",
786
+ "workflow-docs": "Workflow documentation is the dominant change area.",
787
+ "tests": "Tests are the dominant change area.",
788
+ "docs": "Markdown documentation is the dominant change area.",
789
+ "other": "Mixed repository changes are present.",
790
+ }.get(primary, "Mixed repository changes are present.")
791
+ return {
792
+ "summary": summary,
793
+ "primary_category": primary,
794
+ "counts": dict(sorted(category_counter.items())),
795
+ "changed_paths": changed_paths,
796
+ "review_recommended": primary not in {"clean", "docs"} and bool(changed_paths),
797
+ }
798
+
799
+
800
+ def _build_validation_checklist(changed_paths: list[str]) -> dict[str, object]:
801
+ surface = _build_changed_surface_summary(changed_paths)
802
+ checks: list[str] = [
803
+ "Run `python3 -m pytest python_tests/test_logics_manager_cli.py -q`.",
804
+ "Run `python3 -m compileall logics_manager`.",
805
+ "Run `npm run lint:logics`.",
806
+ ]
807
+ if any(path.startswith("src/") for path in changed_paths):
808
+ checks.append("Run the plugin test suite that exercises the VS Code entrypoints.")
809
+ if any(path.startswith("logics_manager/") for path in changed_paths):
810
+ checks.append("Smoke-test `python3 -m logics_manager --help` and the affected native subcommands.")
811
+ if any(path.startswith("logics/") for path in changed_paths):
812
+ checks.append("Run `python3 -m logics_manager lint --require-status` and inspect the workflow docs manually.")
813
+ if any(path.startswith("tests/") or path.startswith("python_tests/") for path in changed_paths):
814
+ checks.append("Run the focused affected tests before broad regression sweeps.")
815
+ if not changed_paths:
816
+ checks.append("No validation needed beyond a clean smoke check; there are no tracked changes.")
817
+ return {
818
+ "profile": "deterministic",
819
+ "checks": checks,
820
+ "confidence": 0.91 if changed_paths else 1.0,
821
+ "rationale": surface["summary"],
822
+ }
823
+
824
+
825
+ def _build_test_impact_summary(changed_paths: list[str]) -> dict[str, object]:
826
+ categories = _build_changed_surface_summary(changed_paths)["counts"]
827
+ recommended: list[str] = []
828
+ if "python-runtime" in categories:
829
+ recommended.append("python3 -m pytest python_tests/test_logics_manager_cli.py -q")
830
+ if "plugin" in categories:
831
+ recommended.append("npm run lint")
832
+ if "workflow-docs" in categories:
833
+ recommended.append("npm run lint:logics")
834
+ if "tests" in categories:
835
+ recommended.append("python3 -m pytest python_tests/test_logics_manager_cli.py -q")
836
+ if not recommended:
837
+ recommended.append("python3 -m pytest python_tests/test_logics_manager_cli.py -q")
838
+ return {
839
+ "summary": "Recommended test order derived from the current change surface.",
840
+ "categories": categories,
841
+ "recommended_commands": list(dict.fromkeys(recommended)),
842
+ "confidence": 0.88 if changed_paths else 1.0,
843
+ }
844
+
845
+
846
+ def _build_doc_consistency(repo_root: Path) -> dict[str, object]:
847
+ doctor = doctor_payload(repo_root)
848
+ lint = lint_payload(repo_root, require_status=True)
849
+ issues: list[dict[str, object]] = []
850
+ for issue in doctor["issues"]:
851
+ issues.append(
852
+ {
853
+ "source": "doctor",
854
+ "path": issue["path"],
855
+ "message": issue["message"],
856
+ "remediation": issue["remediation"],
857
+ "code": issue["code"],
858
+ }
859
+ )
860
+ for issue in lint["issues"]:
861
+ issues.append(
862
+ {
863
+ "source": "lint",
864
+ "path": issue["path"],
865
+ "message": issue["message"],
866
+ "remediation": "Update the doc so lint and workflow conventions stay aligned.",
867
+ "code": "lint_issue",
868
+ }
869
+ )
870
+ for warning in lint["warnings"]:
871
+ issues.append(
872
+ {
873
+ "source": "lint",
874
+ "path": warning["path"],
875
+ "message": warning["message"],
876
+ "remediation": "Review the warning and confirm it is intentional.",
877
+ "code": "lint_warning",
878
+ }
879
+ )
880
+ overall = "clean" if not issues else "issues-found"
881
+ summary = "Workflow docs are consistent across doctor and lint checks." if overall == "clean" else "Workflow docs have consistency issues that should be reviewed."
882
+ follow_up: list[str] = []
883
+ if doctor["issue_count"]:
884
+ follow_up.append("Fix the doctor issues first because they affect workflow shape and required indicators.")
885
+ if lint["issue_count"]:
886
+ follow_up.append("Fix lint issues next so changed docs preserve indicators and status conventions.")
887
+ if lint["warning_count"]:
888
+ follow_up.append("Review lint warnings to confirm they are intentional.")
889
+ if not follow_up:
890
+ follow_up.append("No follow-up required.")
891
+ return {
892
+ "overall": overall,
893
+ "summary": summary,
894
+ "issues": issues,
895
+ "follow_up": follow_up,
896
+ "confidence": 1.0 if overall == "clean" else 0.86,
897
+ "doctor": {
898
+ "ok": doctor["ok"],
899
+ "issue_count": doctor["issue_count"],
900
+ "workflow_doc_count": doctor["workflow_doc_count"],
901
+ "missing_schema_version_count": doctor["missing_schema_version_count"],
902
+ },
903
+ "lint": {
904
+ "ok": lint["ok"],
905
+ "issue_count": lint["issue_count"],
906
+ "warning_count": lint["warning_count"],
907
+ },
908
+ }
909
+
910
+
911
+ def _build_review_checklist(repo_root: Path) -> dict[str, object]:
912
+ changed_paths = _git_changed_paths(repo_root)
913
+ surface = _build_changed_surface_summary(changed_paths)
914
+ consistency = _build_doc_consistency(repo_root)
915
+ checklist: list[str] = [
916
+ "Read the diff with the native `diff-risk` summary before approving.",
917
+ "Verify the impacted docs or code paths match the intended scope.",
918
+ ]
919
+ if surface["primary_category"] == "python-runtime":
920
+ checklist.append("Run the Python CLI smoke tests for the modified runtime paths.")
921
+ if surface["primary_category"] == "plugin":
922
+ checklist.append("Run the plugin command paths touched by the change and confirm the UI still delegates correctly.")
923
+ if surface["primary_category"] == "workflow-docs":
924
+ checklist.append("Check `lint` and `doctor` output for workflow doc consistency.")
925
+ if consistency["overall"] != "clean":
926
+ checklist.append("Resolve doc consistency issues before merging.")
927
+ else:
928
+ checklist.append("Document checks are clean; confirm no hidden workflow regressions remain.")
929
+ checklist.extend([
930
+ "Confirm the change does not reintroduce a manual `skills/` bootstrap step.",
931
+ "Confirm the change does not add a new compatibility residue for the old kit boundary.",
932
+ ])
933
+ return {
934
+ "summary": surface["summary"],
935
+ "surface": surface,
936
+ "doc_consistency": {
937
+ "overall": consistency["overall"],
938
+ "confidence": consistency["confidence"],
939
+ "doctor_issues": consistency["doctor"]["issue_count"],
940
+ "lint_issues": consistency["lint"]["issue_count"],
941
+ },
942
+ "checklist": checklist,
943
+ "confidence": 0.84 if changed_paths else 1.0,
944
+ }
945
+
946
+
947
+ def _build_validation_summary(repo_root: Path) -> dict[str, object]:
948
+ changed_paths = _git_changed_paths(repo_root)
949
+ doc_consistency = _build_doc_consistency(repo_root)
950
+ validation_checklist = _build_validation_checklist(changed_paths)
951
+ test_impact = _build_test_impact_summary(changed_paths)
952
+ overall = "ok" if doc_consistency["overall"] == "clean" else "needs-attention"
953
+ summary = "Repository validations look healthy." if overall == "ok" else "Repository validations need attention."
954
+ next_actions = list(validation_checklist["checks"][:3])
955
+ if doc_consistency["overall"] != "clean":
956
+ next_actions.insert(0, "Fix doc consistency issues before moving forward.")
957
+ if test_impact["recommended_commands"]:
958
+ next_actions.append(f"Primary test command: {test_impact['recommended_commands'][0]}")
959
+ return {
960
+ "overall": overall,
961
+ "summary": summary,
962
+ "doc_consistency": {
963
+ "overall": doc_consistency["overall"],
964
+ "doctor_issues": doc_consistency["doctor"]["issue_count"],
965
+ "lint_issues": doc_consistency["lint"]["issue_count"],
966
+ },
967
+ "validation_checklist": validation_checklist,
968
+ "test_impact": test_impact,
969
+ "next_actions": next_actions,
970
+ "confidence": 0.9 if overall == "ok" else 0.82,
971
+ }
972
+
973
+
974
+ def _build_hybrid_roi_report(
975
+ repo_root: Path,
976
+ *,
977
+ audit_log: Path,
978
+ measurement_log: Path,
979
+ recent_limit: int = DEFAULT_HYBRID_ROI_RECENT_LIMIT,
980
+ window_days: int = DEFAULT_HYBRID_ROI_WINDOW_DAYS,
981
+ ) -> dict[str, Any]:
982
+ effective_recent_limit = max(1, recent_limit)
983
+ effective_window_days = max(1, window_days)
984
+ audit_records, audit_invalid_lines = _load_jsonl_records(audit_log)
985
+ measurement_records, measurement_invalid_lines = _load_jsonl_records(measurement_log)
986
+ now = datetime.now(timezone.utc)
987
+ window_start = now - timedelta(days=effective_window_days)
988
+
989
+ measurement_records_sorted = sorted(
990
+ measurement_records,
991
+ key=lambda record: _parse_recorded_at(record.get("recorded_at")) or datetime.min.replace(tzinfo=timezone.utc),
992
+ )
993
+ audit_records_sorted = sorted(
994
+ audit_records,
995
+ key=lambda record: _parse_recorded_at(record.get("recorded_at")) or datetime.min.replace(tzinfo=timezone.utc),
996
+ )
997
+
998
+ total_runs = len(measurement_records_sorted)
999
+ by_flow: dict[str, dict[str, Any]] = {}
1000
+ backend_requested_counter: Counter[str] = Counter()
1001
+ backend_used_counter: Counter[str] = Counter()
1002
+ execution_path_counter: Counter[str] = Counter()
1003
+ result_status_counter: Counter[str] = Counter()
1004
+ recent_result_distribution_counter: Counter[str] = Counter()
1005
+ degraded_reason_counter: Counter[str] = Counter()
1006
+ fallback_reason_counter: Counter[str] = Counter()
1007
+ review_recommended_count = 0
1008
+ degraded_count = 0
1009
+ fallback_count = 0
1010
+ local_runs_count = 0
1011
+
1012
+ for record in measurement_records_sorted:
1013
+ flow = _normalize_reason_label(record.get("flow"), fallback="unknown-flow")
1014
+ requested_backend = _normalize_reason_label(record.get("backend_requested"), fallback="unknown")
1015
+ used_backend = _normalize_reason_label(record.get("backend_used"), fallback="unknown")
1016
+ execution_path = _normalize_reason_label(record.get("execution_path"), fallback=_execution_path_label(requested_backend, used_backend))
1017
+ result_status = _normalize_reason_label(record.get("result_status"), fallback="unknown")
1018
+ review_recommended = _measurement_review_recommended(record)
1019
+ degraded_reasons = [
1020
+ _normalize_reason_label(reason)
1021
+ for reason in record.get("degraded_reasons", [])
1022
+ if _normalize_reason_label(reason)
1023
+ ]
1024
+ recorded_at = _parse_recorded_at(record.get("recorded_at"))
1025
+
1026
+ backend_requested_counter[requested_backend] += 1
1027
+ backend_used_counter[used_backend] += 1
1028
+ execution_path_counter[execution_path] += 1
1029
+ result_status_counter[result_status] += 1
1030
+ if used_backend == "ollama":
1031
+ local_runs_count += 1
1032
+ if review_recommended:
1033
+ review_recommended_count += 1
1034
+ if result_status == "degraded" or degraded_reasons:
1035
+ degraded_count += 1
1036
+ if _fallback_triggered(record):
1037
+ fallback_count += 1
1038
+
1039
+ if recorded_at is not None and recorded_at >= window_start:
1040
+ recent_result_distribution_counter[result_status] += 1
1041
+ for reason in degraded_reasons:
1042
+ degraded_reason_counter[reason] += 1
1043
+
1044
+ flow_bucket = by_flow.setdefault(
1045
+ flow,
1046
+ {
1047
+ "run_count": 0,
1048
+ "backend_requested": {},
1049
+ "backend_used": {},
1050
+ "execution_paths": {},
1051
+ "result_statuses": {},
1052
+ "fallback_count": 0,
1053
+ "degraded_count": 0,
1054
+ "review_recommended_count": 0,
1055
+ },
1056
+ )
1057
+ flow_bucket["run_count"] += 1
1058
+ flow_bucket["backend_requested"][requested_backend] = flow_bucket["backend_requested"].get(requested_backend, 0) + 1
1059
+ flow_bucket["backend_used"][used_backend] = flow_bucket["backend_used"].get(used_backend, 0) + 1
1060
+ flow_bucket["execution_paths"][execution_path] = flow_bucket["execution_paths"].get(execution_path, 0) + 1
1061
+ flow_bucket["result_statuses"][result_status] = flow_bucket["result_statuses"].get(result_status, 0) + 1
1062
+ if _fallback_triggered(record):
1063
+ flow_bucket["fallback_count"] += 1
1064
+ if result_status == "degraded" or degraded_reasons:
1065
+ flow_bucket["degraded_count"] += 1
1066
+ if review_recommended:
1067
+ flow_bucket["review_recommended_count"] += 1
1068
+
1069
+ for flow_bucket in by_flow.values():
1070
+ run_count = int(flow_bucket["run_count"])
1071
+ flow_bucket["fallback_rate"] = _round_rate(int(flow_bucket["fallback_count"]), run_count)
1072
+ flow_bucket["degraded_rate"] = _round_rate(int(flow_bucket["degraded_count"]), run_count)
1073
+ flow_bucket["review_recommended_rate"] = _round_rate(int(flow_bucket["review_recommended_count"]), run_count)
1074
+
1075
+ recent_runs: list[dict[str, Any]] = []
1076
+ for audit_record in reversed(audit_records_sorted):
1077
+ backend = audit_record.get("backend")
1078
+ backend_requested = "unknown"
1079
+ backend_used = "unknown"
1080
+ if isinstance(backend, dict):
1081
+ backend_requested = _normalize_reason_label(backend.get("requested_backend"), fallback="unknown")
1082
+ backend_used = _normalize_reason_label(backend.get("selected_backend"), fallback="unknown")
1083
+ backend_reason_values = backend.get("reasons")
1084
+ if isinstance(backend_reason_values, list):
1085
+ for reason in backend_reason_values:
1086
+ if backend_used == "codex" and backend_requested in {"auto", "ollama"}:
1087
+ fallback_reason_counter[_normalize_reason_label(reason)] += 1
1088
+ transport = audit_record.get("transport") if isinstance(audit_record.get("transport"), dict) else {}
1089
+ if backend_used == "codex" and backend_requested in {"auto", "ollama"}:
1090
+ transport_reason = transport.get("reason") if isinstance(transport, dict) else None
1091
+ fallback_reason_counter[_normalize_reason_label(transport_reason)] += 1
1092
+ recent_runs.append(
1093
+ {
1094
+ "recorded_at": audit_record.get("recorded_at"),
1095
+ "flow": _normalize_reason_label(audit_record.get("flow"), fallback="unknown-flow"),
1096
+ "result_status": _normalize_reason_label(audit_record.get("result_status"), fallback="unknown"),
1097
+ "backend_requested": backend_requested,
1098
+ "backend_used": backend_used,
1099
+ "execution_path": _execution_path_label(backend_requested, backend_used),
1100
+ "degraded_reasons": [
1101
+ _normalize_reason_label(reason)
1102
+ for reason in audit_record.get("degraded_reasons", [])
1103
+ if _normalize_reason_label(reason)
1104
+ ],
1105
+ "review_recommended": _audit_review_recommended(audit_record),
1106
+ "safety_class": _normalize_reason_label(audit_record.get("safety_class"), fallback="unknown"),
1107
+ "seed_ref": (
1108
+ audit_record.get("context_summary", {}).get("seed_ref")
1109
+ if isinstance(audit_record.get("context_summary"), dict)
1110
+ else None
1111
+ ),
1112
+ "transport": transport if isinstance(transport, dict) else {},
1113
+ "validated_summary": _summarize_validated_payload(audit_record.get("validated_payload", {}))
1114
+ if isinstance(audit_record.get("validated_payload"), dict)
1115
+ else "",
1116
+ "validated_excerpt": _build_validated_excerpt(audit_record.get("validated_payload")),
1117
+ }
1118
+ )
1119
+ if len(recent_runs) >= effective_recent_limit:
1120
+ break
1121
+
1122
+ recent_runs.reverse()
1123
+ fallback_heavy = _round_rate(fallback_count, total_runs) >= 0.25 if total_runs else False
1124
+ degraded_heavy = _round_rate(degraded_count, total_runs) >= 0.2 if total_runs else False
1125
+ review_heavy = _round_rate(review_recommended_count, total_runs) >= 0.35 if total_runs else False
1126
+ local_offload_rate = _round_rate(local_runs_count, total_runs)
1127
+ estimated_remote_token_avoidance = local_runs_count * DEFAULT_ESTIMATED_REMOTE_TOKENS_PER_LOCAL_RUN
1128
+
1129
+ health_summary: list[str] = []
1130
+ if total_runs == 0:
1131
+ health_summary.append("No hybrid assist measurement records are available yet.")
1132
+ else:
1133
+ if fallback_heavy:
1134
+ health_summary.append("Fallback routing is elevated, which suggests local backend instability or explicit codex preference.")
1135
+ if degraded_heavy:
1136
+ health_summary.append("Degraded outcomes are elevated and should be reviewed before treating the ROI proxies as healthy.")
1137
+ if review_heavy:
1138
+ health_summary.append("Review-recommended outcomes are frequent, so operator follow-up remains important.")
1139
+ if not health_summary:
1140
+ health_summary.append("Recent hybrid assist activity looks operationally healthy under the current bounded metrics.")
1141
+
1142
+ return {
1143
+ "schema_version": "1.0",
1144
+ "report_kind": "hybrid-assist-roi-report",
1145
+ "generated_at": now.isoformat(),
1146
+ "ok": True,
1147
+ "sources": {
1148
+ "audit_log": audit_log.relative_to(repo_root).as_posix() if audit_log.is_absolute() else audit_log.as_posix(),
1149
+ "measurement_log": measurement_log.relative_to(repo_root).as_posix() if measurement_log.is_absolute() else measurement_log.as_posix(),
1150
+ "audit_records": len(audit_records_sorted),
1151
+ "measurement_records": total_runs,
1152
+ "invalid_audit_lines": audit_invalid_lines,
1153
+ "invalid_measurement_lines": measurement_invalid_lines,
1154
+ },
1155
+ "limits": {
1156
+ "recent_limit": effective_recent_limit,
1157
+ "window_days": effective_window_days,
1158
+ "window_start": window_start.isoformat(),
1159
+ },
1160
+ "semantics": {
1161
+ "measured": "Values under `measured` come directly from hybrid assist measurement records and recent audit provenance.",
1162
+ "derived": "Values under `derived` are deterministic summaries or rates computed from measured counters.",
1163
+ "estimated": "Values under `estimated` are conservative proxies only. They are not billing truth and must be read alongside degraded and fallback rates.",
1164
+ },
1165
+ "measured": {
1166
+ "totals": {
1167
+ "runs": total_runs,
1168
+ "fallback_runs": fallback_count,
1169
+ "degraded_runs": degraded_count,
1170
+ "review_recommended_runs": review_recommended_count,
1171
+ "local_runs": local_runs_count,
1172
+ },
1173
+ "runs_by_flow": dict(sorted((flow, bucket["run_count"]) for flow, bucket in by_flow.items())),
1174
+ "backend_requested": dict(sorted(backend_requested_counter.items())),
1175
+ "backend_used": dict(sorted(backend_used_counter.items())),
1176
+ "execution_paths": dict(sorted(execution_path_counter.items())),
1177
+ "result_statuses": dict(sorted(result_status_counter.items())),
1178
+ "review_recommended_by_flow": {flow: bucket["review_recommended_count"] for flow, bucket in sorted(by_flow.items())},
1179
+ "recent_result_distribution": dict(sorted(recent_result_distribution_counter.items())),
1180
+ "flow_breakdown": dict(sorted(by_flow.items())),
1181
+ },
1182
+ "derived": {
1183
+ "rates": {
1184
+ "fallback_rate": _round_rate(fallback_count, total_runs),
1185
+ "degraded_rate": _round_rate(degraded_count, total_runs),
1186
+ "review_recommended_rate": _round_rate(review_recommended_count, total_runs),
1187
+ "local_offload_rate": local_offload_rate,
1188
+ },
1189
+ "dispatch_split": [{"label": label, "count": count} for label, count in backend_used_counter.most_common()],
1190
+ "execution_path_split": [{"label": label, "count": count} for label, count in execution_path_counter.most_common()],
1191
+ "top_degraded_reasons": [{"label": label, "count": count} for label, count in degraded_reason_counter.most_common(5)],
1192
+ "top_fallback_reasons": [{"label": label, "count": count} for label, count in fallback_reason_counter.most_common(5)],
1193
+ "health_summary": health_summary,
1194
+ "report_state": {
1195
+ "fallback_heavy": fallback_heavy,
1196
+ "degraded_heavy": degraded_heavy,
1197
+ "review_heavy": review_heavy,
1198
+ },
1199
+ },
1200
+ "estimated": {
1201
+ "assumptions": {
1202
+ "remote_tokens_per_local_run": DEFAULT_ESTIMATED_REMOTE_TOKENS_PER_LOCAL_RUN,
1203
+ "token_avoidance_note": "Each successful local Ollama run is treated as one avoided remote assist dispatch with a conservative illustrative token budget.",
1204
+ "interpretation_note": "Use these proxies for relative trend review only. They are not exact cost or billing metrics.",
1205
+ },
1206
+ "proxies": {
1207
+ "estimated_remote_dispatches_avoided": local_runs_count,
1208
+ "estimated_remote_token_avoidance": estimated_remote_token_avoidance,
1209
+ "estimated_local_offload_share": local_offload_rate,
1210
+ },
1211
+ },
1212
+ "recent_runs": recent_runs,
1213
+ }
1214
+
1215
+
1216
+ def build_parser() -> argparse.ArgumentParser:
1217
+ parser = argparse.ArgumentParser(
1218
+ prog="logics-manager assist",
1219
+ description="Inspect the local assist/runtime surface.",
1220
+ )
1221
+ sub = parser.add_subparsers(dest="command", required=True)
1222
+
1223
+ runtime = sub.add_parser("runtime-status", help="Report local assist runtime readiness.")
1224
+ runtime.add_argument("--backend")
1225
+ runtime.add_argument("--model-profile")
1226
+ runtime.add_argument("--model")
1227
+ runtime.add_argument("--ollama-host")
1228
+ runtime.add_argument("--timeout", type=float)
1229
+ runtime.add_argument("--format", choices=("text", "json"), default="text")
1230
+ runtime.add_argument("--out", help="Write the JSON status payload to this relative path.")
1231
+ runtime.add_argument("--dry-run", action="store_true")
1232
+ runtime.set_defaults(func=cmd_runtime_status)
1233
+
1234
+ diff_risk = sub.add_parser("diff-risk", help="Classify the current git diff using deterministic heuristics.")
1235
+ diff_risk.add_argument("--format", choices=("text", "json"), default="text")
1236
+ diff_risk.add_argument("--dry-run", action="store_true")
1237
+ diff_risk.set_defaults(func=cmd_diff_risk)
1238
+
1239
+ commit_plan = sub.add_parser("commit-plan", help="Draft a minimal commit plan from the current git diff.")
1240
+ commit_plan.add_argument("--format", choices=("text", "json"), default="text")
1241
+ commit_plan.add_argument("--dry-run", action="store_true")
1242
+ commit_plan.set_defaults(func=cmd_commit_plan)
1243
+
1244
+ changed_surface = sub.add_parser("changed-surface-summary", help="Summarize the current changed repository surface.")
1245
+ changed_surface.add_argument("--format", choices=("text", "json"), default="text")
1246
+ changed_surface.add_argument("--dry-run", action="store_true")
1247
+ changed_surface.set_defaults(func=cmd_changed_surface_summary)
1248
+
1249
+ doc_consistency = sub.add_parser("doc-consistency", help="Review workflow docs for consistency issues without mutating them.")
1250
+ doc_consistency.add_argument("--format", choices=("text", "json"), default="text")
1251
+ doc_consistency.add_argument("--dry-run", action="store_true")
1252
+ doc_consistency.set_defaults(func=cmd_doc_consistency)
1253
+
1254
+ review_checklist = sub.add_parser("review-checklist", help="Generate a bounded review checklist for the current change surface.")
1255
+ review_checklist.add_argument("--format", choices=("text", "json"), default="text")
1256
+ review_checklist.add_argument("--dry-run", action="store_true")
1257
+ review_checklist.set_defaults(func=cmd_review_checklist)
1258
+
1259
+ validation_checklist = sub.add_parser("validation-checklist", help="Generate a deterministic validation checklist from the current change surface.")
1260
+ validation_checklist.add_argument("--format", choices=("text", "json"), default="text")
1261
+ validation_checklist.add_argument("--dry-run", action="store_true")
1262
+ validation_checklist.set_defaults(func=cmd_validation_checklist)
1263
+
1264
+ validation_summary = sub.add_parser("validation-summary", help="Summarize lint, doctor, and validation impact signals.")
1265
+ validation_summary.add_argument("--format", choices=("text", "json"), default="text")
1266
+ validation_summary.add_argument("--dry-run", action="store_true")
1267
+ validation_summary.set_defaults(func=cmd_validation_summary)
1268
+
1269
+ test_impact = sub.add_parser("test-impact-summary", help="Summarize the likely test impact of the current change surface.")
1270
+ test_impact.add_argument("--format", choices=("text", "json"), default="text")
1271
+ test_impact.add_argument("--dry-run", action="store_true")
1272
+ test_impact.set_defaults(func=cmd_test_impact_summary)
1273
+
1274
+ roi = sub.add_parser("roi-report", help="Summarize hybrid assist ROI from local audit and measurement logs.")
1275
+ roi.add_argument("--audit-log")
1276
+ roi.add_argument("--measurement-log")
1277
+ roi.add_argument("--recent-limit", type=int, default=DEFAULT_HYBRID_ROI_RECENT_LIMIT)
1278
+ roi.add_argument("--window-days", type=int, default=DEFAULT_HYBRID_ROI_WINDOW_DAYS)
1279
+ roi.add_argument("--format", choices=("text", "json"), default="text")
1280
+ roi.add_argument("--out", help="Write the JSON report payload to this relative path.")
1281
+ roi.add_argument("--dry-run", action="store_true")
1282
+ roi.set_defaults(func=cmd_roi_report)
1283
+
1284
+ claude_bridges = sub.add_parser(
1285
+ "claude-bridges",
1286
+ help="Render the canonical Claude bridge files and prompts derived from the integrated runtime.",
1287
+ )
1288
+ claude_bridges.add_argument("--format", choices=("text", "json"), default="text")
1289
+ claude_bridges.add_argument("--dry-run", action="store_true")
1290
+ claude_bridges.set_defaults(func=cmd_claude_bridges)
1291
+
1292
+ context = sub.add_parser("context", help="Build a shared assist context bundle for a flow.")
1293
+ context.add_argument("flow_name", choices=tuple(sorted(ASSIST_FLOW_DEFAULTS.keys())))
1294
+ context.add_argument("ref", nargs="?", help="Optional workflow ref for flows that target a doc.")
1295
+ context.add_argument("--context-mode", choices=("summary-only", "diff-first", "full"))
1296
+ context.add_argument("--profile", choices=("tiny", "normal", "deep"))
1297
+ context.add_argument("--include-graph", action="store_true", default=None)
1298
+ context.add_argument("--include-registry", action="store_true", default=None)
1299
+ context.add_argument("--include-doctor", action="store_true", default=None)
1300
+ context.add_argument("--format", choices=("text", "json"), default="text")
1301
+ context.add_argument("--out", help="Write the JSON context bundle to this relative path.")
1302
+ context.add_argument("--dry-run", action="store_true")
1303
+ context.set_defaults(func=cmd_context)
1304
+
1305
+ claude_instructions = sub.add_parser(
1306
+ "claude-instructions",
1307
+ help="Render the canonical assistant instructions derived from the integrated runtime.",
1308
+ )
1309
+ claude_instructions.add_argument("--format", choices=("text", "json"), default="text")
1310
+ claude_instructions.add_argument("--dry-run", action="store_true")
1311
+ claude_instructions.set_defaults(func=cmd_claude_instructions)
1312
+
1313
+ next_step = sub.add_parser("next-step", help="Suggest the next bounded Logics step for a target doc.")
1314
+ next_step.add_argument("ref", nargs="?", help="Optional workflow ref for a target doc.")
1315
+ next_step.add_argument("--format", choices=("text", "json"), default="text")
1316
+ next_step.add_argument("--dry-run", action="store_true")
1317
+ next_step.set_defaults(func=cmd_next_step)
1318
+
1319
+ request_draft = sub.add_parser("request-draft", help="Draft a bounded request doc from an intent.")
1320
+ request_draft.add_argument("--intent", required=True, help="Short operator intent to draft the request from.")
1321
+ request_draft.add_argument("--format", choices=("text", "json"), default="text")
1322
+ request_draft.add_argument("--execution-mode", choices=("suggestion-only", "execute"), default="suggestion-only")
1323
+ request_draft.add_argument("--dry-run", action="store_true")
1324
+ request_draft.set_defaults(func=cmd_request_draft)
1325
+
1326
+ spec_first_pass = sub.add_parser("spec-first-pass", help="Draft a first-pass spec outline from a backlog item.")
1327
+ spec_first_pass.add_argument("ref", help="Backlog ref for the spec source.")
1328
+ spec_first_pass.add_argument("--format", choices=("text", "json"), default="text")
1329
+ spec_first_pass.add_argument("--execution-mode", choices=("suggestion-only", "execute"), default="suggestion-only")
1330
+ spec_first_pass.add_argument("--dry-run", action="store_true")
1331
+ spec_first_pass.set_defaults(func=cmd_spec_first_pass)
1332
+
1333
+ backlog_groom = sub.add_parser("backlog-groom", help="Draft a bounded backlog proposal from a request doc.")
1334
+ backlog_groom.add_argument("ref", help="Request ref for the backlog source.")
1335
+ backlog_groom.add_argument("--format", choices=("text", "json"), default="text")
1336
+ backlog_groom.add_argument("--execution-mode", choices=("suggestion-only", "execute"), default="suggestion-only")
1337
+ backlog_groom.add_argument("--dry-run", action="store_true")
1338
+ backlog_groom.set_defaults(func=cmd_backlog_groom)
1339
+
1340
+ closure_summary = sub.add_parser("closure-summary", help="Summarize a delivered request, backlog item, or task.")
1341
+ closure_summary.add_argument("ref", nargs="?", help="Optional workflow ref for a delivered doc.")
1342
+ closure_summary.add_argument("--format", choices=("text", "json"), default="text")
1343
+ closure_summary.add_argument("--dry-run", action="store_true")
1344
+ closure_summary.set_defaults(func=cmd_closure_summary)
1345
+
1346
+ return parser
1347
+
1348
+
1349
+ def _claude_bridge_status(repo_root: Path) -> dict[str, object]:
1350
+ detected_variants: list[str] = []
1351
+ for variant in CLAUDE_BRIDGE_VARIANTS:
1352
+ if (repo_root / variant["command_path"]).is_file() and (repo_root / variant["agent_path"]).is_file():
1353
+ detected_variants.append(variant["id"])
1354
+ return {
1355
+ "available": bool(detected_variants),
1356
+ "preferred_variant": detected_variants[0] if detected_variants else None,
1357
+ "detected_variants": detected_variants,
1358
+ "supported_variants": [variant["id"] for variant in CLAUDE_BRIDGE_VARIANTS],
1359
+ }
1360
+
1361
+
1362
+ def _render_claude_bridge_lines(variant: dict[str, object], prompt: str) -> tuple[str, str]:
1363
+ title = str(variant["title"])
1364
+ command_path = str(variant["command_path"])
1365
+ agent_path = str(variant["agent_path"])
1366
+ reviewer_nudge = variant.get("reviewer_nudge")
1367
+
1368
+ command_lines = [
1369
+ f"# {title}",
1370
+ "",
1371
+ f"Use the repository-local {title.lower()} bridge for this project.",
1372
+ "",
1373
+ "Primary prompt:",
1374
+ prompt,
1375
+ "",
1376
+ ]
1377
+ agent_lines = [
1378
+ f"# {title} Agent",
1379
+ "",
1380
+ f"Use the repository-local {title.lower()} agent for this project.",
1381
+ "",
1382
+ "Default prompt:",
1383
+ prompt,
1384
+ "",
1385
+ ]
1386
+ if reviewer_nudge:
1387
+ command_lines.extend(["Reviewer nudge:", str(reviewer_nudge), ""])
1388
+ agent_lines.extend(["Reviewer nudge:", str(reviewer_nudge), ""])
1389
+ command_lines.extend(["References:", f"- `{agent_path}`", "- `logics_manager`", ""])
1390
+ agent_lines.extend(["References:", f"- `{command_path}`", "- `logics_manager`", ""])
1391
+ return "\n".join(command_lines), "\n".join(agent_lines)
1392
+
1393
+
1394
+ def _build_claude_bridge_manifest(repo_root: Path) -> dict[str, object]:
1395
+ bridges: list[dict[str, object]] = []
1396
+ for variant in CLAUDE_BRIDGE_VARIANTS:
1397
+ prompt = str(variant.get("prompt_override") or variant["fallback_prompt"])
1398
+ command_content, agent_content = _render_claude_bridge_lines(variant, prompt)
1399
+ bridges.append(
1400
+ {
1401
+ "id": variant["id"],
1402
+ "title": variant["title"],
1403
+ "command_path": variant["command_path"],
1404
+ "agent_path": variant["agent_path"],
1405
+ "prompt": prompt,
1406
+ "command_content": command_content,
1407
+ "agent_content": agent_content,
1408
+ }
1409
+ )
1410
+ return {
1411
+ "command": "assist",
1412
+ "kind": "claude-bridge-manifest",
1413
+ "repo_root": repo_root.as_posix(),
1414
+ "bridge_count": len(bridges),
1415
+ "bridges": bridges,
1416
+ }
1417
+
1418
+
1419
+ def _build_claude_instructions(repo_root: Path) -> dict[str, object]:
1420
+ content = "\n".join(
1421
+ [
1422
+ "# Codex Context",
1423
+ "",
1424
+ "This file defines the working context for Codex in this repository.",
1425
+ "",
1426
+ "## Workflow",
1427
+ "",
1428
+ "Use the canonical `logics-manager` CLI to create, promote, and finish Logics docs:",
1429
+ "",
1430
+ "- `python3 -m logics_manager flow new request --title \"...\"`",
1431
+ "- `python3 -m logics_manager flow promote request-to-backlog logics/request/req_NNN_*.md`",
1432
+ "- `python3 -m logics_manager flow finish task logics/tasks/task_NNN_*.md`",
1433
+ "- `python3 -m logics_manager lint --require-status`",
1434
+ "- `python3 -m logics_manager audit --legacy-cutoff-version 1.1.0 --group-by-doc`",
1435
+ "",
1436
+ "Repository-local Claude bridge files and assistant instructions are generated from the integrated runtime.",
1437
+ "Do not edit `.claude/` bridge files by hand unless you are deliberately repairing a generated artifact.",
1438
+ "",
1439
+ "Do not edit indicator lines or workflow links by hand.",
1440
+ "",
1441
+ ]
1442
+ ).rstrip() + "\n"
1443
+ return {
1444
+ "command": "assist",
1445
+ "kind": "claude-instructions",
1446
+ "repo_root": repo_root.as_posix(),
1447
+ "path": "logics/instructions.md",
1448
+ "content": content,
1449
+ "line_count": len(content.splitlines()),
1450
+ }
1451
+
1452
+
1453
+ def _select_backend(requested_backend: str | None, bridge_status: dict[str, object]) -> tuple[str, list[str]]:
1454
+ if requested_backend and requested_backend != "auto":
1455
+ return requested_backend, []
1456
+ if bridge_status.get("available"):
1457
+ return "codex", ["claude bridge files detected"]
1458
+ return "deterministic", ["no bridge files detected"]
1459
+
1460
+
1461
+ def cmd_claude_bridges(args: argparse.Namespace) -> dict[str, object]:
1462
+ try:
1463
+ repo_root = find_repo_root(Path.cwd())
1464
+ except ConfigError:
1465
+ repo_root = Path.cwd().resolve()
1466
+ try:
1467
+ _, config_path = load_repo_config(repo_root)
1468
+ except ConfigError:
1469
+ config_path = None
1470
+ payload = {
1471
+ "command": "assist",
1472
+ "kind": "claude-bridge-manifest",
1473
+ "repo_root": repo_root.as_posix(),
1474
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
1475
+ **_build_claude_bridge_manifest(repo_root),
1476
+ }
1477
+ if args.format == "json":
1478
+ print(json.dumps(payload, indent=2, sort_keys=True))
1479
+ else:
1480
+ print("Claude bridge manifest: OK")
1481
+ for bridge in payload["bridges"]:
1482
+ print(f"- {bridge['command_path']}")
1483
+ print(f"- {bridge['agent_path']}")
1484
+ return payload
1485
+
1486
+
1487
+ def cmd_claude_instructions(args: argparse.Namespace) -> dict[str, object]:
1488
+ try:
1489
+ repo_root = find_repo_root(Path.cwd())
1490
+ except ConfigError:
1491
+ repo_root = Path.cwd().resolve()
1492
+ payload = {
1493
+ **_build_claude_instructions(repo_root),
1494
+ }
1495
+ if args.format == "json":
1496
+ print(json.dumps(payload, indent=2, sort_keys=True))
1497
+ else:
1498
+ print("Claude instructions: OK")
1499
+ print(payload["path"])
1500
+ return payload
1501
+
1502
+
1503
+ def _workflow_docs(repo_root: Path) -> list[Path]:
1504
+ docs: list[Path] = []
1505
+ for directory in ("request", "backlog", "tasks"):
1506
+ docs.extend(sorted((repo_root / "logics" / directory).glob("*.md")))
1507
+ return docs
1508
+
1509
+
1510
+ def _resolve_workflow_doc(repo_root: Path, ref: str) -> Path | None:
1511
+ for path in _workflow_docs(repo_root):
1512
+ if path.stem == ref or path.name == f"{ref}.md":
1513
+ return path
1514
+ return None
1515
+
1516
+
1517
+ def _doc_status(path: Path) -> str:
1518
+ for line in path.read_text(encoding="utf-8").splitlines():
1519
+ stripped = line.strip()
1520
+ if stripped.startswith("> Status:"):
1521
+ return stripped.split(":", 1)[1].strip()
1522
+ return "Unknown"
1523
+
1524
+
1525
+ def _extract_doc_links(path: Path) -> list[str]:
1526
+ links: list[str] = []
1527
+ for line in path.read_text(encoding="utf-8").splitlines():
1528
+ stripped = line.strip()
1529
+ if stripped.startswith("- "):
1530
+ candidate = stripped[2:].strip().strip("`")
1531
+ if candidate:
1532
+ links.append(candidate)
1533
+ return links
1534
+
1535
+
1536
+ def _build_next_step(repo_root: Path, ref: str | None) -> dict[str, object]:
1537
+ if ref:
1538
+ doc_path = _resolve_workflow_doc(repo_root, ref)
1539
+ if doc_path is not None:
1540
+ kind = doc_path.parent.name
1541
+ status = _doc_status(doc_path)
1542
+ if kind == "request":
1543
+ if status.lower() in {"draft", "ready"}:
1544
+ action = "promote request to backlog"
1545
+ rationale = "The request is ready to be split into bounded backlog slices."
1546
+ checklist = [
1547
+ f"Run `python3 -m logics_manager flow promote request-to-backlog {doc_path.relative_to(repo_root).as_posix()}`.",
1548
+ "Validate the generated backlog slice for scope and acceptance criteria.",
1549
+ ]
1550
+ else:
1551
+ action = "review request status"
1552
+ rationale = "The request is not in a promotion-friendly state yet."
1553
+ checklist = [
1554
+ "Inspect the request status and linked backlog coverage.",
1555
+ "Resolve any missing indicators before promotion.",
1556
+ ]
1557
+ elif kind == "backlog":
1558
+ if status.lower() in {"draft", "ready"}:
1559
+ action = "promote backlog to task"
1560
+ rationale = "The backlog item is ready to become an executable task."
1561
+ checklist = [
1562
+ f"Run `python3 -m logics_manager flow promote backlog-to-task {doc_path.relative_to(repo_root).as_posix()}`.",
1563
+ "Confirm the task scope remains bounded and executable.",
1564
+ ]
1565
+ else:
1566
+ action = "review backlog status"
1567
+ rationale = "The backlog item is not ready for task promotion yet."
1568
+ checklist = [
1569
+ "Inspect the backlog status and task linkage.",
1570
+ "Resolve any missing indicators before promotion.",
1571
+ ]
1572
+ else:
1573
+ action = "finish task"
1574
+ rationale = "Tasks are usually the last step in the Logics chain."
1575
+ checklist = [
1576
+ f"Run `python3 -m logics_manager flow finish task {doc_path.relative_to(repo_root).as_posix()}`.",
1577
+ "Verify the linked backlog and request moved to Done if appropriate.",
1578
+ ]
1579
+ return {
1580
+ "ref": ref,
1581
+ "doc_path": doc_path.relative_to(repo_root).as_posix(),
1582
+ "kind": kind,
1583
+ "status": status,
1584
+ "action": action,
1585
+ "rationale": rationale,
1586
+ "checklist": checklist,
1587
+ "confidence": 0.92,
1588
+ }
1589
+ return {
1590
+ "ref": ref,
1591
+ "doc_path": None,
1592
+ "kind": None,
1593
+ "status": None,
1594
+ "action": "run validation-summary",
1595
+ "rationale": "No target doc was resolved, so the safest next step is to inspect repository validation health.",
1596
+ "checklist": [
1597
+ "Run `python3 -m logics_manager assist validation-summary`.",
1598
+ "Then decide whether the next step is a request promotion, backlog promotion, or task finish.",
1599
+ ],
1600
+ "confidence": 0.74,
1601
+ }
1602
+
1603
+
1604
+ def _build_closure_summary(repo_root: Path, ref: str | None) -> dict[str, object]:
1605
+ if not ref:
1606
+ return {
1607
+ "ref": None,
1608
+ "doc_path": None,
1609
+ "kind": None,
1610
+ "status": None,
1611
+ "summary": "No target doc was provided.",
1612
+ "delivered": [],
1613
+ "validations": [],
1614
+ "remaining_risks": ["Resolve the target doc reference first."],
1615
+ "confidence": 0.6,
1616
+ }
1617
+ doc_path = _resolve_workflow_doc(repo_root, ref)
1618
+ if doc_path is None:
1619
+ return {
1620
+ "ref": ref,
1621
+ "doc_path": None,
1622
+ "kind": None,
1623
+ "status": None,
1624
+ "summary": "Target doc could not be resolved.",
1625
+ "delivered": [],
1626
+ "validations": [],
1627
+ "remaining_risks": [f"Unknown workflow ref `{ref}`."],
1628
+ "confidence": 0.55,
1629
+ }
1630
+ kind = doc_path.parent.name
1631
+ status = _doc_status(doc_path)
1632
+ title = next((line.split(" - ", 1)[1].strip() for line in doc_path.read_text(encoding="utf-8").splitlines() if line.startswith("## ")), doc_path.stem)
1633
+ links = _extract_doc_links(doc_path)
1634
+ delivered = [f"{kind} doc `{doc_path.stem}`", f"title: {title}", f"status: {status}"]
1635
+ validations = [
1636
+ "Check that the linked request/backlog/task chain is complete.",
1637
+ "Run the relevant lint/doctor validation before treating the closure as final.",
1638
+ ]
1639
+ remaining_risks: list[str] = []
1640
+ if status.lower() != "done":
1641
+ remaining_risks.append("The doc is not marked Done yet.")
1642
+ if not links:
1643
+ remaining_risks.append("No linked workflow references were found in the document.")
1644
+ return {
1645
+ "ref": ref,
1646
+ "doc_path": doc_path.relative_to(repo_root).as_posix(),
1647
+ "kind": kind,
1648
+ "status": status,
1649
+ "summary": f"{kind.title()} closure summary for {title}.",
1650
+ "delivered": delivered,
1651
+ "validations": validations,
1652
+ "remaining_risks": remaining_risks or ["No obvious remaining risks detected from the local doc shape."],
1653
+ "linked_refs": links,
1654
+ "confidence": 0.9 if status.lower() == "done" else 0.76,
1655
+ }
1656
+
1657
+
1658
+ def _build_context_pack(repo_root: Path, seed_ref: str, *, mode: str, profile: str) -> dict[str, object]:
1659
+ docs = _workflow_docs(repo_root)
1660
+ selected: list[Path] = []
1661
+ for path in docs:
1662
+ text = path.read_text(encoding="utf-8")
1663
+ if seed_ref in path.stem or seed_ref in text:
1664
+ selected.append(path)
1665
+ if not selected:
1666
+ selected = docs[:4]
1667
+ selected = selected[: {"tiny": 2, "normal": 4, "deep": 8}.get(profile, 4)]
1668
+ return {
1669
+ "ref": seed_ref,
1670
+ "mode": mode,
1671
+ "profile": profile,
1672
+ "budgets": {"max_docs": {"tiny": 2, "normal": 4, "deep": 8}.get(profile, 4)},
1673
+ "changed_paths": [],
1674
+ "docs": [
1675
+ {
1676
+ "ref": path.stem,
1677
+ "path": path.relative_to(repo_root).as_posix(),
1678
+ "kind": path.parent.name,
1679
+ "title": path.read_text(encoding="utf-8").splitlines()[0].replace("#", "").strip() if path.read_text(encoding="utf-8").splitlines() else path.stem,
1680
+ }
1681
+ for path in selected
1682
+ ],
1683
+ "estimates": {
1684
+ "doc_count": len(selected),
1685
+ "char_count": sum(len(path.read_text(encoding="utf-8")) for path in selected),
1686
+ },
1687
+ }
1688
+
1689
+
1690
+ def cmd_diff_risk(args: argparse.Namespace) -> dict[str, object]:
1691
+ repo_root = find_repo_root(Path.cwd())
1692
+ config, config_path = load_repo_config(repo_root)
1693
+ changed_paths = _git_changed_paths(repo_root)
1694
+ classification = _classify_diff_risk(changed_paths)
1695
+ payload = {
1696
+ "command": "assist",
1697
+ "kind": "diff-risk",
1698
+ "repo_root": repo_root.as_posix(),
1699
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
1700
+ "changed_paths": changed_paths,
1701
+ **classification,
1702
+ }
1703
+ if args.format == "json":
1704
+ print(json.dumps(payload, indent=2, sort_keys=True))
1705
+ else:
1706
+ print(_render_diff_risk_text(payload))
1707
+ return payload
1708
+
1709
+
1710
+ def cmd_commit_plan(args: argparse.Namespace) -> dict[str, object]:
1711
+ repo_root = find_repo_root(Path.cwd())
1712
+ config, config_path = load_repo_config(repo_root)
1713
+ changed_paths = _git_changed_paths(repo_root)
1714
+ plan = _build_commit_plan(changed_paths)
1715
+ payload = {
1716
+ "command": "assist",
1717
+ "kind": "commit-plan",
1718
+ "repo_root": repo_root.as_posix(),
1719
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
1720
+ **plan,
1721
+ }
1722
+ if args.format == "json":
1723
+ print(json.dumps(payload, indent=2, sort_keys=True))
1724
+ else:
1725
+ print(f"Commit plan: {payload['subject']}")
1726
+ print(f"- scope: {payload['scope']}")
1727
+ print(f"- confidence: {payload['confidence']}")
1728
+ print(f"- review recommended: {'yes' if payload['review_recommended'] else 'no'}")
1729
+ return payload
1730
+
1731
+
1732
+ def cmd_changed_surface_summary(args: argparse.Namespace) -> dict[str, object]:
1733
+ repo_root = find_repo_root(Path.cwd())
1734
+ config, config_path = load_repo_config(repo_root)
1735
+ changed_paths = _git_changed_paths(repo_root)
1736
+ payload = {
1737
+ "command": "assist",
1738
+ "kind": "changed-surface-summary",
1739
+ "repo_root": repo_root.as_posix(),
1740
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
1741
+ **_build_changed_surface_summary(changed_paths),
1742
+ }
1743
+ if args.format == "json":
1744
+ print(json.dumps(payload, indent=2, sort_keys=True))
1745
+ else:
1746
+ print(f"Changed surface: {payload['primary_category']}")
1747
+ print(f"- summary: {payload['summary']}")
1748
+ print(f"- changed paths: {len(changed_paths)}")
1749
+ if payload["counts"]:
1750
+ for label, count in payload["counts"].items():
1751
+ print(f"- {label}: {count}")
1752
+ return payload
1753
+
1754
+
1755
+ def cmd_doc_consistency(args: argparse.Namespace) -> dict[str, object]:
1756
+ repo_root = find_repo_root(Path.cwd())
1757
+ config, config_path = load_repo_config(repo_root)
1758
+ payload = {
1759
+ "command": "assist",
1760
+ "kind": "doc-consistency",
1761
+ "repo_root": repo_root.as_posix(),
1762
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
1763
+ **_build_doc_consistency(repo_root),
1764
+ }
1765
+ if args.format == "json":
1766
+ print(json.dumps(payload, indent=2, sort_keys=True))
1767
+ else:
1768
+ print(f"Doc consistency: {payload['overall'].upper()}")
1769
+ print(f"- summary: {payload['summary']}")
1770
+ print(f"- confidence: {payload['confidence']}")
1771
+ print(f"- doctor issues: {payload['doctor']['issue_count']}")
1772
+ print(f"- lint issues: {payload['lint']['issue_count']}")
1773
+ for follow_up in payload["follow_up"]:
1774
+ print(f"- {follow_up}")
1775
+ return payload
1776
+
1777
+
1778
+ def cmd_review_checklist(args: argparse.Namespace) -> dict[str, object]:
1779
+ repo_root = find_repo_root(Path.cwd())
1780
+ config, config_path = load_repo_config(repo_root)
1781
+ payload = {
1782
+ "command": "assist",
1783
+ "kind": "review-checklist",
1784
+ "repo_root": repo_root.as_posix(),
1785
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
1786
+ **_build_review_checklist(repo_root),
1787
+ }
1788
+ if args.format == "json":
1789
+ print(json.dumps(payload, indent=2, sort_keys=True))
1790
+ else:
1791
+ print("Review checklist:")
1792
+ print(f"- confidence: {payload['confidence']}")
1793
+ print(f"- summary: {payload['summary']}")
1794
+ print(f"- doc consistency: {payload['doc_consistency']['overall']}")
1795
+ for item in payload["checklist"]:
1796
+ print(f"- {item}")
1797
+ return payload
1798
+
1799
+
1800
+ def cmd_validation_checklist(args: argparse.Namespace) -> dict[str, object]:
1801
+ repo_root = find_repo_root(Path.cwd())
1802
+ config, config_path = load_repo_config(repo_root)
1803
+ changed_paths = _git_changed_paths(repo_root)
1804
+ payload = {
1805
+ "command": "assist",
1806
+ "kind": "validation-checklist",
1807
+ "repo_root": repo_root.as_posix(),
1808
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
1809
+ **_build_validation_checklist(changed_paths),
1810
+ }
1811
+ if args.format == "json":
1812
+ print(json.dumps(payload, indent=2, sort_keys=True))
1813
+ else:
1814
+ print("Validation checklist:")
1815
+ print(f"- profile: {payload['profile']}")
1816
+ print(f"- confidence: {payload['confidence']}")
1817
+ for check in payload["checks"]:
1818
+ print(f"- {check}")
1819
+ return payload
1820
+
1821
+
1822
+ def cmd_validation_summary(args: argparse.Namespace) -> dict[str, object]:
1823
+ repo_root = find_repo_root(Path.cwd())
1824
+ config, config_path = load_repo_config(repo_root)
1825
+ payload = {
1826
+ "command": "assist",
1827
+ "kind": "validation-summary",
1828
+ "repo_root": repo_root.as_posix(),
1829
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
1830
+ **_build_validation_summary(repo_root),
1831
+ }
1832
+ if args.format == "json":
1833
+ print(json.dumps(payload, indent=2, sort_keys=True))
1834
+ else:
1835
+ print("Validation summary:")
1836
+ print(f"- overall: {payload['overall']}")
1837
+ print(f"- confidence: {payload['confidence']}")
1838
+ print(f"- summary: {payload['summary']}")
1839
+ print(f"- doc consistency: {payload['doc_consistency']['overall']}")
1840
+ print(f"- test commands: {len(payload['test_impact']['recommended_commands'])}")
1841
+ for action in payload["next_actions"]:
1842
+ print(f"- {action}")
1843
+ return payload
1844
+
1845
+
1846
+ def cmd_test_impact_summary(args: argparse.Namespace) -> dict[str, object]:
1847
+ repo_root = find_repo_root(Path.cwd())
1848
+ config, config_path = load_repo_config(repo_root)
1849
+ changed_paths = _git_changed_paths(repo_root)
1850
+ payload = {
1851
+ "command": "assist",
1852
+ "kind": "test-impact-summary",
1853
+ "repo_root": repo_root.as_posix(),
1854
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
1855
+ **_build_test_impact_summary(changed_paths),
1856
+ }
1857
+ if args.format == "json":
1858
+ print(json.dumps(payload, indent=2, sort_keys=True))
1859
+ else:
1860
+ print("Test impact summary:")
1861
+ print(f"- confidence: {payload['confidence']}")
1862
+ print(f"- summary: {payload['summary']}")
1863
+ for command in payload["recommended_commands"]:
1864
+ print(f"- {command}")
1865
+ return payload
1866
+
1867
+
1868
+ def cmd_roi_report(args: argparse.Namespace) -> dict[str, object]:
1869
+ repo_root = find_repo_root(Path.cwd())
1870
+ config, config_path = load_repo_config(repo_root)
1871
+ audit_log = _repo_path(repo_root, args.audit_log, _hybrid_audit_log(config))
1872
+ measurement_log = _repo_path(repo_root, args.measurement_log, _hybrid_measurement_log(config))
1873
+ payload = _build_hybrid_roi_report(
1874
+ repo_root,
1875
+ audit_log=audit_log,
1876
+ measurement_log=measurement_log,
1877
+ recent_limit=args.recent_limit,
1878
+ window_days=args.window_days,
1879
+ )
1880
+ payload["command"] = "assist"
1881
+ payload["kind"] = "roi-report"
1882
+ payload["repo_root"] = repo_root.as_posix()
1883
+ payload["config_path"] = str(config_path.relative_to(repo_root)) if config_path is not None else None
1884
+
1885
+ if args.out:
1886
+ out_path = (repo_root / args.out).resolve()
1887
+ serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
1888
+ if not args.dry_run:
1889
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1890
+ out_path.write_text(serialized, encoding="utf-8")
1891
+ print(f"Wrote {out_path.relative_to(repo_root)}")
1892
+ payload["output_path"] = out_path.relative_to(repo_root).as_posix()
1893
+ elif args.format == "json":
1894
+ print(json.dumps(payload, indent=2, sort_keys=True))
1895
+ else:
1896
+ print("Assist ROI report: OK")
1897
+ print(f"- runs: {payload['measured']['totals']['runs']}")
1898
+ print(f"- local offload rate: {payload['derived']['rates']['local_offload_rate']}")
1899
+ print(f"- estimated remote token avoidance: {payload['estimated']['proxies']['estimated_remote_token_avoidance']}")
1900
+ return payload
1901
+
1902
+
1903
+ def cmd_runtime_status(args: argparse.Namespace) -> dict[str, object]:
1904
+ repo_root = find_repo_root(Path.cwd())
1905
+ config, config_path = load_repo_config(repo_root)
1906
+ hybrid = config.get("hybrid_assist", {})
1907
+ model_profiles = hybrid.get("model_profiles", {}) if isinstance(hybrid, dict) else {}
1908
+ default_profile = args.model_profile or str(hybrid.get("default_model_profile", "unknown"))
1909
+ profile_entry = model_profiles.get(default_profile, {}) if isinstance(model_profiles, dict) else {}
1910
+ bridge_status = _claude_bridge_status(repo_root)
1911
+
1912
+ requested_backend = args.backend or str(hybrid.get("default_backend", "auto"))
1913
+ selected_backend, reasons = _select_backend(requested_backend, bridge_status)
1914
+ resolved_model = args.model or str(profile_entry.get("model") or hybrid.get("default_model", "unknown"))
1915
+ resolved_host = args.ollama_host or str(hybrid.get("ollama_host", "http://127.0.0.1:11434"))
1916
+ timeout_seconds = args.timeout or float(hybrid.get("timeout_seconds", 20.0))
1917
+
1918
+ payload = {
1919
+ "command": "assist",
1920
+ "kind": "runtime-status",
1921
+ "repo_root": repo_root.as_posix(),
1922
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
1923
+ "requested_backend": requested_backend,
1924
+ "selected_backend": selected_backend,
1925
+ "selection_reasons": reasons,
1926
+ "requested_model_profile": args.model_profile,
1927
+ "resolved_model_profile": default_profile,
1928
+ "requested_model": args.model,
1929
+ "resolved_model": resolved_model,
1930
+ "ollama_host": resolved_host,
1931
+ "timeout_seconds": timeout_seconds,
1932
+ "bridge_status": bridge_status,
1933
+ "runtime_commands": {
1934
+ "codex": which("codex"),
1935
+ "python": which("python3"),
1936
+ },
1937
+ "healthy": bool(bridge_status["available"]) or selected_backend == "deterministic",
1938
+ "model_profiles": sorted(model_profiles.keys()) if isinstance(model_profiles, dict) else [],
1939
+ }
1940
+
1941
+ if args.out:
1942
+ out_path = (repo_root / args.out).resolve()
1943
+ serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
1944
+ if not args.dry_run:
1945
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1946
+ out_path.write_text(serialized, encoding="utf-8")
1947
+ print(f"Wrote {out_path.relative_to(repo_root)}")
1948
+ payload["output_path"] = out_path.relative_to(repo_root).as_posix()
1949
+ elif args.format == "json":
1950
+ print(json.dumps(payload, indent=2, sort_keys=True))
1951
+ else:
1952
+ print("Assist runtime status: " + ("OK" if payload["healthy"] else "DEGRADED"))
1953
+ print(f"- selected backend: {selected_backend}")
1954
+ print(f"- model profile: {default_profile}")
1955
+ print(f"- model: {resolved_model}")
1956
+ print(f"- bridge available: {'yes' if bridge_status['available'] else 'no'}")
1957
+ if bridge_status["preferred_variant"]:
1958
+ print(f"- bridge variant: {bridge_status['preferred_variant']}")
1959
+ return payload
1960
+
1961
+
1962
+ def cmd_next_step(args: argparse.Namespace) -> dict[str, object]:
1963
+ repo_root = find_repo_root(Path.cwd())
1964
+ config, config_path = load_repo_config(repo_root)
1965
+ payload = {
1966
+ "command": "assist",
1967
+ "kind": "next-step",
1968
+ "repo_root": repo_root.as_posix(),
1969
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
1970
+ **_build_next_step(repo_root, args.ref),
1971
+ }
1972
+ if args.format == "json":
1973
+ print(json.dumps(payload, indent=2, sort_keys=True))
1974
+ else:
1975
+ print(f"Next step: {payload['action']}")
1976
+ print(f"- ref: {payload['ref'] or '<none>'}")
1977
+ print(f"- doc path: {payload['doc_path'] or '<none>'}")
1978
+ print(f"- status: {payload['status'] or '<none>'}")
1979
+ print(f"- rationale: {payload['rationale']}")
1980
+ for item in payload["checklist"]:
1981
+ print(f"- {item}")
1982
+ return payload
1983
+
1984
+
1985
+ def cmd_request_draft(args: argparse.Namespace) -> dict[str, object]:
1986
+ repo_root = find_repo_root(Path.cwd())
1987
+ config, config_path = load_repo_config(repo_root)
1988
+ payload = {
1989
+ "command": "assist",
1990
+ "kind": "request-draft",
1991
+ "repo_root": repo_root.as_posix(),
1992
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
1993
+ "execution_mode": args.execution_mode,
1994
+ "intent": args.intent,
1995
+ **_build_request_draft(repo_root, intent=args.intent),
1996
+ }
1997
+ if args.execution_mode == "execute":
1998
+ out_path = repo_root / payload["path"]
1999
+ if not args.dry_run:
2000
+ out_path.parent.mkdir(parents=True, exist_ok=True)
2001
+ out_path.write_text(payload["content"], encoding="utf-8")
2002
+ payload["written"] = True
2003
+ else:
2004
+ payload["written"] = False
2005
+ payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2006
+ else:
2007
+ payload["written"] = False
2008
+ if args.format == "json":
2009
+ print(json.dumps(payload, indent=2, sort_keys=True))
2010
+ else:
2011
+ print(f"Request draft: {payload['title']}")
2012
+ print(f"- ref: {payload['ref']}")
2013
+ print(f"- path: {payload['path']}")
2014
+ print(f"- execution mode: {args.execution_mode}")
2015
+ print(f"- from version: {payload['from_version']}")
2016
+ print("- needs:")
2017
+ for item in payload["needs"]:
2018
+ print(f" - {item}")
2019
+ print("- acceptance:")
2020
+ for item in payload["acceptance"]:
2021
+ print(f" - {item}")
2022
+ if args.execution_mode == "suggestion-only":
2023
+ print("- suggestion only: no file written")
2024
+ elif args.dry_run:
2025
+ print("- dry run: file not written")
2026
+ else:
2027
+ print(f"- written: {'yes' if payload['written'] else 'no'}")
2028
+ return payload
2029
+
2030
+
2031
+ def cmd_spec_first_pass(args: argparse.Namespace) -> dict[str, object]:
2032
+ repo_root = find_repo_root(Path.cwd())
2033
+ config, config_path = load_repo_config(repo_root)
2034
+ payload = {
2035
+ "command": "assist",
2036
+ "kind": "spec-first-pass",
2037
+ "repo_root": repo_root.as_posix(),
2038
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
2039
+ "execution_mode": args.execution_mode,
2040
+ "source_ref": args.ref,
2041
+ **_build_spec_first_pass(repo_root, args.ref),
2042
+ }
2043
+ if args.execution_mode == "execute":
2044
+ out_path = repo_root / payload["path"]
2045
+ if not args.dry_run:
2046
+ out_path.parent.mkdir(parents=True, exist_ok=True)
2047
+ out_path.write_text(payload["content"], encoding="utf-8")
2048
+ payload["written"] = True
2049
+ else:
2050
+ payload["written"] = False
2051
+ payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2052
+ else:
2053
+ payload["written"] = False
2054
+ if args.format == "json":
2055
+ print(json.dumps(payload, indent=2, sort_keys=True))
2056
+ else:
2057
+ print(f"Spec first pass: {payload['title']}")
2058
+ print(f"- source ref: {payload['source_ref']}")
2059
+ print(f"- path: {payload['path']}")
2060
+ print(f"- execution mode: {args.execution_mode}")
2061
+ print(f"- overview: {payload['overview']}")
2062
+ print("- goals:")
2063
+ for item in payload["goals"]:
2064
+ print(f" - {item}")
2065
+ print("- acceptance:")
2066
+ for item in payload["acceptance"]:
2067
+ print(f" - {item}")
2068
+ if args.execution_mode == "suggestion-only":
2069
+ print("- suggestion only: no file written")
2070
+ elif args.dry_run:
2071
+ print("- dry run: file not written")
2072
+ else:
2073
+ print(f"- written: {'yes' if payload['written'] else 'no'}")
2074
+ return payload
2075
+
2076
+
2077
+ def cmd_backlog_groom(args: argparse.Namespace) -> dict[str, object]:
2078
+ repo_root = find_repo_root(Path.cwd())
2079
+ config, config_path = load_repo_config(repo_root)
2080
+ payload = {
2081
+ "command": "assist",
2082
+ "kind": "backlog-groom",
2083
+ "repo_root": repo_root.as_posix(),
2084
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
2085
+ "execution_mode": args.execution_mode,
2086
+ "source_ref": args.ref,
2087
+ **_build_backlog_groom(repo_root, args.ref),
2088
+ }
2089
+ if args.execution_mode == "execute":
2090
+ out_path = repo_root / payload["path"]
2091
+ if not args.dry_run:
2092
+ out_path.parent.mkdir(parents=True, exist_ok=True)
2093
+ out_path.write_text(payload["content"], encoding="utf-8")
2094
+ payload["written"] = True
2095
+ request_path = repo_root / payload["request_path"]
2096
+ _append_section_bullets(request_path, "Backlog", [f"`{payload['ref']}`"], dry_run=False)
2097
+ else:
2098
+ payload["written"] = False
2099
+ payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2100
+ else:
2101
+ payload["written"] = False
2102
+ if args.format == "json":
2103
+ print(json.dumps(payload, indent=2, sort_keys=True))
2104
+ else:
2105
+ print(f"Backlog groom: {payload['title']}")
2106
+ print(f"- source ref: {payload['source_ref']}")
2107
+ print(f"- path: {payload['path']}")
2108
+ print(f"- execution mode: {args.execution_mode}")
2109
+ print(f"- complexity: {payload['complexity']}")
2110
+ print("- acceptance:")
2111
+ for item in payload["acceptance"]:
2112
+ print(f" - {item}")
2113
+ if args.execution_mode == "suggestion-only":
2114
+ print("- suggestion only: no file written")
2115
+ elif args.dry_run:
2116
+ print("- dry run: file not written")
2117
+ else:
2118
+ print(f"- written: {'yes' if payload['written'] else 'no'}")
2119
+ return payload
2120
+
2121
+
2122
+ def cmd_closure_summary(args: argparse.Namespace) -> dict[str, object]:
2123
+ repo_root = find_repo_root(Path.cwd())
2124
+ config, config_path = load_repo_config(repo_root)
2125
+ payload = {
2126
+ "command": "assist",
2127
+ "kind": "closure-summary",
2128
+ "repo_root": repo_root.as_posix(),
2129
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
2130
+ **_build_closure_summary(repo_root, args.ref),
2131
+ }
2132
+ if args.format == "json":
2133
+ print(json.dumps(payload, indent=2, sort_keys=True))
2134
+ else:
2135
+ print(f"Closure summary: {payload['summary']}")
2136
+ print(f"- ref: {payload['ref'] or '<none>'}")
2137
+ print(f"- doc path: {payload['doc_path'] or '<none>'}")
2138
+ print(f"- status: {payload['status'] or '<none>'}")
2139
+ for item in payload["delivered"]:
2140
+ print(f"- delivered: {item}")
2141
+ for item in payload["validations"]:
2142
+ print(f"- validation: {item}")
2143
+ for item in payload["remaining_risks"]:
2144
+ print(f"- risk: {item}")
2145
+ return payload
2146
+
2147
+
2148
+ def cmd_context(args: argparse.Namespace) -> dict[str, object]:
2149
+ repo_root = find_repo_root(Path.cwd())
2150
+ config, config_path = load_repo_config(repo_root)
2151
+ spec = ASSIST_FLOW_DEFAULTS[args.flow_name]
2152
+ context_mode = args.context_mode or spec["mode"]
2153
+ profile = args.profile or spec["profile"]
2154
+ bridge_status = _claude_bridge_status(repo_root)
2155
+ payload = {
2156
+ "command": "assist",
2157
+ "kind": "context",
2158
+ "repo_root": repo_root.as_posix(),
2159
+ "config_path": str(config_path.relative_to(repo_root)) if config_path is not None else None,
2160
+ "flow_name": args.flow_name,
2161
+ "seed_ref": args.ref,
2162
+ "context_profile": {
2163
+ "mode": context_mode,
2164
+ "profile": profile,
2165
+ "include_graph": args.include_graph if args.include_graph is not None else spec["include_graph"],
2166
+ "include_registry": args.include_registry if args.include_registry is not None else spec["include_registry"],
2167
+ "include_doctor": args.include_doctor if args.include_doctor is not None else spec["include_doctor"],
2168
+ },
2169
+ "contract": spec,
2170
+ "assist_schema_version": "1.0",
2171
+ "bridge_status": bridge_status,
2172
+ "context_pack": _build_context_pack(
2173
+ repo_root,
2174
+ args.ref,
2175
+ mode=context_mode,
2176
+ profile=profile,
2177
+ ) if args.ref else {
2178
+ "ref": None,
2179
+ "mode": context_mode,
2180
+ "profile": profile,
2181
+ "budgets": {"max_docs": 0},
2182
+ "changed_paths": [],
2183
+ "docs": [],
2184
+ "estimates": {"doc_count": 0, "char_count": 0},
2185
+ },
2186
+ }
2187
+
2188
+ if args.out:
2189
+ out_path = (repo_root / args.out).resolve()
2190
+ serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
2191
+ if not args.dry_run:
2192
+ out_path.parent.mkdir(parents=True, exist_ok=True)
2193
+ out_path.write_text(serialized, encoding="utf-8")
2194
+ print(f"Wrote {out_path.relative_to(repo_root)}")
2195
+ payload["output_path"] = out_path.relative_to(repo_root).as_posix()
2196
+ elif args.format == "json":
2197
+ print(json.dumps(payload, indent=2, sort_keys=True))
2198
+ else:
2199
+ print(f"Assist context: {args.flow_name}")
2200
+ print(f"- ref: {args.ref or '<flow-default>'}")
2201
+ print(f"- mode: {context_mode}")
2202
+ print(f"- profile: {profile}")
2203
+ print(f"- bridge available: {'yes' if bridge_status['available'] else 'no'}")
2204
+ return payload
2205
+
2206
+
2207
+ def main(argv: list[str]) -> int:
2208
+ parser = build_parser()
2209
+ args = parser.parse_args(argv)
2210
+ payload = args.func(args)
2211
+ return 0 if isinstance(payload, dict) else 1