guardian-framework 0.1.158 → 0.1.160

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/dist/cli.js CHANGED
@@ -3206,7 +3206,7 @@ __export(exports_package, {
3206
3206
  bin: () => bin,
3207
3207
  author: () => author
3208
3208
  });
3209
- var name = "guardian-framework", version = "0.1.158", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
3209
+ var name = "guardian-framework", version = "0.1.160", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
3210
3210
  var init_package = __esm(() => {
3211
3211
  exports = {
3212
3212
  ".": {
package/dist/exports.js CHANGED
@@ -1991,7 +1991,7 @@ __export(exports_package, {
1991
1991
  bin: () => bin,
1992
1992
  author: () => author
1993
1993
  });
1994
- var name = "guardian-framework", version = "0.1.158", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
1994
+ var name = "guardian-framework", version = "0.1.160", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
1995
1995
  var init_package = __esm(() => {
1996
1996
  exports = {
1997
1997
  ".": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guardian-framework",
3
- "version": "0.1.158",
3
+ "version": "0.1.160",
4
4
  "description": "Token-optimized agentic framework scaffolder with pi-first architecture",
5
5
  "type": "module",
6
6
  "main": "dist/exports.js",
@@ -507,7 +507,7 @@ export default function architectExtension(pi: ExtensionAPI) {
507
507
  const tokens = raw ? parseArgs(raw) : [];
508
508
  if (tokens.length === 0) {
509
509
  ctx.ui.notify(
510
- "Usage: /architect [--epic Name] [--tracking-issue N] [--tdd] [--pr-per epic|issue] [--parallel|--worktree] | --roadmap | --phase \"Phase 1\" | --phase-status | --phase-done <N> | --phase-module-done <N> \"Module\" | status | next-epic | abort",
510
+ "Usage: /architect [--epic Name] [--tracking-issue N] [--tdd] [--pr-per epic|issue] [--parallel|--worktree] | --freeze [--module M] [--record --commit SHA] | --roadmap | --phase \"Phase 1\" | --phase-status | --phase-done <N> | --phase-module-done <N> \"Module\" | status | next-epic | abort",
511
511
  "info",
512
512
  );
513
513
  return;
@@ -872,6 +872,75 @@ export default function architectExtension(pi: ExtensionAPI) {
872
872
  return;
873
873
  }
874
874
 
875
+ // ── Freeze phase (spec v1) ────────────────────────────────────
876
+ // `/architect --freeze [--module M]` works ONLY the contract freeze for
877
+ // one module, then stops. `/architect --freeze --record` records the
878
+ // evidence after you merge. Phase B (`--epic --parallel`) runs only
879
+ // modules that pass `freeze.sh verify`.
880
+ if (tokens.includes("--freeze")) {
881
+ const freezeSh = join(ctx.cwd, ".pi", "scripts", "freeze.sh");
882
+ if (!existsSync(freezeSh)) {
883
+ ctx.ui.notify("freeze.sh not found — run `guardian update`.", "error");
884
+ return;
885
+ }
886
+ const modFlag = findFlag(tokens, "--module");
887
+
888
+ if (tokens.includes("--record")) {
889
+ if (!modFlag) {
890
+ ctx.ui.notify("Usage: /architect --freeze --record --module <name> [--commit <sha>]", "error");
891
+ return;
892
+ }
893
+ const shaFlag = findFlag(tokens, "--commit");
894
+ const cmd = `bash .pi/scripts/freeze.sh record --module "${modFlag}"${shaFlag ? ` --commit ${shaFlag}` : ""}`;
895
+ const res = runScript(ctx.cwd, cmd);
896
+ ctx.ui.notify(
897
+ String(res.stdout ?? "").trim().split("\n").slice(-14).join("\n"),
898
+ res.exitCode === 0 ? "success" : "error",
899
+ );
900
+ return;
901
+ }
902
+
903
+ // Which module? explicit, else the next eligible one in order.
904
+ let target = modFlag ?? "";
905
+ if (!target) {
906
+ const nx = runScript(ctx.cwd, "bash .pi/scripts/freeze.sh next");
907
+ target = String(nx.stdout ?? "").trim().split("\n").pop() ?? "";
908
+ if (nx.exitCode !== 0 || !target || target.startsWith("no eligible")) {
909
+ ctx.ui.notify(
910
+ String(nx.stdout ?? "").trim() || "No module is eligible to freeze (dependencies unfrozen).",
911
+ "error",
912
+ );
913
+ return;
914
+ }
915
+ }
916
+
917
+ // Freeze = create the epic + its issue set ONCE, then stop.
918
+ // (Declared here: the shared consts are defined further down.)
919
+ const freezeTracking = findFlag(tokens, "--tracking-issue");
920
+ const freezeTdd = tokens.includes("--tdd");
921
+ const state = await manager.startEpic(ctx, target, freezeTracking, freezeTdd);
922
+ if (!state?.slices?.length) {
923
+ ctx.ui.notify(`Could not start a freeze for "${target}" — check .pi/architecture/modules/.`, "error");
924
+ return;
925
+ }
926
+ ctx.ui.notify(
927
+ [
928
+ `FREEZE PHASE — ${target}`,
929
+ "",
930
+ "Work ONLY the contract-freeze issue. Do not implement.",
931
+ "Deliver: contracts (docs/contracts/…), frozen signatures, and behaviour",
932
+ "tests that FAIL for the right reason (NotImplementedError, not ImportError).",
933
+ "",
934
+ "Then, after you merge the contract-freeze PR:",
935
+ ` /architect --freeze --record --module "${target}" --commit <merge-sha>`,
936
+ "",
937
+ "Until it records and verifies, Phase B refuses this module.",
938
+ ].join("\n"),
939
+ "info",
940
+ );
941
+ return;
942
+ }
943
+
875
944
  const epicName = findFlag(tokens, "--epic");
876
945
  const trackingIssueId = findFlag(tokens, "--tracking-issue");
877
946
  // --pr-per epic (default) | issue — one PR for the whole epic, or the
@@ -1,5 +1,5 @@
1
1
  {
2
- "timestamp": "2026-11-13T19:00:09Z",
2
+ "timestamp": "2026-11-13T19:38:17Z",
3
3
  "mode": "all",
4
4
  "stages_run": [],
5
5
  "summary": {
@@ -34,8 +34,8 @@ FORCE_FREEZE=0
34
34
  DRY_RUN=0
35
35
  NO_SPAWN=0
36
36
  SPAWN=0
37
- ASSUME_FROZEN=0
38
37
  ALLOW_REMOTE=0
38
+ STOP=0
39
39
  BASE="main"
40
40
  TIMEOUT_MS=0
41
41
 
@@ -48,7 +48,12 @@ while [ $# -gt 0 ]; do
48
48
  --dry-run) DRY_RUN=1; shift ;;
49
49
  --no-spawn) NO_SPAWN=1; shift ;;
50
50
  --spawn) SPAWN=1; shift ;;
51
- --assume-frozen) ASSUME_FROZEN=1; shift ;;
51
+ --assume-frozen)
52
+ echo "⛔ --assume-frozen no longer exists: there is no bypass for the freeze gate." >&2
53
+ echo " Freeze the module properly: /architect --freeze --module <m>" >&2
54
+ exit 2
55
+ ;;
56
+ --stop) STOP=1; shift ;;
52
57
  --allow-remote-issues) ALLOW_REMOTE=1; shift ;;
53
58
  --base) BASE="${2:-main}"; shift 2 ;;
54
59
  --timeout-ms) TIMEOUT_MS="${2:-0}"; shift 2 ;;
@@ -86,6 +91,39 @@ if [ -z "$NODE_BIN" ]; then
86
91
  exit 1
87
92
  fi
88
93
 
94
+ # ── Stop: kill every spawned agent recorded in the registry ─────────────────
95
+ if [ "$STOP" -eq 1 ]; then
96
+ PIDS="$(python3 - "$COMMON_ABS/guardian/epics.json" <<'PY' 2>/dev/null
97
+ import json, sys
98
+ try:
99
+ d = json.load(open(sys.argv[1], encoding="utf-8"))
100
+ except Exception:
101
+ raise SystemExit
102
+ for mod, e in (d.get("epics") or {}).items():
103
+ if isinstance(e, dict) and e.get("pid"):
104
+ print(f"{e['pid']} {mod}")
105
+ PY
106
+ )"
107
+ if [ -z "$PIDS" ]; then
108
+ echo "No agents recorded in the registry — nothing to stop."
109
+ exit 0
110
+ fi
111
+ echo "Stopping spawned agents:"
112
+ while read -r pid mod; do
113
+ [ -z "$pid" ] && continue
114
+ if kill "$pid" 2>/dev/null; then
115
+ echo " stopped $mod (pid $pid)"
116
+ bash "$REGISTRY" set "$mod" "status=stopped" "pid=" >/dev/null 2>&1
117
+ else
118
+ echo " $mod (pid $pid) not running"
119
+ bash "$REGISTRY" set "$mod" "status=stopped" "pid=" >/dev/null 2>&1
120
+ fi
121
+ done <<<"$PIDS"
122
+ echo ""
123
+ echo "Note: each agent's own pi child is terminated with its runner."
124
+ exit 0
125
+ fi
126
+
89
127
  # ── Resolve the module list ─────────────────────────────────────────────────
90
128
  # Explicit list wins. Otherwise take roadmap order, skipping implemented modules.
91
129
  SELECTED="$(
@@ -193,6 +231,39 @@ if [ "$SPAWN" -eq 1 ] && [ "$MAX" -le 0 ]; then
193
231
  fi
194
232
  fi
195
233
 
234
+ # ── Freeze gate (spec v1): Phase B only runs FROZEN modules ──────────────────
235
+ # A module is frozen when freeze.sh verify passes: contracts merged, signatures
236
+ # present, behaviour tests RED for the right reason, ACs referenced, deps frozen.
237
+ # There is no bypass: this is the one decision a human makes.
238
+ FREEZE_SH="$ROOT/.pi/scripts/freeze.sh"
239
+ if [ ! -f "$FREEZE_SH" ]; then
240
+ echo "⛔ missing .pi/scripts/freeze.sh — run guardian update" >&2
241
+ exit 1
242
+ fi
243
+
244
+ UNFROZEN=""
245
+ for m in ${MODULES[@]+"${MODULES[@]}"}; do
246
+ if ! bash "$FREEZE_SH" verify --module "$m" >/tmp/guardian-freeze-$$.log 2>&1; then
247
+ UNFROZEN="$UNFROZEN$m
248
+ "
249
+ fi
250
+ done
251
+
252
+ if [ -n "$UNFROZEN" ]; then
253
+ echo "⛔ Not frozen — Phase B only runs frozen modules:"
254
+ printf '%s' "$UNFROZEN" | sed 's/^/ - /'
255
+ echo ""
256
+ echo " Freeze first (one module at a time, in dependency order):"
257
+ echo " /architect --freeze --module <m>"
258
+ echo " then merge the contract-freeze PR and record it:"
259
+ echo " bash .pi/scripts/freeze.sh record --module <m> --commit <sha>"
260
+ echo ""
261
+ echo " Detail for the first module:"
262
+ bash "$FREEZE_SH" verify --module "$(printf '%s' "$UNFROZEN" | head -1)"
263
+ exit 2
264
+ fi
265
+ rm -f /tmp/guardian-freeze-$$.log
266
+
196
267
  # ── Pre-flight: the spawned agents inherit THIS project's .pi/ ──────────────
197
268
  # Without extensions, /architect is not a command: the prompt falls through to
198
269
  # the model, which then improvises (expensive and wrong). Fail before spawning.
@@ -421,45 +492,6 @@ PY
421
492
  exit 0
422
493
  fi
423
494
 
424
- # ── Contract-freeze gate (the module itself) ────────────────────────────────
425
- # Freeze is a human act: it asserts the module's public contracts are reviewed
426
- # and merged. This script must never assert that for you — doing so is what let
427
- # a fan-out start six agents on unreviewed contracts.
428
- if [ "$ASSUME_FROZEN" -eq 0 ]; then
429
- UNFROZEN="$(python3 - "$COMMON_ABS/guardian/epics.json" ${MODULES[@]+"${MODULES[@]}"} <<'PY'
430
- import json, re, sys
431
-
432
- path, batch = sys.argv[1], sys.argv[2:]
433
- frozen = set()
434
- try:
435
- for mod, entry in (json.load(open(path, encoding="utf-8")).get("epics") or {}).items():
436
- if isinstance(entry, dict) and entry.get("contractFrozen"):
437
- frozen.add(re.sub(r"[^a-z0-9]+", "-", mod.lower()).strip("-"))
438
- except Exception:
439
- pass
440
-
441
- missing = []
442
- for m in batch:
443
- slug = re.sub(r"[^a-z0-9]+", "-", m.lower()).strip("-")
444
- if slug not in frozen:
445
- missing.append(m)
446
- print("\n".join(missing))
447
- PY
448
- )"
449
- if [ -n "$UNFROZEN" ]; then
450
- echo "⛔ Contracts are not frozen for:"
451
- echo "$UNFROZEN" | sed 's/^/ - /'
452
- echo ""
453
- echo " Freeze is YOUR assertion that the module's public contracts are"
454
- echo " reviewed and merged. Review the module doc, merge its contract"
455
- echo " freeze, then record it:"
456
- echo " bash .pi/scripts/epic-registry.sh freeze \"<Module>\" --frozen"
457
- echo ""
458
- echo " Bypass only if you are certain: --assume-frozen"
459
- exit 2
460
- fi
461
- fi
462
-
463
495
  # ── Freeze, create worktrees, spawn ─────────────────────────────────────────
464
496
  mkdir -p "$LOGDIR"
465
497
  STARTED=()
@@ -474,9 +506,7 @@ for m in ${MODULES[@]+"${MODULES[@]}"}; do
474
506
 
475
507
 
476
508
  # 2. worktree + epic branch (gated on the freeze we just recorded)
477
- WT_ARGS=("$m" --base "$BASE")
478
- [ "$ASSUME_FROZEN" -eq 1 ] && WT_ARGS+=(--assume-frozen)
479
- WT_OUT="$(bash "$PARALLEL" "${WT_ARGS[@]}" 2>&1)"
509
+ WT_OUT="$(bash "$PARALLEL" "$m" --base "$BASE" 2>&1)"
480
510
  WT_RC=$?
481
511
  if [ "$WT_RC" -ne 0 ]; then
482
512
  echo " ✗ worktree failed (exit $WT_RC)"
@@ -13,10 +13,9 @@ set -uo pipefail
13
13
 
14
14
  MODULE="${1:-}"
15
15
  shift || true
16
- BASE="main"; DIR=""; ASSUME_FROZEN=0
16
+ BASE="main"; DIR=""
17
17
  while [ $# -gt 0 ]; do
18
18
  case "$1" in
19
- --assume-frozen) ASSUME_FROZEN=1; shift ;;
20
19
  --base) BASE="${2:-main}"; shift 2 ;;
21
20
  --dir) DIR="${2:-}"; shift 2 ;;
22
21
  *) shift ;;
@@ -44,32 +43,26 @@ echo " branch: $BRANCH"
44
43
  echo " worktree: $DIR"
45
44
  echo ""
46
45
 
47
- # ── Contract-freeze gate ────────────────────────────────────────────────────
48
- # An epic may only run in parallel once its shared contracts are frozen. The
49
- # registry is the source of truth (set by the contract-freeze issue / a human).
50
- REG_FILE="$REG/guardian/epics.json"
51
- FROZEN="unknown"
52
- if [ -f "$REG_FILE" ]; then
53
- FROZEN=$(python3 - "$REG_FILE" "$MODULE" <<'PY' 2>/dev/null || echo unknown
54
- import json,sys
55
- d=json.load(open(sys.argv[1]))
56
- e=d.get("epics",{}).get(sys.argv[2],{})
57
- print("true" if e.get("contractFrozen") else ("false" if e else "unknown"))
58
- PY
59
- )
60
- fi
61
-
62
- if [ "$FROZEN" != "true" ] && [ "$ASSUME_FROZEN" -eq 0 ]; then
63
- # NOTE: block here (before worktree creation) and exit 2 — a non-zero,
64
- # distinguishable code so callers/agents cannot mistake it for success.
65
- echo "⛔ Blocked: contracts for '$MODULE' are not frozen (registry: $FROZEN)."
66
- echo ""
67
- echo " Parallel epics must not redefine shared contracts. Freeze first:"
68
- echo " 1. run the epic's Contract Freeze issue and merge it to $BASE"
69
- echo " 2. record it: bash .pi/scripts/epic-registry.sh freeze \"$MODULE\" --frozen"
70
- echo ""
71
- echo " (single-epic work does not need this gate)"
72
- exit 2
46
+ # The project dir (owner of .pi/) is NOT necessarily the git top level: a
47
+ # monorepo keeps the app in <repo>/backend with .pi/ inside it.
48
+ PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
49
+ FREEZE_SH="$PROJECT_DIR/.pi/scripts/freeze.sh"
50
+ if [ -f "$FREEZE_SH" ]; then
51
+ if ! bash "$FREEZE_SH" verify --module "$MODULE" >/dev/null 2>&1; then
52
+ echo "⛔ Blocked: '$MODULE' is not frozen (freeze.sh verify failed)."
53
+ echo ""
54
+ echo " Freeze it first:"
55
+ echo " /architect --freeze --module \"$MODULE\""
56
+ echo " then merge the contract-freeze PR and record it:"
57
+ echo " bash .pi/scripts/freeze.sh record --module \"$MODULE\" --commit <sha>"
58
+ echo ""
59
+ echo " Detail:"
60
+ bash "$FREEZE_SH" verify --module "$MODULE" 2>&1 | sed 's/^/ /'
61
+ exit 2
62
+ fi
63
+ else
64
+ echo "⛔ missing .pi/scripts/freeze.sh — run guardian update" >&2
65
+ exit 1
73
66
  fi
74
67
 
75
68
  # ── Worktree creation ───────────────────────────────────────────────────────
@@ -0,0 +1,471 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # freeze.sh — the contract-freeze state machine (spec v1)
4
+ # ============================================================================
5
+ # "Frozen" is not a flag somebody sets. It is evidence that can be re-checked:
6
+ #
7
+ # frozen = contracts merged on the base branch
8
+ # + frozen signatures in source
9
+ # + behaviour tests that are RED for the right reason
10
+ # + every test names an AC that exists in the module doc
11
+ # + no structural-only tests (isinstance / dataclass / hasattr)
12
+ #
13
+ # Usage:
14
+ # freeze.sh verify --module <m> [--root DIR] [--base main] [--json]
15
+ # freeze.sh record --module <m> [--root DIR] [--base main] [--commit SHA]
16
+ # freeze.sh status [--root DIR]
17
+ # freeze.sh list [--root DIR]
18
+ # freeze.sh next [--root DIR] # next module eligible to freeze, in order
19
+ #
20
+ # Exit: 0 pass · 1 verification failed · 2 usage/state error
21
+ # ============================================================================
22
+ set -uo pipefail
23
+
24
+ CMD="${1:-}"; shift || true
25
+ MODULE=""; ROOT_OPT=""; BASE="main"; JSON_OUT=0; COMMIT=""
26
+
27
+ while [ $# -gt 0 ]; do
28
+ case "$1" in
29
+ --module) MODULE="${2:-}"; shift 2 ;;
30
+ --root) ROOT_OPT="${2:-}"; shift 2 ;;
31
+ --base) BASE="${2:-main}"; shift 2 ;;
32
+ --commit) COMMIT="${2:-}"; shift 2 ;;
33
+ --json) JSON_OUT=1; shift ;;
34
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
35
+ esac
36
+ done
37
+
38
+ # Project root: the directory that owns .pi/ (this script lives in <root>/.pi/scripts).
39
+ PROJECT_DIR="$(
40
+ d="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
41
+ while [ "$d" != "/" ]; do
42
+ if [ -d "$d/.pi" ]; then echo "$d"; exit 0; fi
43
+ d="$(dirname "$d")"
44
+ done
45
+ echo "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
46
+ )"
47
+ ROOT="${ROOT_OPT:-$PROJECT_DIR}"
48
+ GIT_TOP="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || echo "$ROOT")"
49
+ COMMON_DIR="$(cd "$(git -C "$ROOT" rev-parse --git-common-dir 2>/dev/null || echo "$GIT_TOP/.git")" 2>/dev/null && pwd || echo "$GIT_TOP/.git")"
50
+ STATE="$COMMON_DIR/guardian/epics.json"
51
+
52
+ usage() {
53
+ sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
54
+ }
55
+
56
+ case "$CMD" in
57
+ verify | record | status | list | next) ;;
58
+ *) usage; exit 2 ;;
59
+ esac
60
+
61
+ exec python3 - "$CMD" "$ROOT" "$GIT_TOP" "$STATE" "$MODULE" "$BASE" "$JSON_OUT" "$COMMIT" <<'PY'
62
+ import json, os, re, subprocess, sys
63
+ from datetime import datetime, timezone
64
+
65
+ cmd, root, git_top, state_path, module, base, json_out, commit = sys.argv[1:9]
66
+ json_out = json_out == "1"
67
+
68
+ MODS = os.path.join(root, ".pi", "architecture", "modules")
69
+
70
+ def slugify(s):
71
+ return re.sub(r"[^a-z0-9]+", "-", (s or "").lower()).strip("-")
72
+
73
+ def read(path):
74
+ try:
75
+ return open(path, encoding="utf-8", errors="ignore").read()
76
+ except OSError:
77
+ return ""
78
+
79
+ def module_doc(slug):
80
+ p = os.path.join(MODS, f"{slug}.md")
81
+ return p if os.path.exists(p) else None
82
+
83
+ def git(*args):
84
+ try:
85
+ return subprocess.run(["git", "-C", git_top] + list(args),
86
+ capture_output=True, text=True, timeout=60).stdout.strip()
87
+ except Exception:
88
+ return ""
89
+
90
+ def load_state():
91
+ try:
92
+ return json.load(open(state_path, encoding="utf-8"))
93
+ except Exception:
94
+ return {"version": 1, "epics": {}}
95
+
96
+ def save_state(state):
97
+ os.makedirs(os.path.dirname(state_path), exist_ok=True)
98
+ json.dump(state, open(state_path, "w", encoding="utf-8"), indent=2)
99
+
100
+ def entry(state, slug):
101
+ e = (state.get("epics") or {}).get(slug)
102
+ return e if isinstance(e, dict) else {}
103
+
104
+ def set_entry(doc, slug, **fields):
105
+ """Write one module entry. First arg is the state DOCUMENT, not a field."""
106
+ doc.setdefault("epics", {})
107
+ e = doc["epics"].get(slug)
108
+ if not isinstance(e, dict):
109
+ e = {}
110
+ e["module"] = slug
111
+ e.update(fields)
112
+ doc["epics"][slug] = e
113
+ return e
114
+
115
+ # ── dependency helpers ──────────────────────────────────────────────────────
116
+ def deps_of(slug):
117
+ p = module_doc(slug)
118
+ if not p:
119
+ return []
120
+ lines = read(p).splitlines()
121
+ scope, sec = list(lines), None
122
+ for i, ln in enumerate(lines):
123
+ if re.match(r"^##\s+Dependencies\b", ln, re.I):
124
+ sec = i
125
+ break
126
+ if sec is not None:
127
+ scope = []
128
+ for ln in lines[sec + 1:]:
129
+ if re.match(r"^##\s+", ln):
130
+ break
131
+ scope.append(ln)
132
+ block, in_deps = [], False
133
+ for ln in scope:
134
+ label = ln.strip()
135
+ m1 = re.match(r"^#{2,4}\s*Depends On\b(.*)$", label, re.I)
136
+ m2 = re.match(r"^\*\*Depends On:?\*\*(.*)$", label, re.I)
137
+ if m1 or m2:
138
+ in_deps = True
139
+ rest = (m1.group(1) if m1 else m2.group(1)).strip()
140
+ if rest:
141
+ block.append(rest)
142
+ continue
143
+ if in_deps and re.match(r"^#{2,4}\s", label):
144
+ in_deps = False
145
+ continue
146
+ if in_deps:
147
+ block.append(label)
148
+ joined = " ".join(block)
149
+ if not block or re.search(r"\bnone\b", joined, re.I):
150
+ return []
151
+ found = []
152
+ for ln in block:
153
+ if ln.startswith("|"):
154
+ cells = [c.strip() for c in ln.strip("|").split("|")]
155
+ if cells:
156
+ nm = cells[0].replace(chr(96), "").replace("*", "").strip()
157
+ if nm and nm.lower() not in ("module", "modules") and not set(nm) <= set("-: "):
158
+ found.append(slugify(nm))
159
+ elif ln.startswith("- "):
160
+ nm = ln[2:].split("\u2014")[0].strip().strip(chr(96) + "* ")
161
+ if nm and nm.lower() != "none":
162
+ found.append(slugify(nm))
163
+ else:
164
+ # inline list: "**Depends On:** Billing, Authority"
165
+ for part in re.split(r"[,;]", ln):
166
+ nm = part.strip().strip(chr(96) + "* ").rstrip(".")
167
+ if nm and not nm.lower().startswith("none") and len(nm) < 60:
168
+ found.append(slugify(nm))
169
+ return found
170
+
171
+ def state_of(state, slug):
172
+ e = entry(state, slug)
173
+ return e.get("state") or ("frozen" if e.get("contractFrozen") else "unfrozen")
174
+
175
+ # ── the verification ────────────────────────────────────────────────────────
176
+ STRUCTURAL_ONLY = re.compile(
177
+ r"\b(isinstance|issubclass|dataclasses\.is_dataclass|is_dataclass|hasattr|"
178
+ r"inspect\.isclass|inspect\.isfunction|inspect\.getmembers|type\(|__mro__|"
179
+ r"is_property|isinstance\(.*Property\))\b"
180
+ )
181
+ # A suite is behavioural when it ENFORCES A RULE: it expects a raise, or it
182
+ # asserts something that is not knowledge about the *shape* of the code.
183
+ # Everything below is shape: subclassing, dataclass internals, member names,
184
+ # signatures, annotations, enum defaults. 199 green assertions of that kind over
185
+ # NotImplementedError bodies is exactly what this check exists to catch.
186
+ RAISES = re.compile(r"(pytest\.raises|assertRaises|toThrow|should_panic|expect_err)", re.I)
187
+ SHAPE_ASSERT = re.compile(
188
+ r"(isinstance|issubclass|hasattr|inspect\.|__dataclass|__annotations__|__mro__|"
189
+ r"_model_kind|signature|dataclasses\.|_fields|__name__|__qualname__|"
190
+ r"\bnames\b|annotations\[|startswith\(\s*[\"']set_|__dict__|dir\()"
191
+ )
192
+
193
+ def classify(code):
194
+ """Return 'behaviour' | 'structural' | 'none'."""
195
+ asserts = [l for l in code.splitlines() if re.match(r"\s*assert\b", l)]
196
+ if RAISES.search(code):
197
+ return "behaviour"
198
+ if not asserts:
199
+ return "none"
200
+ substantive = [a for a in asserts if not SHAPE_ASSERT.search(a)]
201
+ return "behaviour" if substantive else "structural"
202
+
203
+ AC_ID = re.compile(r"\b[A-Z]{1,3}-\d{1,3}\b")
204
+
205
+ def find_tests(slug):
206
+ """Test files for the module. `tests/` wins; `src/` is a fallback only for
207
+ projects that colocate tests, so source files are never counted as suites."""
208
+ for base_dir in ("tests", "src"):
209
+ hits = []
210
+ base_path = os.path.join(root, base_dir)
211
+ if not os.path.isdir(base_path):
212
+ continue
213
+ for dp, _dn, fs in os.walk(base_path):
214
+ for f in fs:
215
+ if not re.search(r"\.(py|ts|rs|go|java)$", f):
216
+ continue
217
+ low = os.path.join(dp, f).lower()
218
+ if slug in low or slug.replace("-", "") in low.replace("-", ""):
219
+ hits.append(os.path.join(dp, f))
220
+ if hits:
221
+ return sorted(set(hits))
222
+ return []
223
+
224
+ def analyse_tests(slug, tests):
225
+ """Return (total, behaviour, structural_only, ac_named, details)."""
226
+ total = behaviour = struct_only = ac_named = 0
227
+ details = []
228
+ for f in tests:
229
+ src = read(f)
230
+ if not src.strip():
231
+ continue
232
+ code = "\n".join(l for l in src.splitlines() if not l.strip().startswith("#"))
233
+ kind = classify(code)
234
+ body_struct = kind == "structural"
235
+ body_behav = kind == "behaviour"
236
+ acs = sorted(set(AC_ID.findall(src)))
237
+ # count test functions/entries crudely but honestly
238
+ n_tests = len(re.findall(r"^\s*(?:async\s+)?def test_|^\s*it\(|\bfn\s+test_|@Test", code, re.M)) or 1
239
+ total += n_tests
240
+ if body_behav:
241
+ behaviour += 1
242
+ elif body_struct:
243
+ struct_only += 1
244
+ details.append(f"{os.path.relpath(f, root)}: structural assertions only")
245
+ if acs:
246
+ ac_named += 1
247
+ return total, behaviour, struct_only, ac_named, details
248
+
249
+ def run_python_tests(tests):
250
+ """Return (status, reason, sample) where status: red_ok|red_wrong|green|unknown."""
251
+ dirs = sorted({os.path.dirname(f) for f in tests})
252
+ if not dirs:
253
+ return "unknown", "no tests found", ""
254
+ venv = os.path.join(root, ".venv", "bin", "python")
255
+ py = venv if os.path.exists(venv) else sys.executable
256
+ try:
257
+ proc = subprocess.run([py, "-m", "pytest", *dirs, "-q", "--no-header", "-x", "--tb=line"],
258
+ cwd=root, capture_output=True, text=True, timeout=900)
259
+ except Exception as e:
260
+ return "unknown", f"could not run pytest: {e}", ""
261
+ out = (proc.stdout or "") + (proc.stderr or "")
262
+ if proc.returncode == 0:
263
+ return "green", "tests pass while the module is supposedly unimplemented", out[-800:]
264
+ if re.search(r"(ImportError|ModuleNotFoundError|SyntaxError|Error while importing|collection error)", out):
265
+ return "red_wrong", "tests fail on import/syntax, not on behaviour", out[-800:]
266
+ if re.search(r"NotImplementedError|AssertionError|assert", out):
267
+ return "red_ok", "tests fail on missing behaviour", out[-800:]
268
+ return "red_wrong", "unrecognised failure reason", out[-800:]
269
+
270
+ def verify(slug):
271
+ checks = []
272
+ ok_all = True
273
+
274
+ doc = module_doc(slug)
275
+ checks.append(("module doc", bool(doc), doc and os.path.relpath(doc, root) or f"missing .pi/architecture/modules/{slug}.md"))
276
+ ok_all &= bool(doc)
277
+ doctext = read(doc) if doc else ""
278
+
279
+ # 1. contract artifacts exist and are referenced by the module doc
280
+ contracts = []
281
+ cdir = os.path.join(root, "docs", "contracts")
282
+ if os.path.isdir(cdir):
283
+ for f in sorted(os.listdir(cdir)):
284
+ if slug in f.lower() or slug.replace("-", "") in f.lower().replace("-", ""):
285
+ contracts.append(os.path.join("docs", "contracts", f))
286
+ referenced = [c for c in contracts if os.path.basename(c) in doctext or c in doctext]
287
+ ok_contracts = bool(contracts) and bool(referenced)
288
+ checks.append(("contracts", ok_contracts,
289
+ f"{len(contracts)} file(s), {len(referenced)} referenced by the module doc"
290
+ if contracts else "no docs/contracts/* for this module"))
291
+ ok_all &= ok_contracts
292
+
293
+ # 2. frozen signatures in source
294
+ src_dirs = [d for d in (os.path.join(root, "src", slug),
295
+ os.path.join(root, "src", slug.replace("-", "_"))) if os.path.isdir(d)]
296
+ sig_files = []
297
+ for d in src_dirs:
298
+ for dp, _dn, fs in os.walk(d):
299
+ sig_files += [os.path.join(dp, f) for f in fs if re.search(r"\.(py|ts|java|rs|go)$", f)]
300
+ ok_sigs = bool(sig_files)
301
+ checks.append(("frozen signatures", ok_sigs, f"{len(sig_files)} source file(s)" if ok_sigs else f"no src/{slug}/"))
302
+ ok_all &= ok_sigs
303
+
304
+ # 3. tests exist, are behaviour (not structural-only), and name ACs
305
+ tests = find_tests(slug)
306
+ total, behaviour, struct_only, ac_named, tdetails = analyse_tests(slug, tests)
307
+ ok_tests = bool(tests)
308
+ checks.append(("contract tests", ok_tests, f"{len(tests)} file(s)" if tests else "no tests referencing the module"))
309
+ ok_all &= ok_tests
310
+
311
+ # Advisory only: a syntactic shape-vs-behaviour classifier is always
312
+ # arguable. The objective gate is the RED check below — tests that are GREEN
313
+ # over NotImplementedError bodies are structural by definition, whatever the
314
+ # assertions look like.
315
+ checks.append(("assertion mix", True,
316
+ f"~{behaviour} substantive / {struct_only} shape-only suite(s) (advisory)"))
317
+
318
+ doc_acs = set(AC_ID.findall(doctext))
319
+ ok_acs = ok_tests and ac_named > 0 and bool(doc_acs)
320
+ checks.append(("AC references", ok_acs,
321
+ f"{ac_named} suite(s) name an AC; module doc has {len(doc_acs)} AC id(s)"
322
+ if ok_acs else f"tests name ACs: {ac_named}; module doc AC ids: {len(doc_acs)}"))
323
+ ok_all &= ok_acs
324
+
325
+ # 4. RED for the right reason
326
+ status, reason, sample = run_python_tests(tests) if tests else ("unknown", "no tests", "")
327
+ ok_red = status == "red_ok"
328
+ if status == "green":
329
+ reason = ("tests are GREEN while the module is unimplemented — they assert shape, "
330
+ "not rules; a freeze needs failing behaviour tests")
331
+ checks.append(("red state", ok_red, f"{status}: {reason}"))
332
+ ok_all &= ok_red
333
+
334
+ # 5. dependencies frozen/implemented
335
+ bad_deps = []
336
+ st = load_state()
337
+ for d in deps_of(slug):
338
+ s = state_of(st, d)
339
+ if s not in ("frozen", "implemented"):
340
+ bad_deps.append(f"{d} ({s})")
341
+ ok_deps = not bad_deps
342
+ checks.append(("dependencies", ok_deps, "all frozen/implemented" if ok_deps else ", ".join(bad_deps)))
343
+ ok_all &= ok_deps
344
+
345
+ return ok_all, checks, sample, {"contracts": contracts, "tests": [os.path.relpath(t, root) for t in tests]}
346
+
347
+ def emit(ok, checks, slug, extra=None, sample=""):
348
+ if json_out:
349
+ print(json.dumps({"module": slug, "ok": ok,
350
+ "checks": [{"name": n, "ok": bool(o), "detail": d} for n, o, d in checks],
351
+ **(extra or {})}, indent=2))
352
+ return
353
+ head = "PASS" if ok else "FAIL"
354
+ print(f"freeze verify: {slug} — {head}")
355
+ for n, o, d in checks:
356
+ print(f" [{'ok' if o else 'XX'}] {n:<20} {d}")
357
+ if sample and not ok:
358
+ print("\n failure sample:")
359
+ for line in sample.strip().splitlines()[-6:]:
360
+ print(f" {line}")
361
+
362
+ def roadmap_order():
363
+ """Modules in roadmap order, Phase 0 skipped, from the roadmap Modules tables."""
364
+ rp = os.path.join(root, ".pi", "architecture", "implementation-roadmap.md")
365
+ order, phase, in_modules = [], None, False
366
+ for line in read(rp).splitlines():
367
+ st = line.strip()
368
+ if st.startswith("## "):
369
+ phase, in_modules = st[3:].strip(), False
370
+ continue
371
+ if st.startswith("### "):
372
+ in_modules = st[4:].strip().lower().startswith("modules")
373
+ continue
374
+ if not in_modules or not st.startswith("|"):
375
+ continue
376
+ cells = [c.strip() for c in st.strip("|").split("|")]
377
+ if len(cells) < 2:
378
+ continue
379
+ name = cells[0].replace(chr(96), "").replace("*", "").strip()
380
+ if not name or name.lower() in ("module", "modules") or set(name) <= set("-: "):
381
+ continue
382
+ if phase and phase.lower().startswith("phase 0"):
383
+ continue
384
+ doc = cells[-1].replace(chr(96), "").strip()
385
+ slug = os.path.basename(doc)[:-3] if doc.endswith(".md") else slugify(name)
386
+ if slug not in order:
387
+ order.append(slug)
388
+ if not order and os.path.isdir(MODS):
389
+ order = sorted(f[:-3] for f in os.listdir(MODS) if f != "module-template.md")
390
+ return order
391
+
392
+ if cmd == "next":
393
+ st = load_state()
394
+ for m in roadmap_order():
395
+ if state_of(st, m) in ("frozen", "implemented"):
396
+ continue
397
+ deps = deps_of(m)
398
+ bad = [f"{d} ({state_of(st, d)})" for d in deps if state_of(st, d) not in ("frozen", "implemented")]
399
+ if bad:
400
+ continue # not eligible yet; a later module never leapfrogs
401
+ print(m)
402
+ raise SystemExit(0)
403
+ print("no eligible module: every module is frozen/implemented or blocked by an unfrozen dependency")
404
+ raise SystemExit(1)
405
+
406
+ # ── commands ────────────────────────────────────────────────────────────────
407
+ if cmd == "list":
408
+ st = load_state()
409
+ mods = sorted(f[:-3] for f in os.listdir(MODS)) if os.path.isdir(MODS) else []
410
+ if not mods:
411
+ print("no modules found")
412
+ raise SystemExit(0)
413
+ print(f"{'module':<28} {'state':<14} evidence")
414
+ for m in mods:
415
+ if m == "module-template":
416
+ continue
417
+ e = entry(st, m)
418
+ ev = (e.get("evidence") or {}).get("commit", "")
419
+ print(f"{m:<28} {state_of(st, m):<14} {ev}")
420
+ raise SystemExit(0)
421
+
422
+ if cmd == "status":
423
+ if not module:
424
+ print("usage: freeze.sh status --module <m>", file=sys.stderr)
425
+ raise SystemExit(2)
426
+ slug = slugify(module)
427
+ st = load_state()
428
+ e = entry(st, slug)
429
+ print(f"module: {slug}\nstate: {state_of(st, slug)}")
430
+ if e.get("evidence"):
431
+ print("evidence:", json.dumps(e["evidence"]))
432
+ raise SystemExit(0)
433
+
434
+ if not module:
435
+ print("usage: freeze.sh verify|record --module <m>", file=sys.stderr)
436
+ raise SystemExit(2)
437
+
438
+ slug = slugify(module)
439
+
440
+ if cmd == "verify":
441
+ ok, checks, sample, extra = verify(slug)
442
+ emit(ok, checks, slug, extra, sample)
443
+ raise SystemExit(0 if ok else 1)
444
+
445
+ # record: only with passing verification AND a real commit on the base branch
446
+ ok, checks, sample, extra = verify(slug)
447
+ if not ok:
448
+ emit(ok, checks, slug, extra, sample)
449
+ print("\nRefusing to record: the freeze does not verify.", file=sys.stderr)
450
+ raise SystemExit(1)
451
+
452
+ sha = commit or git("rev-parse", "HEAD")
453
+ if not sha:
454
+ print("cannot resolve a commit to record", file=sys.stderr)
455
+ raise SystemExit(2)
456
+ on_base = git("merge-base", "--is-ancestor", sha, base)
457
+ is_ancestor = subprocess.run(["git", "-C", git_top, "merge-base", "--is-ancestor", sha, base]).returncode == 0
458
+ if not is_ancestor:
459
+ print(f"Refusing to record: {sha[:8]} is not on '{base}'. Merge the contract freeze first.", file=sys.stderr)
460
+ raise SystemExit(1)
461
+
462
+ st = load_state()
463
+ set_entry(st, slug, state="frozen", contractFrozen=True,
464
+ evidence={"commit": sha, "branch": base,
465
+ "contracts": extra.get("contracts", []),
466
+ "tests": extra.get("tests", []),
467
+ "at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")})
468
+ save_state(st)
469
+ print(f"recorded: {slug} frozen at {sha[:8]} on {base}")
470
+ raise SystemExit(0)
471
+ PY