@pilotspace/add 1.10.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 +40 -0
- package/bin/cli.js +55 -0
- 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 +2 -2
- package/docs/appendix-c-glossary.md +3 -3
- package/package.json +4 -1
- package/skill/add/SKILL.md +7 -1
- 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 +361 -1571
- 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/gitignore.tmpl +6 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""add_engine.accessors — pure active-task / active-milestone state-dict accessors.
|
|
3
|
+
|
|
4
|
+
In-memory readers/mutators over the state dict (no file IO, no imports beyond
|
|
5
|
+
__future__). Extracted from add.py (engine-modularization 4/N); add.py re-exports
|
|
6
|
+
them as module globals so `add._active_task` etc. resolve unchanged.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _active_milestone(state: dict) -> str | None:
|
|
12
|
+
"""The primary active milestone — the N<=1 scalar mirror (== active_milestones[0])."""
|
|
13
|
+
return state.get("active_milestone")
|
|
14
|
+
|
|
15
|
+
def _active_task(state: dict, milestone: str | None = None) -> str | None:
|
|
16
|
+
"""The active task: per-milestone when `milestone` is given (partial-state -> None),
|
|
17
|
+
else the global/primary scalar active task. Total — never raises."""
|
|
18
|
+
if milestone is None:
|
|
19
|
+
return state.get("active_task")
|
|
20
|
+
return (state.get("active_tasks") or {}).get(milestone)
|
|
21
|
+
|
|
22
|
+
def _set_active_milestone(state: dict, slug: str | None) -> None:
|
|
23
|
+
"""Set the primary active milestone, keeping `active_milestones` consistent (N<=1 sync)."""
|
|
24
|
+
state["active_milestone"] = slug
|
|
25
|
+
state["active_milestones"] = [] if slug is None else [slug]
|
|
26
|
+
|
|
27
|
+
def _set_active_task(state: dict, slug: str | None, milestone: str | None = None) -> None:
|
|
28
|
+
"""Set the active task, keeping the scalar mirror AND the per-milestone map in sync.
|
|
29
|
+
With no owning active milestone the active task is scalar-only (the migration's orphan
|
|
30
|
+
rule); clearing (slug is None) pops the milestone's entry."""
|
|
31
|
+
state["active_task"] = slug
|
|
32
|
+
ms = milestone if milestone is not None else _active_milestone(state)
|
|
33
|
+
tasks_map = state.setdefault("active_tasks", {})
|
|
34
|
+
if ms is None:
|
|
35
|
+
return
|
|
36
|
+
if slug is None:
|
|
37
|
+
tasks_map.pop(ms, None)
|
|
38
|
+
else:
|
|
39
|
+
tasks_map[ms] = slug
|
|
40
|
+
|
|
41
|
+
def _activate_milestone(state: dict, slug: str) -> None:
|
|
42
|
+
"""Add a milestone to the active SET (idempotent) and make it the primary focus,
|
|
43
|
+
syncing the scalar active_task to that milestone's entry. Does NOT remove other members
|
|
44
|
+
(this is how a user reaches N>=2 active milestones)."""
|
|
45
|
+
ms_list = state.setdefault("active_milestones", [])
|
|
46
|
+
if slug not in ms_list:
|
|
47
|
+
ms_list.append(slug)
|
|
48
|
+
state["active_milestone"] = slug
|
|
49
|
+
state["active_task"] = (state.get("active_tasks") or {}).get(slug)
|
|
50
|
+
|
|
51
|
+
def _deactivate_milestone(state: dict, slug: str) -> None:
|
|
52
|
+
"""Remove a milestone from the active SET, pop its active-task entry, and (if it was the
|
|
53
|
+
primary) repoint the primary to the most-recent remaining member (or None when empty)."""
|
|
54
|
+
ms_list = state.setdefault("active_milestones", [])
|
|
55
|
+
if slug in ms_list:
|
|
56
|
+
ms_list.remove(slug)
|
|
57
|
+
(state.setdefault("active_tasks", {})).pop(slug, None)
|
|
58
|
+
if state.get("active_milestone") == slug:
|
|
59
|
+
new = ms_list[-1] if ms_list else None
|
|
60
|
+
state["active_milestone"] = new
|
|
61
|
+
state["active_task"] = (state.get("active_tasks") or {}).get(new) if new else None
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""add_engine.autonomy — autonomy-level read & resolve (engine-modularization 16/N).
|
|
2
|
+
|
|
3
|
+
Read the declared level off a task header, resolve the effective level (declared else the
|
|
4
|
+
project default), and read the project default token. A closed, unpatched cluster
|
|
5
|
+
(transitive-closure AST = zero outbound). The cluster-private _AUTONOMY_LINE_RE lives here;
|
|
6
|
+
the SHARED _AUTONOMY_LEVELS lives in constants.py. Deps: constants + taskdoc (_task_header)
|
|
7
|
+
+ 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 _AUTONOMY_LEVELS
|
|
15
|
+
from add_engine.taskdoc import _task_header
|
|
16
|
+
|
|
17
|
+
_AUTONOMY_LINE_RE = re.compile(r"(?:^|·)[ \t]*autonomy:[ \t]*([^\s<#|]+)", re.MULTILINE)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _autonomy_level(hdr: str):
|
|
21
|
+
"""The declared autonomy rung from a TASK.md header region (HTML comments
|
|
22
|
+
already stripped by _task_header). Returns a member of _AUTONOMY_LEVELS, or
|
|
23
|
+
None when no `autonomy:` line is present (UNSET — an unfilled `<…>` placeholder,
|
|
24
|
+
whose value the regex declines, counts as unset), or "?" when a REAL token outside
|
|
25
|
+
the set was written (unknown). PURE."""
|
|
26
|
+
m = _AUTONOMY_LINE_RE.search(hdr)
|
|
27
|
+
if not m:
|
|
28
|
+
return None
|
|
29
|
+
tok = m.group(1).strip().lower()
|
|
30
|
+
return tok if tok in _AUTONOMY_LEVELS else "?"
|
|
31
|
+
|
|
32
|
+
def _effective_autonomy(root: Path, state: dict, slug: str) -> str:
|
|
33
|
+
"""The autonomy rung that governs `slug` right now: the task's own declared rung,
|
|
34
|
+
falling back to the project default when the task line is UNSET (None) or an
|
|
35
|
+
unrecognized token ("?") — the same fail-safe chain cmd_new_task seeds from
|
|
36
|
+
(_project_autonomy: absent -> auto, garbled -> conservative). PURE. `state` is unused
|
|
37
|
+
today; it is kept in the signature beside _driver_stop for symmetry."""
|
|
38
|
+
lvl = _autonomy_level(_task_header(root, slug))
|
|
39
|
+
return lvl if lvl in _AUTONOMY_LEVELS else _project_autonomy(root)
|
|
40
|
+
|
|
41
|
+
def _project_autonomy_token(root: Path):
|
|
42
|
+
"""The RAW autonomy declaration in PROJECT.md — a recognized rung, None when no
|
|
43
|
+
declaration line is present, or "?" for a real-but-unrecognized token. Uses the
|
|
44
|
+
anchored _autonomy_level (a title/prose substring is never a declaration) with
|
|
45
|
+
HTML comments stripped. Unreadable foundation -> None. Read-only and PURE."""
|
|
46
|
+
try:
|
|
47
|
+
text = (root / "PROJECT.md").read_text(encoding="utf-8")
|
|
48
|
+
except OSError:
|
|
49
|
+
return None
|
|
50
|
+
return _autonomy_level(re.sub(r"<!--.*?-->", "", text, flags=re.S))
|
|
51
|
+
|
|
52
|
+
def _project_autonomy(root: Path) -> str:
|
|
53
|
+
"""The autonomy rung a new task INHERITS from the project default. Fail-SAFE:
|
|
54
|
+
no declaration -> "auto" (the method default; v7: absent = auto); an unrecognized
|
|
55
|
+
token -> "conservative" (NEVER silently "auto"); an unreadable foundation -> "auto".
|
|
56
|
+
Read-only and PURE — mirrors _project_goal; the seed source for cmd_new_task."""
|
|
57
|
+
tok = _project_autonomy_token(root)
|
|
58
|
+
return "auto" if tok is None else ("conservative" if tok == "?" else tok)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""add_engine.components — the component-aware-add subsystem (engine-modularization 11/N).
|
|
2
|
+
|
|
3
|
+
Registry (.add/components.toml) + produced/consumed contracts + cross-repo federation +
|
|
4
|
+
scope confinement. A closed, unpatched cluster (transitive-closure AST = zero outbound).
|
|
5
|
+
Replicates add.py's degrade-safe tomllib guard so `import` stays safe on Python < 3.11
|
|
6
|
+
(where the registry degrades to opt-out). Opt-in: no components.toml -> every reader is
|
|
7
|
+
byte-identical to single-component. Deps: stdlib only (no add, no add_engine).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
try: # component registry parse (Python 3.11+ stdlib); degrade-safe
|
|
15
|
+
import tomllib
|
|
16
|
+
except ModuleNotFoundError: # < 3.11: registry unsupported -> tomllib None -> opt-out
|
|
17
|
+
tomllib = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _confined(p: Path, rootp: Path) -> bool:
|
|
21
|
+
"""True only if p resolves (symlinks followed) inside rootp; errors -> False.
|
|
22
|
+
The v2 confinement check — no read is attempted on a path that fails it."""
|
|
23
|
+
try:
|
|
24
|
+
return p.resolve().is_relative_to(rootp)
|
|
25
|
+
except OSError:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
def _components(root: Path) -> dict[str, dict]:
|
|
29
|
+
"""The registry from .add/components.toml → {name: {root, verify, green_bar,
|
|
30
|
+
language}}. `root` required per entry; an entry missing it is skipped (the finding
|
|
31
|
+
surface reports it). `verify` is stored OPAQUE — parsed as data, NEVER executed. PURE."""
|
|
32
|
+
if tomllib is None:
|
|
33
|
+
return {}
|
|
34
|
+
try:
|
|
35
|
+
raw = (root / "components.toml").read_bytes()
|
|
36
|
+
except OSError:
|
|
37
|
+
return {}
|
|
38
|
+
try:
|
|
39
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
40
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
|
|
41
|
+
return {}
|
|
42
|
+
out: dict[str, dict] = {}
|
|
43
|
+
for name, spec in (data.get("component") or {}).items():
|
|
44
|
+
# "?" is the reserved unknown-binding sentinel (_task_component) — a component
|
|
45
|
+
# named "?" would collide and silently drop cover, so it never registers.
|
|
46
|
+
if name == "?" or not isinstance(spec, dict) or not isinstance(spec.get("root"), str):
|
|
47
|
+
continue
|
|
48
|
+
out[name] = {"root": spec["root"], "verify": spec.get("verify"),
|
|
49
|
+
"green_bar": spec.get("green_bar"), "language": spec.get("language")}
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
def _cite_region(body: str) -> str:
|
|
53
|
+
"""The user-authored "Build expectations" evidence region of a §6 body, stamp-stripped —
|
|
54
|
+
the only place a per-component green-bar cite counts (per-component-verify, v3). PURE.
|
|
55
|
+
|
|
56
|
+
The marker matches BOTH template shapes: the standard "### Build expectations …" heading AND
|
|
57
|
+
the fast-lane bare "Build expectations (from …):" line, running up to the GATE RECORD sub-block.
|
|
58
|
+
So the top-of-§6 checklist ("- [ ] all tests pass") and the "Outcome: <PASS|…>" placeholder are
|
|
59
|
+
excluded, and a component-bound FAST task is still citable. The trailing strip removes the
|
|
60
|
+
engine's own "component: … · expected green-bar: …" stamp wherever it landed, so a stamp that
|
|
61
|
+
fell inside the region can never self-satisfy the gate. No marker -> "" (fail-closed for a bound
|
|
62
|
+
task: it must declare its evidence)."""
|
|
63
|
+
m = re.search(r"(?im)^#*[ \t]*Build expectations\b.*?(?=\n#+[ \t]*GATE RECORD\b|\Z)", body, re.DOTALL)
|
|
64
|
+
region = m.group(0) if m else ""
|
|
65
|
+
return re.sub(r"(?m)^component:.*·.*expected green-bar:.*$", "", region)
|
|
66
|
+
|
|
67
|
+
def _contracts(root: Path) -> dict[str, dict]:
|
|
68
|
+
"""[contract.<id>] from .add/components.toml -> {id: {producer: str, consumers: list[str]}}.
|
|
69
|
+
A malformed entry (producer not a str) is skipped (the finding surface reports it). PURE."""
|
|
70
|
+
if tomllib is None:
|
|
71
|
+
return {}
|
|
72
|
+
try:
|
|
73
|
+
data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
|
|
74
|
+
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
|
|
75
|
+
return {}
|
|
76
|
+
out: dict[str, dict] = {}
|
|
77
|
+
for cid, spec in (data.get("contract") or {}).items():
|
|
78
|
+
if not isinstance(spec, dict) or not isinstance(spec.get("producer"), str):
|
|
79
|
+
continue
|
|
80
|
+
cons = spec.get("consumers")
|
|
81
|
+
out[cid] = {"producer": spec["producer"],
|
|
82
|
+
"consumers": [c for c in cons if isinstance(c, str)] if isinstance(cons, list) else []}
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
def _federation(root: Path) -> dict[str, dict]:
|
|
86
|
+
"""[federation.<id>] from .add/components.toml -> {id: {source: str, pin: str|None}}.
|
|
87
|
+
The cross-REPO join: a consumer repo names where a producer repo's published snapshot lives.
|
|
88
|
+
A malformed entry (no string source) is skipped; a non-string `pin` degrades to None. Degrade-safe
|
|
89
|
+
— never raises. PURE. On Python < 3.11 (no tomllib) this returns {} like the other component
|
|
90
|
+
readers, so `federate` reports federation_unknown — components.toml needs a 3.11+ runtime."""
|
|
91
|
+
if tomllib is None:
|
|
92
|
+
return {}
|
|
93
|
+
try:
|
|
94
|
+
data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
|
|
95
|
+
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
|
|
96
|
+
return {}
|
|
97
|
+
out: dict[str, dict] = {}
|
|
98
|
+
for fid, spec in (data.get("federation") or {}).items():
|
|
99
|
+
if not isinstance(spec, dict) or not isinstance(spec.get("source"), str):
|
|
100
|
+
continue
|
|
101
|
+
pin = spec.get("pin")
|
|
102
|
+
out[fid] = {"source": spec["source"], "pin": pin if isinstance(pin, str) else None}
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
def _contract_snapshot(root: Path, cid: str) -> Path:
|
|
106
|
+
return root / "contracts" / f"{cid}.json"
|
|
107
|
+
|
|
108
|
+
def _in_scope(rel: str, declared: list[str]) -> bool:
|
|
109
|
+
"""True when rel falls under any declared token — exact match for a file
|
|
110
|
+
token, whole-subtree prefix containment for a directory token ('…/')."""
|
|
111
|
+
for tok in declared:
|
|
112
|
+
if tok.endswith("/"):
|
|
113
|
+
if rel.startswith(tok) or rel == tok.rstrip("/"):
|
|
114
|
+
return True
|
|
115
|
+
elif rel == tok:
|
|
116
|
+
return True
|
|
117
|
+
return False
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""add_engine.constants — engine constants (moved verbatim from add.py).
|
|
2
|
+
|
|
3
|
+
Pure module-level constants. add.py re-exports these so `import add; add.STAGES`
|
|
4
|
+
(and the 6 _-prefixed names) still resolve. `__all__` lists the public names so
|
|
5
|
+
`from add_engine.constants import *` brings exactly them (not the Path import).
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ROOT_DIRNAME",
|
|
12
|
+
"STATE_FILE",
|
|
13
|
+
"MILESTONE_FILE",
|
|
14
|
+
"GOAL_UNSET",
|
|
15
|
+
"STAGES",
|
|
16
|
+
"GRADUATION_CUE",
|
|
17
|
+
"RELEASABLE_CUE",
|
|
18
|
+
"RELEASES_FILE",
|
|
19
|
+
"PHASES",
|
|
20
|
+
"GATES",
|
|
21
|
+
"HEAL_CAP",
|
|
22
|
+
"PHASE_GUIDE",
|
|
23
|
+
"PHASE_OWNER",
|
|
24
|
+
"SETUP_FILES",
|
|
25
|
+
"GUIDELINE_FILES",
|
|
26
|
+
"RULES_FILE_REL",
|
|
27
|
+
"WORKFLOW_HEADINGS",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
ROOT_DIRNAME = ".add"
|
|
31
|
+
STATE_FILE = "state.json"
|
|
32
|
+
MILESTONE_FILE = "MILESTONE.md"
|
|
33
|
+
# The project GOAL (v20) is read live from PROJECT.md — never copied into state.json
|
|
34
|
+
# (single-source; the foundation is the truth). A missing/blank source degrades to
|
|
35
|
+
# this sentinel so the read-only orientation surfaces never blank or crash.
|
|
36
|
+
GOAL_UNSET = "(unset — add a 'goal:' line to PROJECT.md)"
|
|
37
|
+
STAGES = ("prototype", "poc", "mvp", "production")
|
|
38
|
+
# v22 stage-graduation: the read-only cue `status` shows when the MVP is covered.
|
|
39
|
+
# Worded as the ACTION (never a file) so it stands before graduate.md exists.
|
|
40
|
+
GRADUATION_CUE = "MVP covered → propose graduation"
|
|
41
|
+
# release-altitude: the read-only cue `status` shows when ≥1 closed milestone is
|
|
42
|
+
# unreleased. The 5th scope level (release.md). `{n}` is filled at print time; the
|
|
43
|
+
# wording matches SKILL.md's "Beyond the bundle" cross-ref byte-for-byte.
|
|
44
|
+
RELEASABLE_CUE = "releasable: {n} milestone(s) closed since last release"
|
|
45
|
+
# the append-only release ledger lives at the PROJECT ROOT (the dir containing .add/),
|
|
46
|
+
# a sibling of CHANGELOG.md — NOT inside .add/. The ledger IS the attribution source:
|
|
47
|
+
# a milestone is "released" iff its slug appears on a `milestones:` row.
|
|
48
|
+
RELEASES_FILE = "RELEASES.md"
|
|
49
|
+
PHASES = ("ground", "specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
|
|
50
|
+
GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
|
|
51
|
+
# heal-then-escalate (verify-integrity): the bounded self-heal loop cap. A CONFIRMED cheat
|
|
52
|
+
# (mechanical tripwire divergence, or an agent-reported semantic refute-read finding) returns
|
|
53
|
+
# the task to BUILD for an honest redo; after HEAL_CAP such attempts the next confirmed cheat
|
|
54
|
+
# forces a HARD-STOP escalation to the human. MONOTONIC — attempts never auto-resets (a gamed
|
|
55
|
+
# green is never auto-passed; the loop is never unbounded).
|
|
56
|
+
HEAL_CAP = 3
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# `add.py guide` copy: per-phase (concrete next action, book chapter to read).
|
|
61
|
+
# Keep the action wording aligned with each phase's EXIT line in the TASK template.
|
|
62
|
+
PHASE_GUIDE = {
|
|
63
|
+
"ground": ("gather the real codebase the task touches — files, symbols, signatures, conventions, and the anchor points the contract will cite; defer to PROJECT.md/CONVENTIONS.md and gather only the task delta",
|
|
64
|
+
"02-the-flow.md"),
|
|
65
|
+
"specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions lowest-confidence first and flag the biggest risk",
|
|
66
|
+
"03-step-1-specify.md"),
|
|
67
|
+
"scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable",
|
|
68
|
+
"04-step-2-scenarios.md"),
|
|
69
|
+
"contract": ("freeze the shape — signature, fields, error codes; names match the glossary",
|
|
70
|
+
"05-step-3-contract.md"),
|
|
71
|
+
"tests": ("write one failing test per scenario; run them RED for the right reason",
|
|
72
|
+
"06-step-4-tests.md"),
|
|
73
|
+
"build": ("write the minimum code to pass the tests; change no test and no contract",
|
|
74
|
+
"07-step-5-build.md"),
|
|
75
|
+
"verify": ("run the suite + non-functional checks, then record the gate",
|
|
76
|
+
"08-step-6-verify.md"),
|
|
77
|
+
"observe": ("note what to watch + the spec delta for the next loop",
|
|
78
|
+
"09-the-loop.md"),
|
|
79
|
+
"done": ("this task is done — pick the next feature",
|
|
80
|
+
"02-the-flow.md"),
|
|
81
|
+
}
|
|
82
|
+
# Phase -> who owns it, for the `--json` autonomy signal. An autonomous harness may run a
|
|
83
|
+
# phase only when owner=="ai" (stop is false); every other phase is a checkpoint. The map
|
|
84
|
+
# follows the book's who-does-what table (Verify is "human only"); `tests`/`build`/`observe`
|
|
85
|
+
# are AI-led. A phase missing here is `unmapped_phase` (fail closed) — never defaulted.
|
|
86
|
+
PHASE_OWNER = {
|
|
87
|
+
"ground": "ai",
|
|
88
|
+
"specify": "human", "scenarios": "human", "contract": "seam",
|
|
89
|
+
"tests": "ai", "build": "ai", "verify": "human", "observe": "ai", "done": "human",
|
|
90
|
+
}
|
|
91
|
+
SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist", "DESIGN.md", "SOUL.md")
|
|
92
|
+
|
|
93
|
+
# Scaffolded into .add/.gitignore at init so the engine's transient LOCAL artifacts
|
|
94
|
+
# never reach git. Bare-filename patterns match at any depth under .add/ (tasks/,
|
|
95
|
+
# milestones/, archive/). These are working state, not records: scope-snapshot.json
|
|
96
|
+
# is the tests->build touch baseline the verify scope-gate reads from disk (the
|
|
97
|
+
# durable scope declaration is the state.json anchor); pre-archive-state.bak.json is
|
|
98
|
+
# archive-milestone's pre-delete recovery net — needed on disk, never in history;
|
|
99
|
+
# pre-update-state.bak.json is the installer update's pre-write state backup (cli.js
|
|
100
|
+
# cmdUpdate / pip _installer.update) — same "on disk, never in git" need; .update-cache.json
|
|
101
|
+
# is the update-nudge's once-a-day registry throttle. All stay on disk; git-ignoring them
|
|
102
|
+
# is hygiene, never deletion. SINGLE-SOURCED: this constant is kept byte-identical to
|
|
103
|
+
# tooling/templates/gitignore.tmpl (test_gitignore_bak_seed parity) so the installers,
|
|
104
|
+
# which seed/refresh .add/.gitignore from that template on update, never drift from init.
|
|
105
|
+
_GITIGNORE_BODY = """\
|
|
106
|
+
# ADD engine transient artifacts — local working state, never committed.
|
|
107
|
+
# (Scaffolded by `add.py init`; refreshed additively by the installer on update.)
|
|
108
|
+
scope-snapshot.json
|
|
109
|
+
pre-archive-state.bak.json
|
|
110
|
+
pre-update-state.bak.json
|
|
111
|
+
.update-cache.json
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
# Guideline-injection targets + version-stable markers. NEVER change these marker
|
|
115
|
+
# strings: a re-run finds the old block by exact match, so changing them would
|
|
116
|
+
# orphan every block written by a prior version (see TASK guideline-inject).
|
|
117
|
+
GUIDELINE_FILES = ("AGENTS.md", "CLAUDE.md")
|
|
118
|
+
_GUIDE_BEGIN = "<!-- ADD:BEGIN — managed by `add.py sync-guidelines`; do not edit inside -->"
|
|
119
|
+
_GUIDE_END = "<!-- ADD:END -->"
|
|
120
|
+
|
|
121
|
+
# Rule-file mode (ccsk-style projects): instead of inlining the block into CLAUDE.md,
|
|
122
|
+
# write it to a dedicated rule file under .claude/rules/ and leave a one-line reference
|
|
123
|
+
# in CLAUDE.md's Workflows section. .claude/rules/ is a CLAUDE-only convention, so this
|
|
124
|
+
# mode only ever relocates CLAUDE.md — AGENTS.md/.clinerules keep the inline block.
|
|
125
|
+
RULES_FILE_REL = Path(".claude") / "rules" / "add-workflows.md"
|
|
126
|
+
# Headings (most→least specific) a project may already use to group rule/workflow links.
|
|
127
|
+
# Match is case-insensitive on the heading TEXT, at any `#` level.
|
|
128
|
+
WORKFLOW_HEADINGS = ("Rules & Workflows", "Workflows", "Rules")
|
|
129
|
+
_RULE_REF_LINE = "- ADD (AI-Driven Development) Workflows rules: ./.claude/rules/add-workflows.md"
|
|
130
|
+
|
|
131
|
+
# Minimal embedded fallback so the tool still works if templates/ is missing
|
|
132
|
+
# (circuit breaker: never hard-fail just because a template file was deleted).
|
|
133
|
+
_FALLBACK_TASK = """# TASK: {title}
|
|
134
|
+
|
|
135
|
+
slug: {slug} · created: {date} · stage: {stage}
|
|
136
|
+
autonomy: auto
|
|
137
|
+
phase: ground
|
|
138
|
+
|
|
139
|
+
## 0 · GROUND
|
|
140
|
+
Touches (files · symbols · signatures):
|
|
141
|
+
Honors (patterns / conventions):
|
|
142
|
+
Anchors the contract cites:
|
|
143
|
+
|
|
144
|
+
## 1 · SPECIFY
|
|
145
|
+
Feature:
|
|
146
|
+
Framings weighed:
|
|
147
|
+
Must:
|
|
148
|
+
Reject:
|
|
149
|
+
After:
|
|
150
|
+
Assumptions — lowest-confidence first:
|
|
151
|
+
⚠ <most likely wrong> — lowest confidence because <why>; if wrong: <cost>
|
|
152
|
+
|
|
153
|
+
## 2 · SCENARIOS
|
|
154
|
+
## 3 · CONTRACT
|
|
155
|
+
Status: DRAFT
|
|
156
|
+
## 4 · TESTS
|
|
157
|
+
## 5 · BUILD
|
|
158
|
+
## 6 · VERIFY
|
|
159
|
+
### GATE RECORD
|
|
160
|
+
Outcome:
|
|
161
|
+
## 7 · OBSERVE
|
|
162
|
+
### Spec delta
|
|
163
|
+
### Competency deltas
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Fast-lane fallback: the minimal TASK.md variant (sections {0,1,3,4,5,6}; §2 + §7 dropped).
|
|
168
|
+
# Mirrors templates/TASK.fast.md.tmpl's section set (circuit-breaker parity); a deleted
|
|
169
|
+
# templates/ never hard-fails the fast lane. Keeps the trust floor: §3 freeze-flag + Status,
|
|
170
|
+
# §6 GATE RECORD/Outcome, §0 Anchors, §4 red-before-build, §5 Scope.
|
|
171
|
+
_FALLBACK_TASK_FAST = """# TASK: {title}
|
|
172
|
+
|
|
173
|
+
slug: {slug} · created: {date} · stage: {stage}
|
|
174
|
+
autonomy: auto
|
|
175
|
+
phase: ground
|
|
176
|
+
fast: true
|
|
177
|
+
|
|
178
|
+
## 0 · GROUND
|
|
179
|
+
Touches (files · symbols):
|
|
180
|
+
Anchors the contract cites:
|
|
181
|
+
|
|
182
|
+
## 1 · SPECIFY
|
|
183
|
+
Feature:
|
|
184
|
+
Must:
|
|
185
|
+
Reject:
|
|
186
|
+
Accept:
|
|
187
|
+
Assumptions: ⚠ <most likely wrong> — why; if wrong: <cost>
|
|
188
|
+
|
|
189
|
+
## 3 · CONTRACT
|
|
190
|
+
Least-sure flag surfaced at freeze:
|
|
191
|
+
Status: DRAFT
|
|
192
|
+
|
|
193
|
+
## 4 · TESTS
|
|
194
|
+
Plan:
|
|
195
|
+
Tests live in: `./tests/` · MUST run red before Build.
|
|
196
|
+
|
|
197
|
+
## 5 · BUILD
|
|
198
|
+
Scope (may touch): `./src/`
|
|
199
|
+
|
|
200
|
+
## 6 · VERIFY
|
|
201
|
+
Build expectations (from §1 Accept + §3 CONTRACT):
|
|
202
|
+
### GATE RECORD
|
|
203
|
+
Outcome:
|
|
204
|
+
Reviewed by:
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
_DEFAULT_WIDTH = 72 # fixed width for the persisted/canonical render (RETRO.md)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# --- shared delta-parsing regexes (used by taskdoc readers AND the deltas-web lint) ---
|
|
211
|
+
_DELTA_RE = re.compile(
|
|
212
|
+
r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$"
|
|
213
|
+
)
|
|
214
|
+
_EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
|
|
215
|
+
_SPEC_DELTA_RE = re.compile(
|
|
216
|
+
r"\s*-\s*\[\s*(SPEC)\s*·\s*(open|seeded|dropped)\s*\]\s*(.+)$"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --- autonomy levels (shared: autonomy resolvers + _AUTONOMY_ORDER/cmd_autonomy) ---
|
|
221
|
+
_AUTONOMY_LEVELS = ("manual", "conservative", "auto")
|