@pilotspace/add 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ # Self-improving the voice — how SOUL.md converges to the human
2
+
3
+ `SOUL.md` is the AI's voice (tone · communication style · trust). It ships as a **proposed starter**
4
+ and is **human-owned**; this doc is how it stops being write-once and starts converging to *this*
5
+ human. It mirrors `deltas.md` (emit) + `fold.md` (confirm → rewrite), but a confirmed voice delta is
6
+ consolidated into **SOUL.md**, not the foundation — because voice is not one of the five competencies.
7
+
8
+ You (the AI) **emit** voice deltas as `open`. Only the **human** confirms one, and only a confirmed
9
+ delta rewrites SOUL.md. **The human's confirm is the only writer** — you never self-approve a voice
10
+ rewrite (rewriting someone's voice is theirs to approve, like a foundation consolidation).
11
+
12
+ ## What a voice delta is drawn from
13
+
14
+ A voice delta is grounded in how the human actually shows up **in session**:
15
+ - their **wordings** — the words they reach for, and the words they correct you on (they cross out a
16
+ bit of jargon and write the plain word over it);
17
+ - their **flow** — what they skip, what they double-check, where they want the summary before the detail.
18
+
19
+ NOT from their private **memory** files or anything outside the working session — SOUL learns from what
20
+ you observed together, never from reading a personal store.
21
+
22
+ ## The grammar (mirrors deltas.md)
23
+
24
+ Each voice delta begins on its own tag line; the observation may wrap, and a required `(evidence: …)`
25
+ clause closes it:
26
+
27
+ ```
28
+ - [VOICE · <status>] <observation about the voice> (evidence: <in-session pointer>)
29
+ ```
30
+
31
+ - `<status>` — `open` | `confirmed` | `declined`. A newly emitted delta is **`open`**.
32
+ - `<observation>` — what the voice should become ("lead with the decision, not the preamble").
33
+ - `(evidence: …)` — **required**, non-empty: a moment in the session (a correction, a re-ask, a
34
+ visible preference). No evidence → it is a guess about the human, not a delta. Drop it.
35
+
36
+ ```
37
+ - [VOICE · open] the human strips hedging from my drafts — cut "I think / it seems" and state it plainly
38
+ (evidence: they rewrote two replies this session to remove the qualifier)
39
+ ```
40
+
41
+ ## The loop — observe → confirm → rewrite
42
+
43
+ 1. **Emit** (OBSERVE) — at a task's observe phase (or on demand), propose 0–N voice deltas as `open`
44
+ from the session's wordings + flow. Surface them in the report; show-before-ask.
45
+ 2. **Confirm** — the human accepts or declines each. **No SOUL.md write happens without this.**
46
+ 3. **Rewrite** — on a confirmed delta, edit the routed SOUL.md section, then record the delta line
47
+ (status `confirmed`) at the **top** of the "Voice deltas" ledger (**newest-first**, append-only — a
48
+ declined delta flips to `declined` and stays in place, so "considered, chose not to" is auditable).
49
+
50
+ ## Routing — every voice delta has a SOUL.md home
51
+
52
+ | the delta is about… | rewrite this SOUL.md section |
53
+ |---------------------|------------------------------|
54
+ | how I *sound* (warmth, directness, hedging) | **## Tone** |
55
+ | how I *structure* what I say (summary-first, show-before-ask, length) | **## Communication style** |
56
+ | what keeps the human's *trust* (gates, honesty, what I never do) | **## Trust** |
57
+
58
+ The rewrite is surgical: refine or append the bullet the delta names; never silently rewrite the rest
59
+ of the voice. Every confirmed delta also gets its line in **## Voice deltas** (newest-first).
60
+
61
+ ## Reject codes (the AI is first check, the human the backstop)
62
+
63
+ <reject_codes>
64
+ - `unconfirmed_voice_rewrite` — a SOUL.md write was attempted without a recorded human confirm. The AI
65
+ proposes; it never self-approves. Stop and get the confirm. (The identity-owned floor.)
66
+ - `no_open_voice_deltas` — nothing is `open`. The loop is a no-op; do not touch SOUL.md.
67
+ - `unroutable_voice_delta` — the observation maps to no SOUL.md section (not tone/style/trust). Fix the
68
+ delta or widen the routing before writing.
69
+ </reject_codes>
70
+
71
+ ## Where it plugs in
72
+
73
+ - **Emit**: `phases/7-observe.md` proposes voice deltas beside its competency/spec deltas.
74
+ - **Target**: `SOUL.md` (scaffolded by setup) — its "## Voice deltas" ledger holds the confirmed history.
75
+ - **Kin**: `deltas.md` (competency learnings → foundation) and `fold.md` (the same propose→confirm→write
76
+ discipline). This is the voice's version of that discipline; the engine stays judgment-free (no
77
+ `add.py` command writes the voice).
@@ -1,8 +1,11 @@
1
1
  # Parallel streams — pipelining independent tasks
2
2
 
3
- Load this **only** when a milestone has more than one task and you want to run them
4
- concurrently. The default ADD path is one task at a time; this rubric is the opt-in
5
- escape hatch for when independent tasks are queued and a human is ready to review.
3
+ Load this when a milestone has more than one task and you want to run them concurrently.
4
+ **Default:** when a project confirms `parallel + auto` as its run mode at setup
5
+ (`phases/0-setup.md` "Run mode"), parallel streaming is the project default an **opt-out**, not
6
+ the opt-in it once was; downgrade in one step (`add.py autonomy set conservative --project`, or just
7
+ run tasks one at a time). A project that kept the conservative run mode still treats this rubric as
8
+ the opt-in escape hatch. Either way it changes nothing below.
6
9
 
7
10
  It changes **no `add.py` code and no phase semantics**. It is a way *you, the
8
11
  orchestrator*, drive several tasks at once by reading the dependency DAG that
@@ -35,6 +38,44 @@ Compute both from one `python3 .add/tooling/add.py status` — no new state:
35
38
  └──────────────── a task gating PASS unblocks its dependents ──────────────┘
36
39
  ```
37
40
 
41
+ ## The DAG strategy — let the engine schedule the waves (`add.py waves`)
42
+
43
+ Do **not** eyeball the READY-QUEUE by hand once a milestone has more than a couple of
44
+ tasks — the engine computes the whole schedule from the dependency DAG `status` already
45
+ holds. `add.py waves` (read-only, writes nothing) groups the active milestone's not-done
46
+ tasks into **topological waves**, names the **critical path**, and emits an advisory
47
+ **tier hint** — exactly the inputs you need to fan out effectively:
48
+
49
+ ```
50
+ $ add.py waves
51
+ milestone: v13-onboarding-polish
52
+ wave 1: dag-scheduler, setup-suggest-milestone, setup-domain-deepdive, soul-artifact
53
+ wave 2: setup-run-mode (deps: dag-scheduler), soul-self-improve (deps: soul-artifact)
54
+ critical path: dag-scheduler → setup-run-mode (2 tasks)
55
+ tier hint: top → dag-scheduler, setup-run-mode; mid → the rest
56
+ ```
57
+
58
+ Read the schedule as a strategy, not a command:
59
+
60
+ - **Wave = a fan-out batch.** Every task in a wave has all its in-milestone deps already
61
+ PASS, so the whole wave is spawnable at once (one worker per task, `isolation="worktree"`).
62
+ Finish a wave, gate its tasks PASS, then `add.py waves` again — the next wave is unblocked.
63
+ - **Run the widest wave first.** It hides the most build latency under the human's review
64
+ latency (the honest frame above): more concurrent builds while the reviewer reads one bundle.
65
+ - **Spend your strongest model on the critical path.** The critical-path tasks gate the most
66
+ downstream work, so a wrong-but-plausible result there is the costliest — give them the
67
+ **top** tier (`run.md` tiers); off-path tasks take **mid**. The tier hint is exactly this rule,
68
+ applied to the graph. It is **advisory** — graph position is a proxy for scope difficulty, not a
69
+ gate; override it when you know a task is harder than its position suggests.
70
+ - **`--json`** (`{ milestone, waves, critical_path, critical_path_len, tiers, blocked }`) feeds a
71
+ runner that spawns the wave programmatically. `blocked` lists any task whose dep can never be
72
+ satisfied within this milestone (a cross-milestone dep) — surfaced, never silently dropped; a
73
+ `dependency_cycle` is refused with the offending members named (no schedule exists).
74
+
75
+ What `waves` does **not** change: the irreducible floor below still holds — one human approval
76
+ per contract, builds overlap but the review queue stays serial. `waves` decides *order and model*,
77
+ never *whether the human gate fires*.
78
+
38
79
  ## The autonomy level is the throttle (not a new flag)
39
80
 
40
81
  How much concurrency you actually get is set by each task's `autonomy:` header
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
@@ -1551,7 +1653,7 @@ def cmd_check(args: argparse.Namespace) -> None:
1551
1653
  "unknown_autonomy_level (token outside manual|conservative|auto)"))
1552
1654
  if _alvl is None and t.get("phase") not in ("done", "observe"):
1553
1655
  warnings.append((f"task '{slug}'", "has no explicit autonomy level (implicit_autonomy) "
1554
- "— set `autonomy: manual|conservative|auto` in the header"))
1656
+ "— run `add.py autonomy set <level>` to set it"))
1555
1657
  for dep in t.get("depends_on") or []:
1556
1658
  checks.append((dep in tasks or dep in archived_slugs,
1557
1659
  f"task '{slug}' dep '{dep}' resolves", "unknown task"))
@@ -1903,6 +2005,144 @@ def cmd_ready(args: argparse.Namespace) -> None:
1903
2005
  print(f" {slug}{suffix}")
1904
2006
 
1905
2007
 
2008
+ def _wave_schedule(state: dict, mslug: str) -> dict:
2009
+ """Pure, total: derive the DAG schedule for milestone `mslug` from state — never
2010
+ mutates, never raises on dict input. Returns one of:
2011
+ {"cycle": [slug, ...]} — unschedulable cycle
2012
+ {"waves", "critical_path", "critical_path_len", "tiers", "blocked"} — a schedule
2013
+
2014
+ A dep is SATISFIED (does not block) if it is archived or `_task_done` — the SAME
2015
+ predicate cmd_ready uses. A not-done dep that is an OPEN MEMBER of this milestone
2016
+ forces a later wave. A not-done dep that is NOT an open member (external/unknown)
2017
+ is UNSATISFIABLE here -> the task is `blocked`, never scheduled. Critical path is the
2018
+ longest chain (most tasks) through the scheduled sub-DAG; ties break by sorted slug.
2019
+ Tier is advisory: `top` on the critical path, `mid` elsewhere (scheduled tasks only)."""
2020
+ tasks = state.get("tasks") or {}
2021
+ archived = _archived_task_slugs(state)
2022
+
2023
+ def _ok(d: str) -> bool: # satisfied externally / already done
2024
+ return d in archived or (d in tasks and _task_done(tasks[d]))
2025
+
2026
+ open_members = {s: t for s, t in tasks.items()
2027
+ if t.get("milestone") == mslug and not _task_done(t)}
2028
+
2029
+ # partition open members into blocked vs schedulable — to a FIXED POINT, so blocking
2030
+ # propagates transitively: a task is blocked if any dep is unsatisfiable here, where
2031
+ # unsatisfiable = not _ok AND not a STILL-schedulable member. A dep on an already-blocked
2032
+ # member is itself unsatisfiable, so the dependent blocks too (it would otherwise be
2033
+ # mis-reported as wave-1-ready while its only dep can never complete).
2034
+ blocked: dict[str, list[str]] = {}
2035
+ changed = True
2036
+ while changed:
2037
+ changed = False
2038
+ for s, t in open_members.items():
2039
+ if s in blocked:
2040
+ continue
2041
+ bad = [d for d in (t.get("depends_on") or [])
2042
+ if not _ok(d) and not (d in open_members and d not in blocked)]
2043
+ if bad:
2044
+ blocked[s] = sorted(set(bad))
2045
+ changed = True
2046
+ schedulable = {s for s in open_members if s not in blocked}
2047
+ blocked_sorted = {k: blocked[k] for k in sorted(blocked)}
2048
+ if not schedulable:
2049
+ # nothing to schedule (all-done, empty, or every open task externally blocked)
2050
+ return {"waves": [], "critical_path": [], "critical_path_len": 0,
2051
+ "tiers": {}, "blocked": blocked_sorted}
2052
+
2053
+ def _member_deps(s: str) -> set[str]: # deps that are open members forcing order
2054
+ return {d for d in (open_members[s].get("depends_on") or []) if d in schedulable}
2055
+
2056
+ # Kahn waves over the schedulable sub-DAG
2057
+ waves: list[list[str]] = []
2058
+ placed: set[str] = set()
2059
+ remaining = set(schedulable)
2060
+ while remaining:
2061
+ wave = sorted(s for s in remaining if _member_deps(s) <= placed)
2062
+ if not wave: # no progress => a cycle among the remaining
2063
+ sub = {s: tasks[s] for s in remaining}
2064
+ cyc = _find_cycle(sub) or sorted(remaining)
2065
+ return {"cycle": cyc}
2066
+ waves.append(wave)
2067
+ placed.update(wave)
2068
+ remaining -= set(wave)
2069
+
2070
+ # critical path = longest chain by memoized depth over member-deps
2071
+ depth: dict[str, int] = {}
2072
+ pick: dict[str, str | None] = {}
2073
+
2074
+ def _depth(s: str) -> int:
2075
+ if s in depth:
2076
+ return depth[s]
2077
+ best_d, best_dep = 0, None
2078
+ for d in sorted(_member_deps(s)):
2079
+ dd = _depth(d)
2080
+ if dd > best_d or (dd == best_d and (best_dep is None or d < best_dep)):
2081
+ best_d, best_dep = dd, d
2082
+ depth[s] = 1 + best_d
2083
+ pick[s] = best_dep
2084
+ return depth[s]
2085
+
2086
+ leaf = min(schedulable, key=lambda s: (-_depth(s), s)) # deepest, tie -> smallest slug
2087
+ chain: list[str] = []
2088
+ cur: str | None = leaf
2089
+ while cur is not None:
2090
+ chain.append(cur)
2091
+ cur = pick.get(cur)
2092
+ critical = list(reversed(chain)) # root -> leaf order
2093
+ crit_set = set(critical)
2094
+ tiers = {s: ("top" if s in crit_set else "mid") for s in sorted(schedulable)}
2095
+ return {"waves": waves, "critical_path": critical, "critical_path_len": len(critical),
2096
+ "tiers": tiers, "blocked": blocked_sorted}
2097
+
2098
+
2099
+ def cmd_waves(args: argparse.Namespace) -> None:
2100
+ """READ-ONLY DAG scheduler: print the active milestone's topological waves, critical
2101
+ path, advisory tier hint, and blocked set. Writes nothing; emits no `next:` footer."""
2102
+ is_json = getattr(args, "json", False)
2103
+ if is_json:
2104
+ _, state = _load_state_for_json()
2105
+ else:
2106
+ state = load_state(_require_root())
2107
+ mslug = getattr(args, "milestone", None) or state.get("active_milestone")
2108
+ if not mslug:
2109
+ _die("no_active_milestone: no active milestone and no --milestone given")
2110
+ if mslug not in (state.get("milestones") or {}):
2111
+ _die(f"unknown_milestone: '{mslug}' is not a milestone in this project")
2112
+ sched = _wave_schedule(state, mslug)
2113
+ if "cycle" in sched:
2114
+ _die(f"dependency_cycle: not-done deps form a cycle "
2115
+ f"({' -> '.join(sched['cycle'])}) — no valid schedule")
2116
+
2117
+ if is_json:
2118
+ print(json.dumps({"milestone": mslug, **sched}))
2119
+ return
2120
+
2121
+ print(f"milestone: {mslug}")
2122
+ if not sched["waves"]:
2123
+ if sched["blocked"]:
2124
+ for s in sched["blocked"]:
2125
+ print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2126
+ else:
2127
+ print("all tasks done — nothing to schedule")
2128
+ return
2129
+ scheduled_set = {x for w in sched["waves"] for x in w}
2130
+ for i, wave in enumerate(sched["waves"], start=1):
2131
+ parts = []
2132
+ for s in wave:
2133
+ md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
2134
+ if d in scheduled_set)
2135
+ parts.append(f"{s} (deps: {', '.join(md)})" if md else s)
2136
+ print(f"wave {i}: {', '.join(parts)}")
2137
+ crit = sched["critical_path"]
2138
+ print(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
2139
+ tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
2140
+ mids = [s for s, tier in sched["tiers"].items() if tier == "mid"]
2141
+ print(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
2142
+ for s in sched["blocked"]:
2143
+ print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2144
+
2145
+
1906
2146
  def cmd_milestone_done(args: argparse.Namespace) -> None:
1907
2147
  root = _require_root()
1908
2148
  state = load_state(root)
@@ -4019,6 +4259,13 @@ def build_parser() -> argparse.ArgumentParser:
4019
4259
  pr.add_argument("--json", action="store_true", help="machine-readable JSON output")
4020
4260
  pr.set_defaults(func=cmd_ready)
4021
4261
 
4262
+ pwa = sub.add_parser("waves", help="read-only DAG schedule of a milestone: topological "
4263
+ "waves + critical path + advisory tier hint")
4264
+ pwa.add_argument("--milestone", default=None,
4265
+ help="milestone slug to schedule (default: the active milestone)")
4266
+ pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
4267
+ pwa.set_defaults(func=cmd_waves)
4268
+
4022
4269
  pmd = sub.add_parser("milestone-done", help="exit-gate a milestone (all tasks must PASS)")
4023
4270
  pmd.add_argument("slug")
4024
4271
  pmd.set_defaults(func=cmd_milestone_done)
@@ -4060,6 +4307,16 @@ def build_parser() -> argparse.ArgumentParser:
4060
4307
  pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
4061
4308
  pg.set_defaults(func=cmd_gate, _opt_positionals=("slug",))
4062
4309
 
4310
+ pan = sub.add_parser("autonomy", help="show or set the autonomy level (the verify-gate owner)")
4311
+ pan.add_argument("action", nargs="?", choices=("show", "set"), default="show")
4312
+ pan.add_argument("a1", nargs="?", default=None, help="set: <level>; show: [slug]")
4313
+ pan.add_argument("a2", nargs="?", default=None, help="set: [slug]")
4314
+ pan.add_argument("--project", action="store_true",
4315
+ help="set the PROJECT.md default instead of a task header")
4316
+ pan.add_argument("--yes", action="store_true",
4317
+ help="confirm a RAISE toward auto (a human-owned trust escalation)")
4318
+ pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
4319
+
4063
4320
  pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
4064
4321
  pr.add_argument("slug", nargs="?", default=None)
4065
4322
  # --to / --reason are validated in-body (not argparse choices) so the named reject
@@ -4171,11 +4428,120 @@ def _rebind_optional_positionals(parser: argparse.ArgumentParser,
4171
4428
  return args
4172
4429
 
4173
4430
 
4431
+ # --- agent-agnostic update nudge --------------------------------------------
4432
+ # ADD is agent-agnostic: ANY agent (Claude Code · Gemini CLI · Codex) is told by the
4433
+ # guideline block to run `add.py status`/`guide` FIRST, every session. That is the one
4434
+ # universal chokepoint to tell a stale install to refresh — a plain line on STDERR the
4435
+ # agent reads and acts on. Bounded + fail-open by design (see _maybe_nudge_update).
4436
+ #
4437
+ # This is the engine's ONE deliberate, isolated network touch. It is justified narrowly:
4438
+ # an agent that is offline cannot run at all, so when the network is unreachable this
4439
+ # silently does nothing and nothing is lost. It NEVER changes a command's stdout or exit.
4440
+ _UPDATE_CACHE = ".update-cache.json"
4441
+ _UPDATE_TTL = timedelta(hours=24) # hit the registry at most once / day
4442
+ _REGISTRY_LATEST = "https://registry.npmjs.org/@pilotspace/add/latest"
4443
+
4444
+
4445
+ def _read_json_safe(path: Path):
4446
+ try:
4447
+ return json.loads(path.read_text(encoding="utf-8"))
4448
+ except (OSError, json.JSONDecodeError):
4449
+ return None
4450
+
4451
+
4452
+ def _write_json_safe(path: Path, obj) -> None:
4453
+ try:
4454
+ path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
4455
+ except OSError:
4456
+ pass
4457
+
4458
+
4459
+ def _version_gt(a: str, b: str) -> bool:
4460
+ """True if version a is newer than b (dotted numeric; prerelease suffix dropped)."""
4461
+ def key(v: str):
4462
+ out = []
4463
+ for part in str(v).split("."):
4464
+ part = part.split("-", 1)[0]
4465
+ out.append((0, int(part)) if part.isdigit() else (1, part))
4466
+ return out
4467
+ try:
4468
+ return key(a) > key(b)
4469
+ except Exception:
4470
+ return False
4471
+
4472
+
4473
+ def _fetch_latest_version(timeout: float = 1.5):
4474
+ """GET the registry's latest version. Returns a string, or None on ANY failure
4475
+ (offline, timeout, bad payload) — the caller treats None as 'unknown, skip'."""
4476
+ try:
4477
+ req = urllib.request.Request(_REGISTRY_LATEST, headers={"Accept": "application/json"})
4478
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
4479
+ data = json.loads(resp.read().decode("utf-8"))
4480
+ v = data.get("version")
4481
+ return v if isinstance(v, str) and v else None
4482
+ except Exception:
4483
+ return None
4484
+
4485
+
4486
+ def _cached_latest(add_dir: Path):
4487
+ """The registry's latest version, throttled: served from .update-cache.json within
4488
+ the TTL, else refreshed over the network (fail-open). None when unknown."""
4489
+ cache = _read_json_safe(add_dir / _UPDATE_CACHE)
4490
+ if cache and cache.get("latest") and cache.get("checked_at"):
4491
+ try:
4492
+ ts = datetime.fromisoformat(cache["checked_at"])
4493
+ if datetime.now(timezone.utc) - ts < _UPDATE_TTL:
4494
+ return cache["latest"] # fresh -> no network
4495
+ except ValueError:
4496
+ pass
4497
+ latest = _fetch_latest_version()
4498
+ if latest:
4499
+ _write_json_safe(add_dir / _UPDATE_CACHE, {
4500
+ "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
4501
+ "latest": latest,
4502
+ })
4503
+ return latest
4504
+ return cache.get("latest") if cache else None # stale beats nothing
4505
+
4506
+
4507
+ def _maybe_nudge_update(args: argparse.Namespace) -> None:
4508
+ """If the tooling is stale, print an ACTION-REQUIRED line to STDERR so the agent
4509
+ updates before the user's task. Fully bounded + fail-open — never raises, never
4510
+ touches stdout or the exit code."""
4511
+ try:
4512
+ if os.environ.get("ADD_NO_UPDATE_CHECK"):
4513
+ return
4514
+ fn = getattr(args, "func", None)
4515
+ # orientation reads only — never a mid-run mutating verb (gate/advance/...)
4516
+ if getattr(fn, "__name__", "") not in ("cmd_status", "cmd_guide"):
4517
+ return
4518
+ add_dir = find_root()
4519
+ if add_dir is None:
4520
+ return
4521
+ stamp = _read_json_safe(add_dir / ".add-version")
4522
+ current = (stamp or {}).get("version")
4523
+ if not current:
4524
+ return # no launcher stamp -> inert (engine-only/test projects)
4525
+ latest = _cached_latest(add_dir)
4526
+ if not latest or not _version_gt(latest, current):
4527
+ return
4528
+ cmd = ("pipx run pilotspace-add update"
4529
+ if (stamp or {}).get("channel") == "pip"
4530
+ else "npx @pilotspace/add@latest update")
4531
+ sys.stderr.write(
4532
+ f"\n⚠ ADD is out of date: this project is on {current}, latest is {latest}.\n"
4533
+ f" ACTION REQUIRED before the user's task: run `{cmd}`, then re-read the "
4534
+ f"guide from disk, then continue.\n\n")
4535
+ except Exception:
4536
+ return # a nudge must NEVER break a command (design-for-failure)
4537
+
4538
+
4174
4539
  def main(argv: list[str] | None = None) -> int:
4175
4540
  parser = build_parser()
4176
4541
  args, extras = parser.parse_known_args(argv)
4177
4542
  if extras:
4178
4543
  args = _rebind_optional_positionals(parser, args, extras)
4544
+ _maybe_nudge_update(args) # advisory preamble; stderr-only, fail-open
4179
4545
  args.func(args)
4180
4546
  return 0
4181
4547
 
@@ -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)