@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.
@@ -0,0 +1,252 @@
1
+ """add_engine.guidelines — the guidelines / CLAUDE.md-injection subsystem (engine-modularization 8/N).
2
+
3
+ Inject one stable, marker-delimited ADD block into the project root's AGENTS.md and
4
+ CLAUDE.md. DYNAMIC-BY-REFERENCE: the block tells the agent to run `add.py status` and
5
+ read PROJECT.md — it never embeds live state. A closed, self-contained cluster (8 fns +
6
+ the cluster-private _INIT_EXCLUDE); transitive-closure AST scan: ZERO outbound calls to
7
+ non-cluster add fns. Deps: constants + _atomic_write (io_state) + stdlib. None patched.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from add_engine.constants import (
17
+ GUIDELINE_FILES, RULES_FILE_REL, WORKFLOW_HEADINGS,
18
+ _GUIDE_BEGIN, _GUIDE_END, _RULE_REF_LINE,
19
+ )
20
+ from add_engine.io_state import _atomic_write
21
+
22
+
23
+ def _guideline_block() -> str:
24
+ """The canonical ADD block (markers + body, no trailing newline).
25
+
26
+ Agent-agnostic by design (v14 agent-portability): the routing steps depend
27
+ only on the CLI and plain files, so any agent — Claude, Cursor, Copilot,
28
+ Codex — can follow them. Claude additionally gets the `add` skill."""
29
+ return (
30
+ f"{_GUIDE_BEGIN}\n"
31
+ "## ADD — how to work in this repo\n"
32
+ "\n"
33
+ "This project uses **ADD (AI-Driven Development)**: you, the AI, drive the build;\n"
34
+ "the human owns direction and verification. The loop below works for any agent —\n"
35
+ "Claude, Cursor, Copilot, Codex — through the CLI alone. Before you change code:\n"
36
+ "\n"
37
+ "1. Run `python3 .add/tooling/add.py status` — where the project is and what's\n"
38
+ " next (the resume point; read it first every session).\n"
39
+ "2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task\n"
40
+ " builds on.\n"
41
+ "3. Run `python3 .add/tooling/add.py guide` — it names the phase and the exact\n"
42
+ " phase-guide file to read (the `guide :` line). Work ONLY that phase — each\n"
43
+ " guide ends with its exit gate and the command to move on.\n"
44
+ "\n"
45
+ "The flow: INTAKE sizes a request into a milestone; each task runs the\n"
46
+ "**specification bundle** — Spec+Scenarios+Contract+Tests as one bundle,\n"
47
+ "ONE human approval at the frozen contract — then a self-driving build→verify\n"
48
+ "run. Non-negotiable for every agent:\n"
49
+ "Never weaken a test or edit a frozen contract to make a build pass; a security\n"
50
+ "finding is always HARD-STOP — never auto-passed.\n"
51
+ "\n"
52
+ "On Claude Code the `add` skill drives this loop automatically; other agents\n"
53
+ "follow the three steps. The book is in `.add/docs/`. This block is generated\n"
54
+ "by `add.py sync-guidelines`; edit outside the markers, not inside.\n"
55
+ f"{_GUIDE_END}"
56
+ )
57
+
58
+
59
+ def _inject_block(path: Path) -> str:
60
+ """Write the ADD block into `path`. Returns created|updated|unchanged.
61
+
62
+ - unchanged: on-disk block already matches -> no write, no .bak (idempotent).
63
+ - updated: existing content changes -> back up the original to <path>.bak first.
64
+ - created: file did not exist -> write the block, no .bak.
65
+ User content outside the markers is always preserved.
66
+ """
67
+ block = _guideline_block()
68
+ if path.exists():
69
+ current = path.read_text(encoding="utf-8")
70
+ begin = current.find(_GUIDE_BEGIN)
71
+ if begin != -1:
72
+ end = current.find(_GUIDE_END, begin)
73
+ if end != -1: # replace only the marked region
74
+ end += len(_GUIDE_END)
75
+ new = current[:begin] + block + current[end:]
76
+ else: # begin without end: corrupt — append fresh
77
+ print(f"add: warning: {path.name}: found an ADD:BEGIN with no ADD:END "
78
+ "— appending a fresh block; review the result", file=sys.stderr)
79
+ new = current.rstrip("\n") + "\n\n" + block + "\n"
80
+ else: # no block yet — append, keep user content
81
+ new = current.rstrip("\n") + "\n\n" + block + "\n"
82
+ if new == current:
83
+ return "unchanged"
84
+ _atomic_write(Path(str(path) + ".bak"), current) # rollback path before mutate
85
+ _atomic_write(path, new)
86
+ return "updated"
87
+ _atomic_write(path, block + "\n")
88
+ return "created"
89
+
90
+
91
+ def _rule_file_mode(project_root: Path, flag: bool = False) -> bool:
92
+ """True when the ADD block should live in .claude/rules/add-workflows.md (referenced
93
+ from CLAUDE.md) instead of inline. Re-derived from disk EACH phase — no persisted
94
+ state — so an explicit `--rule-file` at install carries into init via the rule file it
95
+ leaves behind. Three triggers: the explicit flag, a ccsk project (.ccsk/ sibling to
96
+ .claude/), or a rule file already written by a prior run. Pure + fail-soft."""
97
+ if flag:
98
+ return True
99
+ try:
100
+ if (project_root / ".ccsk").is_dir():
101
+ return True
102
+ if (project_root / RULES_FILE_REL).exists():
103
+ return True
104
+ except OSError:
105
+ pass
106
+ return False
107
+
108
+
109
+ def _strip_inline_block(text: str) -> str:
110
+ """Remove an inline ADD:BEGIN..ADD:END region (migration to rule-file mode), collapsing
111
+ the blank-line gap it leaves behind. An unterminated BEGIN (no END) is left as-is rather
112
+ than eating the rest of the file (design-for-failure)."""
113
+ begin = text.find(_GUIDE_BEGIN)
114
+ if begin == -1:
115
+ return text
116
+ end = text.find(_GUIDE_END, begin)
117
+ if end == -1:
118
+ return text
119
+ end += len(_GUIDE_END)
120
+ head = text[:begin].rstrip("\n")
121
+ tail = text[end:].lstrip("\n")
122
+ if head and tail:
123
+ return head + "\n\n" + tail
124
+ return head or tail
125
+
126
+
127
+ def _insert_rule_reference(text: str) -> str:
128
+ """Insert the ADD rule-file bullet under an existing Workflows/Rules heading, or append a
129
+ fresh '## Workflows' section when none is found. Caller guarantees the bullet is absent."""
130
+ lines = text.split("\n")
131
+ heading_idx = -1
132
+ for i, line in enumerate(lines):
133
+ m = re.match(r"^(#{1,6})\s+(.*?)\s*$", line)
134
+ if m and any(m.group(2).strip().lower() == h.lower() for h in WORKFLOW_HEADINGS):
135
+ heading_idx = i
136
+ break
137
+ if heading_idx == -1: # no section — append a fresh one
138
+ body = text.rstrip("\n")
139
+ sep = "\n\n" if body else ""
140
+ return f"{body}{sep}## Workflows\n\n{_RULE_REF_LINE}\n"
141
+ level = len(re.match(r"^(#{1,6})", lines[heading_idx]).group(1))
142
+ end = len(lines) # section ends at next same/higher heading or EOF
143
+ for j in range(heading_idx + 1, len(lines)):
144
+ m = re.match(r"^(#{1,6})\s+", lines[j])
145
+ if m and len(m.group(1)) <= level:
146
+ end = j
147
+ break
148
+ insert_at = heading_idx + 1 # after the last non-blank line in the section
149
+ for j in range(heading_idx + 1, end):
150
+ if lines[j].strip():
151
+ insert_at = j + 1
152
+ lines.insert(insert_at, _RULE_REF_LINE)
153
+ return "\n".join(lines)
154
+
155
+
156
+ def _ensure_claude_reference(claude_md: Path) -> str:
157
+ """Make CLAUDE.md reference the ADD rule file under a Workflows/Rules heading, migrating
158
+ any prior inline ADD block out. Returns created|updated|unchanged.
159
+
160
+ - Strips a prior inline ADD:BEGIN..END block (rule-file mode supersedes inline).
161
+ - If a reference to add-workflows.md already exists -> no bullet change.
162
+ - Else inserts the bullet into the first matching section, or appends '## Workflows'.
163
+ .bak on change; idempotent. User content outside the touched region is preserved.
164
+ """
165
+ existed = claude_md.exists()
166
+ current = claude_md.read_text(encoding="utf-8") if existed else ""
167
+ new = _strip_inline_block(current)
168
+ if "add-workflows.md" not in new:
169
+ new = _insert_rule_reference(new)
170
+ if not new.endswith("\n"):
171
+ new += "\n"
172
+ if new == current:
173
+ return "unchanged"
174
+ if existed:
175
+ _atomic_write(Path(str(claude_md) + ".bak"), current) # rollback path before mutate
176
+ _atomic_write(claude_md, new)
177
+ return "updated" if existed else "created"
178
+
179
+
180
+ def _inject_guidelines(project_root: Path, rule_file: bool = False) -> list[tuple[str, str]]:
181
+ """Inject the block into each guideline file under `project_root`.
182
+
183
+ Symlink-dedup: targets resolving (os.path.realpath) to the same inode are
184
+ written once, against the REAL file (never replacing the symlink with a
185
+ regular file). Per-target OSError is isolated (warn+skip) so one unwritable
186
+ file never aborts the run or `init`.
187
+
188
+ Rule-file mode (ccsk projects / `--rule-file`): CLAUDE.md's full block is relocated
189
+ to .claude/rules/add-workflows.md and CLAUDE.md keeps only a reference bullet. This is
190
+ CLAUDE-only — AGENTS.md (and any other guideline file) keeps the inline block.
191
+ """
192
+ results: list[tuple[str, str]] = []
193
+ seen: set[str] = set()
194
+ mode = _rule_file_mode(project_root, rule_file)
195
+ for name in GUIDELINE_FILES:
196
+ if name == "CLAUDE.md" and mode:
197
+ rules_path = project_root / RULES_FILE_REL
198
+ real = os.path.realpath(rules_path)
199
+ if real not in seen:
200
+ seen.add(real)
201
+ try:
202
+ action = _inject_block(rules_path)
203
+ except (OSError, UnicodeDecodeError) as exc:
204
+ print(f"add: warning: could not sync {RULES_FILE_REL} — {exc}; skipped",
205
+ file=sys.stderr)
206
+ action = "skipped"
207
+ results.append((str(RULES_FILE_REL), action))
208
+ try:
209
+ ref_action = _ensure_claude_reference(project_root / "CLAUDE.md")
210
+ except (OSError, UnicodeDecodeError) as exc:
211
+ print(f"add: warning: could not sync CLAUDE.md — {exc}; skipped",
212
+ file=sys.stderr)
213
+ ref_action = "skipped"
214
+ results.append(("CLAUDE.md", ref_action))
215
+ continue
216
+ target = project_root / name
217
+ real = os.path.realpath(target)
218
+ if real in seen:
219
+ continue
220
+ seen.add(real)
221
+ write_target = Path(real) if target.is_symlink() else target
222
+ try:
223
+ action = _inject_block(write_target)
224
+ except (OSError, UnicodeDecodeError) as exc:
225
+ # design for failure: an unwritable target OR a non-UTF-8 existing file
226
+ # (e.g. a UTF-16 CLAUDE.md from a Windows editor) must not crash init or
227
+ # abort the other target — warn and skip this one.
228
+ print(f"add: warning: could not sync {name} — {exc}; skipped",
229
+ file=sys.stderr)
230
+ action = "skipped"
231
+ results.append((name, action))
232
+ return results
233
+
234
+
235
+ # --- commands ----------------------------------------------------------------
236
+
237
+ _INIT_EXCLUDE = {
238
+ ".add", "AGENTS.md", "CLAUDE.md", ".git",
239
+ ".gitignore", ".gitattributes", ".github", ".editorconfig", # VCS/CI/editor scaffolding — no domain signal
240
+ "LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", # legal boilerplate — no domain signal
241
+ } # README/docs/source are NOT excluded: they carry domain content adopt.md maps -> brownfield
242
+
243
+
244
+ def _is_brownfield(base: Path) -> bool:
245
+ """True when `base` already holds project content beyond the tool's own scaffolding.
246
+
247
+ Judgment-free: a mechanical fact (does the dir hold a non-excluded entry?), so the
248
+ autonomous-onboarding flow knows to map existing code into the living documentation. INTERPRETING
249
+ that code stays with the AI (skill/add/adopt.md) — the engine only detects + signals."""
250
+ if not base.is_dir():
251
+ return False
252
+ return any(child.name not in _INIT_EXCLUDE for child in base.iterdir())
@@ -0,0 +1,107 @@
1
+ """add_engine.identity — the git-native actor/identity seam (engine-modularization 6/N).
2
+
3
+ The 7 identity/actor functions, moved verbatim from add.py: git/OS actor resolution
4
+ (`_git_config` · `_os_user` · `_whoami`), the structured stamp every human-written
5
+ action records (`_actor_stamp` · `_render_actor_line`), and ownership parsing/matching
6
+ (`_parse_actor_arg` · `_actor_matches`).
7
+
8
+ NOT a pure-move leaf: add.py commands call `_whoami` BOTH directly and via `_actor_stamp`,
9
+ so add.py QUALIFIES its call sites to `identity._whoami(...)` and the identity tests patch
10
+ `add_engine.identity.<name>` — one target that reaches every call path (direct + internal).
11
+ Stdlib-only deps (getpass/re/shutil/subprocess); no add_engine imports (a leaf).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import getpass
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+
20
+
21
+ def _git_config(key: str) -> str | None:
22
+ """Read one `git config --get <key>`, STRICTLY fail-soft: the engine's FIRST git call,
23
+ so it never raises, never hangs, never shells. Returns the trimmed value, or None when
24
+ git is absent / errors / times out / the value is empty."""
25
+ if shutil.which("git") is None:
26
+ return None
27
+ try:
28
+ out = subprocess.run(
29
+ ["git", "config", "--get", key],
30
+ capture_output=True, text=True, timeout=2,
31
+ ).stdout.strip()
32
+ except (OSError, subprocess.SubprocessError, ValueError):
33
+ # OSError: git vanished between which() and run() / spawn error · SubprocessError:
34
+ # TimeoutExpired · ValueError: a non-UTF-8 config value (latin-1 legacy name) makes
35
+ # text=True decoding raise UnicodeDecodeError (a ValueError) — all fail soft to None.
36
+ return None
37
+ return out or None
38
+
39
+
40
+ def _os_user() -> str:
41
+ """The guaranteed non-empty OS floor. getpass.getuser() reads LOGNAME/USER/... then
42
+ falls back to the passwd database — but in a bare container (no env var AND no passwd
43
+ entry) CPython raises KeyError (OSError only on 3.13+). Catch broadly and return a
44
+ sentinel so _whoami stays TOTAL: it always yields a non-empty name, never crashes."""
45
+ try:
46
+ return getpass.getuser() or "unknown"
47
+ except (KeyError, OSError):
48
+ return "unknown"
49
+
50
+
51
+ def _whoami(state: dict) -> dict:
52
+ """Resolve the current git-native ACTOR -> {name, email, source}. Priority:
53
+ (1) an `actor_override` (whoami --set) with a non-blank name -> source 'override';
54
+ (2) `git config user.name`/`user.email` -> source 'git';
55
+ (3) the OS user (_os_user) -> source 'os', the guaranteed non-empty floor.
56
+ Total: always returns a dict with a non-empty name; `email` may be None."""
57
+ ov = state.get("actor_override")
58
+ if ov and (ov.get("name") or "").strip():
59
+ return {"name": ov["name"], "email": ov.get("email"), "source": "override"}
60
+ name = _git_config("user.name")
61
+ if name:
62
+ return {"name": name, "email": _git_config("user.email"), "source": "git"}
63
+ return {"name": _os_user(), "email": None, "source": "os"}
64
+
65
+
66
+ def _actor_stamp(state: dict) -> dict:
67
+ """The SINGLE source of the structured-actor stamp every engine-WRITTEN human action
68
+ records — lock · gate · milestone-done · release (user-identity actor-stamping). It IS
69
+ `_whoami(state)`: a TOTAL {name,email,source} (always a non-empty name), so a stamp can
70
+ never fail or block a write. Descriptive only — no command's decision reads it."""
71
+ return _whoami(state)
72
+
73
+
74
+ def _render_actor_line(state: dict) -> str:
75
+ """Render the actor stamp as one human-readable line: name, an optional angle-bracketed
76
+ email, then the source in parens — used on the RELEASES.md row (no state.json write)."""
77
+ a = _actor_stamp(state)
78
+ email = f" <{a['email']}>" if a.get("email") else ""
79
+ return f"{a['name']}{email} ({a['source']})"
80
+
81
+
82
+ def _parse_actor_arg(s: str) -> dict:
83
+ """Parse an `assign --owner`/`--assignee` value into a {name, email, source: "assigned"}
84
+ actor (ownership-assignment). "Name <email>" -> both; a bare "Name" -> email None. TOTAL:
85
+ a malformed value (no closing bracket) never raises — the whole stripped string is the name.
86
+ `source` is "assigned" — a human typed this name (not git-resolved nor an ADD override)."""
87
+ m = re.match(r"^\s*(.*?)\s*<([^>]*)>\s*$", s)
88
+ if m:
89
+ return {"name": m.group(1), "email": m.group(2) or None, "source": "assigned"}
90
+ return {"name": s.strip(), "email": None, "source": "assigned"}
91
+
92
+
93
+ def _actor_matches(rec_actor: dict | None, me: dict) -> bool:
94
+ """Does a recorded owner/assignee actor identify the SAME person as `me` (multi-active-UX)?
95
+ Email-first (the stabler key): when BOTH carry a non-empty email, emails decide; otherwise
96
+ fall back to name-equality. Both comparisons are stripped + case-insensitive. TOTAL — a None,
97
+ non-dict, or blank-name record returns False (an unowned/garbage slot is no one's)."""
98
+ if not isinstance(rec_actor, dict):
99
+ return False
100
+ rec_name = (rec_actor.get("name") or "").strip()
101
+ if not rec_name:
102
+ return False
103
+ rec_email = (rec_actor.get("email") or "").strip()
104
+ me_email = (me.get("email") or "").strip()
105
+ if rec_email and me_email:
106
+ return rec_email.lower() == me_email.lower()
107
+ return rec_name.lower() == (me.get("name") or "").strip().lower()
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ """add_engine.io_state — low-level IO + state primitives for the ADD engine.
3
+
4
+ The 4 atomic-write primitives (designed for failure: temp-then-rename, no silent
5
+ clobber, all-or-nothing for the many-writer). Extracted from add.py (engine-
6
+ modularization 2/N); add.py re-exports them as module globals so callers and
7
+ monkeypatch sites (`add._atomic_write = spy`) keep resolving unchanged.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+ import tempfile
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ from add_engine.constants import ROOT_DIRNAME, STATE_FILE
21
+
22
+ _CONFLICT_MARKER_RE = re.compile(r"(?m)^(<{7}|={7}|>{7})") # git merge-conflict markers in state
23
+
24
+
25
+ # --- low-level IO (designed for failure: atomic, no silent clobber) ----------
26
+
27
+ def _now() -> str:
28
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
29
+
30
+
31
+ def _atomic_write(path: Path, text: str) -> None:
32
+ """Write via a temp file in the same dir, then atomically replace.
33
+
34
+ Avoids a half-written file if the process dies mid-write.
35
+ """
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
38
+ try:
39
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
40
+ fh.write(text)
41
+ os.replace(tmp, path)
42
+ finally:
43
+ if os.path.exists(tmp):
44
+ os.unlink(tmp)
45
+
46
+
47
+ def _atomic_write_bytes(path: Path, data: bytes) -> None:
48
+ """Binary sibling of `_atomic_write` — lands `data` UNCHANGED (no newline translation), so a
49
+ byte-for-byte copy stays exact. Same temp-then-replace crash-safety."""
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
52
+ try:
53
+ with os.fdopen(fd, "wb") as fh:
54
+ fh.write(data)
55
+ os.replace(tmp, path)
56
+ finally:
57
+ if os.path.exists(tmp):
58
+ os.unlink(tmp)
59
+
60
+
61
+ def _atomic_write_many(writes: list[tuple[Path, str]]) -> None:
62
+ """True all-or-nothing commit across N files — design-for-failure for a multi-file write.
63
+
64
+ Phase 1 STAGES every (path, text) to a sibling `.tmp`, flushing + fsync-ing each, so the
65
+ realistic IO failures (disk full, permission denied) surface HERE, before any target changes —
66
+ and on any stage failure every staged temp is removed, so NOTHING is committed. Phase 2 then
67
+ COMMITS each file by renaming any existing target ASIDE to a sibling `.bak`, then `os.replace`-ing
68
+ the staged `.tmp` into place. If ANY commit rename raises, every file already committed is rolled
69
+ back IN REVERSE (remove the landed new file, rename its `.bak` back, or leave it absent) and the
70
+ original error re-raised — so the whole set is all-or-nothing: either every file holds its new
71
+ text, or every file holds its prior content. Restoring is an atomic rename of an already-written
72
+ `.bak` (no content held in memory, no re-write), the cheapest recovery under failing IO. Leftover
73
+ `.tmp`/`.bak` siblings are removed on every exit path.
74
+ """
75
+ staged: list[tuple[str, Path]] = []
76
+ committed: list[list] = [] # [path, bak_or_None, new_landed] per committed file
77
+ try:
78
+ for path, text in writes: # phase 1: stage every temp (fsync'd before any commit)
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
81
+ staged.append((tmp, path)) # track BEFORE write so a write/fsync failure still cleans it
82
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
83
+ fh.write(text)
84
+ fh.flush()
85
+ os.fsync(fh.fileno())
86
+ try:
87
+ for tmp, path in staged: # phase 2: commit via rename-aside
88
+ bak = None
89
+ existed = path.exists()
90
+ if existed:
91
+ fd2, bak = tempfile.mkstemp(dir=str(path.parent), suffix=".bak")
92
+ os.close(fd2)
93
+ committed.append([path, bak, False]) # track .bak NOW so cleanup never leaks it
94
+ if existed:
95
+ os.replace(path, bak) # move the existing target aside
96
+ os.replace(tmp, path) # move the new file in
97
+ committed[-1][2] = True
98
+ except OSError:
99
+ for path, bak, landed in reversed(committed): # roll back, newest-first
100
+ try:
101
+ if landed and path.exists():
102
+ os.unlink(path) # drop the new file we put in
103
+ if bak is not None and not path.exists():
104
+ os.replace(bak, path) # restore the prior target (atomic rename)
105
+ except OSError:
106
+ pass # best-effort restore under already-failing IO
107
+ raise
108
+ finally:
109
+ for tmp, _ in staged: # leftover .tmp (failed/aborted stage) never persists
110
+ if os.path.exists(tmp):
111
+ os.unlink(tmp)
112
+ for path, bak, landed in committed: # leftover .bak (success, or orphaned post-rollback)
113
+ if bak is not None and os.path.exists(bak):
114
+ os.unlink(bak)
115
+
116
+
117
+ # --- root finding + state load/save + the shared error primitive ------------
118
+
119
+ def find_root(start: Path | None = None) -> Path | None:
120
+ """Walk up from cwd to find a .add/ project root."""
121
+ cur = (start or Path.cwd()).resolve()
122
+ for d in (cur, *cur.parents):
123
+ if (d / ROOT_DIRNAME / STATE_FILE).exists():
124
+ return d / ROOT_DIRNAME
125
+ return None
126
+
127
+ def _require_root() -> Path:
128
+ root = find_root()
129
+ if root is None:
130
+ _die("no .add/ project found. Run `add.py init` first.")
131
+ return root
132
+
133
+ def _migrate_state(state: dict) -> dict:
134
+ """Forward-migrate a single-active state to the multi-active schema (team-collaboration
135
+ foundation). PURE · idempotent · TOTAL · never raises · no I/O.
136
+
137
+ A state lacking the `active_milestones` key gains it — DERIVED from the scalar
138
+ `active_milestone` (grandfather-by-missing-key, mirroring `_setup_locked`): None -> [],
139
+ "x" -> ["x"]. A per-milestone active-task map `active_tasks` is added; the old global
140
+ `active_task` is placed under its owning active milestone only when it genuinely belongs
141
+ there, else it stays as the top-level scalar fallback (orphan rule, FROZEN decision (a)).
142
+ The scalar `active_milestone` / `active_task` keys are KEPT as the N<=1 mirror so the
143
+ not-yet-routed readers keep working. An already-migrated state (key present) is returned
144
+ unchanged — never re-derived, never clobbered. Corrupt parsing stays the loader's job.
145
+
146
+ PURE in the observable sense: the caller's dict is NEVER mutated — a state that needs
147
+ migrating is upgraded on a fresh top-level copy (nested objects are shared but only read)."""
148
+ if not isinstance(state, dict) or "active_milestones" in state:
149
+ return state
150
+ migrated = dict(state)
151
+ active_ms = migrated.get("active_milestone")
152
+ migrated["active_milestones"] = [] if active_ms is None else [active_ms]
153
+ active_task = migrated.get("active_task")
154
+ tasks = migrated.get("tasks") or {}
155
+ owns = (active_ms is not None and active_task is not None
156
+ and isinstance(tasks.get(active_task), dict)
157
+ and tasks[active_task].get("milestone") == active_ms)
158
+ migrated["active_tasks"] = {active_ms: active_task} if owns else {}
159
+ return migrated
160
+
161
+ def _state_text_or_die(root: Path) -> str:
162
+ """Read state.json's raw text, failing CLOSED with a merge-SPECIFIC `state_conflicted`
163
+ message when it carries git conflict markers (an unresolved merge — the major's #1 failure
164
+ mode). A genuine read OSError is NOT swallowed: it propagates to the caller, which maps it
165
+ to its own existing code (state_invalid / no_state). The guard only READS — never writes."""
166
+ text = (root / STATE_FILE).read_text(encoding="utf-8")
167
+ if _CONFLICT_MARKER_RE.search(text):
168
+ _die(f"state_conflicted: {root / STATE_FILE} has unresolved git merge markers "
169
+ f"(<<<<<<< / ======= / >>>>>>>) — resolve them (or "
170
+ f"`git checkout --ours/--theirs {STATE_FILE}`), then run `add.py doctor` to verify")
171
+ return text
172
+
173
+ def _die(msg: str, code: int = 1) -> None:
174
+ print(f"add: error: {msg}", file=sys.stderr)
175
+ raise SystemExit(code)
176
+
177
+
178
+ def _load_state_for_json() -> tuple[Path, dict]:
179
+ """Fail-closed state load for `--json` paths: a missing project or unparseable
180
+ state.json -> `no_state` on stderr + exit 1, with EMPTY stdout (never a partial
181
+ JSON object a harness might parse). Built from State only — reads no docs/ chapter.
182
+ The parsed state is forward-migrated to the multi-active schema before it is returned."""
183
+ root = find_root()
184
+ if root is None:
185
+ _die("no_state")
186
+ try:
187
+ return root, _migrate_state(json.loads(_state_text_or_die(root)))
188
+ except (json.JSONDecodeError, OSError):
189
+ _die("no_state")
190
+
191
+
192
+ def _md5_text(s: str) -> str:
193
+ return hashlib.md5(s.encode("utf-8")).hexdigest()
194
+
195
+ def _md5_file(p: Path) -> str | None:
196
+ """md5 of a file's bytes; None on ANY read error (fail-closed — a tracked file
197
+ that cannot be read counts as DIVERGED at the gate, never a crash)."""
198
+ try:
199
+ return hashlib.md5(p.read_bytes()).hexdigest()
200
+ except OSError:
201
+ return None
@@ -0,0 +1,108 @@
1
+ """add_engine.milestones — MILESTONE.md / state milestone readers (engine-modularization 10/N).
2
+
3
+ Goal, exit-criteria (and how many cite verify-evidence), stage-criteria, all-milestones-done,
4
+ and the production-roadmap check. A closed, unpatched cluster (transitive-closure AST = zero
5
+ outbound). The cluster-private _VERIFY_CITE_RE lives here. Deps: constants + stdlib (no add).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from pathlib import Path
11
+
12
+ from add_engine.constants import GOAL_UNSET, MILESTONE_FILE
13
+
14
+ _VERIFY_CITE_RE = re.compile(r"\(verify:\s*\S.*?\)", re.I)
15
+
16
+
17
+ def _has_production_roadmap(state: dict) -> bool:
18
+ """True iff ≥1 milestone in state has stage == "production" (STATUS-AGNOSTIC).
19
+ The single source of the stage-graduation floor (v22 graduate-guide): the guard counts
20
+ that a production-roadmap RECORD exists — it never judges whether those milestones are
21
+ done/good/sufficient (gather-not-judge). An archived-out-of-state roadmap falls to --force."""
22
+ return any(m.get("stage") == "production"
23
+ for m in state.get("milestones", {}).values())
24
+
25
+ def _project_goal(root: Path) -> str:
26
+ """The project GOAL — the value of the first `goal:` line in PROJECT.md, else
27
+ GOAL_UNSET. Read-only and fail-closed: a missing/unreadable foundation or a
28
+ blank value degrades to the sentinel (orientation never raises). Mirrors how
29
+ _milestone_doc reads the milestone goal — the foundation is the single source."""
30
+ f = root / "PROJECT.md"
31
+ try:
32
+ for line in f.read_text(encoding="utf-8").splitlines():
33
+ if line.startswith("goal:"):
34
+ return line.split(":", 1)[1].strip() or GOAL_UNSET
35
+ except OSError:
36
+ pass
37
+ return GOAL_UNSET
38
+
39
+ def _milestone_doc(root: Path, mslug: str) -> tuple[str, str]:
40
+ """(title, goal) from MILESTONE.md; ('(unknown)','(unknown)') if the doc is gone."""
41
+ f = root / "milestones" / mslug / MILESTONE_FILE
42
+ if not f.exists():
43
+ return "(unknown)", "(unknown)"
44
+ title, goal = "(unknown)", "(unknown)"
45
+ for line in f.read_text(encoding="utf-8").splitlines():
46
+ if line.startswith("# MILESTONE:"):
47
+ title = line.split(":", 1)[1].strip() or "(unknown)"
48
+ elif line.startswith("goal:"):
49
+ goal = line.split(":", 1)[1].strip() or "(unknown)"
50
+ break
51
+ return title, goal
52
+
53
+ def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
54
+ """(met, total) checkbox tally inside MILESTONE.md's 'Exit criteria' section."""
55
+ f = root / "milestones" / mslug / MILESTONE_FILE
56
+ if not f.exists():
57
+ return 0, 0
58
+ m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
59
+ if not m:
60
+ return 0, 0
61
+ sec = m.group(0)
62
+ met = len(re.findall(r"- \[x\]", sec))
63
+ total = met + len(re.findall(r"- \[ \]", sec))
64
+ return met, total
65
+
66
+ def _exit_criteria_cited(root: Path, mslug: str) -> tuple[int, int]:
67
+ """(cited, total) over MILESTONE.md's 'Exit criteria' section. total = every
68
+ `- [ ]`/`- [x]` criterion line; cited = those carrying a NON-EMPTY
69
+ `(verify: <citation>)`. Read-only and PURE; missing file/section -> (0, 0).
70
+ Mirrors _exit_criteria (the checkbox tally) — an ADDITIVE classification beside
71
+ it; it never touches `milestone_goal_unmet`."""
72
+ f = root / "milestones" / mslug / MILESTONE_FILE
73
+ if not f.exists():
74
+ return 0, 0
75
+ m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
76
+ if not m:
77
+ return 0, 0
78
+ cited = total = 0
79
+ for ln in m.group(0).splitlines():
80
+ if re.match(r"\s*- \[[ x]\]", ln):
81
+ total += 1
82
+ if _VERIFY_CITE_RE.search(ln):
83
+ cited += 1
84
+ return cited, total
85
+
86
+ def _stage_criteria(root: Path) -> tuple[int, int]:
87
+ """(met, total) checkbox tally inside PROJECT.md's 'Stage goal criteria' section — the
88
+ PROJECT.md analog of _exit_criteria (v22): the human's stage-covered affirmation. Read-only
89
+ and fail-closed to (0, 0): a missing file, a missing section, or any read error never raises
90
+ and never fabricates a cue (so an unreadable foundation withholds graduation, design-for-failure)."""
91
+ try:
92
+ text = (root / "PROJECT.md").read_text(encoding="utf-8")
93
+ except OSError:
94
+ return 0, 0
95
+ m = re.search(r"## Stage goal criteria.*?(?=\n## |\Z)", text, re.S)
96
+ if not m:
97
+ return 0, 0
98
+ sec = m.group(0)
99
+ met = len(re.findall(r"- \[x\]", sec))
100
+ total = met + len(re.findall(r"- \[ \]", sec))
101
+ return met, total
102
+
103
+ def _all_milestones_done(state: dict) -> bool:
104
+ """True when the project HAS milestones and EVERY one is status=done (v22). Archived
105
+ milestones are absent from state['milestones'] (removed by the archive lifecycle), so they
106
+ do not count; a project with zero milestones is not 'covered' and returns False."""
107
+ ms = state.get("milestones") or {}
108
+ return bool(ms) and all(m.get("status") == "done" for m in ms.values())