@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.
- package/CHANGELOG.md +104 -0
- package/bin/cli.js +192 -2
- package/docs/09-the-loop.md +1 -1
- package/docs/11-governance.md +1 -1
- package/docs/14-foundation.md +1 -1
- package/docs/16-releasing.md +4 -4
- package/docs/17-components.md +125 -0
- package/docs/appendix-c-glossary.md +9 -3
- package/package.json +5 -2
- package/skill/add/SKILL.md +11 -1
- package/skill/add/components.md +54 -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 +717 -1352
- 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/TASK.fast.md.tmpl +2 -0
- package/tooling/templates/TASK.md.tmpl +1 -1
- package/tooling/templates/gitignore.tmpl +6 -0
|
@@ -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())
|