dev-autopilot 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. autopilot/__init__.py +4 -0
  2. autopilot/__main__.py +6 -0
  3. autopilot/backends/__init__.py +159 -0
  4. autopilot/backends/claude_cli.py +368 -0
  5. autopilot/backends/command.py +90 -0
  6. autopilot/cli.py +1614 -0
  7. autopilot/commands/__init__.py +0 -0
  8. autopilot/commands/preflight.py +131 -0
  9. autopilot/config.py +155 -0
  10. autopilot/context.py +414 -0
  11. autopilot/dash_term.py +493 -0
  12. autopilot/dash_web.py +347 -0
  13. autopilot/dashboard.py +410 -0
  14. autopilot/decisions.py +85 -0
  15. autopilot/deploy.py +67 -0
  16. autopilot/docs.py +301 -0
  17. autopilot/doctor.py +478 -0
  18. autopilot/gate.py +383 -0
  19. autopilot/github_util.py +42 -0
  20. autopilot/gitops.py +251 -0
  21. autopilot/growth.py +85 -0
  22. autopilot/handoff.py +27 -0
  23. autopilot/layers.py +134 -0
  24. autopilot/log_scrub.py +11 -0
  25. autopilot/mcp.py +104 -0
  26. autopilot/notify.py +98 -0
  27. autopilot/orchestrator.py +2214 -0
  28. autopilot/plain.py +238 -0
  29. autopilot/plan.py +320 -0
  30. autopilot/proc.py +176 -0
  31. autopilot/prompts/auditor.md +39 -0
  32. autopilot/prompts/decide.md +38 -0
  33. autopilot/prompts/docs.md +48 -0
  34. autopilot/prompts/fixer.md +26 -0
  35. autopilot/prompts/onboard.md +43 -0
  36. autopilot/prompts/plan_writer.md +40 -0
  37. autopilot/prompts/replanner.md +42 -0
  38. autopilot/prompts/research.md +25 -0
  39. autopilot/prompts/resume.md +10 -0
  40. autopilot/prompts/retry.md +8 -0
  41. autopilot/prompts/review.md +40 -0
  42. autopilot/prompts/system.md +16 -0
  43. autopilot/prompts/task.md +78 -0
  44. autopilot/prompts/triage.md +30 -0
  45. autopilot/prompts/unstick.md +37 -0
  46. autopilot/prompts/verify.md +32 -0
  47. autopilot/repomap.py +71 -0
  48. autopilot/report.py +633 -0
  49. autopilot/rights.py +184 -0
  50. autopilot/schemas.py +42 -0
  51. autopilot/state.py +298 -0
  52. autopilot/templates/__pycache__/cloudflare_apply.cpython-313.pyc +0 -0
  53. autopilot/templates/agent/BRAIN.md +21 -0
  54. autopilot/templates/agent/CLAUDE.md.snippet +10 -0
  55. autopilot/templates/agent/DECISIONS.md +5 -0
  56. autopilot/templates/agent/HANDOFF.md +3 -0
  57. autopilot/templates/agent/gitignore +10 -0
  58. autopilot/templates/agent/ide_rules.md +8 -0
  59. autopilot/templates/agent/plan.yaml +48 -0
  60. autopilot/templates/agent/project.yaml +259 -0
  61. autopilot/templates/cloudflare_apply.py +211 -0
  62. autopilot/templates/deploy.sh +72 -0
  63. autopilot/templates/email_verify.py +144 -0
  64. autopilot/templates/infra/cloudflare.yaml +11 -0
  65. autopilot/templates/presets/docker.yaml +4 -0
  66. autopilot/templates/presets/generic.yaml +3 -0
  67. autopilot/templates/presets/go.yaml +6 -0
  68. autopilot/templates/presets/java.yaml +4 -0
  69. autopilot/templates/presets/node.yaml +7 -0
  70. autopilot/templates/presets/python.yaml +6 -0
  71. autopilot/templates/presets/rust.yaml +5 -0
  72. autopilot/templates/presets/static.yaml +3 -0
  73. autopilot/upgrade.py +192 -0
  74. dev_autopilot-0.4.0.dist-info/METADATA +148 -0
  75. dev_autopilot-0.4.0.dist-info/RECORD +79 -0
  76. dev_autopilot-0.4.0.dist-info/WHEEL +5 -0
  77. dev_autopilot-0.4.0.dist-info/entry_points.txt +3 -0
  78. dev_autopilot-0.4.0.dist-info/licenses/LICENSE +21 -0
  79. dev_autopilot-0.4.0.dist-info/top_level.txt +1 -0
autopilot/context.py ADDED
@@ -0,0 +1,414 @@
1
+ """Context packs: the small, fresh bundle every session starts from (instead of a long-lived chat)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import secrets
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ from . import proc
13
+ from .gate import verify_commands
14
+ from .repomap import helper_index
15
+
16
+ PROMPTS = Path(__file__).parent / "prompts"
17
+ TEST_RULE = {False: "Add or extend automated tests that prove each acceptance criterion.",
18
+ True: ("Write one smoke test per acceptance criterion: the smallest test that fails when the feature breaks. "
19
+ "No coverage target and no edge-case suites unless the task text asks for them.")} # O10
20
+ VARIATION_STEP = {False: ("3. When a journey works as written, try to break that feature once: one variation that a real user "
21
+ "can meet (other\n input, an edge case, a real-environment condition). "
22
+ "One variation for each feature, no more."),
23
+ True: "3. Do not try variations: one journey for each feature, run once."} # O10
24
+ FIX_RULES = ("This is a corrective task. 1) Add a regression test that fails before your fix and passes after it. "
25
+ "2) Include `rca` (symptom, root_cause, fix, prevention) in your final report.")
26
+
27
+
28
+ TAMPER_COUNT_RX = re.compile(r"test count dropped in (\S+): (\d+) -> \d+")
29
+
30
+
31
+ def render(name: str, **vars) -> str:
32
+ text = (PROMPTS / name).read_text(encoding="utf-8")
33
+ for k, v in vars.items():
34
+ text = text.replace("{{" + k + "}}", str(v))
35
+ return text
36
+
37
+
38
+ def read_capped(path: Path, max_chars: int, tail: bool = False, default: str = "(none)") -> str:
39
+ if not path.exists():
40
+ return default
41
+ text = path.read_text(encoding="utf-8", errors="replace").strip()
42
+ if not text:
43
+ return default
44
+ if len(text) <= max_chars:
45
+ return text
46
+ return ("…(truncated)…\n" + text[-max_chars:]) if tail else (text[:max_chars] + "\n…(truncated)…")
47
+
48
+
49
+ def bullets(items, empty: str = "(none)") -> str:
50
+ items = [str(i) for i in (items or []) if str(i).strip()]
51
+ return "\n".join(f"- {i}" for i in items) if items else empty
52
+
53
+
54
+ def progress_line(plan, status: dict) -> str:
55
+ total = len(plan.all_tasks())
56
+ counts = {}
57
+ for t in plan.all_tasks():
58
+ s = status.get(t.id, "pending")
59
+ counts[s] = counts.get(s, 0) + 1
60
+ done = counts.get("done", 0)
61
+ pct = (100 * done // total) if total else 0
62
+ return (f"{done}/{total} tasks done ({pct}%), {counts.get('blocked', 0)} blocked, "
63
+ f"{counts.get('pending', 0)} pending across {len(plan.phases)} phases")
64
+
65
+
66
+ def plan_status(plan, state, include_criteria: bool = True, max_chars: int = 30000) -> str:
67
+ lines = []
68
+ status = state.status_map()
69
+ for p in plan.phases:
70
+ lines.append(f"\n### {p.id} {p.title}")
71
+ for t in p.tasks:
72
+ s = status.get(t.id, "pending")
73
+ lines.append(f"- [{s}] {t.id} {t.title} (risk {t.risk})")
74
+ if include_criteria and s == "done":
75
+ for ac in t.acceptance_criteria:
76
+ lines.append(f" - AC: {ac}")
77
+ if s == "blocked":
78
+ row = state.task(t.id)
79
+ reason = (row["last_error"] or "")[:300].replace("\n", " ") if row else ""
80
+ lines.append(f" - BLOCKED: {reason}")
81
+ text = "\n".join(lines)
82
+ return text if len(text) <= max_chars else text[:max_chars] + "\n…(truncated)…"
83
+
84
+
85
+ class ContextBuilder:
86
+ def __init__(self, cfg, plan, state):
87
+ self.cfg, self.plan, self.state = cfg, plan, state
88
+ self.ad = cfg.agent_dir
89
+
90
+ def brain(self) -> str:
91
+ return read_capped(self.ad / "BRAIN.md", 6000 if self.cfg.lean else 12000)
92
+
93
+ def decisions(self) -> str:
94
+ return read_capped(self.ad / "DECISIONS.md", 2000 if self.cfg.lean else 6000, tail=True)
95
+
96
+ def handoff(self) -> str:
97
+ return read_capped(self.ad / "HANDOFF.md", 3000)
98
+
99
+ def followups(self) -> str:
100
+ return read_capped(self.ad / "FOLLOWUPS.md", 5000, tail=True)
101
+
102
+ def verify_cmds(self, extra=None) -> str:
103
+ return bullets([f"`{c}`" for c in verify_commands(self.cfg, extra)], "(no commands configured)")
104
+
105
+ def owner_answers(self, task_id: str) -> str:
106
+ rows = [d for d in self.state.decisions("APPLIED") if task_id in json.loads(d["blocks"] or "[]")]
107
+ out = bullets([f"{d['id']}: {d['question']} → {d['answer']}" for d in reversed(rows)])
108
+ hints = self.hints(task_id)
109
+ return f"{out}\n\n{hints}" if hints else out
110
+
111
+ def hints(self, task_id: str) -> str:
112
+ """Owner advice from `autopilot hint`: data for every attempt of the task; the rules still apply."""
113
+ h = self.state.get_meta(f"hints:{task_id}", [])
114
+ return ("Hints from the owner (advice, the rules above still apply):\n"
115
+ + bullets([f"Hint from the owner: {x['text']}" for x in h])) if h else ""
116
+
117
+ def unstick_prompt(self, task, error: str) -> str:
118
+ phase = self.plan.phase_of(task)
119
+ return render("unstick.md", task_id=task.id, task_title=task.title, description=task.description or task.title,
120
+ acceptance=bullets(task.acceptance_criteria), phase_goal=phase.goal or phase.title,
121
+ decisions=self.decisions(), error=(error or "(none)")[-6000:])
122
+
123
+ def review_prompt(self, task, diff: str) -> str:
124
+ phase = self.plan.phase_of(task)
125
+ if len(diff) > 30000:
126
+ diff = diff[:30000] + "\n... (diff cut; read the rest with `git show HEAD`)"
127
+ fence = "`" * max(5, 1 + max((len(m) for m in re.findall(r"`+", diff)), default=0)) # the diff cannot close it
128
+ return render("review.md", task_id=task.id, task_title=task.title, description=task.description or task.title,
129
+ acceptance=bullets(task.acceptance_criteria), phase_goal=phase.goal or phase.title,
130
+ decisions=self.decisions(), fence=fence, diff=diff or "(empty)")
131
+
132
+ def decide_prompt(self, task) -> str:
133
+ phase = self.plan.phase_of(task)
134
+ return render("decide.md", task_id=task.id, task_title=task.title, phase_goal=phase.goal or phase.title,
135
+ risk=task.risk, description=task.description or task.title,
136
+ acceptance=bullets(task.acceptance_criteria), scope=bullets(task.files_in_scope, "(not restricted)"),
137
+ decisions=self.decisions(), research_block=self.research_block(task))
138
+
139
+ def verify_prompt(self, phase, features: list[dict]) -> str:
140
+ init = self.cfg.get("functional.init", "")
141
+ return render("verify.md", phase_id=phase.id, phase_title=phase.title,
142
+ features="\n".join(f"- `{f['id']}` {f['title']}: {f['journey']}" for f in features),
143
+ init=f"Run `{init}`." if init else "Work it out from the project brain, the README and the files.",
144
+ once="" if self.cfg.lean else ", then one variation", variation=VARIATION_STEP[self.cfg.lean])
145
+
146
+ def docs_prompt(self, phase, state) -> str:
147
+ """O11: one documentation session per phase: RCA entries for retried or repaired tasks, a README section per
148
+ feature, and DEVELOPING.md for a person."""
149
+ root, hist = self.cfg.root, self.cfg.get("docs.history_dir", ".agent/history")
150
+ rca, readme, dev = self.cfg.get("docs.rca", "docs/RCA.md"), "README.md", self.cfg.get("docs.developing", "docs/DEVELOPING.md")
151
+ retried, ids = [], []
152
+ for t in phase.tasks:
153
+ row = state.task(t.id)
154
+ if row and row["status"] == "done" and (int(row["attempts"] or 0) > 1 or phase.priority):
155
+ ids.append(t.id)
156
+ retried.append(f"### {t.id} {t.title} (attempts: {row['attempts']})\n"
157
+ + read_capped(root / hist / phase.id / f"{t.id}.md", 2500, default="(no history file)"))
158
+ learned = [l for l in read_capped(self.ad / "LEARNINGS.md", 8000, tail=True, default="").splitlines()
159
+ if any(f"[{i}]" in l for i in ids)]
160
+ feats = [f"`{f['id']}` {f['title']}: {f['journey']}" for f in phase.features]
161
+ feats += [f"`{t.id}` {t.title}: " + "; ".join(t.acceptance_criteria or [t.description or t.title])[:600]
162
+ for t in phase.tasks if (state.task(t.id) or {"status": ""})["status"] == "done" and not phase.features]
163
+ readme_text = read_capped(root / readme, 20000, default="")
164
+ rca_text = read_capped(root / rca, 40000, default="")
165
+ return render("docs.md", phase_id=phase.id, phase_title=phase.title, readme=readme, rca=rca, developing=dev,
166
+ retried="\n\n".join(retried) or "(none: every task passed first time)",
167
+ learned=bullets(learned), features=bullets(feats),
168
+ commands=yaml.safe_dump(self.cfg.get("commands") or {}, sort_keys=False).strip(),
169
+ brain=read_capped(self.ad / "BRAIN.md", 4000),
170
+ readme_headings=", ".join(l[3:].strip() for l in readme_text.splitlines() if l.startswith("## ")) or "(no README)",
171
+ rca_ids=", ".join(re.findall(r"^## \S+ · (\S+)", rca_text, re.M)) or "(none)",
172
+ developing_state="exists; update it" if (root / dev).exists() else "missing; create it")
173
+
174
+ def research_prompt(self, task, topic: str) -> str:
175
+ return render("research.md", topic=topic, task_id=task.id, task_title=task.title,
176
+ description=task.description or task.title,
177
+ acceptance=bullets(task.acceptance_criteria))
178
+
179
+ def triage_prompt(self, number, title: str, body: str) -> str:
180
+ tag = f"UNTRUSTED-{secrets.token_hex(6)}" # unguessable, so the issue text can't fake the end of its block
181
+ return render("triage.md", number=number, tag=tag, title=" ".join(str(title).split())[:200],
182
+ body=str(body or "(empty)")[:6000])
183
+
184
+ def research_block(self, task) -> str:
185
+ """The research notes for the task's topics: summaries written by a research session, never raw web text."""
186
+ from .docs import research_path # local: import cycle with docs
187
+ notes = [read_capped(research_path(self.cfg, t), 4000, default="") for t in task.research]
188
+ notes = [n for n in notes if n]
189
+ head = "## Research for this task (summaries; use them, don't re-search)\n"
190
+ return head + "\n\n".join(notes) if notes else ""
191
+
192
+ def adr_block(self, task) -> str:
193
+ rel = self.state.get_meta(f"adr:{task.id}")
194
+ if not rel:
195
+ return ""
196
+ return (f"## Approach decided for this task (follow it; ADR `{rel}`)\n"
197
+ + read_capped(self.cfg.root / rel, 8000, default="(the ADR file is missing)"))
198
+
199
+ def task_prompt(self, task, attempt: int, last_error: str | None, git_log: str = "") -> str:
200
+ phase = self.plan.phase_of(task)
201
+ retry = ""
202
+ if attempt > 1 and last_error:
203
+ retry = render("retry.md", attempt=attempt - 1, errors=last_error[-6000:])
204
+ # J14.4: a tamper failure becomes one instruction with the real file and count
205
+ keep = [f"Keep all {m[1]} tests in {m[0]}. Add, do not remove." for m in TAMPER_COUNT_RX.findall(last_error)]
206
+ retry += "".join(f"\n**{k}**\n" for k in keep)
207
+ return render(
208
+ "task.md", task_id=task.id, task_title=task.title, phase_id=phase.id, phase_title=phase.title,
209
+ phase_goal=phase.goal, risk=task.risk,
210
+ description=task.description or task.title, acceptance=bullets(task.acceptance_criteria),
211
+ scope=bullets(task.files_in_scope, "(not restricted)"), docs=bullets(task.docs, "(whatever this change affects)"),
212
+ decisions=self.decisions(), handoff=self.handoff(),
213
+ progress=progress_line(self.plan, self.state.status_map()), retry_block=retry,
214
+ verify_cmds=self.verify_cmds(task.verify), owner_answers=self.owner_answers(task.id),
215
+ git_log=git_log.strip() or "(none)", fix_rules=FIX_RULES if phase.priority else "",
216
+ adr_block=self.adr_block(task), research_block=self.research_block(task),
217
+ helper_index=helper_index(self.cfg.root) or "(none)", test_rule=TEST_RULE[self.cfg.lean],
218
+ )
219
+
220
+ def resume_prompt(self, task, attempt: int, last_error: str | None) -> str:
221
+ """Retry inside the failed attempt's own session: the brief is already in its context, so only the failure."""
222
+ return render("resume.md", task_id=task.id, task_title=task.title, verify_cmds=self.verify_cmds(task.verify),
223
+ retry_block=render("retry.md", attempt=attempt - 1, errors=(last_error or "(none)")[-6000:])
224
+ + (f"\n\n{h}" if (h := self.hints(task.id)) else ""))
225
+
226
+ def fixer_prompt(self, errors: str, git_log: str) -> str:
227
+ return render("fixer.md", decisions=self.decisions(), git_log=git_log,
228
+ errors=errors[-8000:], verify_cmds=self.verify_cmds())
229
+
230
+ def auditor_prompt(self, kind: str, max_new: int) -> str:
231
+ completion = ("Completion: is the product described in the goal actually complete and usable end to end? "
232
+ "Set complete=true ONLY if nothing important is missing." if kind == "completion"
233
+ else "Set complete=false unless the entire goal is already met.")
234
+ return render("auditor.md", audit_kind=kind, plan_status=plan_status(self.plan, self.state),
235
+ decisions=self.decisions(), followups=self.followups(), completion_check=completion, max_new=max_new)
236
+
237
+ def replanner_prompt(self, backlog: list[str] | None = None, source_diff: str = "") -> str:
238
+ blocked = [f"{r['id']}: {(r['last_error'] or '')[:500]}" for r in self.state.tasks("blocked")]
239
+ return render("replanner.md", plan_status=plan_status(self.plan, self.state, include_criteria=False),
240
+ blocked=bullets(blocked), decisions=self.decisions(), followups=self.followups(),
241
+ backlog=bullets([i[:500] for i in backlog or []][:30]),
242
+ source_diff=source_diff or "(none)")
243
+
244
+ def system_append(self) -> str:
245
+ """The rules plus the project context every session needs. U7: byte-identical for every session until
246
+ BRAIN.md, LEARNINGS.md or the goal changes, and it rides in the system prompt, which Claude Code caches; so
247
+ later sessions on the same model read it from the cache. No timestamps or task data here."""
248
+ text = ((PROMPTS / "system.md").read_text(encoding="utf-8").rstrip()
249
+ + f"\n\n# Project context (the same for every session)\n\n## Project goal\n"
250
+ f"{self.plan.goal or '(see the project brain)'}\n\n"
251
+ f"## Project brain (architecture, conventions, invariants)\n{self.brain()}\n")
252
+ learned = read_capped(self.ad / "LEARNINGS.md", 4000, tail=True, default="")
253
+ if learned:
254
+ text += f"\n## Learnings from earlier failures (don't repeat them)\n{learned}\n"
255
+ return text
256
+
257
+
258
+ # N1: discovery pack for onboarding: read before write
259
+ PACK_CAP = 60000
260
+ _SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build"}
261
+ _DOC_DIRS = {"docs", ".kiro", ".cursor", ".github"}
262
+ _DOC_STEMS = {"roadmap", "todo", "plan", "changelog", "notes"}
263
+ _PLAN_STEMS = {"plan", "roadmap", "todo", "notes"}
264
+
265
+
266
+ def _cap(text: str, limit: int) -> str:
267
+ return text if len(text) <= limit else text[:limit] + f"\n…(truncated: section over {limit} characters)…"
268
+
269
+
270
+ def _doc_files(root: Path) -> list[Path]:
271
+ out = []
272
+ for dp, dns, fns in os.walk(root):
273
+ rel = Path(dp).relative_to(root)
274
+ dns[:] = sorted(d for d in dns if d not in _SKIP_DIRS and (rel / d).as_posix() != ".agent/logs")
275
+ for f in sorted(fns):
276
+ r = rel / f
277
+ top = r.parts[0] if len(r.parts) > 1 else ""
278
+ if f.lower().endswith(".md") or top in _DOC_DIRS or Path(f).stem.lower() in _DOC_STEMS:
279
+ out.append(r)
280
+ return out
281
+
282
+
283
+ def _is_plan_like(r: Path) -> bool:
284
+ return r.stem.lower() in _PLAN_STEMS or (len(r.parts) > 1 and r.parts[0].lower() == "notes")
285
+
286
+
287
+ def _head(path: Path, lines: int) -> str:
288
+ try:
289
+ return "\n".join(path.read_text(encoding="utf-8", errors="replace").splitlines()[:lines])
290
+ except OSError:
291
+ return ""
292
+
293
+
294
+ def _git_section(root: Path) -> str:
295
+ try:
296
+ p = proc.run_proc(["git", "log", "--oneline", "-200"], cwd=str(root), timeout=60)
297
+ except OSError:
298
+ return ""
299
+ return p.stdout.strip() if p.rc == 0 else ""
300
+
301
+
302
+ def _stack_section(root: Path) -> str:
303
+ names: list[str] = []
304
+ for f in sorted((Path(__file__).parent / "templates" / "presets").glob("*.yaml")):
305
+ try:
306
+ names += [str(n) for n in (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("detect", [])]
307
+ except (OSError, yaml.YAMLError):
308
+ pass
309
+ parts = [f"### {n}\n```\n{_head(root / n, 30)}\n```" for n in dict.fromkeys(names) if (root / n).is_file()]
310
+ return "\n\n".join(parts) or "(no stack files found)"
311
+
312
+
313
+ def _test_section(root: Path, cfg) -> str:
314
+ cmds = cfg.commands("test")
315
+ if not cmds:
316
+ return "no test command"
317
+ secs = int(cfg.get("onboard.test_timeout_sec", 300) or 300)
318
+ out = []
319
+ for c in cmds:
320
+ try:
321
+ p = proc.run_proc(c, cwd=str(root), timeout=secs, shell=True)
322
+ except OSError as e:
323
+ out.append(f"$ {c}\nexit code: not started ({e})")
324
+ continue
325
+ tail = "\n".join((p.stdout + "\n" + p.stderr).strip().splitlines()[-40:])
326
+ status = f"timed out after {secs} s, stopped" if p.timed_out else f"exit code: {p.rc}"
327
+ out.append(f"$ {c}\n{status}\n```\n{tail}\n```")
328
+ return "\n\n".join(out)
329
+
330
+
331
+ # N1 conflicts: two documents give one task two states
332
+ _BOX = re.compile(r"^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$")
333
+ _MARK = re.compile(r"^\s*[-*]\s+(.+?)\s*[:(\-]+\s*(done|open)\)?\s*$", re.I)
334
+ CONFLICT_RULE = ("The git log and the code win; then the newest document by commit date. "
335
+ "Follow the resolved state in the plan.")
336
+
337
+
338
+ def _norm(title: str) -> str:
339
+ return " ".join(re.findall(r"[a-z0-9]+", title.lower()))
340
+
341
+
342
+ def _doc_states(root: Path, rel: Path) -> dict[str, str]:
343
+ """N1 conflicts: normalised title -> done/open for the checkbox and marker lines of one document."""
344
+ out: dict[str, str] = {}
345
+ try:
346
+ text = (root / rel).read_text(encoding="utf-8", errors="replace")
347
+ except OSError:
348
+ return out
349
+ for ln in text.splitlines():
350
+ if m := _BOX.match(ln):
351
+ title, state = m.group(2), "done" if m.group(1) in "xX" else "open"
352
+ elif m := _MARK.match(ln):
353
+ title, state = m.group(1), m.group(2).lower()
354
+ else:
355
+ continue
356
+ out.setdefault(_norm(title), state)
357
+ return out
358
+
359
+
360
+ def _commit_time(root: Path, rel: Path) -> int:
361
+ try:
362
+ p = proc.run_proc(["git", "log", "-1", "--format=%ct", "--", rel.as_posix()], cwd=str(root), timeout=60)
363
+ return int(p.stdout.strip() or 0)
364
+ except (OSError, ValueError):
365
+ return 0
366
+
367
+
368
+ def document_conflicts(root, git: str) -> list[str]:
369
+ """N1 conflicts: one line per task two plan documents disagree on; the git log wins, then the newer file."""
370
+ root, docs = Path(root), {}
371
+ for r in _doc_files(root):
372
+ if _is_plan_like(r) and (s := _doc_states(root, r)):
373
+ docs[r.as_posix()] = s
374
+ titles = {t for s in docs.values() for t in s}
375
+ log, out, times = [_norm(x) for x in git.splitlines()], [], {}
376
+ for t in sorted(titles):
377
+ have = {d: s[t] for d, s in docs.items() if t in s}
378
+ if len(set(have.values())) < 2:
379
+ continue
380
+ if t and any(t in x for x in log):
381
+ out += [f"{d} says {st}, git shows the commit: marked done" for d, st in have.items() if st == "open"]
382
+ continue
383
+ for d in have:
384
+ times.setdefault(d, _commit_time(root, Path(d)))
385
+ new = max(have, key=lambda d: times[d])
386
+ old = next((d for d in have if have[d] != have[new]), "")
387
+ if times[new] > times[old]:
388
+ out.append(f"{old} says {have[old]}, {new} is newer: marked {have[new]}")
389
+ return out
390
+
391
+
392
+ def discovery_pack(root, cfg) -> str:
393
+ """N1: git log, document tree, stack files and one test run, as markdown under PACK_CAP characters."""
394
+ root = Path(root)
395
+ git = _git_section(root)
396
+ tree = []
397
+ for r in _doc_files(root):
398
+ try:
399
+ size = (root / r).stat().st_size
400
+ except OSError:
401
+ continue
402
+ tree.append(f"- {r.as_posix()} ({size} bytes)")
403
+ if _is_plan_like(r):
404
+ h = _head(root / r, 40)
405
+ if h:
406
+ tree.append(" ```\n" + "\n".join(" " + x for x in h.splitlines()) + "\n ```")
407
+ secs = [("Git log (last 200 commits)", git or "(no git history)", 8000),
408
+ ("Documents", "\n".join(tree) or "(no documents found)", 24000),
409
+ ("Stack files", _stack_section(root), 12000),
410
+ ("Test command result", _test_section(root, cfg), 8000)]
411
+ if conflicts := document_conflicts(root, git): # N1 conflicts
412
+ secs.insert(2, ("Conflicts resolved", CONFLICT_RULE + "\n" + "\n".join(f"- {c}" for c in conflicts), 2000))
413
+ pack = "\n\n".join(f"## {t}\n{_cap(b, n)}" for t, b, n in secs)
414
+ return _cap(pack, PACK_CAP)