@pilotspace/add 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
@@ -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
  ---
@@ -1,7 +1,7 @@
1
1
  # TASK: {{title}}
2
2
 
3
3
  slug: {{slug}} · created: {{date}} · stage: {{stage}}
4
- autonomy: {{autonomy}} <!-- inherited from the project default (PROJECT.md); explicit level: manual < conservative < auto (visible · overridable) — lower below if a high-risk task needs it, or run `add.py autonomy set`. -->
4
+ autonomy: {{autonomy}} <!-- inherited from the project default (PROJECT.md); explicit level: manual < conservative < auto (visible · overridable) — lower below if a high-risk task needs it, or run `add.py autonomy set`. Multi-component repo (monorepo/multi-repo)? add a `component: <name>` line (declared in `.add/components.toml`) to ADD that component's root to your §5 Scope; omit for single-component projects (byte-identical default). -->
5
5
  phase: ground <!-- ground -> specify -> scenarios -> contract -> tests -> build -> verify -> observe -> done -->
6
6
  <!-- high-risk/method-defining scope? declare `risk: high` on the slug line above and lower the
7
7
  autonomy level to `manual` or `conservative` — the engine refuses an unguarded completion
@@ -0,0 +1,6 @@
1
+ # ADD engine transient artifacts — local working state, never committed.
2
+ # (Scaffolded by `add.py init`; refreshed additively by the installer on update.)
3
+ scope-snapshot.json
4
+ pre-archive-state.bak.json
5
+ pre-update-state.bak.json
6
+ .update-cache.json