guardian-framework 0.1.155 → 0.1.157

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.155", 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.157", 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.155", 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.157", 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.155",
3
+ "version": "0.1.157",
4
4
  "description": "Token-optimized agentic framework scaffolder with pi-first architecture",
5
5
  "type": "module",
6
6
  "main": "dist/exports.js",
@@ -209,7 +209,11 @@ class EpicManager {
209
209
  runScript(this.cwd, 'git commit -m "Initial Guardian scaffold"');
210
210
  }
211
211
 
212
- const repoTool = readRepoTool(this.cwd);
212
+ // GUARDIAN_LOCAL_ONLY=1 forces local-only: no remote issues are created.
213
+ // epic-fanout.sh sets this so an automated run cannot write to a forge
214
+ // without a human explicitly asking for it.
215
+ const localOnly = process.env.GUARDIAN_LOCAL_ONLY === "1";
216
+ const repoTool = localOnly ? "local" : readRepoTool(this.cwd);
213
217
  const repository = readRepository(this.cwd);
214
218
  const targetRepo = repository || slice.module;
215
219
  let hasRemote = false;
@@ -880,16 +884,22 @@ export default function architectExtension(pi: ExtensionAPI) {
880
884
  // roadmap order is used (implemented modules are skipped).
881
885
  const modulesFlag = findFlag(tokens, "--modules");
882
886
  const parallelMode = parallelFlag || useWorktree;
883
- try {
884
- const epicStatePath = join(ctx.cwd, ".pi", ".guardian-epic-state.json");
885
- const st = existsSync(epicStatePath)
886
- ? (JSON.parse(readFileSync(epicStatePath, "utf-8")) as Record<string, unknown>)
887
- : {};
888
- st.prStrategy = epicPrStrategy;
889
- mkdirSync(join(ctx.cwd, ".pi"), { recursive: true });
890
- writeFileSync(epicStatePath, JSON.stringify(st, null, 2), "utf-8");
891
- } catch {
892
- // ignore — strategy falls back to epic
887
+ // In parallel mode fan-out owns the state: each spawned agent writes its
888
+ // own epic state INSIDE its worktree. Writing here would leave a stray
889
+ // .guardian-epic-state.json in the main checkout that makes
890
+ // `/architect status` report an epic that does not exist.
891
+ if (!parallelMode) {
892
+ try {
893
+ const epicStatePath = join(ctx.cwd, ".pi", ".guardian-epic-state.json");
894
+ const st = existsSync(epicStatePath)
895
+ ? (JSON.parse(readFileSync(epicStatePath, "utf-8")) as Record<string, unknown>)
896
+ : {};
897
+ st.prStrategy = epicPrStrategy;
898
+ mkdirSync(join(ctx.cwd, ".pi"), { recursive: true });
899
+ writeFileSync(epicStatePath, JSON.stringify(st, null, 2), "utf-8");
900
+ } catch {
901
+ // ignore — strategy falls back to epic
902
+ }
893
903
  }
894
904
  const tddEnabled = tokens.includes("--tdd");
895
905
 
@@ -953,17 +963,6 @@ export default function architectExtension(pi: ExtensionAPI) {
953
963
  ctx.ui.notify(`Fan-out failed:\n${out.trim().slice(-600)}`, "error");
954
964
  return;
955
965
  }
956
- // Record orchestration on the state so later steps stay consistent.
957
- try {
958
- const p = join(ctx.cwd, ".pi/.guardian-epic-state.json");
959
- const st = existsSync(p) ? (JSON.parse(readFileSync(p, "utf-8")) as Record<string, unknown>) : {};
960
- st.prStrategy = epicPrStrategy;
961
- st.parallel = true;
962
- st.modules = list || "(roadmap order)";
963
- writeFileSync(p, JSON.stringify(st, null, 2), "utf-8");
964
- } catch {
965
- // ignore
966
- }
967
966
  ctx.ui.notify(out.trim().split("\n").slice(-12).join("\n"), "info");
968
967
  return;
969
968
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "timestamp": "2026-11-13T17:15:30Z",
2
+ "timestamp": "2026-11-13T17:52:57Z",
3
3
  "mode": "all",
4
4
  "stages_run": [],
5
5
  "summary": {
@@ -13,9 +13,15 @@
13
13
  #
14
14
  # Usage:
15
15
  # epic-fanout.sh [--modules a,b,c] [--tdd] [--max N]
16
- # [--force-freeze] [--dry-run] [--no-spawn]
16
+ # [--spawn] [--dry-run] [--assume-frozen] [--local]
17
17
  # [--base main] [--timeout-ms N]
18
18
  #
19
+ # SAFETY: nothing is spawned without --spawn. The default prepares worktrees
20
+ # and prints the exact commands, so you can inspect before any agent runs.
21
+ # Contracts must ALREADY be frozen in the registry; this script never freezes
22
+ # on your behalf (that made the gate meaningless and started agents on
23
+ # unreviewed contracts).
24
+ #
19
25
  # Exit: 0 started/prepared · 2 blocked by a dependency · 1 error
20
26
  # ============================================================================
21
27
  set -uo pipefail
@@ -27,6 +33,9 @@ MAX=0
27
33
  FORCE_FREEZE=0
28
34
  DRY_RUN=0
29
35
  NO_SPAWN=0
36
+ SPAWN=0
37
+ ASSUME_FROZEN=0
38
+ ALLOW_REMOTE=0
30
39
  BASE="main"
31
40
  TIMEOUT_MS=0
32
41
 
@@ -38,6 +47,9 @@ while [ $# -gt 0 ]; do
38
47
  --force-freeze) FORCE_FREEZE=1; shift ;;
39
48
  --dry-run) DRY_RUN=1; shift ;;
40
49
  --no-spawn) NO_SPAWN=1; shift ;;
50
+ --spawn) SPAWN=1; shift ;;
51
+ --assume-frozen) ASSUME_FROZEN=1; shift ;;
52
+ --allow-remote-issues) ALLOW_REMOTE=1; shift ;;
41
53
  --base) BASE="${2:-main}"; shift 2 ;;
42
54
  --timeout-ms) TIMEOUT_MS="${2:-0}"; shift 2 ;;
43
55
  -h | --help) sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
@@ -58,6 +70,16 @@ RUNNER="$ROOT/.pi/scripts/epic-runner.mjs"
58
70
  [ -f "$RUNNER" ] || { echo "missing $RUNNER — run guardian update" >&2; exit 1; }
59
71
 
60
72
  # The runner is a Node script; bun also executes it.
73
+ # The project directory is the one that owns .pi/ (this script lives in
74
+ # <project>/.pi/scripts). It is NOT necessarily the git top level: a monorepo
75
+ # often keeps the app in <repo>/backend with .pi/ inside it. Agents must run in
76
+ # the project dir, otherwise pi finds no extensions and /architect is not a
77
+ # command at all.
78
+ PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
79
+ GIT_TOP="$(git -C "$PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$PROJECT_DIR")"
80
+ PROJECT_REL="${PROJECT_DIR#"$GIT_TOP"}"
81
+ PROJECT_REL="${PROJECT_REL#/}"
82
+
61
83
  NODE_BIN="$(command -v node || command -v bun || true)"
62
84
  if [ -z "$NODE_BIN" ]; then
63
85
  echo "neither node nor bun found — required to spawn headless agents" >&2
@@ -158,6 +180,30 @@ while IFS= read -r modline; do
158
180
  [ -n "$modline" ] && MODULES+=("$modline")
159
181
  done <<<"$SELECTED"
160
182
 
183
+ # ── Batch-size guard ────────────────────────────────────────────────────────
184
+ # An unbounded automated batch is how six agents started at once. Spawning
185
+ # requires an explicit bound: --max N, or an explicit --modules list of <= 3.
186
+ if [ "$SPAWN" -eq 1 ] && [ "$MAX" -le 0 ]; then
187
+ COUNT=${#MODULES[@]}
188
+ if [ "$COUNT" -gt 3 ]; then
189
+ echo "⛔ Refusing to spawn ${COUNT} agents unbounded." >&2
190
+ echo " Add --max N (recommended 1-3) or pass an explicit --modules list of at most 3." >&2
191
+ echo " Nothing was created." >&2
192
+ exit 1
193
+ fi
194
+ fi
195
+
196
+ # ── Pre-flight: the spawned agents inherit THIS project's .pi/ ──────────────
197
+ # Without extensions, /architect is not a command: the prompt falls through to
198
+ # the model, which then improvises (expensive and wrong). Fail before spawning.
199
+ if [ "$NO_SPAWN" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
200
+ if [ ! -d "$PROJECT_DIR/.pi/extensions" ]; then
201
+ echo "⛔ $PROJECT_DIR/.pi/extensions is missing — the agent would have no /architect command." >&2
202
+ echo " Run guardian update in $PROJECT_DIR, or use --no-spawn." >&2
203
+ exit 1
204
+ fi
205
+ fi
206
+
161
207
  echo "============================================"
162
208
  echo " Parallel epic fan-out"
163
209
  echo "============================================"
@@ -346,6 +392,25 @@ if [ "$DEP_RC" -ne 0 ]; then
346
392
  fi
347
393
 
348
394
  if [ "$DRY_RUN" -eq 1 ]; then
395
+ echo "Contract freeze status (a real run refuses unfrozen modules):"
396
+ for m in ${MODULES[@]+"${MODULES[@]}"}; do
397
+ slug="$(echo "$m" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')"
398
+ st="$(python3 - "$COMMON_ABS/guardian/epics.json" "$slug" <<'PY' 2>/dev/null
399
+ import json, re, sys
400
+ try:
401
+ d = json.load(open(sys.argv[1], encoding="utf-8"))
402
+ except Exception:
403
+ print("no"); raise SystemExit
404
+ want = sys.argv[2]
405
+ for mod, e in (d.get("epics") or {}).items():
406
+ if isinstance(e, dict) and re.sub(r"[^a-z0-9]+", "-", mod.lower()).strip("-") == want:
407
+ print("yes" if e.get("contractFrozen") else "no"); raise SystemExit
408
+ print("no")
409
+ PY
410
+ )"
411
+ [ "$st" = "yes" ] && echo " ✓ $m" || echo " ✗ $m (not frozen)"
412
+ done
413
+ echo ""
349
414
  echo "Would do, per module:"
350
415
  for m in ${MODULES[@]+"${MODULES[@]}"}; do
351
416
  slug="$(echo "$m" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')"
@@ -356,6 +421,45 @@ if [ "$DRY_RUN" -eq 1 ]; then
356
421
  exit 0
357
422
  fi
358
423
 
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
+
359
463
  # ── Freeze, create worktrees, spawn ─────────────────────────────────────────
360
464
  mkdir -p "$LOGDIR"
361
465
  STARTED=()
@@ -368,11 +472,11 @@ for m in ${MODULES[@]+"${MODULES[@]}"}; do
368
472
  echo "────────────────────────────────────────────"
369
473
  echo "▶ $m"
370
474
 
371
- # 1. record the contract freeze (the gate below requires it)
372
- bash "$REGISTRY" freeze "$m" --frozen >/dev/null 2>&1
373
475
 
374
476
  # 2. worktree + epic branch (gated on the freeze we just recorded)
375
- WT_OUT="$(bash "$PARALLEL" "$m" --base "$BASE" 2>&1)"
477
+ WT_ARGS=("$m" --base "$BASE")
478
+ [ "$ASSUME_FROZEN" -eq 1 ] && WT_ARGS+=(--assume-frozen)
479
+ WT_OUT="$(bash "$PARALLEL" "${WT_ARGS[@]}" 2>&1)"
376
480
  WT_RC=$?
377
481
  if [ "$WT_RC" -ne 0 ]; then
378
482
  echo " ✗ worktree failed (exit $WT_RC)"
@@ -387,16 +491,27 @@ for m in ${MODULES[@]+"${MODULES[@]}"}; do
387
491
  fi
388
492
  echo " ✓ worktree $WT"
389
493
 
390
- if [ "$NO_SPAWN" -eq 1 ]; then
391
- echo " → run: cd \"$WT\" && /architect --epic \"$m\"$( [ "$TDD" -eq 1 ] && echo " --tdd")"
494
+ if [ "$SPAWN" -eq 0 ] || [ "$NO_SPAWN" -eq 1 ]; then
495
+ echo " → run: cd \"$WT${PROJECT_REL:+/$PROJECT_REL}\" && /architect --epic \"$m\"$( [ "$TDD" -eq 1 ] && echo " --tdd")"
392
496
  STARTED+=("$m")
393
497
  continue
394
498
  fi
395
499
 
396
500
  # 3. spawn one headless agent in the worktree (RPC mode dispatches the command)
501
+ # cwd must be the PROJECT dir inside the worktree (where .pi/ lives).
502
+ SPAWN_CWD="$WT"
503
+ [ -n "$PROJECT_REL" ] && SPAWN_CWD="$WT/$PROJECT_REL"
504
+ if [ ! -d "$SPAWN_CWD/.pi/extensions" ]; then
505
+ echo " ✗ $SPAWN_CWD/.pi/extensions missing — refusing to spawn a blind agent"
506
+ FAILED+=("$m")
507
+ continue
508
+ fi
509
+ # GUARDIAN_LOCAL_ONLY keeps the spawned epic from creating remote issues
510
+ # unless the caller explicitly allowed it (--allow-remote-issues).
511
+ export GUARDIAN_LOCAL_ONLY="$([ "$ALLOW_REMOTE" -eq 1 ] && echo 0 || echo 1)"
397
512
  "$NODE_BIN" "$RUNNER" \
398
513
  "/architect --epic \"$m\"$( [ "$TDD" -eq 1 ] && echo " --tdd")" \
399
- --cwd "$WT" --log "$log" ${TIMEOUT_MS:+--timeout-ms "$TIMEOUT_MS"} \
514
+ --cwd "$SPAWN_CWD" --log "$log" ${TIMEOUT_MS:+--timeout-ms "$TIMEOUT_MS"} \
400
515
  >/dev/null 2>&1 &
401
516
  PID=$!
402
517
  bash "$REGISTRY" set "$m" "pid=$PID" "log=$log" "spawnedAt=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >/dev/null 2>&1
@@ -408,8 +523,13 @@ echo "────────────────────────
408
523
  echo ""
409
524
  echo "Started ${#STARTED[@]}/${#MODULES[@]} epic(s)."
410
525
  [ "${#FAILED[@]}" -gt 0 ] && echo "Failed: ${FAILED[*]:-}"
526
+ if [ "$SPAWN" -eq 0 ] || [ "$NO_SPAWN" -eq 1 ]; then
527
+ echo ""
528
+ echo "Nothing was spawned (default). Worktrees are ready and empty."
529
+ echo "Re-run with --spawn to launch agents, or run the commands above yourself."
530
+ fi
411
531
 
412
- if [ "${#STARTED[@]}" -gt 0 ]; then
532
+ if [ "${#STARTED[@]}" -gt 0 ] && [ "$SPAWN" -eq 1 ] && [ "$NO_SPAWN" -eq 0 ]; then
413
533
  echo ""
414
534
  echo "Monitor:"
415
535
  echo " bash .pi/scripts/epic-registry.sh list"
@@ -13,9 +13,10 @@ set -uo pipefail
13
13
 
14
14
  MODULE="${1:-}"
15
15
  shift || true
16
- BASE="main"; DIR=""
16
+ BASE="main"; DIR=""; ASSUME_FROZEN=0
17
17
  while [ $# -gt 0 ]; do
18
18
  case "$1" in
19
+ --assume-frozen) ASSUME_FROZEN=1; shift ;;
19
20
  --base) BASE="${2:-main}"; shift 2 ;;
20
21
  --dir) DIR="${2:-}"; shift 2 ;;
21
22
  *) shift ;;
@@ -58,7 +59,7 @@ PY
58
59
  )
59
60
  fi
60
61
 
61
- if [ "$FROZEN" != "true" ]; then
62
+ if [ "$FROZEN" != "true" ] && [ "$ASSUME_FROZEN" -eq 0 ]; then
62
63
  # NOTE: block here (before worktree creation) and exit 2 — a non-zero,
63
64
  # distinguishable code so callers/agents cannot mistake it for success.
64
65
  echo "⛔ Blocked: contracts for '$MODULE' are not frozen (registry: $FROZEN)."
@@ -115,9 +115,32 @@ function handleLine(line) {
115
115
  // The prompt was accepted by an extension command?
116
116
  if (msg.type === "response" && msg.command === "prompt" && msg.success === true) {
117
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();
118
+ // "handled" = an extension command consumed it (what we need).
119
+ // "started"/"queued" = it went to the MODEL. For a /command that means
120
+ // the extension was not loaded (wrong cwd, missing .pi/extensions) — the
121
+ // model then improvises the workflow, which looks like success and burns
122
+ // tokens. Treat that as a hard error.
123
+ const isCommand = opts.command.startsWith("/");
124
+ if (d === "handled") {
125
+ handled = true;
126
+ armGrace();
127
+ } else if (isCommand) {
128
+ emit(
129
+ JSON.stringify({
130
+ type: "runner_error",
131
+ reason: "command_not_dispatched",
132
+ detail:
133
+ `'${opts.command.split(/\s+/)[0]}' was routed to the model ` +
134
+ `(disposition "${d}") — the extension is not loaded in ${opts.cwd}. ` +
135
+ "Check that .pi/extensions exists in that directory.",
136
+ }),
137
+ );
138
+ failed = true;
139
+ finish(3);
140
+ } else {
141
+ handled = true;
142
+ armGrace();
143
+ }
121
144
  }
122
145
  if (msg.type === "response" && msg.command === "prompt" && msg.success === false) {
123
146
  emit(JSON.stringify({ type: "runner_error", reason: "prompt rejected" }));