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/docs.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Session-end documentation, written by the orchestrator so it happens every time, reliably."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .context import bullets
|
|
9
|
+
from .gate import scan_secrets, verify_commands
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def redact_secrets(text: str) -> str:
|
|
13
|
+
"""Agent text that the orchestrator commits without a gate (feature evidence): drop each line that looks like a
|
|
14
|
+
secret, with the same patterns as the gate's secret scan. A PEM block goes whole: its body lines match nothing."""
|
|
15
|
+
out, in_pem = [], False
|
|
16
|
+
for ln in str(text).splitlines():
|
|
17
|
+
if not in_pem and re.search(r"-----BEGIN [A-Z ]*PRIVATE KEY", ln):
|
|
18
|
+
in_pem = True
|
|
19
|
+
if in_pem or scan_secrets([ln]):
|
|
20
|
+
if not out or out[-1] != REDACTED:
|
|
21
|
+
out.append(REDACTED)
|
|
22
|
+
else:
|
|
23
|
+
out.append(ln)
|
|
24
|
+
if in_pem and re.search(r"-----END [A-Z ]*PRIVATE KEY", ln):
|
|
25
|
+
in_pem = False
|
|
26
|
+
return "\n".join(out)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
REDACTED = "(removed: it looked like a secret)"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def warned_tasks(cfg, phase) -> list[str]:
|
|
33
|
+
"""G11: the tasks of a phase that merged with a gate warning. The history file of each task is the record."""
|
|
34
|
+
hist = cfg.root / cfg.get("docs.history_dir", ".agent/history") / phase.id
|
|
35
|
+
out = []
|
|
36
|
+
for t in phase.tasks:
|
|
37
|
+
try:
|
|
38
|
+
text = (hist / f"{t.id}.md").read_text(encoding="utf-8")
|
|
39
|
+
except OSError:
|
|
40
|
+
continue
|
|
41
|
+
if text.rpartition("## Gate warnings\n")[2].startswith("- "): # the last heading: a summary can quote one
|
|
42
|
+
out.append(t.id)
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def scope_added(cfg, phase) -> list[str]:
|
|
47
|
+
"""O1: every `scope added: <file>: <reason>` warning of the phase, from the history files, for PROOF.md."""
|
|
48
|
+
hist = cfg.root / cfg.get("docs.history_dir", ".agent/history") / phase.id
|
|
49
|
+
out = []
|
|
50
|
+
for t in phase.tasks:
|
|
51
|
+
try:
|
|
52
|
+
tail = (hist / f"{t.id}.md").read_text(encoding="utf-8").rpartition("## Gate warnings\n")[2]
|
|
53
|
+
except OSError:
|
|
54
|
+
continue
|
|
55
|
+
out += [f"{t.id} {ln[2:]}" for ln in tail.splitlines() if ln.startswith("- scope added: ")]
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def not_verified(warned: int, unchecked: int) -> str:
|
|
60
|
+
"""G11: one line that says what no check covered; '' when there is nothing to say."""
|
|
61
|
+
n = lambda k, word: f"{k} {word}{'' if k == 1 else 's'}" # noqa: E731
|
|
62
|
+
return (f"Not verified: {n(warned, 'task')} merged with gate warnings, {n(unchecked, 'feature')} not checked."
|
|
63
|
+
if warned or unchecked else "")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _append(path: Path, text: str, header: str = ""):
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
if not path.exists() and header:
|
|
69
|
+
path.write_text(header, encoding="utf-8")
|
|
70
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
71
|
+
fh.write(text)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
BACKLOG_DONE = "## Imported"
|
|
75
|
+
BACKLOG_HEAD = ("# Backlog\n\nNew ideas, one per line starting with `- `. Autopilot adds them to the plan with one replan "
|
|
76
|
+
"(which drops ideas the plan already covers) and moves them under \"Imported\".\n\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def today() -> str:
|
|
80
|
+
return dt.date.today().isoformat()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def slug(text: str, n: int = 50, default: str = "note") -> str:
|
|
84
|
+
return re.sub(r"[^a-z0-9]+", "-", str(text).lower()).strip("-")[:n].strip("-") or default
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def research_path(cfg, topic: str) -> Path:
|
|
88
|
+
"""docs/research/<topic>.md: one note per topic, shared by every task that names the topic."""
|
|
89
|
+
return cfg.root / cfg.get("research.dir", "docs/research") / f"{slug(topic, 60, 'topic')}.md"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Documenter:
|
|
93
|
+
def __init__(self, cfg):
|
|
94
|
+
self.cfg = cfg
|
|
95
|
+
self.ad = cfg.agent_dir
|
|
96
|
+
self.root = cfg.root
|
|
97
|
+
|
|
98
|
+
def task_done(self, task, phase, report: dict, *, model: str, attempt: int, cost: float,
|
|
99
|
+
gate_warnings: list[str], files: list[str], next_task: str | None):
|
|
100
|
+
summary = report.get("summary") or f"Implemented {task.title}."
|
|
101
|
+
decisions = report.get("decisions") or []
|
|
102
|
+
followups = report.get("followups") or []
|
|
103
|
+
|
|
104
|
+
# 1. per-task history record (committed; the long-term audit trail)
|
|
105
|
+
hist = self.root / self.cfg.get("docs.history_dir", ".agent/history") / phase.id / f"{task.id}.md"
|
|
106
|
+
hist.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
hist.write_text(
|
|
108
|
+
f"# {task.id} — {task.title}\n\n- Date: {today()}\n- Model: {model} (attempt {attempt})\n"
|
|
109
|
+
f"- Cost: ${cost:.2f}\n- Risk: {task.risk}\n\n## Summary\n{summary}\n\n"
|
|
110
|
+
f"## Acceptance criteria\n{bullets(task.acceptance_criteria)}\n\n"
|
|
111
|
+
f"## Files changed\n{bullets(files)}\n\n## Tests added\n{bullets(report.get('tests_added'))}\n\n"
|
|
112
|
+
f"## Docs updated\n{bullets(report.get('docs_updated'))}\n\n## Decisions\n{bullets(decisions)}\n\n"
|
|
113
|
+
f"## Follow-ups\n{bullets(followups)}\n\n## Gate warnings\n{bullets(gate_warnings)}\n\n"
|
|
114
|
+
f"## How to verify\n{bullets([f'`{c}`' for c in verify_commands(self.cfg, task.verify)], '(no commands configured)')}\n\n"
|
|
115
|
+
f"## Rollback\nRevert the merge commit `[autopilot] merge {task.id}`: `git revert -m 1 <sha>`\n",
|
|
116
|
+
encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
# 2. decision log
|
|
119
|
+
if decisions:
|
|
120
|
+
_append(self.ad / "DECISIONS.md", "".join(f"- [{today()}] [{task.id}] {d}\n" for d in decisions))
|
|
121
|
+
|
|
122
|
+
# 3. follow-ups queue (read by auditor / replanner)
|
|
123
|
+
if followups:
|
|
124
|
+
_append(self.ad / "FOLLOWUPS.md", "".join(f"- [{task.id}] {f}\n" for f in followups),
|
|
125
|
+
"# Follow-ups\n\nReported by sessions; consumed by the auditor and replanner.\n\n")
|
|
126
|
+
|
|
127
|
+
# 4. changelog
|
|
128
|
+
cl = self.cfg.get("docs.changelog", "CHANGELOG.md")
|
|
129
|
+
if cl:
|
|
130
|
+
_append(self.root / cl, f"- {today()} **{task.id}** {task.title} — {summary.splitlines()[0][:200]}\n",
|
|
131
|
+
"# Changelog\n\n")
|
|
132
|
+
|
|
133
|
+
# 5. handoff for the next session
|
|
134
|
+
(self.ad / "HANDOFF.md").write_text(
|
|
135
|
+
f"# Handoff\n\nLast completed: **{task.id} — {task.title}** ({today()})\n\n{summary}\n\n"
|
|
136
|
+
f"Files: {', '.join(files[:25]) or '(none)'}\n\nFollow-ups noted:\n{bullets(followups)}\n\n"
|
|
137
|
+
f"Next planned task: {next_task or '(none)'}\n", encoding="utf-8")
|
|
138
|
+
|
|
139
|
+
def rca(self, ident: str, title: str, rca: dict):
|
|
140
|
+
"""Append a root-cause entry to the RCA log; missing fields are marked, never rejected."""
|
|
141
|
+
rca = rca if isinstance(rca, dict) else {}
|
|
142
|
+
f = lambda k: " ".join(str(rca.get(k) or "").split()) or "(not reported)" # noqa: E731
|
|
143
|
+
_append(self.root / self.cfg.get("docs.rca", "docs/RCA.md"),
|
|
144
|
+
f"## {today()} · {ident} {title}\n- Symptom: {f('symptom')}\n- Root cause: {f('root_cause')}\n"
|
|
145
|
+
f"- Fix: {f('fix')}\n- Prevention: {f('prevention')}\n\n", "# RCA log\n\n")
|
|
146
|
+
|
|
147
|
+
def adr(self, task, rep: dict, source: str) -> Path:
|
|
148
|
+
"""Write an ADR (MADR layout) as docs/adr/NNNN-title.md and return its path relative to the project root."""
|
|
149
|
+
d = self.root / self.cfg.get("docs.adr_dir", "docs/adr")
|
|
150
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
n = max([int(m[1]) for f in d.glob("*.md") if (m := re.match(r"(\d{4})-", f.name))] + [0]) + 1
|
|
152
|
+
title = " ".join(str(rep.get("title") or task.title).split())[:120]
|
|
153
|
+
options = []
|
|
154
|
+
for i, o in enumerate(rep.get("options") or [], 1):
|
|
155
|
+
o = o if isinstance(o, dict) else {"name": str(o)}
|
|
156
|
+
score = f" (score {o['score']}/10)" if o.get("score") not in (None, "") else ""
|
|
157
|
+
options.append(f"{i}. **{o.get('name', '?')}**{score}: {o.get('summary') or ''}".rstrip(": "))
|
|
158
|
+
options += [f" - Good: {x}" for x in o.get("pros") or []] + [f" - Bad: {x}" for x in o.get("cons") or []]
|
|
159
|
+
path = d / f"{n:04d}-{slug(title, 50, 'decision')}.md"
|
|
160
|
+
path.write_text(
|
|
161
|
+
f"# {n:04d}. {title}\n\n- Status: accepted ({source})\n- Date: {today()}\n- Task: {task.id} {task.title}\n\n"
|
|
162
|
+
f"## Context and problem statement\n{rep.get('context') or task.description or task.title}\n\n"
|
|
163
|
+
f"## Decision drivers\n{bullets(rep.get('criteria'))}\n\n"
|
|
164
|
+
f"## Considered options\n{chr(10).join(options) or '(none listed)'}\n\n"
|
|
165
|
+
f"## Decision outcome\nChosen option: **{rep.get('decision')}**"
|
|
166
|
+
+ (f", because {rep['rationale']}" if rep.get("rationale") else "") + "\n\n"
|
|
167
|
+
f"### Consequences\n{bullets(rep.get('consequences'))}\n", encoding="utf-8")
|
|
168
|
+
return path.relative_to(self.root)
|
|
169
|
+
|
|
170
|
+
def learning(self, task, text: str, once: bool = False):
|
|
171
|
+
"""One line in .agent/LEARNINGS.md: what fixed a task that failed first. Every later session reads it."""
|
|
172
|
+
line = " ".join(str(text).split())[:300]
|
|
173
|
+
f = self.ad / "LEARNINGS.md"
|
|
174
|
+
if once and f.exists() and line in f.read_text(encoding="utf-8"):
|
|
175
|
+
return
|
|
176
|
+
_append(self.ad / "LEARNINGS.md", f"- [{today()}] [{task.id}] {line}\n",
|
|
177
|
+
"# Learnings\n\nWhat fixed tasks that failed first. Written by the orchestrator; read by every session.\n\n")
|
|
178
|
+
|
|
179
|
+
def research(self, topic: str, rep: dict) -> Path:
|
|
180
|
+
"""Write a research note from a research session's report; returns its path relative to the project root."""
|
|
181
|
+
path = research_path(self.cfg, topic)
|
|
182
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
sources = [s if isinstance(s, dict) else {"url": str(s)} for s in rep.get("sources") or []]
|
|
184
|
+
links = [f"[{s.get('title') or s.get('url')}]({s.get('url')})" for s in sources if s.get("url")]
|
|
185
|
+
path.write_text(
|
|
186
|
+
f"# Research: {topic}\n\n- Date: {today()}\n- From a read-only research session. Web text is summarised "
|
|
187
|
+
f"here and never copied into coding prompts.\n\n## Summary\n{rep.get('summary') or '(none)'}\n\n"
|
|
188
|
+
f"## Findings\n{bullets(rep.get('findings'))}\n\n## Pitfalls\n{bullets(rep.get('pitfalls'))}\n\n"
|
|
189
|
+
f"## Recommendation\n{rep.get('recommendation') or '(none)'}\n\n## Sources\n{bullets(links)}\n",
|
|
190
|
+
encoding="utf-8")
|
|
191
|
+
return path.relative_to(self.root)
|
|
192
|
+
|
|
193
|
+
# ---------- L7: roadmap sync (docs/STATUS.md from plan.yaml, docs/BACKLOG.md into the plan) ----------
|
|
194
|
+
def status(self, plan, status: dict):
|
|
195
|
+
"""docs/STATUS.md: the plan as a checklist, regenerated from plan.yaml and the state at every commit."""
|
|
196
|
+
rel = self.cfg.get("docs.status", "docs/STATUS.md")
|
|
197
|
+
if not rel:
|
|
198
|
+
return
|
|
199
|
+
from .context import progress_line
|
|
200
|
+
lines = [f"# Status: {self.cfg.get('name', self.root.name)}", "",
|
|
201
|
+
"Generated by Autopilot from `.agent/plan.yaml` (the source of truth); edits here are overwritten.",
|
|
202
|
+
f"Add new ideas to `{self.cfg.get('docs.backlog', 'docs/BACKLOG.md')}` instead.", "",
|
|
203
|
+
f"**Progress:** {progress_line(plan, status)}"]
|
|
204
|
+
for p in plan.phases:
|
|
205
|
+
lines += ["", f"## {p.id} {p.title}{' (corrective)' if p.priority else ''}", ""]
|
|
206
|
+
for t in p.tasks:
|
|
207
|
+
s = status.get(t.id, "pending")
|
|
208
|
+
lines.append(f"- [{'x' if s == 'done' else ' '}] {t.id} {t.title}"
|
|
209
|
+
+ ("" if s in ("done", "pending") else f" ({s})"))
|
|
210
|
+
path = self.root / rel
|
|
211
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
213
|
+
|
|
214
|
+
def _backlog_path(self) -> Path | None:
|
|
215
|
+
rel = self.cfg.get("docs.backlog", "docs/BACKLOG.md")
|
|
216
|
+
return self.root / rel if rel else None
|
|
217
|
+
|
|
218
|
+
def backlog(self) -> list[str]:
|
|
219
|
+
"""New ideas: the `- ` lines above the `## Imported` heading of docs/BACKLOG.md."""
|
|
220
|
+
p = self._backlog_path()
|
|
221
|
+
if not p or not p.exists():
|
|
222
|
+
return []
|
|
223
|
+
new = p.read_text(encoding="utf-8", errors="replace").split(BACKLOG_DONE)[0]
|
|
224
|
+
return [ln[2:].strip() for ln in new.splitlines() if ln.startswith("- ") and ln[2:].strip()]
|
|
225
|
+
|
|
226
|
+
def backlog_add(self, idea: str):
|
|
227
|
+
p = self._backlog_path()
|
|
228
|
+
if not p:
|
|
229
|
+
return
|
|
230
|
+
text = p.read_text(encoding="utf-8") if p.exists() else BACKLOG_HEAD
|
|
231
|
+
new, sep, done = text.partition(BACKLOG_DONE)
|
|
232
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
p.write_text(f"{new.rstrip()}\n- {' '.join(idea.split())}\n\n{sep}{done}".rstrip() + "\n", encoding="utf-8")
|
|
234
|
+
|
|
235
|
+
def backlog_imported(self, items: list[str]):
|
|
236
|
+
"""Move imported ideas under `## Imported`, dated, so they are never imported twice."""
|
|
237
|
+
p = self._backlog_path()
|
|
238
|
+
new, _, done = p.read_text(encoding="utf-8").partition(BACKLOG_DONE)
|
|
239
|
+
keep = [ln for ln in new.splitlines() if not (ln.startswith("- ") and ln[2:].strip() in items)]
|
|
240
|
+
moved = "".join(f"- [{today()}] {i}\n" for i in items)
|
|
241
|
+
p.write_text("\n".join(keep).rstrip() + f"\n\n{BACKLOG_DONE}\n{moved}{done.lstrip(chr(10))}", encoding="utf-8")
|
|
242
|
+
|
|
243
|
+
def phase_done(self, phase, state, status: str, deploy_note: str = ""):
|
|
244
|
+
lines = [f"# Phase {phase.id} — {phase.title}\n", f"- Closed: {today()}", f"- Status: {status}",
|
|
245
|
+
f"- Cost: ${state.cost(phase=phase.id):.2f}"]
|
|
246
|
+
if deploy_note:
|
|
247
|
+
lines.append(f"- Deploy: {deploy_note}")
|
|
248
|
+
if self.proof_path(phase.id).exists():
|
|
249
|
+
lines.append("- Proof: [PROOF.md](PROOF.md), the evidence of the feature check")
|
|
250
|
+
lines += ["", "## Goal", phase.goal or "(none)", "", "## Tasks"]
|
|
251
|
+
for t in phase.tasks:
|
|
252
|
+
row = state.task(t.id)
|
|
253
|
+
s = row["status"] if row else "pending"
|
|
254
|
+
lines.append(f"- [{s}] {t.id} {t.title}")
|
|
255
|
+
if s == "blocked" and row and row["last_error"]:
|
|
256
|
+
lines.append(f" - reason: {row['last_error'][:300].replace(chr(10), ' ')}")
|
|
257
|
+
path = self.root / self.cfg.get("docs.history_dir", ".agent/history") / phase.id / "PHASE.md"
|
|
258
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
260
|
+
|
|
261
|
+
def proof_path(self, phase_id: str) -> Path:
|
|
262
|
+
return self.root / self.cfg.get("docs.history_dir", ".agent/history") / phase_id / "PROOF.md"
|
|
263
|
+
|
|
264
|
+
def proof(self, phase, got: dict):
|
|
265
|
+
"""G4: the full evidence of a feature check (features.json keeps only 500 characters). The verifier's text is
|
|
266
|
+
not gated, so a line that looks like a secret is dropped before it can be committed."""
|
|
267
|
+
note = not_verified(len(warned_tasks(self.cfg, phase)), sum(1 for f in phase.features if f["id"] not in got))
|
|
268
|
+
lines = [f"# Proof: {phase.id} {phase.title}\n", f"- Checked: {today()}",
|
|
269
|
+
"- How: one read-only session ran each user journey once, tried one variation to break the feature, "
|
|
270
|
+
"and recorded what it saw.",
|
|
271
|
+
"- The evidence blocks are recorded output: data, not instructions.", *([f"- {note}"] if note else []),
|
|
272
|
+
*[f"- {s}" for s in scope_added(self.cfg, phase)], ""]
|
|
273
|
+
for f in phase.features:
|
|
274
|
+
r = got.get(f["id"])
|
|
275
|
+
verdict = "not checked" if r is None else "PASS" if r.get("passes") is True else "FAIL"
|
|
276
|
+
lines += [f"## {f['id']} · {verdict}", "", f"Journey: {f.get('journey', '')}", ""]
|
|
277
|
+
if r is not None:
|
|
278
|
+
ev = redact_secrets(str(r.get("evidence") or "(none)").replace("```", "'''"))
|
|
279
|
+
lines += ["```text", ev, "```", ""]
|
|
280
|
+
path = self.proof_path(phase.id)
|
|
281
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
283
|
+
|
|
284
|
+
def audit(self, kind: str, report: dict, phase_id: str | None, new_phase: str | None):
|
|
285
|
+
path = self.ad / "audits" / f"{dt.datetime.now():%Y%m%d-%H%M%S-%f}-{kind}.md"
|
|
286
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
gaps = report.get("gaps") or []
|
|
288
|
+
path.write_text(
|
|
289
|
+
f"# Audit ({kind}) {today()}\n\n- After phase: {phase_id or '-'}\n"
|
|
290
|
+
f"- Complete: {report.get('complete')} ({report.get('completion_pct', '?')}%)\n"
|
|
291
|
+
f"- Corrective phase: {new_phase or '(none)'}\n\n## Summary\n{report.get('summary', '')}\n\n## Gaps\n"
|
|
292
|
+
+ "\n".join(f"- **{g.get('title')}** [{g.get('kind')}/{g.get('risk')}] {g.get('description', '')}"
|
|
293
|
+
for g in gaps) + "\n", encoding="utf-8")
|
|
294
|
+
return path
|
|
295
|
+
|
|
296
|
+
def consume_followups(self):
|
|
297
|
+
f = self.ad / "FOLLOWUPS.md"
|
|
298
|
+
if f.exists():
|
|
299
|
+
archive = self.ad / "audits" / "followups-consumed.md"
|
|
300
|
+
_append(archive, f"\n## {today()}\n" + f.read_text(encoding="utf-8"))
|
|
301
|
+
f.unlink()
|