@pilotspace/add 1.10.0 → 1.12.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.
- package/CHANGELOG.md +102 -0
- package/bin/cli.js +55 -0
- package/docs/02-the-flow.md +10 -0
- package/docs/09-the-loop.md +1 -1
- package/docs/11-governance.md +1 -1
- package/docs/14-foundation.md +7 -5
- package/docs/16-releasing.md +2 -2
- package/docs/appendix-c-glossary.md +10 -4
- package/package.json +4 -1
- package/skill/add/SKILL.md +9 -3
- package/skill/add/design.md +19 -2
- package/skill/add/intake.md +16 -0
- package/skill/add/phases/0-ground.md +9 -3
- package/skill/add/phases/0-setup.md +2 -2
- package/skill/add/scope.md +1 -1
- package/tooling/add.py +614 -1598
- package/tooling/add_engine/__init__.py +5 -0
- package/tooling/add_engine/accessors.py +61 -0
- package/tooling/add_engine/autonomy.py +58 -0
- package/tooling/add_engine/components.py +117 -0
- package/tooling/add_engine/constants.py +221 -0
- package/tooling/add_engine/guidelines.py +252 -0
- package/tooling/add_engine/identity.py +107 -0
- package/tooling/add_engine/io_state.py +201 -0
- package/tooling/add_engine/milestones.py +108 -0
- package/tooling/add_engine/predicates.py +75 -0
- package/tooling/add_engine/release.py +86 -0
- package/tooling/add_engine/render.py +90 -0
- package/tooling/add_engine/taskdoc.py +217 -0
- package/tooling/add_engine/version.py +45 -0
- package/tooling/templates/DESIGN.md.tmpl +11 -0
- package/tooling/templates/TASK.fast.md.tmpl +2 -0
- package/tooling/templates/gitignore.tmpl +6 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""add_engine.predicates — pure state/markdown predicates for the ADD engine.
|
|
3
|
+
|
|
4
|
+
Phase ownership, setup/milestone gating checks, and section-filled detection.
|
|
5
|
+
Extracted from add.py (engine-modularization 5/N); add.py re-exports them as module
|
|
6
|
+
globals so `add._phase_owner` etc. resolve unchanged.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from add_engine.constants import PHASE_OWNER
|
|
13
|
+
from add_engine.io_state import _die
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _phase_owner(phase: str) -> str:
|
|
17
|
+
"""Map a phase to its owner (human|seam|ai); `unmapped_phase` if absent (fail closed)."""
|
|
18
|
+
owner = PHASE_OWNER.get(phase)
|
|
19
|
+
if owner is None:
|
|
20
|
+
_die("unmapped_phase")
|
|
21
|
+
return owner
|
|
22
|
+
|
|
23
|
+
def _setup_locked(state: dict) -> bool:
|
|
24
|
+
"""True when the project's setup is locked — i.e. the build-boundary gate is OPEN.
|
|
25
|
+
|
|
26
|
+
A state with NO "setup" key is GRANDFATHERED-locked: plain `init` and every legacy
|
|
27
|
+
project are never gated (the lock is opt-in via `init --await-lock`). The gate is
|
|
28
|
+
therefore active in exactly one case: "setup" present AND locked is False."""
|
|
29
|
+
return ("setup" not in state) or (state["setup"].get("locked") is True)
|
|
30
|
+
|
|
31
|
+
def _milestone_confirmed(state: dict, mslug: str) -> bool:
|
|
32
|
+
"""True when milestone `mslug` is confirmed — i.e. the new-task gate is OPEN.
|
|
33
|
+
|
|
34
|
+
Mirrors `_setup_locked` one level down. A milestone record with NO "confirmed" key is
|
|
35
|
+
GRANDFATHERED-confirmed: every milestone created WITHOUT `--await-confirm` (and every
|
|
36
|
+
pre-existing one) is never gated. Opt-in: `new-milestone --await-confirm` seeds confirmed:false,
|
|
37
|
+
so the gate is active in exactly one case: the record is present AND confirmed is False. An
|
|
38
|
+
unknown milestone is treated as confirmed here (existence is cmd_new_task's separate check)."""
|
|
39
|
+
m = (state.get("milestones") or {}).get(mslug)
|
|
40
|
+
if not isinstance(m, dict) or "confirmed" not in m:
|
|
41
|
+
return True
|
|
42
|
+
return m["confirmed"] is True
|
|
43
|
+
|
|
44
|
+
def _section_unfilled(md_text: str, header: str) -> bool:
|
|
45
|
+
"""True iff the `header` section is PRESENT but UNFILLED — empty (no real bullet) or
|
|
46
|
+
still a `<…>` template placeholder. ABSENT section -> False (grandfathered legacy);
|
|
47
|
+
a filled section (>=1 real bullet, no `<…>`) -> False. Pure predicate — the shared
|
|
48
|
+
placeholder test the fill gates use (contract-fill at confirm; build-expectations at build)."""
|
|
49
|
+
body, in_sec, present = [], False, False
|
|
50
|
+
for ln in md_text.splitlines():
|
|
51
|
+
if ln.startswith(header):
|
|
52
|
+
in_sec, present = True, True
|
|
53
|
+
continue
|
|
54
|
+
if in_sec:
|
|
55
|
+
if ln.startswith("#"): # ANY next header (## or ###) ends our section
|
|
56
|
+
break
|
|
57
|
+
if ln.lstrip().startswith(">"): # skip blockquote GUIDANCE — it is not content
|
|
58
|
+
continue
|
|
59
|
+
body.append(ln)
|
|
60
|
+
if not present:
|
|
61
|
+
return False # absent -> grandfather
|
|
62
|
+
text = "\n".join(body).strip()
|
|
63
|
+
if not text:
|
|
64
|
+
return True # present but empty
|
|
65
|
+
return bool(re.search(r"<[^>\n]+>", text)) # a <…> placeholder remains
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _task_done(t: dict) -> bool:
|
|
69
|
+
# Matrix 3: a task is done when Verify reads PASS *or a signed RISK-ACCEPTED*.
|
|
70
|
+
# Both completing gates advance phase to "done" (cmd_gate), and a waiver is
|
|
71
|
+
# signed at gate time — so a verdict gate is enough here; we need not re-read
|
|
72
|
+
# the waiver. HARD-STOP never reaches "done". A bare `phase done` (escape
|
|
73
|
+
# hatch, gate still "none") deliberately does NOT count: completion needs a
|
|
74
|
+
# recorded verdict, not just a phase marker.
|
|
75
|
+
return t.get("phase") == "done" and t.get("gate") in ("PASS", "RISK-ACCEPTED")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""add_engine.release — the RELEASE-pillar render helpers (engine-modularization 14/N).
|
|
2
|
+
|
|
3
|
+
Render the CHANGELOG block and the append-only RELEASES.md attribution row, locate the
|
|
4
|
+
ledger, find closed milestones / key decisions for a cut, and summarise the in-flight build.
|
|
5
|
+
A closed, unpatched cluster (transitive-closure AST = zero outbound). Deps: constants + stdlib.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from add_engine.constants import RELEASES_FILE
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _releases_path(root: Path) -> Path:
|
|
16
|
+
"""The append-only release ledger — at the PROJECT ROOT (root IS the .add dir, so its
|
|
17
|
+
parent), a sibling of CHANGELOG.md. NOT inside .add/."""
|
|
18
|
+
return root.parent / RELEASES_FILE
|
|
19
|
+
|
|
20
|
+
def _closed_milestones(state: dict) -> list[dict]:
|
|
21
|
+
"""Every CLOSED milestone (its milestone-done gate passed): LIVE done milestones
|
|
22
|
+
(status == 'done', still in state) + ARCHIVED milestones (all were PASS-done before
|
|
23
|
+
archive — see _archived_task_slugs). Each: {slug, title, tier}."""
|
|
24
|
+
out: list[dict] = []
|
|
25
|
+
for slug, m in (state.get("milestones") or {}).items():
|
|
26
|
+
if m.get("status") == "done":
|
|
27
|
+
out.append({"slug": slug, "title": m.get("title", slug), "tier": "live"})
|
|
28
|
+
for rec in state.get("archived") or []:
|
|
29
|
+
if rec.get("slug"):
|
|
30
|
+
out.append({"slug": rec["slug"], "title": rec.get("title", rec["slug"]),
|
|
31
|
+
"tier": "archived"})
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
def _key_decisions_for(root: Path, slug: str) -> list[str]:
|
|
35
|
+
"""Best-effort §Key-Decisions rows from PROJECT.md that NAME this milestone slug — the
|
|
36
|
+
consolidated decisions the changelog can cite. Fail-open: a missing section / unreadable
|
|
37
|
+
foundation / no slug match -> [] (a gather never raises). READ-ONLY."""
|
|
38
|
+
try:
|
|
39
|
+
text = (root / "PROJECT.md").read_text(encoding="utf-8")
|
|
40
|
+
except OSError:
|
|
41
|
+
return []
|
|
42
|
+
m = re.search(r"^#{1,6}[^\n]*key decision[^\n]*$(.*?)(?=^#{1,6}\s|\Z)", text, re.S | re.M | re.I)
|
|
43
|
+
if not m:
|
|
44
|
+
return []
|
|
45
|
+
return [st.lstrip("-* ").strip() for st in (ln.strip() for ln in m.group(1).splitlines())
|
|
46
|
+
if st.startswith(("-", "*")) and slug in st]
|
|
47
|
+
|
|
48
|
+
def _build_in_flight(state: dict) -> bool:
|
|
49
|
+
"""release_tests_red proxy (PURE): is any ACTIVE task mid-build without a recorded green gate
|
|
50
|
+
— phase ∈ {build, verify} AND gate == 'none'? The tool-agnostic engine never runs the suite,
|
|
51
|
+
so an entered-but-ungated build is the recorded-evidence stand-in for 'the suite is red'."""
|
|
52
|
+
return any(t.get("phase") in ("build", "verify") and t.get("gate") == "none"
|
|
53
|
+
for t in (state.get("tasks") or {}).values())
|
|
54
|
+
|
|
55
|
+
def _render_changelog_block(version: str, day: str, bundle: list[dict],
|
|
56
|
+
changed_by_slug: dict) -> str:
|
|
57
|
+
"""A CHANGELOG block: `## <version> — <date>` + one bullet per bundled milestone (title +
|
|
58
|
+
carried-delta / key-decision counts from release_data['changed'])."""
|
|
59
|
+
lines = [f"## {version} — {day}", ""]
|
|
60
|
+
if bundle:
|
|
61
|
+
for m in bundle:
|
|
62
|
+
c = changed_by_slug.get(m["slug"], {})
|
|
63
|
+
lines.append(f"- {m['title']} — {c.get('carried_deltas', 0)} carried · "
|
|
64
|
+
f"{len(c.get('key_decisions', []))} key decision(s)")
|
|
65
|
+
else:
|
|
66
|
+
lines.append("- (no milestone bundled)")
|
|
67
|
+
return "\n".join(lines) + "\n\n"
|
|
68
|
+
|
|
69
|
+
def _render_releases_row(version: str, day: str, bundle: list[dict],
|
|
70
|
+
waiver_slugs: list[str], evidence: str | None,
|
|
71
|
+
actor: str | None = None, loose: list[dict] | None = None) -> str:
|
|
72
|
+
"""One append-only RELEASES.md row — the attribution source (`milestones:` membership +,
|
|
73
|
+
additively, `loose tasks:` membership for done milestone-free standalones). The `actor:`
|
|
74
|
+
line records WHO cut the release (structured-actor stamping); absent on a legacy row
|
|
75
|
+
(back-compat) when no actor is supplied. `loose` defaults to None so existing callers keep
|
|
76
|
+
working; the `loose tasks:` line always renders (`none` when empty), the other lines unchanged."""
|
|
77
|
+
ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
|
|
78
|
+
lt = ", ".join(t["slug"] for t in loose) if loose else "none"
|
|
79
|
+
wv = ", ".join(waiver_slugs) if waiver_slugs else "none"
|
|
80
|
+
actor_line = f"actor: {actor}\n" if actor else ""
|
|
81
|
+
return (f"## {version} — {day}\n"
|
|
82
|
+
f"milestones: {ms}\n"
|
|
83
|
+
f"loose tasks: {lt}\n"
|
|
84
|
+
f"waivers: {wv}\n"
|
|
85
|
+
f"{actor_line}"
|
|
86
|
+
f"evidence: {evidence or 'recorded by add.py release'}\n\n")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""add_engine.render — terminal-render primitives (engine-modularization 9/N).
|
|
2
|
+
|
|
3
|
+
Progress bars, the phase track, ASCII/Unicode + color tiers, clip/wrap. A closed,
|
|
4
|
+
unpatched cluster (transitive-closure AST = zero outbound calls). The render-private
|
|
5
|
+
_ANSI palette lives here; the SHARED _DEFAULT_WIDTH is imported from constants (its
|
|
6
|
+
single source — the staying report default-args use it too).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from add_engine.constants import PHASES, _DEFAULT_WIDTH
|
|
16
|
+
|
|
17
|
+
_ANSI = {"green": "\x1b[32m", "yellow": "\x1b[33m", "red": "\x1b[31m",
|
|
18
|
+
"dim": "\x1b[2m", "reset": "\x1b[0m"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _bar(num: int, den: int, cells: int, g: dict) -> str:
|
|
22
|
+
"""A progress bar; 0/0 -> all-empty (no divide-by-zero)."""
|
|
23
|
+
filled = 0 if den <= 0 else round(num / den * cells)
|
|
24
|
+
filled = max(0, min(cells, filled))
|
|
25
|
+
return g["reached"] * filled + g["pending"] * (cells - filled)
|
|
26
|
+
|
|
27
|
+
def _phase_track(phase: str, g: dict) -> str:
|
|
28
|
+
"""Compact 9-cell pipeline (no labels — a single legend explains it):
|
|
29
|
+
reached · current · pending. A done task -> all reached."""
|
|
30
|
+
try:
|
|
31
|
+
ci = PHASES.index(phase)
|
|
32
|
+
except ValueError:
|
|
33
|
+
ci = 0
|
|
34
|
+
cells = []
|
|
35
|
+
for i in range(len(PHASES)):
|
|
36
|
+
if phase == "done" or i < ci:
|
|
37
|
+
cells.append(g["reached"])
|
|
38
|
+
elif i == ci:
|
|
39
|
+
cells.append(g["current"])
|
|
40
|
+
else:
|
|
41
|
+
cells.append(g["pending"])
|
|
42
|
+
return "".join(cells)
|
|
43
|
+
|
|
44
|
+
def _use_ascii() -> bool:
|
|
45
|
+
"""ASCII tier when the terminal can't render Unicode (non-UTF-8 / dumb)."""
|
|
46
|
+
enc = (getattr(sys.stdout, "encoding", "") or "").lower()
|
|
47
|
+
return ("utf" not in enc) or (os.environ.get("TERM") == "dumb")
|
|
48
|
+
|
|
49
|
+
def _color_enabled() -> bool:
|
|
50
|
+
"""Color only on an interactive tty, honoring NO_COLOR and TERM."""
|
|
51
|
+
return (sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
52
|
+
and os.environ.get("TERM", "") not in ("dumb", ""))
|
|
53
|
+
|
|
54
|
+
def _term_width() -> int:
|
|
55
|
+
try:
|
|
56
|
+
import shutil
|
|
57
|
+
return min(max(shutil.get_terminal_size().columns, 64), 100)
|
|
58
|
+
except Exception:
|
|
59
|
+
return _DEFAULT_WIDTH
|
|
60
|
+
|
|
61
|
+
def _colorize(s: str) -> str:
|
|
62
|
+
"""Apply ANSI to status tokens — redundant to the text, never the sole signal.
|
|
63
|
+
Applied ONLY to tty stdout; the persisted RETRO.md string stays plain."""
|
|
64
|
+
c = _ANSI
|
|
65
|
+
s = re.sub(r"\bDONE\b", c["green"] + "DONE" + c["reset"], s)
|
|
66
|
+
s = re.sub(r"\bBLOCKED\b", c["red"] + "BLOCKED" + c["reset"], s)
|
|
67
|
+
s = re.sub(r"\bPASS\b", c["green"] + "PASS" + c["reset"], s)
|
|
68
|
+
s = re.sub(r"\bRISK\b", c["yellow"] + "RISK" + c["reset"], s)
|
|
69
|
+
s = re.sub(r"\bSTOP\b", c["red"] + "STOP" + c["reset"], s)
|
|
70
|
+
return s
|
|
71
|
+
|
|
72
|
+
def _clip(s: str, maxlen: int) -> str:
|
|
73
|
+
"""Trim a string to fit a fixed-width frame, ellipsizing if it overruns."""
|
|
74
|
+
return s if len(s) <= maxlen else s[:maxlen - 1].rstrip() + "…"
|
|
75
|
+
|
|
76
|
+
def _wrap(text: str, width: int, label: str) -> list[str]:
|
|
77
|
+
"""Wrap `text` to `width`; the first line carries `label`, continuations are
|
|
78
|
+
blank-indented to the same width (so a multi-line goal shows 'goal' once)."""
|
|
79
|
+
cont = " " * len(label)
|
|
80
|
+
lines, cur = [], ""
|
|
81
|
+
for w in text.split():
|
|
82
|
+
if cur and len(cur) + 1 + len(w) > width:
|
|
83
|
+
lines.append(cur)
|
|
84
|
+
cur = w
|
|
85
|
+
else:
|
|
86
|
+
cur = f"{cur} {w}".strip()
|
|
87
|
+
if cur:
|
|
88
|
+
lines.append(cur)
|
|
89
|
+
lines = lines or ["(unknown)"]
|
|
90
|
+
return [(label if i == 0 else cont) + ln for i, ln in enumerate(lines)]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""add_engine.taskdoc — TASK.md structural readers (engine-modularization 15/N).
|
|
2
|
+
|
|
3
|
+
Header, prose, test-count, phase-span, raw phase bodies, and the §7 spec-delta entries —
|
|
4
|
+
the pure parsers that read a task document's shape. A closed, unpatched cluster
|
|
5
|
+
(transitive-closure AST = zero outbound). The 3 delta-parsing regexes are SHARED with the
|
|
6
|
+
deltas-web lint, so they live in constants.py (single source). Deps: constants + components
|
|
7
|
+
(_confined) + stdlib; no add.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from add_engine.constants import _DELTA_RE, _EVIDENCE_RE, _SPEC_DELTA_RE
|
|
15
|
+
from add_engine.components import _confined
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _task_header(root: Path, slug: str) -> str:
|
|
19
|
+
"""The TASK.md header region — where declared tokens (risk · autonomy)
|
|
20
|
+
live — with HTML comments stripped. Missing file -> '' (no tokens)."""
|
|
21
|
+
try:
|
|
22
|
+
text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
|
|
23
|
+
except OSError:
|
|
24
|
+
return ""
|
|
25
|
+
return re.sub(r"<!--.*?-->", "", text.split("\n## ", 1)[0], flags=re.S)
|
|
26
|
+
|
|
27
|
+
def _count_test_defs(f: Path) -> int:
|
|
28
|
+
"""`def test_` occurrences in one file — the ONE counting regex (primary and
|
|
29
|
+
§4-declared fallback share it by construction). OSError -> 0, fail-closed."""
|
|
30
|
+
try:
|
|
31
|
+
return len(re.findall(r"^\s*def test_", f.read_text(encoding="utf-8"), re.M))
|
|
32
|
+
except OSError:
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
def _primary_test_files(root: Path, slug: str) -> list[Path]:
|
|
36
|
+
"""The PRIMARY test set — *.py directly in the task's tests/ dir (the stable
|
|
37
|
+
path). A list so the tamper tripwire can hash exactly what the engine counts."""
|
|
38
|
+
d = root / "tasks" / slug / "tests"
|
|
39
|
+
if not d.is_dir():
|
|
40
|
+
return []
|
|
41
|
+
return sorted(d.glob("*.py"))
|
|
42
|
+
|
|
43
|
+
def _tests_count(root: Path, slug: str) -> int:
|
|
44
|
+
return sum(_count_test_defs(f) for f in _primary_test_files(root, slug))
|
|
45
|
+
|
|
46
|
+
def _declared_test_files(root: Path, slug: str) -> list[Path]:
|
|
47
|
+
"""Resolve the §4 'Tests live in:' declared path(s) to a deduped file list. PURE.
|
|
48
|
+
Tokens are the backticked spans on the FIRST declaring line of the raw §4 body.
|
|
49
|
+
Resolution: './…' -> task dir · contains '/' -> project root (parent of .add) ·
|
|
50
|
+
bare name -> sibling of the previous resolved token (else task dir). A directory
|
|
51
|
+
token yields the *.py files directly inside it; resolved files are deduped.
|
|
52
|
+
v2 confinement: every path must resolve inside the project root — '..' traversal,
|
|
53
|
+
absolute tokens, and symlink escapes are all dropped, fail-closed."""
|
|
54
|
+
body = _raw_phase_bodies(root, slug).get(4, "")
|
|
55
|
+
m = re.search(r"^\s*Tests live in:.*$", body, re.M)
|
|
56
|
+
if not m:
|
|
57
|
+
return []
|
|
58
|
+
tdir = root / "tasks" / slug
|
|
59
|
+
rootp = root.parent.resolve()
|
|
60
|
+
files: list[Path] = []
|
|
61
|
+
prev_dir = None
|
|
62
|
+
for tok in re.findall(r"`([^`]+)`", m.group(0)):
|
|
63
|
+
tok = tok.strip()
|
|
64
|
+
if tok.startswith("./"):
|
|
65
|
+
p = tdir / tok[2:]
|
|
66
|
+
elif "/" in tok:
|
|
67
|
+
p = root.parent / tok
|
|
68
|
+
else:
|
|
69
|
+
p = (prev_dir or tdir) / tok
|
|
70
|
+
try:
|
|
71
|
+
if not _confined(p, rootp):
|
|
72
|
+
continue
|
|
73
|
+
if p.is_dir():
|
|
74
|
+
cand, prev_dir = sorted(f for f in p.glob("*.py")
|
|
75
|
+
if _confined(f, rootp)), p
|
|
76
|
+
elif p.is_file() and p.suffix == ".py":
|
|
77
|
+
cand, prev_dir = [p], p.parent
|
|
78
|
+
else:
|
|
79
|
+
continue
|
|
80
|
+
except OSError:
|
|
81
|
+
continue
|
|
82
|
+
files.extend(f for f in cand if f not in files)
|
|
83
|
+
return files
|
|
84
|
+
|
|
85
|
+
def _declared_tests_count(root: Path, slug: str) -> int:
|
|
86
|
+
"""Count tests at the §4 'Tests live in:' declared path(s). PURE, fail-closed 0."""
|
|
87
|
+
return sum(_count_test_defs(f) for f in _declared_test_files(root, slug))
|
|
88
|
+
|
|
89
|
+
def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
|
|
90
|
+
"""(count, declared). The tests/ dir count ALWAYS wins when > 0; otherwise the
|
|
91
|
+
§4-declared fallback — flagged True only when it supplied a non-zero count, so
|
|
92
|
+
a true zero stays a bare, honest 0."""
|
|
93
|
+
primary = _tests_count(root, slug)
|
|
94
|
+
if primary > 0:
|
|
95
|
+
return primary, False
|
|
96
|
+
declared = _declared_tests_count(root, slug)
|
|
97
|
+
return (declared, True) if declared > 0 else (0, False)
|
|
98
|
+
|
|
99
|
+
def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
|
|
100
|
+
"""(observe_delta, [delta lines]) from the task's TASK.md §7 — captured at FULL
|
|
101
|
+
fidelity: both fields wrap across physical lines in real files, so continuation
|
|
102
|
+
lines are JOINED. Scoped to the OBSERVE section so we read the FIELD, not §1 prose
|
|
103
|
+
that names it. Fail-closed to '(unknown)' on a missing file / `<...>` placeholder."""
|
|
104
|
+
f = root / "tasks" / slug / "TASK.md"
|
|
105
|
+
if not f.exists():
|
|
106
|
+
return "(unknown)", []
|
|
107
|
+
text = f.read_text(encoding="utf-8")
|
|
108
|
+
m7 = re.search(r"##\s*7\s*·\s*OBSERVE.*\Z", text, re.S)
|
|
109
|
+
section = m7.group(0) if m7 else text
|
|
110
|
+
lines = section.splitlines()
|
|
111
|
+
# observe: prefer the first OPEN SPEC delta from the "### Spec delta" block; fall
|
|
112
|
+
# back to the legacy "Spec delta for the next loop:" free-text field (archived
|
|
113
|
+
# tasks predate the block); else "(unknown)".
|
|
114
|
+
observe = "(unknown)"
|
|
115
|
+
for unit in _spec_delta_entries(section):
|
|
116
|
+
m = _SPEC_DELTA_RE.match(unit[0])
|
|
117
|
+
if m.group(2) != "open":
|
|
118
|
+
continue
|
|
119
|
+
tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
|
|
120
|
+
em = _EVIDENCE_RE.match(tail)
|
|
121
|
+
first = (em.group(1).strip() if em else tail)
|
|
122
|
+
if first and not first.startswith("<"):
|
|
123
|
+
observe = first
|
|
124
|
+
break
|
|
125
|
+
if observe == "(unknown)":
|
|
126
|
+
for i, ln in enumerate(lines):
|
|
127
|
+
m = re.match(r"\s*Spec delta for the next loop:\s*(.*)", ln)
|
|
128
|
+
if not m:
|
|
129
|
+
continue
|
|
130
|
+
parts = [m.group(1).strip()]
|
|
131
|
+
for nxt in lines[i + 1:]:
|
|
132
|
+
t = nxt.strip()
|
|
133
|
+
if not t or t.startswith("#") or t.startswith("- ") or t.startswith("Watch"):
|
|
134
|
+
break
|
|
135
|
+
parts.append(t)
|
|
136
|
+
joined = " ".join(p for p in parts if p).strip()
|
|
137
|
+
if joined and not joined.startswith("<"):
|
|
138
|
+
observe = joined
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
# deltas: each "- [COMP · status] ..." plus its indented continuation lines
|
|
142
|
+
deltas, i = [], 0
|
|
143
|
+
while i < len(lines):
|
|
144
|
+
m = _DELTA_RE.match(lines[i])
|
|
145
|
+
if not m:
|
|
146
|
+
i += 1
|
|
147
|
+
continue
|
|
148
|
+
parts, j = [m.group(3).strip()], i + 1
|
|
149
|
+
while j < len(lines):
|
|
150
|
+
t = lines[j].strip()
|
|
151
|
+
if not t or t.startswith("#") or _DELTA_RE.match(lines[j]):
|
|
152
|
+
break
|
|
153
|
+
parts.append(t)
|
|
154
|
+
j += 1
|
|
155
|
+
deltas.append(f"{m.group(1)} · {m.group(2)} · {' '.join(parts).strip()}")
|
|
156
|
+
i = j
|
|
157
|
+
return observe, deltas
|
|
158
|
+
|
|
159
|
+
def _phase_spans(text: str) -> dict[int, str]:
|
|
160
|
+
"""Split a TASK.md into RAW §1–§7 bodies keyed by section number — the ONE
|
|
161
|
+
canonical heading scan (`^##\\s*<n>\\s*·`, case/locale-proof); a body runs from
|
|
162
|
+
its heading to the next `## `/`---`/EOF. RAW = byte-faithful lines, no cleaning:
|
|
163
|
+
the decision-marker extractor (decide-digest) depends on byte-verbatim text.
|
|
164
|
+
KNOWN LIMIT: a §body containing a line-start `## ` or bare `---` truncates early —
|
|
165
|
+
today's TASK.md bodies don't (box-chars ─═, `### ` sub-heads)."""
|
|
166
|
+
lines = text.splitlines()
|
|
167
|
+
head = re.compile(r"^##\s*(\d+)\s*·")
|
|
168
|
+
starts: dict[int, int] = {}
|
|
169
|
+
for idx, ln in enumerate(lines):
|
|
170
|
+
m = head.match(ln)
|
|
171
|
+
if m:
|
|
172
|
+
n = int(m.group(1))
|
|
173
|
+
if 0 <= n <= 7 and n not in starts:
|
|
174
|
+
starts[n] = idx
|
|
175
|
+
out: dict[int, str] = {}
|
|
176
|
+
for n, idx in starts.items():
|
|
177
|
+
body_lines = []
|
|
178
|
+
for ln in lines[idx + 1:]:
|
|
179
|
+
if re.match(r"^##\s", ln) or re.match(r"^---\s*$", ln):
|
|
180
|
+
break
|
|
181
|
+
body_lines.append(ln)
|
|
182
|
+
out[n] = "\n".join(body_lines)
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
def _raw_phase_bodies(root: Path, slug: str) -> dict[int, str]:
|
|
186
|
+
"""RAW §bodies for one task (byte-faithful, for marker extraction). PURE.
|
|
187
|
+
Missing/unreadable TASK.md -> {} (fail-closed, like task_phases)."""
|
|
188
|
+
f = root / "tasks" / slug / "TASK.md"
|
|
189
|
+
try:
|
|
190
|
+
return _phase_spans(f.read_text(encoding="utf-8"))
|
|
191
|
+
except OSError:
|
|
192
|
+
return {}
|
|
193
|
+
|
|
194
|
+
def _spec_delta_entries(text: str) -> list[list[str]]:
|
|
195
|
+
"""Group a "### Spec delta" block into entries (tag line + continuation lines).
|
|
196
|
+
|
|
197
|
+
Same grouping discipline as _collect_open_deltas' competency pass, keyed on
|
|
198
|
+
_SPEC_DELTA_RE: a tag line starts an entry; a non-"- " line continues it; a
|
|
199
|
+
blank/comment or a new "- " item ends it. Returns [] when the block is absent."""
|
|
200
|
+
bm = re.search(r"###\s*Spec delta\s*\n(.*?)(?=\n##|\Z)", text, re.S)
|
|
201
|
+
if not bm:
|
|
202
|
+
return []
|
|
203
|
+
entries: list[list[str]] = []
|
|
204
|
+
current: list[str] | None = None
|
|
205
|
+
for line in bm.group(1).splitlines():
|
|
206
|
+
stripped = line.strip()
|
|
207
|
+
if not stripped or stripped.startswith("<!--"):
|
|
208
|
+
current = None
|
|
209
|
+
continue
|
|
210
|
+
if _SPEC_DELTA_RE.match(stripped):
|
|
211
|
+
current = [stripped]
|
|
212
|
+
entries.append(current)
|
|
213
|
+
elif current is not None and not stripped.startswith("-"):
|
|
214
|
+
current.append(stripped) # genuine wrap of the current entry
|
|
215
|
+
else:
|
|
216
|
+
current = None # a new / malformed list item ends the run
|
|
217
|
+
return entries
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""add_engine.version — the npm/PyPI update-nudge version helpers (engine-modularization 13/N).
|
|
2
|
+
|
|
3
|
+
Safe JSON read, semantic-ish version compare, and a fail-soft registry fetch (timeout-bounded,
|
|
4
|
+
never raises into the nudge path). A closed cluster: the three functions don't call each other.
|
|
5
|
+
The cluster-private _REGISTRY_LATEST (the npm 'latest' URL) lives here. Deps: stdlib only.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_REGISTRY_LATEST = "https://registry.npmjs.org/@pilotspace/add/latest"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _read_json_safe(path: Path):
|
|
17
|
+
try:
|
|
18
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
19
|
+
except (OSError, json.JSONDecodeError):
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
def _version_gt(a: str, b: str) -> bool:
|
|
23
|
+
"""True if version a is newer than b (dotted numeric; prerelease suffix dropped)."""
|
|
24
|
+
def key(v: str):
|
|
25
|
+
out = []
|
|
26
|
+
for part in str(v).split("."):
|
|
27
|
+
part = part.split("-", 1)[0]
|
|
28
|
+
out.append((0, int(part)) if part.isdigit() else (1, part))
|
|
29
|
+
return out
|
|
30
|
+
try:
|
|
31
|
+
return key(a) > key(b)
|
|
32
|
+
except Exception:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
def _fetch_latest_version(timeout: float = 1.5):
|
|
36
|
+
"""GET the registry's latest version. Returns a string, or None on ANY failure
|
|
37
|
+
(offline, timeout, bad payload) — the caller treats None as 'unknown, skip'."""
|
|
38
|
+
try:
|
|
39
|
+
req = urllib.request.Request(_REGISTRY_LATEST, headers={"Accept": "application/json"})
|
|
40
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
41
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
42
|
+
v = data.get("version")
|
|
43
|
+
return v if isinstance(v, str) and v else None
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
@@ -31,6 +31,17 @@ The design intent the AI drafts UI against — the persona, the one job, the fee
|
|
|
31
31
|
- **Design principles** — <!-- 3–5 lines, e.g. "one primary action per screen" · "never hide state" -->
|
|
32
32
|
- **Accessibility floor** — <!-- e.g. WCAG AA contrast · focus-visible · hit-target ≥ 44px -->
|
|
33
33
|
|
|
34
|
+
## Design intake
|
|
35
|
+
|
|
36
|
+
The per-design picks the `design-intake` beat collects (see the `add` skill's `design.md`).
|
|
37
|
+
These are project **defaults**; a screen records only its **overrides** in its prototype note.
|
|
38
|
+
VISUAL DESIGN draws its color/type from **## Identity** above — don't restate the brand here.
|
|
39
|
+
|
|
40
|
+
- **Fidelity** — <!-- lo-fi wireframe | hi-fi mockup | production — how far this design renders -->
|
|
41
|
+
- **Concept** — <!-- the design idea / mood / direction, in a line -->
|
|
42
|
+
- **Layout** — <!-- the structure / grid / hierarchy the screens lean on -->
|
|
43
|
+
- **Visual design** — <!-- spacing scale · imagery style (color · type come from ## Identity) -->
|
|
44
|
+
|
|
34
45
|
## Screens
|
|
35
46
|
|
|
36
47
|
An index of the app's screens — one `design/prototypes/<name>.json` per screen (a flat
|
|
@@ -14,6 +14,8 @@ fast: true <!-- the fast lane: a small task, collapsed flow + minimal template
|
|
|
14
14
|
## 0 · GROUND — the real codebase
|
|
15
15
|
|
|
16
16
|
Touches (files · symbols): <path:symbol — what it is / how it is keyed>
|
|
17
|
+
Context (working folder): <docs · config · data the task touches — task-delta only>
|
|
18
|
+
Honors (patterns / conventions): <PROJECT.md / CONVENTIONS.md anchors — task-delta>
|
|
17
19
|
Anchors the contract cites: <the symbols §3 will name>
|
|
18
20
|
|
|
19
21
|
---
|