guardian-framework 0.1.150 → 0.1.151
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 +14 -5
- package/dist/exports.js +14 -5
- package/package.json +14 -5
- package/templates/pi/extensions/architect.ts +42 -22
- package/templates/pi/preflight_report.json +1 -1
- package/templates/pi/scripts/epic-fanout.sh +355 -0
- package/templates/pi/scripts/epic-registry.sh +7 -1
- package/templates/pi/scripts/epic-runner.mjs +194 -0
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.
|
|
3209
|
+
var name = "guardian-framework", version = "0.1.151", 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 = [
|
|
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: [
|
|
3239
|
-
|
|
3240
|
-
|
|
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",
|
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.
|
|
1994
|
+
var name = "guardian-framework", version = "0.1.151", 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 = [
|
|
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: [
|
|
2024
|
-
|
|
2025
|
-
|
|
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",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guardian-framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.151",
|
|
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": [
|
|
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": [
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
|
920
|
-
|
|
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
|
|
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 (
|
|
928
|
-
ctx.ui.notify(`
|
|
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
|
|
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.
|
|
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
|
-
|
|
943
|
-
"success",
|
|
944
|
-
);
|
|
963
|
+
ctx.ui.notify(out.trim().split("\n").slice(-12).join("\n"), "info");
|
|
964
|
+
return;
|
|
945
965
|
}
|
|
946
966
|
|
|
947
|
-
|
|
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");
|
|
@@ -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 _line; do
|
|
158
|
+
[ -n "$_line" ] && MODULES+=("$_line")
|
|
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`);
|