guardian-framework 0.1.150 → 0.1.152

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.150", 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.152", 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
  ".": {
@@ -3217,7 +3217,10 @@ var init_package = __esm(() => {
3217
3217
  bin = {
3218
3218
  "guardian-framework": "dist/cli.js"
3219
3219
  };
3220
- files = ["dist/", "templates/"];
3220
+ files = [
3221
+ "dist/",
3222
+ "templates/"
3223
+ ];
3221
3224
  engines = {
3222
3225
  bun: ">=1.0.0"
3223
3226
  };
@@ -3235,9 +3238,15 @@ var init_package = __esm(() => {
3235
3238
  access: "public"
3236
3239
  };
3237
3240
  pi = {
3238
- extensions: ["./templates/pi/extensions"],
3239
- skills: ["./templates/pi/skills"],
3240
- prompts: ["./templates/pi/prompts"]
3241
+ extensions: [
3242
+ "./templates/pi/extensions"
3243
+ ],
3244
+ skills: [
3245
+ "./templates/pi/skills"
3246
+ ],
3247
+ prompts: [
3248
+ "./templates/pi/prompts"
3249
+ ]
3241
3250
  };
3242
3251
  repository = {
3243
3252
  type: "git",
@@ -3391,10 +3400,13 @@ function renderTemplate(content, context) {
3391
3400
  result = result.replaceAll(`[${alias}]`, value);
3392
3401
  }
3393
3402
  }
3394
- result = result.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_match, varName) => {
3395
- const envVal = process.env[varName];
3396
- return envVal !== undefined ? envVal : `$${varName}`;
3397
- });
3403
+ for (const key of Object.keys(context)) {
3404
+ const value = context[key];
3405
+ if (value === undefined)
3406
+ continue;
3407
+ const envStyle = key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z])([A-Z][a-z])/g, "$1_$2").toUpperCase();
3408
+ result = result.replaceAll(`$${envStyle}`, String(value));
3409
+ }
3398
3410
  return result;
3399
3411
  }
3400
3412
  function getDefaultContext(language, projectName, repoTool, buildTool, groupId) {
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.150", 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.152", 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
  ".": {
@@ -2002,7 +2002,10 @@ var init_package = __esm(() => {
2002
2002
  bin = {
2003
2003
  "guardian-framework": "dist/cli.js"
2004
2004
  };
2005
- files = ["dist/", "templates/"];
2005
+ files = [
2006
+ "dist/",
2007
+ "templates/"
2008
+ ];
2006
2009
  engines = {
2007
2010
  bun: ">=1.0.0"
2008
2011
  };
@@ -2020,9 +2023,15 @@ var init_package = __esm(() => {
2020
2023
  access: "public"
2021
2024
  };
2022
2025
  pi = {
2023
- extensions: ["./templates/pi/extensions"],
2024
- skills: ["./templates/pi/skills"],
2025
- prompts: ["./templates/pi/prompts"]
2026
+ extensions: [
2027
+ "./templates/pi/extensions"
2028
+ ],
2029
+ skills: [
2030
+ "./templates/pi/skills"
2031
+ ],
2032
+ prompts: [
2033
+ "./templates/pi/prompts"
2034
+ ]
2026
2035
  };
2027
2036
  repository = {
2028
2037
  type: "git",
@@ -2176,10 +2185,13 @@ function renderTemplate(content, context) {
2176
2185
  result = result.replaceAll(`[${alias}]`, value);
2177
2186
  }
2178
2187
  }
2179
- result = result.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_match, varName) => {
2180
- const envVal = process.env[varName];
2181
- return envVal !== undefined ? envVal : `$${varName}`;
2182
- });
2188
+ for (const key of Object.keys(context)) {
2189
+ const value = context[key];
2190
+ if (value === undefined)
2191
+ continue;
2192
+ const envStyle = key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z])([A-Z][a-z])/g, "$1_$2").toUpperCase();
2193
+ result = result.replaceAll(`$${envStyle}`, String(value));
2194
+ }
2183
2195
  return result;
2184
2196
  }
2185
2197
  function getDefaultContext(language, projectName, repoTool, buildTool, groupId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guardian-framework",
3
- "version": "0.1.150",
3
+ "version": "0.1.152",
4
4
  "description": "Token-optimized agentic framework scaffolder with pi-first architecture",
5
5
  "type": "module",
6
6
  "main": "dist/exports.js",
@@ -14,7 +14,10 @@
14
14
  "bin": {
15
15
  "guardian-framework": "dist/cli.js"
16
16
  },
17
- "files": ["dist/", "templates/"],
17
+ "files": [
18
+ "dist/",
19
+ "templates/"
20
+ ],
18
21
  "engines": {
19
22
  "bun": ">=1.0.0"
20
23
  },
@@ -32,9 +35,15 @@
32
35
  "access": "public"
33
36
  },
34
37
  "pi": {
35
- "extensions": ["./templates/pi/extensions"],
36
- "skills": ["./templates/pi/skills"],
37
- "prompts": ["./templates/pi/prompts"]
38
+ "extensions": [
39
+ "./templates/pi/extensions"
40
+ ],
41
+ "skills": [
42
+ "./templates/pi/skills"
43
+ ],
44
+ "prompts": [
45
+ "./templates/pi/prompts"
46
+ ]
38
47
  },
39
48
  "repository": {
40
49
  "type": "git",
@@ -876,6 +876,10 @@ export default function architectExtension(pi: ExtensionAPI) {
876
876
  const epicPrStrategy: "issue" | "epic" = prPerFlag === "issue" ? "issue" : "epic";
877
877
  const useWorktree = tokens.includes("--worktree");
878
878
  const parallelFlag = tokens.includes("--parallel");
879
+ // --modules a,b,c — which modules to fan out. Optional: without it the
880
+ // roadmap order is used (implemented modules are skipped).
881
+ const modulesFlag = findFlag(tokens, "--modules");
882
+ const parallelMode = parallelFlag || useWorktree;
879
883
  try {
880
884
  const epicStatePath = join(ctx.cwd, ".pi", ".guardian-epic-state.json");
881
885
  const st = existsSync(epicStatePath)
@@ -889,9 +893,12 @@ export default function architectExtension(pi: ExtensionAPI) {
889
893
  }
890
894
  const tddEnabled = tokens.includes("--tdd");
891
895
 
892
- if (!epicName) {
896
+ // A name is required for a single epic; in parallel mode the module
897
+ // list may come from --modules or the roadmap instead.
898
+ if (!epicName && !parallelMode) {
893
899
  ctx.ui.notify(
894
- 'Usage: /architect --epic "Epic Name" [--tracking-issue N] [--tdd] [--pr-per epic|issue] [--parallel|--worktree]',
900
+ 'Usage: /architect --epic "Epic Name" [--tracking-issue N] [--tdd] [--pr-per epic|issue]\n' +
901
+ " /architect --epic --parallel [--modules a,b,c] [--tdd] [--max N] [--force-freeze] [--dry-run]",
895
902
  "error",
896
903
  );
897
904
  return;
@@ -906,45 +913,58 @@ export default function architectExtension(pi: ExtensionAPI) {
906
913
  return;
907
914
  }
908
915
 
909
- // ── Parallel epics (--parallel / --worktree) ───────────────
910
- // One module per worktree keeps parallel epics isolated; the
911
- // contract-freeze gate refuses to start before shared contracts
912
- // are frozen (epic-parallel.sh exits 2 when blocked).
913
- if (parallelFlag || useWorktree) {
914
- const reg = join(ctx.cwd, ".pi", "scripts", "epic-registry.sh");
915
- if (!existsSync(reg)) {
916
- ctx.ui.notify("epic-registry.sh not found — run `guardian update` to install parallel-epic scripts.", "error");
916
+ // ── Parallel epics: delegate the whole fan-out ───────────────
917
+ // epic-fanout.sh does everything in one pass: resolve the module
918
+ // list (explicit or roadmap order), check dependencies, freeze the
919
+ // contracts, create one worktree+branch per module, and spawn one
920
+ // headless agent per worktree.
921
+ //
922
+ // This also fixes the old behaviour where the worktree was created
923
+ // but the epic was started in the MAIN checkout (duplicate issues).
924
+ if (parallelMode) {
925
+ const fanout = join(ctx.cwd, ".pi", "scripts", "epic-fanout.sh");
926
+ if (!existsSync(fanout)) {
927
+ ctx.ui.notify("epic-fanout.sh not found — run `guardian update` to install parallel-epic scripts.", "error");
917
928
  return;
918
929
  }
919
- const prep = runScript(ctx.cwd, `bash .pi/scripts/epic-parallel.sh "${epicName.replace(/"/g, '\\"')}"`);
920
- if (prep.exitCode === 2) {
930
+ const list = (modulesFlag ?? epicName ?? "").trim();
931
+ const args = ["bash .pi/scripts/epic-fanout.sh"];
932
+ if (list) args.push(`--modules "${list.replace(/"/g, '\\"')}"`);
933
+ if (tddEnabled) args.push("--tdd");
934
+ if (tokens.includes("--force-freeze")) args.push("--force-freeze");
935
+ if (tokens.includes("--dry-run")) args.push("--dry-run");
936
+ const maxFlag = findFlag(tokens, "--max");
937
+ if (maxFlag) args.push(`--max ${Number(maxFlag) || 0}`);
938
+
939
+ const res = runScript(ctx.cwd, args.join(" "));
940
+ const out = String(res.stdout ?? "");
941
+ if (res.exitCode === 2) {
921
942
  ctx.ui.notify(
922
- `Parallel epic blocked: contracts for "${epicName}" are not frozen. Merge the Contract Freeze issue, then: bash .pi/scripts/epic-registry.sh freeze "${epicName}" --frozen`,
943
+ `Parallel epic blocked — dependencies are not satisfied.\n${out.trim().slice(-600)}`,
923
944
  "error",
924
945
  );
925
946
  return;
926
947
  }
927
- if (prep.exitCode !== 0) {
928
- ctx.ui.notify(`Worktree preparation failed: ${String(prep.stdout).slice(0, 300)}`, "error");
948
+ if (res.exitCode !== 0) {
949
+ ctx.ui.notify(`Fan-out failed:\n${out.trim().slice(-600)}`, "error");
929
950
  return;
930
951
  }
931
- // Record on the epic state so later steps stay consistent.
952
+ // Record orchestration on the state so later steps stay consistent.
932
953
  try {
933
954
  const p = join(ctx.cwd, ".pi/.guardian-epic-state.json");
934
955
  const st = existsSync(p) ? (JSON.parse(readFileSync(p, "utf-8")) as Record<string, unknown>) : {};
956
+ st.prStrategy = epicPrStrategy;
935
957
  st.parallel = true;
936
- st.worktree = String(prep.stdout).match(/worktree:\s*(\S+)/)?.[1] ?? "";
958
+ st.modules = list || "(roadmap order)";
937
959
  writeFileSync(p, JSON.stringify(st, null, 2), "utf-8");
938
960
  } catch {
939
961
  // ignore
940
962
  }
941
- ctx.ui.notify(
942
- "Parallel epic prepared — worktree + epic branch created (contracts frozen).",
943
- "success",
944
- );
963
+ ctx.ui.notify(out.trim().split("\n").slice(-12).join("\n"), "info");
964
+ return;
945
965
  }
946
966
 
947
- const state = await manager.startEpic(ctx, epicName, trackingIssueId, tddEnabled);
967
+ const state = await manager.startEpic(ctx, epicName, trackingIssueId, tddEnabled);
948
968
 
949
969
  if (!state?.slices?.length) {
950
970
  ctx.ui.notify("Failed to discover architecture components. Check .pi/architecture/modules/.", "error");
@@ -1,5 +1,5 @@
1
1
  {
2
- "timestamp": "2026-11-13T15:22:46Z",
2
+ "timestamp": "2026-11-13T16:42:50Z",
3
3
  "mode": "all",
4
4
  "stages_run": [],
5
5
  "summary": {
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # epic-fanout.sh — start several module epics at once, fully prepared
4
+ # ============================================================================
5
+ # One call does everything needed for parallel epics:
6
+ #
7
+ # 1. resolve the modules (--modules a,b OR roadmap order)
8
+ # 2. check dependencies (refuse if a dep is not implemented)
9
+ # 3. freezes the contracts (registry: contractFrozen=true)
10
+ # 4. create worktree+branch per module (epic/<module>)
11
+ # 5. spawn one headless pi agent per worktree, logging to
12
+ # .git/guardian/logs/<module>.log
13
+ #
14
+ # Usage:
15
+ # epic-fanout.sh [--modules a,b,c] [--tdd] [--max N]
16
+ # [--force-freeze] [--dry-run] [--no-spawn]
17
+ # [--base main] [--timeout-ms N]
18
+ #
19
+ # Exit: 0 started/prepared · 2 blocked by a dependency · 1 error
20
+ # ============================================================================
21
+ set -uo pipefail
22
+
23
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
24
+ MODULES_RAW=""
25
+ TDD=0
26
+ MAX=0
27
+ FORCE_FREEZE=0
28
+ DRY_RUN=0
29
+ NO_SPAWN=0
30
+ BASE="main"
31
+ TIMEOUT_MS=0
32
+
33
+ while [ $# -gt 0 ]; do
34
+ case "$1" in
35
+ --modules) MODULES_RAW="${2:-}"; shift 2 ;;
36
+ --tdd) TDD=1; shift ;;
37
+ --max) MAX="${2:-0}"; shift 2 ;;
38
+ --force-freeze) FORCE_FREEZE=1; shift ;;
39
+ --dry-run) DRY_RUN=1; shift ;;
40
+ --no-spawn) NO_SPAWN=1; shift ;;
41
+ --base) BASE="${2:-main}"; shift 2 ;;
42
+ --timeout-ms) TIMEOUT_MS="${2:-0}"; shift 2 ;;
43
+ -h | --help) sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
44
+ *) echo "unknown argument: $1" >&2; exit 1 ;;
45
+ esac
46
+ done
47
+
48
+ COMMON="$(git rev-parse --git-common-dir 2>/dev/null || echo ".git")"
49
+ [ -d "$COMMON" ] || COMMON=".git"
50
+ COMMON_ABS="$(cd "$COMMON" 2>/dev/null && pwd || echo "$COMMON")"
51
+ LOGDIR="$COMMON_ABS/guardian/logs"
52
+ REGISTRY="$ROOT/.pi/scripts/epic-registry.sh"
53
+ PARALLEL="$ROOT/.pi/scripts/epic-parallel.sh"
54
+ RUNNER="$ROOT/.pi/scripts/epic-runner.mjs"
55
+
56
+ [ -f "$REGISTRY" ] || { echo "missing $REGISTRY — run guardian update" >&2; exit 1; }
57
+ [ -f "$PARALLEL" ] || { echo "missing $PARALLEL — run guardian update" >&2; exit 1; }
58
+ [ -f "$RUNNER" ] || { echo "missing $RUNNER — run guardian update" >&2; exit 1; }
59
+
60
+ # The runner is a Node script; bun also executes it.
61
+ NODE_BIN="$(command -v node || command -v bun || true)"
62
+ if [ -z "$NODE_BIN" ]; then
63
+ echo "neither node nor bun found — required to spawn headless agents" >&2
64
+ exit 1
65
+ fi
66
+
67
+ # ── Resolve the module list ─────────────────────────────────────────────────
68
+ # Explicit list wins. Otherwise take roadmap order, skipping implemented modules.
69
+ SELECTED="$(
70
+ python3 - "$ROOT" "$MODULES_RAW" "$MAX" <<'PY'
71
+ import os, re, sys
72
+
73
+ root, raw, max_n = sys.argv[1], sys.argv[2], int(sys.argv[3] or 0)
74
+ mods_dir = os.path.join(root, ".pi/architecture/modules")
75
+ roadmap = os.path.join(root, ".pi/architecture/implementation-roadmap.md")
76
+
77
+ def is_implemented(doc_slug):
78
+ for cand in (f"{doc_slug}.md",):
79
+ p = os.path.join(mods_dir, cand)
80
+ if not os.path.exists(p):
81
+ continue
82
+ text = open(p, encoding="utf-8", errors="ignore").read()
83
+ m = re.search(r"\*\*Status:\*\*\s*([A-Za-z ]+)", text)
84
+ if m and m.group(1).strip().lower().startswith(("implemented", "done", "complete")):
85
+ return True
86
+ if len(re.findall(r"status:\s*implemented", text, re.I)) > 0:
87
+ return True
88
+ return False
89
+
90
+ def slugify(name):
91
+ s = name.lower()
92
+ s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
93
+ return s
94
+
95
+ if raw.strip():
96
+ items = [m.strip() for m in raw.split(",") if m.strip()]
97
+ print("\n".join(items))
98
+ sys.exit(0)
99
+
100
+ if not os.path.exists(roadmap):
101
+ print("__ERROR__no roadmap at .pi/architecture/implementation-roadmap.md", file=sys.stderr)
102
+ sys.exit(3)
103
+
104
+ # Walk phases in order; collect modules from each '### Modules' table.
105
+ order, phase = [], None
106
+ in_modules = False
107
+ for line in open(roadmap, encoding="utf-8", errors="ignore"):
108
+ stripped = line.strip()
109
+ if stripped.startswith("## "):
110
+ phase = stripped[3:].strip()
111
+ in_modules = False
112
+ continue
113
+ if stripped.startswith("### "):
114
+ in_modules = stripped[4:].strip().lower().startswith("modules")
115
+ continue
116
+ if not in_modules or not stripped.startswith("|"):
117
+ continue
118
+ cells = [c.strip() for c in stripped.strip("|").split("|")]
119
+ if len(cells) < 3:
120
+ continue
121
+ BT = chr(96)
122
+ name = cells[0].replace(BT, "").strip()
123
+ if not name or name.lower() in ("module", "modules") or set(name) <= set("-: "):
124
+ continue
125
+ if phase and phase.lower().startswith("phase 0"):
126
+ continue
127
+ doc = cells[-1].replace(BT, "").strip()
128
+ slug = os.path.basename(doc)[:-3] if doc.endswith(".md") else slugify(name)
129
+ order.append((phase or "", name, slug))
130
+
131
+ selected, seen = [], set()
132
+ for ph, name, slug in order:
133
+ if slug in seen:
134
+ continue
135
+ seen.add(slug)
136
+ if is_implemented(slug):
137
+ continue
138
+ selected.append(slug)
139
+ if max_n and len(selected) >= max_n:
140
+ break
141
+
142
+ print("\n".join(selected))
143
+ PY
144
+ )"
145
+
146
+ if printf '%s' "$SELECTED" | grep -q "^__ERROR__"; then
147
+ echo "${SELECTED#__ERROR__}" >&2
148
+ exit 1
149
+ fi
150
+ if [ -z "$SELECTED" ]; then
151
+ echo "Nothing to start: no planned modules found (roadmap order, implemented skipped)."
152
+ echo " pass --modules a,b to name them explicitly"
153
+ exit 0
154
+ fi
155
+
156
+ MODULES=()
157
+ while IFS= read -r modline; do
158
+ [ -n "$modline" ] && MODULES+=("$modline")
159
+ done <<<"$SELECTED"
160
+
161
+ echo "============================================"
162
+ echo " Parallel epic fan-out"
163
+ echo "============================================"
164
+ echo " modules: ${MODULES[*]:-}"
165
+ echo " tdd: $([ "$TDD" -eq 1 ] && echo enabled || echo disabled)"
166
+ echo " base: $BASE"
167
+ [ "$DRY_RUN" -eq 1 ] && echo " mode: DRY RUN (nothing is created)"
168
+ echo ""
169
+
170
+ # ── Dependency gate ─────────────────────────────────────────────────────────
171
+ # A module may only run in parallel once the modules it depends on are
172
+ # implemented. Deps inside this same batch cannot be satisfied yet either.
173
+ DEP_CHECK="$(python3 - "$ROOT" ${MODULES[@]+"${MODULES[@]}"} <<'PY'
174
+ import os, re, sys
175
+
176
+ root = sys.argv[1]
177
+ batch = sys.argv[2:]
178
+ mods_dir = os.path.join(root, ".pi/architecture/modules")
179
+
180
+ def doc_path(slug):
181
+ p = os.path.join(mods_dir, f"{slug}.md")
182
+ return p if os.path.exists(p) else None
183
+
184
+ def status_of(slug):
185
+ p = doc_path(slug)
186
+ if not p:
187
+ return "unknown"
188
+ text = open(p, encoding="utf-8", errors="ignore").read()
189
+ m = re.search(r"\*\*Status:\*\*\s*([A-Za-z ]+)", text)
190
+ if m:
191
+ s = m.group(1).strip().lower()
192
+ if s.startswith(("implemented", "done", "complete")):
193
+ return "implemented"
194
+ if s.startswith(("partial", "in progress", "wip")):
195
+ return "partial"
196
+ return "planned"
197
+ if re.search(r"status:\s*implemented", text, re.I):
198
+ return "implemented"
199
+ return "planned"
200
+
201
+ def deps_of(slug):
202
+ p = doc_path(slug)
203
+ if not p:
204
+ return []
205
+ text = open(p, encoding="utf-8", errors="ignore").read()
206
+ m = re.search(r"\*\*Depends On:\*\*(.*?)(?:\n##|\Z)", text, re.S | re.I)
207
+ if not m:
208
+ m = re.search(r"##\s*Dependencies(.*?)(?:\n##|\Z)", text, re.S | re.I)
209
+ if not m:
210
+ return []
211
+ block = m.group(1)
212
+ if re.search(r"\bnone\b", block, re.I):
213
+ return []
214
+ found = []
215
+ # Inline form: '**Depends On:** Alpha, Beta' or 'Alpha' (single line, no table)
216
+ first = next((ln.strip() for ln in block.splitlines() if ln.strip()), "")
217
+ if first and not first.startswith("|") and not first.startswith("-"):
218
+ for part in re.split(r"[,;]", first):
219
+ nm = part.strip().strip(chr(96) + "* ").rstrip(".")
220
+ if nm and not nm.lower().startswith("none"):
221
+ found.append(nm)
222
+ for line in block.splitlines():
223
+ line = line.strip()
224
+ if not line.startswith("|"):
225
+ continue
226
+ cells = [c.strip() for c in line.strip("|").split("|")]
227
+ if not cells:
228
+ continue
229
+ name = cells[0].replace(chr(96), "").replace("*", "").strip()
230
+ if not name or name.lower() in ("module", "modules") or set(name) <= set("-: "):
231
+ continue
232
+ found.append(name)
233
+ # also allow "- Name" bullets
234
+ for line in block.splitlines():
235
+ line = line.strip()
236
+ if line.startswith("- "):
237
+ name = line[2:].split("—")[0].split(":")[0].strip().strip(chr(96) + "* ")
238
+ if name and name.lower() != "none":
239
+ found.append(name)
240
+ return found
241
+
242
+ def slugify(name):
243
+ return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
244
+
245
+ batch_slugs = set(batch)
246
+ problems = []
247
+ for slug in batch:
248
+ for dep_name in deps_of(slug):
249
+ dep = slugify(dep_name)
250
+ st = status_of(dep)
251
+ if st in ("implemented",):
252
+ continue
253
+ if dep in batch_slugs:
254
+ problems.append(f"{slug}: depends on '{dep_name}' which is in this same batch — start it in an earlier batch")
255
+ elif st == "unknown":
256
+ problems.append(f"{slug}: depends on '{dep_name}' which has no module doc under .pi/architecture/modules/")
257
+ else:
258
+ problems.append(f"{slug}: depends on '{dep_name}' ({st}) — not implemented yet")
259
+
260
+ if problems:
261
+ print("\n".join(problems))
262
+ sys.exit(1)
263
+ sys.exit(0)
264
+ PY
265
+ )"
266
+ DEP_RC=$?
267
+
268
+ if [ "$DEP_RC" -ne 0 ]; then
269
+ echo "⛔ Blocked — dependencies are not satisfied:"
270
+ echo "$DEP_CHECK" | sed 's/^/ /'
271
+ echo ""
272
+ if [ "$FORCE_FREEZE" -ne 1 ]; then
273
+ echo " Parallel epics must not build on unfolded contracts."
274
+ echo " Finish the dependency first, or re-run with --force-freeze to override."
275
+ exit 2
276
+ fi
277
+ echo " --force-freeze given — continuing anyway (recorded in the registry)."
278
+ echo ""
279
+ fi
280
+
281
+ if [ "$DRY_RUN" -eq 1 ]; then
282
+ echo "Would do, per module:"
283
+ for m in ${MODULES[@]+"${MODULES[@]}"}; do
284
+ slug="$(echo "$m" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')"
285
+ echo " • freeze $m"
286
+ echo " • worktree ../$(basename "$ROOT")-$slug (branch epic/$slug)"
287
+ [ "$NO_SPAWN" -eq 0 ] && echo " • spawn pi (rpc) → log .git/guardian/logs/$slug.log"
288
+ done
289
+ exit 0
290
+ fi
291
+
292
+ # ── Freeze, create worktrees, spawn ─────────────────────────────────────────
293
+ mkdir -p "$LOGDIR"
294
+ STARTED=()
295
+ FAILED=()
296
+
297
+ for m in ${MODULES[@]+"${MODULES[@]}"}; do
298
+ slug="$(echo "$m" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')"
299
+ log="$LOGDIR/$slug.log"
300
+
301
+ echo "────────────────────────────────────────────"
302
+ echo "▶ $m"
303
+
304
+ # 1. record the contract freeze (the gate below requires it)
305
+ bash "$REGISTRY" freeze "$m" --frozen >/dev/null 2>&1
306
+
307
+ # 2. worktree + epic branch (gated on the freeze we just recorded)
308
+ WT_OUT="$(bash "$PARALLEL" "$m" --base "$BASE" 2>&1)"
309
+ WT_RC=$?
310
+ if [ "$WT_RC" -ne 0 ]; then
311
+ echo " ✗ worktree failed (exit $WT_RC)"
312
+ echo "$WT_OUT" | sed 's/^/ /'
313
+ FAILED+=("$m")
314
+ continue
315
+ fi
316
+ WT="$(echo "$WT_OUT" | sed -n 's/.*worktree: *//p' | head -1)"
317
+ if [ -z "$WT" ] || [ ! -d "$WT" ]; then
318
+ # epic-parallel prints the path; fall back to the conventional name
319
+ WT="$(dirname "$ROOT")/$(basename "$ROOT")-$slug"
320
+ fi
321
+ echo " ✓ worktree $WT"
322
+
323
+ if [ "$NO_SPAWN" -eq 1 ]; then
324
+ echo " → run: cd \"$WT\" && /architect --epic \"$m\"$( [ "$TDD" -eq 1 ] && echo " --tdd")"
325
+ STARTED+=("$m")
326
+ continue
327
+ fi
328
+
329
+ # 3. spawn one headless agent in the worktree (RPC mode dispatches the command)
330
+ "$NODE_BIN" "$RUNNER" \
331
+ "/architect --epic \"$m\"$( [ "$TDD" -eq 1 ] && echo " --tdd")" \
332
+ --cwd "$WT" --log "$log" ${TIMEOUT_MS:+--timeout-ms "$TIMEOUT_MS"} \
333
+ >/dev/null 2>&1 &
334
+ PID=$!
335
+ bash "$REGISTRY" set "$m" "pid=$PID" "log=$log" "spawnedAt=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >/dev/null 2>&1
336
+ echo " ✓ spawned pid $PID → $log"
337
+ STARTED+=("$m")
338
+ done
339
+
340
+ echo "────────────────────────────────────────────"
341
+ echo ""
342
+ echo "Started ${#STARTED[@]}/${#MODULES[@]} epic(s)."
343
+ [ "${#FAILED[@]}" -gt 0 ] && echo "Failed: ${FAILED[*]:-}"
344
+
345
+ if [ "${#STARTED[@]}" -gt 0 ]; then
346
+ echo ""
347
+ echo "Monitor:"
348
+ echo " bash .pi/scripts/epic-registry.sh list"
349
+ for m in ${STARTED[@]+"${STARTED[@]}"}; do
350
+ slug="$(echo "$m" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')"
351
+ echo " tail -f \"$LOGDIR/$slug.log\""
352
+ done
353
+ fi
354
+ [ "${#FAILED[@]}" -gt 0 ] && exit 1
355
+ exit 0
@@ -116,6 +116,12 @@ PY
116
116
  write_epic "$MOD" "contractFrozen=$V" "updatedAt=$(now)" >/dev/null
117
117
  echo "$MOD: contractFrozen=$V"
118
118
  ;;
119
+ set)
120
+ MOD="${1:?usage: set <module> key=value ...}"; shift || true
121
+ if [ $# -eq 0 ]; then echo "set: need at least one key=value" >&2; exit 1; fi
122
+ write_epic "$MOD" "$@" >/dev/null
123
+ echo "$MOD: $*"
124
+ ;;
119
125
  list|get)
120
126
  MOD="${1:-}"
121
127
  ensure
@@ -133,6 +139,6 @@ else:
133
139
  PY
134
140
  ;;
135
141
  *)
136
- echo "usage: epic-registry.sh register|status|freeze|list|get|path ..." >&2
142
+ echo "usage: epic-registry.sh register|status|freeze|set|list|get|path ..." >&2
137
143
  exit 1 ;;
138
144
  esac
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * epic-runner.mjs — run one Guardian command in a headless pi session.
4
+ *
5
+ * Why this exists: `pi -p "/architect ..."` does NOT dispatch extension
6
+ * commands (the text is sent to the model instead). RPC mode does — a
7
+ * `prompt` whose message is an extension command "executes immediately"
8
+ * (pi docs, rpc-commands.md#prompt), and reports
9
+ * `data.disposition: "handled"`.
10
+ *
11
+ * So we spawn `pi --mode rpc`, send the command as a prompt, mirror every
12
+ * JSONL event into a log, and exit when pi settles.
13
+ *
14
+ * Usage:
15
+ * epic-runner.mjs "<command>" [--log <path>] [--cwd <dir>]
16
+ * [--timeout-ms N] [--session <id>]
17
+ *
18
+ * Exit codes:
19
+ * 0 command handled and session settled
20
+ * 1 failure (spawn error, rejected prompt, aborted, timeout)
21
+ * 3 command was NOT an extension command (fell through to the model)
22
+ */
23
+ import { spawn } from "node:child_process";
24
+ import { createWriteStream, mkdirSync } from "node:fs";
25
+ import { dirname, resolve } from "node:path";
26
+
27
+ function parseArgs(argv) {
28
+ const out = { command: null, log: null, cwd: process.cwd(), timeoutMs: 0, session: null };
29
+ const rest = [];
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === "--log") out.log = argv[++i];
33
+ else if (a === "--cwd") out.cwd = resolve(argv[++i]);
34
+ else if (a === "--timeout-ms") out.timeoutMs = Number(argv[++i]) || 0;
35
+ else if (a === "--session") out.session = argv[++i];
36
+ else rest.push(a);
37
+ }
38
+ out.command = rest.join(" ").trim();
39
+ return out;
40
+ }
41
+
42
+ const opts = parseArgs(process.argv.slice(2));
43
+ if (!opts.command) {
44
+ console.error('usage: epic-runner.mjs "<command>" [--log path] [--cwd dir] [--timeout-ms N]');
45
+ process.exit(1);
46
+ }
47
+
48
+ let logStream = null;
49
+ if (opts.log) {
50
+ mkdirSync(dirname(opts.log), { recursive: true });
51
+ logStream = createWriteStream(opts.log, { flags: "a" });
52
+ }
53
+
54
+ function emit(line) {
55
+ if (logStream) logStream.write(`${line}\n`);
56
+ process.stdout.write(`${line}\n`);
57
+ }
58
+
59
+ const args = ["--mode", "rpc"];
60
+ // A dedicated session per runner keeps parallel agents isolated; --no-session
61
+ // keeps the repo clean. Prefer a session when one is named.
62
+ if (opts.session) args.push("--session-id", opts.session);
63
+ else args.push("--no-session");
64
+
65
+ const child = spawn("pi", args, {
66
+ cwd: opts.cwd,
67
+ stdio: ["pipe", "pipe", "pipe"],
68
+ env: process.env,
69
+ });
70
+
71
+ let settled = false;
72
+ let handled = false;
73
+ let failed = false;
74
+ let buffered = "";
75
+
76
+ const timer = opts.timeoutMs
77
+ ? setTimeout(() => {
78
+ emit(JSON.stringify({ type: "runner_timeout", timeoutMs: opts.timeoutMs }));
79
+ failed = true;
80
+ finish(1);
81
+ }, opts.timeoutMs)
82
+ : null;
83
+
84
+ function finish(code) {
85
+ if (settled) return;
86
+ settled = true;
87
+ if (timer) clearTimeout(timer);
88
+ if (typeof cancelGrace === "function") cancelGrace();
89
+ try {
90
+ child.stdin.end();
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ // give the child a moment to flush, then stop it
95
+ setTimeout(() => {
96
+ try {
97
+ child.kill("SIGTERM");
98
+ } catch {
99
+ /* ignore */
100
+ }
101
+ logStream?.end();
102
+ process.exit(code);
103
+ }, 250);
104
+ }
105
+
106
+ function handleLine(line) {
107
+ if (!line.trim()) return;
108
+ emit(line);
109
+ let msg;
110
+ try {
111
+ msg = JSON.parse(line);
112
+ } catch {
113
+ return;
114
+ }
115
+ // The prompt was accepted by an extension command?
116
+ if (msg.type === "response" && msg.command === "prompt" && msg.success === true) {
117
+ const d = msg.data?.disposition;
118
+ if (d === "handled") handled = true;
119
+ else if (d === "started" || d === "queued") handled = true;
120
+ if (handled) armGrace();
121
+ }
122
+ if (msg.type === "response" && msg.command === "prompt" && msg.success === false) {
123
+ emit(JSON.stringify({ type: "runner_error", reason: "prompt rejected" }));
124
+ failed = true;
125
+ finish(1);
126
+ }
127
+ // A low-level run started: the command is doing real work, so wait for the
128
+ // settle event instead of using the grace timer.
129
+ if (msg.type === "agent_start") {
130
+ sawRun = true;
131
+ cancelGrace();
132
+ }
133
+ if (msg.type === "agent_settled") {
134
+ finish(msg.aborted ? 1 : 0);
135
+ }
136
+ }
137
+
138
+ // A "handled" extension command may start no LLM run at all (e.g. /architect
139
+ // status), in which case `agent_settled` never fires and stdin stays open. If
140
+ // no run activity appears within the grace window, treat the command as done.
141
+ // pi docs (rpc.md#prompt): with disposition "handled", don't wait for
142
+ // agent_settled unless the extension itself starts work.
143
+ const GRACE_MS = Number(process.env.GUARDIAN_RUNNER_GRACE_MS || 5000);
144
+ let graceTimer = null;
145
+ let sawRun = false;
146
+
147
+ function cancelGrace() {
148
+ if (graceTimer) {
149
+ clearTimeout(graceTimer);
150
+ graceTimer = null;
151
+ }
152
+ }
153
+
154
+ function armGrace() {
155
+ cancelGrace();
156
+ graceTimer = setTimeout(() => {
157
+ if (!sawRun) {
158
+ emit(JSON.stringify({ type: "runner_done", reason: "handled, no run started" }));
159
+ finish(0);
160
+ }
161
+ }, GRACE_MS);
162
+ }
163
+
164
+ child.stdout.on("data", (buf) => {
165
+ buffered += buf.toString();
166
+ let idx = buffered.indexOf("\n");
167
+ while (idx !== -1) {
168
+ handleLine(buffered.slice(0, idx));
169
+ buffered = buffered.slice(idx + 1);
170
+ idx = buffered.indexOf("\n");
171
+ }
172
+ });
173
+
174
+ child.stderr.on("data", (buf) => {
175
+ for (const line of buf.toString().split("\n")) {
176
+ if (line.trim()) emit(JSON.stringify({ type: "stderr", line }));
177
+ }
178
+ });
179
+
180
+ child.on("error", (err) => {
181
+ emit(JSON.stringify({ type: "runner_error", reason: String(err) }));
182
+ failed = true;
183
+ finish(1);
184
+ });
185
+
186
+ child.on("exit", (code) => {
187
+ if (buffered.trim()) handleLine(buffered);
188
+ if (settled) return;
189
+ // Settle on exit: a handled command that ended cleanly is a success.
190
+ finish(failed ? 1 : handled && code === 0 ? 0 : handled ? 0 : 3);
191
+ });
192
+
193
+ // Send the prompt once the session is up. pi reads JSONL from stdin.
194
+ child.stdin.write(`${JSON.stringify({ id: "1", type: "prompt", message: opts.command })}\n`);