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.
- autopilot/__init__.py +4 -0
- autopilot/__main__.py +6 -0
- autopilot/backends/__init__.py +159 -0
- autopilot/backends/claude_cli.py +368 -0
- autopilot/backends/command.py +90 -0
- autopilot/cli.py +1614 -0
- autopilot/commands/__init__.py +0 -0
- autopilot/commands/preflight.py +131 -0
- autopilot/config.py +155 -0
- autopilot/context.py +414 -0
- autopilot/dash_term.py +493 -0
- autopilot/dash_web.py +347 -0
- autopilot/dashboard.py +410 -0
- autopilot/decisions.py +85 -0
- autopilot/deploy.py +67 -0
- autopilot/docs.py +301 -0
- autopilot/doctor.py +478 -0
- autopilot/gate.py +383 -0
- autopilot/github_util.py +42 -0
- autopilot/gitops.py +251 -0
- autopilot/growth.py +85 -0
- autopilot/handoff.py +27 -0
- autopilot/layers.py +134 -0
- autopilot/log_scrub.py +11 -0
- autopilot/mcp.py +104 -0
- autopilot/notify.py +98 -0
- autopilot/orchestrator.py +2214 -0
- autopilot/plain.py +238 -0
- autopilot/plan.py +320 -0
- autopilot/proc.py +176 -0
- autopilot/prompts/auditor.md +39 -0
- autopilot/prompts/decide.md +38 -0
- autopilot/prompts/docs.md +48 -0
- autopilot/prompts/fixer.md +26 -0
- autopilot/prompts/onboard.md +43 -0
- autopilot/prompts/plan_writer.md +40 -0
- autopilot/prompts/replanner.md +42 -0
- autopilot/prompts/research.md +25 -0
- autopilot/prompts/resume.md +10 -0
- autopilot/prompts/retry.md +8 -0
- autopilot/prompts/review.md +40 -0
- autopilot/prompts/system.md +16 -0
- autopilot/prompts/task.md +78 -0
- autopilot/prompts/triage.md +30 -0
- autopilot/prompts/unstick.md +37 -0
- autopilot/prompts/verify.md +32 -0
- autopilot/repomap.py +71 -0
- autopilot/report.py +633 -0
- autopilot/rights.py +184 -0
- autopilot/schemas.py +42 -0
- autopilot/state.py +298 -0
- autopilot/templates/__pycache__/cloudflare_apply.cpython-313.pyc +0 -0
- autopilot/templates/agent/BRAIN.md +21 -0
- autopilot/templates/agent/CLAUDE.md.snippet +10 -0
- autopilot/templates/agent/DECISIONS.md +5 -0
- autopilot/templates/agent/HANDOFF.md +3 -0
- autopilot/templates/agent/gitignore +10 -0
- autopilot/templates/agent/ide_rules.md +8 -0
- autopilot/templates/agent/plan.yaml +48 -0
- autopilot/templates/agent/project.yaml +259 -0
- autopilot/templates/cloudflare_apply.py +211 -0
- autopilot/templates/deploy.sh +72 -0
- autopilot/templates/email_verify.py +144 -0
- autopilot/templates/infra/cloudflare.yaml +11 -0
- autopilot/templates/presets/docker.yaml +4 -0
- autopilot/templates/presets/generic.yaml +3 -0
- autopilot/templates/presets/go.yaml +6 -0
- autopilot/templates/presets/java.yaml +4 -0
- autopilot/templates/presets/node.yaml +7 -0
- autopilot/templates/presets/python.yaml +6 -0
- autopilot/templates/presets/rust.yaml +5 -0
- autopilot/templates/presets/static.yaml +3 -0
- autopilot/upgrade.py +192 -0
- dev_autopilot-0.4.0.dist-info/METADATA +148 -0
- dev_autopilot-0.4.0.dist-info/RECORD +79 -0
- dev_autopilot-0.4.0.dist-info/WHEEL +5 -0
- dev_autopilot-0.4.0.dist-info/entry_points.txt +3 -0
- dev_autopilot-0.4.0.dist-info/licenses/LICENSE +21 -0
- 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)
|