@pilotspace/add 1.3.0 → 1.5.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/tooling/add.py CHANGED
@@ -19,7 +19,8 @@ import os
19
19
  import re
20
20
  import sys
21
21
  import tempfile
22
- from datetime import date, datetime, timezone
22
+ import urllib.request
23
+ from datetime import date, datetime, timedelta, timezone
23
24
  from pathlib import Path
24
25
 
25
26
  # --- constants ---------------------------------------------------------------
@@ -80,7 +81,23 @@ PHASE_OWNER = {
80
81
  "specify": "human", "scenarios": "human", "contract": "seam",
81
82
  "tests": "ai", "build": "ai", "verify": "human", "observe": "ai", "done": "human",
82
83
  }
83
- SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist", "DESIGN.md")
84
+ SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist", "DESIGN.md", "SOUL.md")
85
+
86
+ # Scaffolded into .add/.gitignore at init so the engine's transient LOCAL artifacts
87
+ # never reach git. Bare-filename patterns match at any depth under .add/ (tasks/,
88
+ # milestones/, archive/). These are working state, not records: scope-snapshot.json
89
+ # is the tests->build touch baseline the verify scope-gate reads from disk (the
90
+ # durable scope declaration is the state.json anchor); pre-archive-state.bak.json is
91
+ # archive-milestone's pre-delete recovery net — needed on disk, never in history;
92
+ # .update-cache.json is the update-nudge's once-a-day registry throttle. All stay on
93
+ # disk; git-ignoring them is hygiene, never deletion.
94
+ _GITIGNORE_BODY = """\
95
+ # ADD engine transient artifacts — local working state, never committed.
96
+ # (Scaffolded by `add.py init`; edit freely — init never clobbers an existing copy.)
97
+ scope-snapshot.json
98
+ pre-archive-state.bak.json
99
+ .update-cache.json
100
+ """
84
101
 
85
102
  # Guideline-injection targets + version-stable markers. NEVER change these marker
86
103
  # strings: a re-run finds the old block by exact match, so changing them would
@@ -370,6 +387,13 @@ def cmd_init(args: argparse.Namespace) -> None:
370
387
  _die(f"already initialised at {root} (use --force to reset state)")
371
388
 
372
389
  (root / "tasks").mkdir(parents=True, exist_ok=True)
390
+ # Keep the engine's transient local artifacts out of git. Never-clobber: a
391
+ # human may have customised .add/.gitignore, so an existing one is left as-is
392
+ # (mirrors the SETUP_FILES skip-not-clobber idiom). Writes ONLY this file — no
393
+ # scope-snapshot.json or .bak is created, deleted, or modified.
394
+ gitignore = root / ".gitignore"
395
+ if not gitignore.exists():
396
+ _atomic_write(gitignore, _GITIGNORE_BODY)
373
397
  today = date.today().isoformat()
374
398
  proj_name = args.name or base.name
375
399
 
@@ -456,7 +480,7 @@ def cmd_new_task(args: argparse.Namespace) -> None:
456
480
  if _project_autonomy_token(root) == "?":
457
481
  print("warning: garbled_project_autonomy — PROJECT.md declares an unrecognized "
458
482
  f"autonomy token; new task seeded fail-safe '{autonomy}' "
459
- "(set autonomy: manual|conservative|auto in PROJECT.md)", file=sys.stderr)
483
+ "(fix it with `add.py autonomy set <level> --project`)", file=sys.stderr)
460
484
 
461
485
  state["tasks"][slug] = {
462
486
  "title": title,
@@ -706,8 +730,8 @@ def cmd_gate(args: argparse.Namespace) -> None:
706
730
  hdr = _task_header(root, slug)
707
731
  if _RISK_HIGH_RE.search(hdr) and not _autonomy_lowered(hdr):
708
732
  _die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
709
- "without a lowered autonomy level — set autonomy: manual or conservative in "
710
- "the TASK.md header; a human must own a high-risk gate (run.md guard)")
733
+ "without a lowered autonomy level — run `add.py autonomy set conservative` "
734
+ "(or manual); a human must own a high-risk gate (run.md guard)")
711
735
  # tamper tripwire (verify-integrity): the method's first mechanical cheat
712
736
  # block. A completing outcome is refused if the red suite or the frozen §3
713
737
  # changed since the tests->build snapshot. Placed BEFORE the waiver write so
@@ -738,6 +762,80 @@ def cmd_gate(args: argparse.Namespace) -> None:
738
762
  print(_next_footer(root, state))
739
763
 
740
764
 
765
+ # the autonomy level as a first-class verb (task autonomy-command): autonomy was the ONLY mutable,
766
+ # security-relevant task/project token WITHOUT a CLI verb — so an agent under `auto`, applying the
767
+ # correct "first-class state has a command" model, hallucinated `add.py autonomy` and derailed.
768
+ # `show` reads the resolved level; `set` is the FIRST writer of the header token — idempotent (one
769
+ # declaration line, trailing comment preserved, NEVER appended), with the raise + risk:high guards
770
+ # enforced BEFORE the write. state.json is untouched — autonomy stays a header token.
771
+ _AUTONOMY_ORDER = {lvl: i for i, lvl in enumerate(_AUTONOMY_LEVELS)} # manual(0) < conservative(1) < auto(2)
772
+
773
+
774
+ def _autonomy_decl_line(text: str, level: str) -> str:
775
+ """Rewrite the SINGLE `autonomy:` declaration line to `level`, PRESERVING its trailing comment,
776
+ idempotently (replace in place, count=1 — never a second line). If absent, insert it: after the
777
+ `slug:` line for a task header, else after a leading `#` heading (PROJECT.md), else prepend. PURE
778
+ on the text; the caller does the atomic write."""
779
+ pat = re.compile(r"(?m)^(autonomy:[ \t]*)[^\s<#|]+(.*)$")
780
+ if pat.search(text):
781
+ return pat.sub(lambda m: f"{m.group(1)}{level}{m.group(2)}", text, count=1)
782
+ if re.search(r"(?m)^slug:", text):
783
+ return re.sub(r"(?m)^(slug:.*)$", r"\1\nautonomy: " + level, text, count=1)
784
+ lines = text.splitlines(keepends=True)
785
+ if lines and lines[0].lstrip().startswith("#"):
786
+ return lines[0] + f"autonomy: {level}\n" + "".join(lines[1:])
787
+ return f"autonomy: {level}\n" + text
788
+
789
+
790
+ def _guard_autonomy_raise(current: str, target: str, yes: bool) -> None:
791
+ """RAISING the level toward `auto` is a human-owned trust escalation (run.md: the AI may LOWER
792
+ freely — RECOMMEND-only — but RAISING needs a human). Refuse a raise unless --yes confirms it."""
793
+ if _AUTONOMY_ORDER.get(target, -1) > _AUTONOMY_ORDER.get(current, -1) and not yes:
794
+ _die(f"autonomy_raise_unconfirmed: raising autonomy {current} -> {target} is a human-owned "
795
+ "trust escalation (the AI may LOWER freely; RAISING needs a human) — pass --yes to confirm")
796
+
797
+
798
+ def _print_autonomy(root: Path, state: dict, slug: str) -> None:
799
+ """The read-only level view: declared · effective (fallback-resolved) · project default · the
800
+ verify-gate owner under it (the SAME _driver_stop the footer/guide render). Writes nothing."""
801
+ declared = _autonomy_level(_task_header(root, slug))
802
+ stop = _driver_stop(root, state, slug, "verify")
803
+ print(f"task : {slug}")
804
+ print(f"declared : {declared if declared in _AUTONOMY_LEVELS else 'unset'}")
805
+ print(f"effective : {_effective_autonomy(root, state, slug)}")
806
+ print(f"project : {_project_autonomy(root)}")
807
+ print(f"verify gate : {'human gate' if stop else 'you drive'}")
808
+
809
+
810
+ def cmd_autonomy(args: argparse.Namespace) -> None:
811
+ """show / set the autonomy level — the verify-gate owner (task autonomy-command)."""
812
+ root = _require_root() # reused -> "no .add/ project found …"
813
+ state = load_state(root)
814
+ if (getattr(args, "action", None) or "show") == "show":
815
+ _print_autonomy(root, state, _resolve_task(state, args.a1)) # reused -> "unknown task '<slug>'"
816
+ return
817
+ # action == "set"
818
+ level = args.a1
819
+ if level not in _AUTONOMY_LEVELS:
820
+ _die("autonomy_level_invalid: level must be one of "
821
+ f"{', '.join(_AUTONOMY_LEVELS)} (got {level!r})")
822
+ if getattr(args, "project", False):
823
+ target = root / "PROJECT.md"
824
+ _guard_autonomy_raise(_project_autonomy(root), level, getattr(args, "yes", False))
825
+ _atomic_write(target, _autonomy_decl_line(target.read_text(encoding="utf-8"), level))
826
+ print(f"project autonomy -> {level}")
827
+ return
828
+ slug = _resolve_task(state, args.a2) # reused -> "unknown task '<slug>'"
829
+ task_md = root / "tasks" / slug / "TASK.md"
830
+ if _RISK_HIGH_RE.search(_task_header(root, slug)) and level not in ("manual", "conservative"):
831
+ _die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high — autonomy must stay "
832
+ f"lowered (manual|conservative); refusing '{level}' (a human must own a high-risk gate)")
833
+ _guard_autonomy_raise(_effective_autonomy(root, state, slug), level, getattr(args, "yes", False))
834
+ _atomic_write(task_md, _autonomy_decl_line(task_md.read_text(encoding="utf-8"), level))
835
+ print(f"task '{slug}' autonomy -> {level}")
836
+ _print_autonomy(root, state, slug)
837
+
838
+
741
839
  def cmd_reopen(args: argparse.Namespace) -> None:
742
840
  """Return an already-`done` task to an earlier phase with a never-silent record.
743
841
 
@@ -918,6 +1016,10 @@ def cmd_status(args: argparse.Namespace) -> None:
918
1016
  # foundation pointer — read the cross-milestone context first (anti-rot)
919
1017
  if (root / "PROJECT.md").exists():
920
1018
  print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)")
1019
+ # voice pointer — the AI's SOUL (tone · style · trust); read each session, edit freely.
1020
+ # Existence-only: no open/parse, so the pointer adds no IO failure path (a non-file is no voice).
1021
+ if (root / "SOUL.md").exists():
1022
+ print("voice : .add/SOUL.md (how I sound & what keeps your trust — read each session)")
921
1023
  # wave resume hint — a live ledger outranks memory (streams.md "Wave ledger").
922
1024
  # Existence-only: no open/read/parse, so the hint adds no IO failure path; a
923
1025
  # non-file at the path is not a ledger. One line PER live ledger — more than
@@ -1504,6 +1606,27 @@ def _udd_named_set_checks(root: Path) -> list[tuple[bool, str, str]]:
1504
1606
  return out
1505
1607
 
1506
1608
 
1609
+ _CAPTURE_EXTS = ("png", "svg", "jpg", "jpeg", "webp")
1610
+
1611
+
1612
+ def _missing_captures(root: Path) -> list[str]:
1613
+ """Prototype names under `.add/design/prototypes/` lacking a design-confirm capture.
1614
+
1615
+ A prototype `<name>.json` is CAPTURED iff a file `.add/design/captures/<name>.<ext>`
1616
+ exists (ext in _CAPTURE_EXTS). Returns the uncaptured names in document (sorted) order.
1617
+ PURE · TOTAL (missing dirs -> []) · READ-ONLY (never writes, never renders): the engine
1618
+ MEASURES capture presence; producing the image is the agent's tool-agnostic choice
1619
+ (design.md beat 4; default `@json-render/image`). [] == every prototype captured / none exist.
1620
+ """
1621
+ proto_dir = root / "design" / "prototypes"
1622
+ cap_dir = root / "design" / "captures"
1623
+ if not proto_dir.is_dir():
1624
+ return []
1625
+ names = sorted(p.stem for p in proto_dir.glob("*.json") if p.is_file())
1626
+ return [n for n in names
1627
+ if not any((cap_dir / f"{n}.{ext}").is_file() for ext in _CAPTURE_EXTS)]
1628
+
1629
+
1507
1630
  def cmd_check(args: argparse.Namespace) -> None:
1508
1631
  """Read-only integrity check of the .add project. Exit 1 if anything fails."""
1509
1632
  as_json = getattr(args, "json", False)
@@ -1551,7 +1674,7 @@ def cmd_check(args: argparse.Namespace) -> None:
1551
1674
  "unknown_autonomy_level (token outside manual|conservative|auto)"))
1552
1675
  if _alvl is None and t.get("phase") not in ("done", "observe"):
1553
1676
  warnings.append((f"task '{slug}'", "has no explicit autonomy level (implicit_autonomy) "
1554
- "— set `autonomy: manual|conservative|auto` in the header"))
1677
+ "— run `add.py autonomy set <level>` to set it"))
1555
1678
  for dep in t.get("depends_on") or []:
1556
1679
  checks.append((dep in tasks or dep in archived_slugs,
1557
1680
  f"task '{slug}' dep '{dep}' resolves", "unknown task"))
@@ -1670,6 +1793,16 @@ def cmd_check(args: argparse.Namespace) -> None:
1670
1793
  # Silent when absent; read-only; fail-closed on malformed JSON.
1671
1794
  checks.extend(_udd_named_set_checks(root))
1672
1795
 
1796
+ # capture-evidence: a never-red WARN naming each prototype with no design-confirm capture
1797
+ # at .add/design/captures/<name>.<ext>. Measure-never-block — rides `warnings`, NEVER
1798
+ # `checks` (so never feeds `failed`); silent-when-absent (no prototypes -> []). The engine
1799
+ # MEASURES capture presence; producing the image is the agent's tool-agnostic choice.
1800
+ for _pname in _missing_captures(root):
1801
+ warnings.append(("missing_capture",
1802
+ f"prototype '{_pname}' has no design-confirm capture at "
1803
+ f".add/design/captures/{_pname}.<png|svg|…> — render + confirm it "
1804
+ "before build (design.md beat 4)"))
1805
+
1673
1806
  passed = sum(1 for ok, _, _ in checks if ok)
1674
1807
  failed = len(checks) - passed
1675
1808
  if as_json:
@@ -1903,6 +2036,144 @@ def cmd_ready(args: argparse.Namespace) -> None:
1903
2036
  print(f" {slug}{suffix}")
1904
2037
 
1905
2038
 
2039
+ def _wave_schedule(state: dict, mslug: str) -> dict:
2040
+ """Pure, total: derive the DAG schedule for milestone `mslug` from state — never
2041
+ mutates, never raises on dict input. Returns one of:
2042
+ {"cycle": [slug, ...]} — unschedulable cycle
2043
+ {"waves", "critical_path", "critical_path_len", "tiers", "blocked"} — a schedule
2044
+
2045
+ A dep is SATISFIED (does not block) if it is archived or `_task_done` — the SAME
2046
+ predicate cmd_ready uses. A not-done dep that is an OPEN MEMBER of this milestone
2047
+ forces a later wave. A not-done dep that is NOT an open member (external/unknown)
2048
+ is UNSATISFIABLE here -> the task is `blocked`, never scheduled. Critical path is the
2049
+ longest chain (most tasks) through the scheduled sub-DAG; ties break by sorted slug.
2050
+ Tier is advisory: `top` on the critical path, `mid` elsewhere (scheduled tasks only)."""
2051
+ tasks = state.get("tasks") or {}
2052
+ archived = _archived_task_slugs(state)
2053
+
2054
+ def _ok(d: str) -> bool: # satisfied externally / already done
2055
+ return d in archived or (d in tasks and _task_done(tasks[d]))
2056
+
2057
+ open_members = {s: t for s, t in tasks.items()
2058
+ if t.get("milestone") == mslug and not _task_done(t)}
2059
+
2060
+ # partition open members into blocked vs schedulable — to a FIXED POINT, so blocking
2061
+ # propagates transitively: a task is blocked if any dep is unsatisfiable here, where
2062
+ # unsatisfiable = not _ok AND not a STILL-schedulable member. A dep on an already-blocked
2063
+ # member is itself unsatisfiable, so the dependent blocks too (it would otherwise be
2064
+ # mis-reported as wave-1-ready while its only dep can never complete).
2065
+ blocked: dict[str, list[str]] = {}
2066
+ changed = True
2067
+ while changed:
2068
+ changed = False
2069
+ for s, t in open_members.items():
2070
+ if s in blocked:
2071
+ continue
2072
+ bad = [d for d in (t.get("depends_on") or [])
2073
+ if not _ok(d) and not (d in open_members and d not in blocked)]
2074
+ if bad:
2075
+ blocked[s] = sorted(set(bad))
2076
+ changed = True
2077
+ schedulable = {s for s in open_members if s not in blocked}
2078
+ blocked_sorted = {k: blocked[k] for k in sorted(blocked)}
2079
+ if not schedulable:
2080
+ # nothing to schedule (all-done, empty, or every open task externally blocked)
2081
+ return {"waves": [], "critical_path": [], "critical_path_len": 0,
2082
+ "tiers": {}, "blocked": blocked_sorted}
2083
+
2084
+ def _member_deps(s: str) -> set[str]: # deps that are open members forcing order
2085
+ return {d for d in (open_members[s].get("depends_on") or []) if d in schedulable}
2086
+
2087
+ # Kahn waves over the schedulable sub-DAG
2088
+ waves: list[list[str]] = []
2089
+ placed: set[str] = set()
2090
+ remaining = set(schedulable)
2091
+ while remaining:
2092
+ wave = sorted(s for s in remaining if _member_deps(s) <= placed)
2093
+ if not wave: # no progress => a cycle among the remaining
2094
+ sub = {s: tasks[s] for s in remaining}
2095
+ cyc = _find_cycle(sub) or sorted(remaining)
2096
+ return {"cycle": cyc}
2097
+ waves.append(wave)
2098
+ placed.update(wave)
2099
+ remaining -= set(wave)
2100
+
2101
+ # critical path = longest chain by memoized depth over member-deps
2102
+ depth: dict[str, int] = {}
2103
+ pick: dict[str, str | None] = {}
2104
+
2105
+ def _depth(s: str) -> int:
2106
+ if s in depth:
2107
+ return depth[s]
2108
+ best_d, best_dep = 0, None
2109
+ for d in sorted(_member_deps(s)):
2110
+ dd = _depth(d)
2111
+ if dd > best_d or (dd == best_d and (best_dep is None or d < best_dep)):
2112
+ best_d, best_dep = dd, d
2113
+ depth[s] = 1 + best_d
2114
+ pick[s] = best_dep
2115
+ return depth[s]
2116
+
2117
+ leaf = min(schedulable, key=lambda s: (-_depth(s), s)) # deepest, tie -> smallest slug
2118
+ chain: list[str] = []
2119
+ cur: str | None = leaf
2120
+ while cur is not None:
2121
+ chain.append(cur)
2122
+ cur = pick.get(cur)
2123
+ critical = list(reversed(chain)) # root -> leaf order
2124
+ crit_set = set(critical)
2125
+ tiers = {s: ("top" if s in crit_set else "mid") for s in sorted(schedulable)}
2126
+ return {"waves": waves, "critical_path": critical, "critical_path_len": len(critical),
2127
+ "tiers": tiers, "blocked": blocked_sorted}
2128
+
2129
+
2130
+ def cmd_waves(args: argparse.Namespace) -> None:
2131
+ """READ-ONLY DAG scheduler: print the active milestone's topological waves, critical
2132
+ path, advisory tier hint, and blocked set. Writes nothing; emits no `next:` footer."""
2133
+ is_json = getattr(args, "json", False)
2134
+ if is_json:
2135
+ _, state = _load_state_for_json()
2136
+ else:
2137
+ state = load_state(_require_root())
2138
+ mslug = getattr(args, "milestone", None) or state.get("active_milestone")
2139
+ if not mslug:
2140
+ _die("no_active_milestone: no active milestone and no --milestone given")
2141
+ if mslug not in (state.get("milestones") or {}):
2142
+ _die(f"unknown_milestone: '{mslug}' is not a milestone in this project")
2143
+ sched = _wave_schedule(state, mslug)
2144
+ if "cycle" in sched:
2145
+ _die(f"dependency_cycle: not-done deps form a cycle "
2146
+ f"({' -> '.join(sched['cycle'])}) — no valid schedule")
2147
+
2148
+ if is_json:
2149
+ print(json.dumps({"milestone": mslug, **sched}))
2150
+ return
2151
+
2152
+ print(f"milestone: {mslug}")
2153
+ if not sched["waves"]:
2154
+ if sched["blocked"]:
2155
+ for s in sched["blocked"]:
2156
+ print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2157
+ else:
2158
+ print("all tasks done — nothing to schedule")
2159
+ return
2160
+ scheduled_set = {x for w in sched["waves"] for x in w}
2161
+ for i, wave in enumerate(sched["waves"], start=1):
2162
+ parts = []
2163
+ for s in wave:
2164
+ md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
2165
+ if d in scheduled_set)
2166
+ parts.append(f"{s} (deps: {', '.join(md)})" if md else s)
2167
+ print(f"wave {i}: {', '.join(parts)}")
2168
+ crit = sched["critical_path"]
2169
+ print(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
2170
+ tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
2171
+ mids = [s for s, tier in sched["tiers"].items() if tier == "mid"]
2172
+ print(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
2173
+ for s in sched["blocked"]:
2174
+ print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2175
+
2176
+
1906
2177
  def cmd_milestone_done(args: argparse.Namespace) -> None:
1907
2178
  root = _require_root()
1908
2179
  state = load_state(root)
@@ -4019,6 +4290,13 @@ def build_parser() -> argparse.ArgumentParser:
4019
4290
  pr.add_argument("--json", action="store_true", help="machine-readable JSON output")
4020
4291
  pr.set_defaults(func=cmd_ready)
4021
4292
 
4293
+ pwa = sub.add_parser("waves", help="read-only DAG schedule of a milestone: topological "
4294
+ "waves + critical path + advisory tier hint")
4295
+ pwa.add_argument("--milestone", default=None,
4296
+ help="milestone slug to schedule (default: the active milestone)")
4297
+ pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
4298
+ pwa.set_defaults(func=cmd_waves)
4299
+
4022
4300
  pmd = sub.add_parser("milestone-done", help="exit-gate a milestone (all tasks must PASS)")
4023
4301
  pmd.add_argument("slug")
4024
4302
  pmd.set_defaults(func=cmd_milestone_done)
@@ -4060,6 +4338,16 @@ def build_parser() -> argparse.ArgumentParser:
4060
4338
  pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
4061
4339
  pg.set_defaults(func=cmd_gate, _opt_positionals=("slug",))
4062
4340
 
4341
+ pan = sub.add_parser("autonomy", help="show or set the autonomy level (the verify-gate owner)")
4342
+ pan.add_argument("action", nargs="?", choices=("show", "set"), default="show")
4343
+ pan.add_argument("a1", nargs="?", default=None, help="set: <level>; show: [slug]")
4344
+ pan.add_argument("a2", nargs="?", default=None, help="set: [slug]")
4345
+ pan.add_argument("--project", action="store_true",
4346
+ help="set the PROJECT.md default instead of a task header")
4347
+ pan.add_argument("--yes", action="store_true",
4348
+ help="confirm a RAISE toward auto (a human-owned trust escalation)")
4349
+ pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
4350
+
4063
4351
  pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
4064
4352
  pr.add_argument("slug", nargs="?", default=None)
4065
4353
  # --to / --reason are validated in-body (not argparse choices) so the named reject
@@ -4171,11 +4459,120 @@ def _rebind_optional_positionals(parser: argparse.ArgumentParser,
4171
4459
  return args
4172
4460
 
4173
4461
 
4462
+ # --- agent-agnostic update nudge --------------------------------------------
4463
+ # ADD is agent-agnostic: ANY agent (Claude Code · Gemini CLI · Codex) is told by the
4464
+ # guideline block to run `add.py status`/`guide` FIRST, every session. That is the one
4465
+ # universal chokepoint to tell a stale install to refresh — a plain line on STDERR the
4466
+ # agent reads and acts on. Bounded + fail-open by design (see _maybe_nudge_update).
4467
+ #
4468
+ # This is the engine's ONE deliberate, isolated network touch. It is justified narrowly:
4469
+ # an agent that is offline cannot run at all, so when the network is unreachable this
4470
+ # silently does nothing and nothing is lost. It NEVER changes a command's stdout or exit.
4471
+ _UPDATE_CACHE = ".update-cache.json"
4472
+ _UPDATE_TTL = timedelta(hours=24) # hit the registry at most once / day
4473
+ _REGISTRY_LATEST = "https://registry.npmjs.org/@pilotspace/add/latest"
4474
+
4475
+
4476
+ def _read_json_safe(path: Path):
4477
+ try:
4478
+ return json.loads(path.read_text(encoding="utf-8"))
4479
+ except (OSError, json.JSONDecodeError):
4480
+ return None
4481
+
4482
+
4483
+ def _write_json_safe(path: Path, obj) -> None:
4484
+ try:
4485
+ path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
4486
+ except OSError:
4487
+ pass
4488
+
4489
+
4490
+ def _version_gt(a: str, b: str) -> bool:
4491
+ """True if version a is newer than b (dotted numeric; prerelease suffix dropped)."""
4492
+ def key(v: str):
4493
+ out = []
4494
+ for part in str(v).split("."):
4495
+ part = part.split("-", 1)[0]
4496
+ out.append((0, int(part)) if part.isdigit() else (1, part))
4497
+ return out
4498
+ try:
4499
+ return key(a) > key(b)
4500
+ except Exception:
4501
+ return False
4502
+
4503
+
4504
+ def _fetch_latest_version(timeout: float = 1.5):
4505
+ """GET the registry's latest version. Returns a string, or None on ANY failure
4506
+ (offline, timeout, bad payload) — the caller treats None as 'unknown, skip'."""
4507
+ try:
4508
+ req = urllib.request.Request(_REGISTRY_LATEST, headers={"Accept": "application/json"})
4509
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
4510
+ data = json.loads(resp.read().decode("utf-8"))
4511
+ v = data.get("version")
4512
+ return v if isinstance(v, str) and v else None
4513
+ except Exception:
4514
+ return None
4515
+
4516
+
4517
+ def _cached_latest(add_dir: Path):
4518
+ """The registry's latest version, throttled: served from .update-cache.json within
4519
+ the TTL, else refreshed over the network (fail-open). None when unknown."""
4520
+ cache = _read_json_safe(add_dir / _UPDATE_CACHE)
4521
+ if cache and cache.get("latest") and cache.get("checked_at"):
4522
+ try:
4523
+ ts = datetime.fromisoformat(cache["checked_at"])
4524
+ if datetime.now(timezone.utc) - ts < _UPDATE_TTL:
4525
+ return cache["latest"] # fresh -> no network
4526
+ except ValueError:
4527
+ pass
4528
+ latest = _fetch_latest_version()
4529
+ if latest:
4530
+ _write_json_safe(add_dir / _UPDATE_CACHE, {
4531
+ "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
4532
+ "latest": latest,
4533
+ })
4534
+ return latest
4535
+ return cache.get("latest") if cache else None # stale beats nothing
4536
+
4537
+
4538
+ def _maybe_nudge_update(args: argparse.Namespace) -> None:
4539
+ """If the tooling is stale, print an ACTION-REQUIRED line to STDERR so the agent
4540
+ updates before the user's task. Fully bounded + fail-open — never raises, never
4541
+ touches stdout or the exit code."""
4542
+ try:
4543
+ if os.environ.get("ADD_NO_UPDATE_CHECK"):
4544
+ return
4545
+ fn = getattr(args, "func", None)
4546
+ # orientation reads only — never a mid-run mutating verb (gate/advance/...)
4547
+ if getattr(fn, "__name__", "") not in ("cmd_status", "cmd_guide"):
4548
+ return
4549
+ add_dir = find_root()
4550
+ if add_dir is None:
4551
+ return
4552
+ stamp = _read_json_safe(add_dir / ".add-version")
4553
+ current = (stamp or {}).get("version")
4554
+ if not current:
4555
+ return # no launcher stamp -> inert (engine-only/test projects)
4556
+ latest = _cached_latest(add_dir)
4557
+ if not latest or not _version_gt(latest, current):
4558
+ return
4559
+ cmd = ("pipx run pilotspace-add update"
4560
+ if (stamp or {}).get("channel") == "pip"
4561
+ else "npx @pilotspace/add@latest update")
4562
+ sys.stderr.write(
4563
+ f"\n⚠ ADD is out of date: this project is on {current}, latest is {latest}.\n"
4564
+ f" ACTION REQUIRED before the user's task: run `{cmd}`, then re-read the "
4565
+ f"guide from disk, then continue.\n\n")
4566
+ except Exception:
4567
+ return # a nudge must NEVER break a command (design-for-failure)
4568
+
4569
+
4174
4570
  def main(argv: list[str] | None = None) -> int:
4175
4571
  parser = build_parser()
4176
4572
  args, extras = parser.parse_known_args(argv)
4177
4573
  if extras:
4178
4574
  args = _rebind_optional_positionals(parser, args, extras)
4575
+ _maybe_nudge_update(args) # advisory preamble; stderr-only, fail-open
4179
4576
  args.func(args)
4180
4577
  return 0
4181
4578
 
@@ -30,3 +30,7 @@ lowest-confidence flag: the AI's ranked declaration of the 1–2 points most lik
30
30
  decision point: a stop for human judgment — the contract-freeze approval, an escalated verify gate, intake confirmation, milestone close; the machine names seam (--json owner enum, decide key) and seam-audit (CI job) keep their names (formerly "seam").
31
31
  retrospective consolidation: gathering confirmed lessons learned at milestone close and writing them append-only into the versioned foundation — human-confirmed, never self-approved; the machine names fold.md, the folded status, and add.py deltas keep their names (formerly "the fold / fold ritual").
32
32
  specification bundle: a task's spec, scenarios, contract, and failing tests drafted as one piece and approved by a person once at the contract freeze (formerly "the one-approval front").
33
+ foundation compaction: the retrospective shrink — collapse a foundation spec's stable, shipped, zero-residue tail into one rolled-up settled line; the AI proposes and the human confirms; summarize and point, never delete; a SEPARATE step from the retrospective consolidation; distinct from the engine `add.py compact` (which archives finished-milestone files).
34
+ rolled-up settled line: the single line left when a stable run is collapsed — lossy on prose, lossless on traceability (carries a `see git` pointer).
35
+ per-spec shape: each foundation spec's own tailored rolled-line format (PROJECT §Spec bullets · §Key-Decisions rows · CONVENTIONS learnings · GLOSSARY definition · MODEL_REGISTRY rows), all sharing one eligibility rule: shipped + zero open residues.
36
+ newest-first append-only: every append-only foundation sequence prepends the newest record at the top; the rolled-up settled line anchors at the bottom (the oldest end), so compaction collapses upward.
@@ -0,0 +1,40 @@
1
+ # SOUL — Trusting
2
+
3
+ > The AI's **voice** for {{project}}: how it sounds, how it communicates, and what keeps your trust.
4
+ > This is a **living doc** and it is **human-owned**. The prose below is a **starter** — a proposed
5
+ > default you can rewrite at any time. It is **overridable**, never a frozen claim about who the AI is.
6
+ > The AI proposes voice changes from how you actually talk and work; **your confirm is the only writer**.
7
+ > The observe→confirm→rewrite loop that tunes this file is the `add` skill's `soul-self-improve` path.
8
+ > Edit freely — the engine never clobbers your SOUL.md.
9
+
10
+ ## Name
11
+ **Trusting** — the voice earns trust by showing its work, not by sounding confident.
12
+
13
+ ## Tone
14
+ - Plain and direct; first-principles over jargon. Say the thing, then the why.
15
+ - Calm under uncertainty: name what you don't know instead of writing past it.
16
+ - Warm but not performative — no flattery, no filler, no congratulating the human for asking.
17
+
18
+ ## Communication style
19
+ - Lead with the summary: intent + target first, then the detail.
20
+ - Show before you ask: render the diff / result / artifact, THEN ask the human to decide.
21
+ - Surface tradeoffs and the single lowest-confidence flag; don't hide confusion.
22
+ - Keep it lean: the smallest message that moves the work forward, not more than the work needs.
23
+
24
+ ## Trust
25
+ - Never pre-stamp a human decision — freeze / gate / lock only AFTER the human has answered.
26
+ - Close a disclosed gap before recording a PASS; a security finding is always a hard stop.
27
+ - Report outcomes faithfully: failing tests stay failing in the telling, skipped steps are named.
28
+ - Identity decisions (voice, naming, brand) are the human's — ask open, don't offer a menu of guesses.
29
+
30
+ ## Learns from
31
+ - The human's **wordings** — the words they reach for, and the words they correct you on — and the
32
+ **flow** of how they actually work (what they skip, what they double-check). NOT their private memory.
33
+ - Each session: read this file when orienting, and notice where your voice diverged from theirs.
34
+
35
+ ## Voice deltas
36
+ The self-improve loop (the `add` skill's `soul.md`) proposes a confirmable **voice delta** when it sees
37
+ a gap between this voice and the human's. Lifecycle `open → confirmed`, like a foundation delta; the
38
+ human's confirm rewrites the sections above (the human is the only writer). Until confirmed, the starter
39
+ voice stands. New confirmed deltas are recorded here newest-first.
40
+ - (no confirmed voice deltas yet)
@@ -1,7 +1,7 @@
1
1
  # TASK: {{title}}
2
2
 
3
3
  slug: {{slug}} · created: {{date}} · stage: {{stage}}
4
- autonomy: {{autonomy}} <!-- inherited from the project default (PROJECT.md); explicit level: manual < conservative < auto (visible · overridable) — lower below if a high-risk task needs it. -->
4
+ autonomy: {{autonomy}} <!-- inherited from the project default (PROJECT.md); explicit level: manual < conservative < auto (visible · overridable) — lower below if a high-risk task needs it, or run `add.py autonomy set`. -->
5
5
  phase: ground <!-- ground -> specify -> scenarios -> contract -> tests -> build -> verify -> observe -> done -->
6
6
  <!-- high-risk/method-defining scope? declare `risk: high` on the slug line above and lower the
7
7
  autonomy level to `manual` or `conservative` — the engine refuses an unguarded completion
@@ -0,0 +1,53 @@
1
+ /* kit.sample.css — one class per catalog.sample.json component (the reusable kit).
2
+ Every visual value resolves a var(--…) from tokens.sample.css — no literals here, so
3
+ a token flip in tokens.sample.css restyles every screen that composes from this kit.
4
+ Screens use these classes and style nothing inline. */
5
+
6
+ /* Screen — the root viewport container */
7
+ .screen {
8
+ display: flex;
9
+ flex-direction: column;
10
+ align-items: center;
11
+ min-height: 100vh;
12
+ gap: var(--semantic-space-inset-md);
13
+ padding: var(--semantic-space-inset-md);
14
+ background: var(--semantic-color-surface);
15
+ font-family: var(--primitive-font-family-sans);
16
+ }
17
+
18
+ /* Card — a surface that groups related content */
19
+ .card {
20
+ display: flex;
21
+ flex-direction: column;
22
+ gap: var(--semantic-space-inset-sm);
23
+ width: 100%;
24
+ max-width: 360px;
25
+ padding: var(--semantic-space-inset-md);
26
+ background: var(--semantic-color-surface);
27
+ border-radius: var(--semantic-space-inset-sm);
28
+ }
29
+
30
+ /* Text — a run of styled text */
31
+ .text {
32
+ margin: 0;
33
+ line-height: 1.5;
34
+ color: var(--semantic-color-text);
35
+ font-family: var(--primitive-font-family-sans);
36
+ }
37
+ .text--bold { font-weight: var(--primitive-font-weight-bold); }
38
+ .text--regular { font-weight: var(--primitive-font-weight-regular); }
39
+
40
+ /* Button — a primary call-to-action */
41
+ .button {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+ padding: var(--semantic-space-inset-sm) var(--semantic-space-inset-md);
46
+ border: 0;
47
+ border-radius: var(--semantic-space-inset-sm);
48
+ background: var(--semantic-color-accent);
49
+ color: var(--semantic-color-surface);
50
+ font-family: var(--primitive-font-family-sans);
51
+ font-weight: var(--primitive-font-weight-bold);
52
+ cursor: pointer;
53
+ }
@@ -0,0 +1,25 @@
1
+ <!DOCTYPE html>
2
+ <!-- settings.sample.html — a SECOND screen that proves REUSE. It composes from the SAME
3
+ kit classes (screen/card/text/button) and links the SAME tokens.sample.css +
4
+ kit.sample.css as welcome. It redefines nothing — no per-screen stylesheet, no inline
5
+ styles — so a single semantic-token flip in tokens.sample.css re-renders BOTH screens
6
+ identically. This is consistency by construction. -->
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8">
10
+ <meta name="viewport" content="width=device-width, initial-scale=1">
11
+ <title>Settings — design mock</title>
12
+ <link rel="stylesheet" href="tokens.sample.css">
13
+ <link rel="stylesheet" href="kit.sample.css">
14
+ </head>
15
+ <body>
16
+ <!-- reuses the welcome kit: Screen > Card > {Text, Text, Button} -->
17
+ <main class="screen">
18
+ <section class="card">
19
+ <h1 class="text text--bold">Settings</h1>
20
+ <p class="text text--regular">Manage your workspace preferences.</p>
21
+ <button class="button" type="button">Save changes</button>
22
+ </section>
23
+ </main>
24
+ </body>
25
+ </html>