@miller-tech/uap 1.40.1 → 1.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +17 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/deliver-defaults.d.ts +23 -0
- package/dist/cli/deliver-defaults.d.ts.map +1 -0
- package/dist/cli/deliver-defaults.js +121 -0
- package/dist/cli/deliver-defaults.js.map +1 -0
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +50 -2
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +29 -0
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/react.d.ts +25 -0
- package/dist/cli/react.d.ts.map +1 -0
- package/dist/cli/react.js +59 -0
- package/dist/cli/react.js.map +1 -0
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +19 -0
- package/dist/cli/setup.js.map +1 -1
- package/dist/coordination/reactor.d.ts +38 -0
- package/dist/coordination/reactor.d.ts.map +1 -0
- package/dist/coordination/reactor.js +124 -0
- package/dist/coordination/reactor.js.map +1 -0
- package/dist/mcp-router/server.d.ts +2 -1
- package/dist/mcp-router/server.d.ts.map +1 -1
- package/dist/mcp-router/server.js +5 -2
- package/dist/mcp-router/server.js.map +1 -1
- package/dist/mcp-router/tools/react.d.ts +58 -0
- package/dist/mcp-router/tools/react.d.ts.map +1 -0
- package/dist/mcp-router/tools/react.js +57 -0
- package/dist/mcp-router/tools/react.js.map +1 -0
- package/dist/memory/model-router.d.ts +1 -1
- package/dist/memory/model-router.d.ts.map +1 -1
- package/dist/memory/model-router.js +27 -1
- package/dist/memory/model-router.js.map +1 -1
- package/dist/models/openai-compat-client.d.ts.map +1 -1
- package/dist/models/openai-compat-client.js +5 -0
- package/dist/models/openai-compat-client.js.map +1 -1
- package/dist/models/types.d.ts +8 -0
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +22 -0
- package/dist/models/types.js.map +1 -1
- package/dist/policies/policy-tools.d.ts +7 -0
- package/dist/policies/policy-tools.d.ts.map +1 -1
- package/dist/policies/policy-tools.js +24 -2
- package/dist/policies/policy-tools.js.map +1 -1
- package/dist/types/config.d.ts +12 -0
- package/dist/types/config.d.ts.map +1 -1
- package/docs/design/UAP_REACTOR.md +170 -0
- package/package.json +3 -1
- package/src/policies/enforcers/7ebbc721-7540-4e9f-879a-770e0213a09b_architecture_review.py +101 -0
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/_common.py +100 -0
- package/src/policies/enforcers/artifact_hygiene.py +52 -0
- package/src/policies/enforcers/cluster_routing.py +63 -0
- package/src/policies/enforcers/codebase_read_before_plan.py +52 -0
- package/src/policies/enforcers/coord_overlap.py +81 -0
- package/src/policies/enforcers/delivery_enforcement.py +97 -0
- package/src/policies/enforcers/doc_live_over_report.py +50 -0
- package/src/policies/enforcers/expert_review_required.py +135 -0
- package/src/policies/enforcers/iac_parity.py +53 -0
- package/src/policies/enforcers/mcp_router_first.py +37 -0
- package/src/policies/enforcers/memory_before_plan.py +61 -0
- package/src/policies/enforcers/parallel_reads.py +50 -0
- package/src/policies/enforcers/rtk_wrap.py +44 -0
- package/src/policies/enforcers/schema_diff_gate.py +80 -0
- package/src/policies/enforcers/session_memory_write.py +52 -0
- package/src/policies/enforcers/task_required.py +131 -0
- package/src/policies/enforcers/test_gate.py +58 -0
- package/src/policies/enforcers/validate_plan_before_build.py +75 -0
- package/src/policies/enforcers/worktree_required.py +57 -0
- package/src/policies/schemas/policies/architecture-review.md +51 -0
- package/src/policies/schemas/policies/artifact-hygiene.md +29 -0
- package/src/policies/schemas/policies/cluster-routing.md +31 -0
- package/src/policies/schemas/policies/codebase-read-before-plan.md +30 -0
- package/src/policies/schemas/policies/coord-overlap.md +24 -0
- package/src/policies/schemas/policies/delivery-enforcement.md +45 -0
- package/src/policies/schemas/policies/doc-live-over-report.md +32 -0
- package/src/policies/schemas/policies/expert-review-required.md +60 -0
- package/src/policies/schemas/policies/iac-parity.md +31 -0
- package/src/policies/schemas/policies/mandatory-testing-deployment.md +147 -0
- package/src/policies/schemas/policies/mcp-router-first.md +24 -0
- package/src/policies/schemas/policies/memory-before-plan.md +24 -0
- package/src/policies/schemas/policies/merge-deploy-monitor-verify.md +145 -0
- package/src/policies/schemas/policies/parallel-reads.md +24 -0
- package/src/policies/schemas/policies/rtk-wrap.md +26 -0
- package/src/policies/schemas/policies/schema-diff-gate.md +30 -0
- package/src/policies/schemas/policies/session-memory-write.md +24 -0
- package/src/policies/schemas/policies/task-required.md +49 -0
- package/src/policies/schemas/policies/test-gate.md +24 -0
- package/src/policies/schemas/policies/validate-plan-before-build.md +28 -0
- package/src/policies/schemas/policies/worktree-required.md +28 -0
- package/templates/hooks/uap-policy-gate.sh +5 -0
- package/templates/hooks/uap-reactor-prompt.sh +44 -0
- package/templates/hooks/uap-schema-post.sh +26 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""mcp-router-first enforcer: MCP tools must be loaded on demand."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
9
|
+
from _common import arg_str, emit, parse_cli # noqa: E402
|
|
10
|
+
|
|
11
|
+
BULK_PATTERNS = re.compile(r"(load[-_ ]all|bulk[-_ ]load|all[-_ ]tools|eager)", re.I)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
op, args = parse_cli()
|
|
16
|
+
blob = f"{op} {arg_str(args)}"
|
|
17
|
+
|
|
18
|
+
if op not in {"ToolSearch", "tool_search", "mcp-router", "mcp_router"}:
|
|
19
|
+
emit(True, "not an MCP-router op")
|
|
20
|
+
|
|
21
|
+
query = (args.get("query") or "").strip()
|
|
22
|
+
max_results = int(args.get("max_results") or 5)
|
|
23
|
+
|
|
24
|
+
if BULK_PATTERNS.search(blob):
|
|
25
|
+
emit(False, "mcp-router-first: bulk/eager MCP tool load detected; query by specific tool name instead")
|
|
26
|
+
|
|
27
|
+
if not query or max_results > 20:
|
|
28
|
+
emit(
|
|
29
|
+
False,
|
|
30
|
+
"mcp-router-first: ToolSearch must use a specific query and max_results<=20",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
emit(True, "scoped MCP router query")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""memory-before-plan enforcer: plans require a recent uap memory query."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import re
|
|
5
|
+
import sqlite3
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
11
|
+
from _common import arg_str, emit, parse_cli, repo_root # noqa: E402
|
|
12
|
+
|
|
13
|
+
PLAN_OPS = {"ExitPlanMode", "Plan", "TodoWrite", "plan", "design"}
|
|
14
|
+
# Only match standalone words, not compounds like 'validate-plan-before-build'
|
|
15
|
+
PLAN_WORD_RE = re.compile(r"(?<![-\w/])(plan the|design the|architect the|propose a plan|roadmap for)", re.I)
|
|
16
|
+
RECENT_SEC = 300
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def recent_memory_query(root: Path) -> bool:
|
|
20
|
+
db = root / "agents" / "data" / "memory" / "short_term.db"
|
|
21
|
+
if not db.exists():
|
|
22
|
+
return False
|
|
23
|
+
try:
|
|
24
|
+
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=1.0)
|
|
25
|
+
cur = con.execute(
|
|
26
|
+
"SELECT timestamp FROM session_memories "
|
|
27
|
+
"WHERE content LIKE '%uap memory query%' OR type='memory_query' "
|
|
28
|
+
"ORDER BY id DESC LIMIT 1"
|
|
29
|
+
)
|
|
30
|
+
row = cur.fetchone()
|
|
31
|
+
con.close()
|
|
32
|
+
if not row:
|
|
33
|
+
return False
|
|
34
|
+
raw = row[0][:19].replace("T", " ")
|
|
35
|
+
try:
|
|
36
|
+
ts = time.mktime(time.strptime(raw, "%Y-%m-%d %H:%M:%S"))
|
|
37
|
+
except Exception: # noqa: BLE001
|
|
38
|
+
return False
|
|
39
|
+
# Memory is stored as UTC from datetime('now'); compare with UTC now
|
|
40
|
+
return (time.time() - (ts - time.timezone)) < RECENT_SEC
|
|
41
|
+
except sqlite3.Error:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> None:
|
|
46
|
+
op, args = parse_cli()
|
|
47
|
+
blob = f"{op} {arg_str(args)}"
|
|
48
|
+
if op not in PLAN_OPS and not PLAN_WORD_RE.search(blob):
|
|
49
|
+
emit(True, "not a plan operation")
|
|
50
|
+
|
|
51
|
+
if recent_memory_query(repo_root()):
|
|
52
|
+
emit(True, "recent uap memory query on record")
|
|
53
|
+
|
|
54
|
+
emit(
|
|
55
|
+
False,
|
|
56
|
+
"memory-before-plan: run `uap memory query <topic>` before planning to surface prior context",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""parallel-reads enforcer: nudge when serial read fan-out is detected."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
10
|
+
from _common import emit, parse_cli # noqa: E402
|
|
11
|
+
|
|
12
|
+
READ_OPS = {"Read", "Grep", "Glob", "WebFetch", "read", "grep", "glob", "webfetch"}
|
|
13
|
+
STATE = Path(os.environ.get("UAP_STATE_DIR", ".uap")) / "parallel_reads.state"
|
|
14
|
+
WINDOW_SEC = 4.0
|
|
15
|
+
THRESHOLD = 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
op, _args = parse_cli()
|
|
20
|
+
if op not in READ_OPS:
|
|
21
|
+
emit(True, "not a read op")
|
|
22
|
+
|
|
23
|
+
STATE.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
now = time.time()
|
|
25
|
+
history: list[float] = []
|
|
26
|
+
if STATE.exists():
|
|
27
|
+
try:
|
|
28
|
+
history = [
|
|
29
|
+
float(l) for l in STATE.read_text().splitlines() if l.strip()
|
|
30
|
+
]
|
|
31
|
+
except ValueError:
|
|
32
|
+
history = []
|
|
33
|
+
|
|
34
|
+
history = [t for t in history if now - t < WINDOW_SEC]
|
|
35
|
+
history.append(now)
|
|
36
|
+
STATE.write_text("\n".join(f"{t}" for t in history[-10:]))
|
|
37
|
+
|
|
38
|
+
if len(history) > THRESHOLD:
|
|
39
|
+
emit(
|
|
40
|
+
True,
|
|
41
|
+
f"parallel-reads: {len(history)} serial read ops in {WINDOW_SEC}s "
|
|
42
|
+
"— batch independent reads in a single tool-call message for 2-5x speed-up",
|
|
43
|
+
warning=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
emit(True, "read cadence within batch window")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""rtk-wrap enforcer: heavy CLIs must be invoked via rtk."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
9
|
+
from _common import emit, parse_cli # noqa: E402
|
|
10
|
+
|
|
11
|
+
WRAPPED = ("git", "kubectl", "docker", "docker-compose", "npm", "pnpm", "yarn", "helm", "terraform")
|
|
12
|
+
RTK_META = re.compile(r"^\s*rtk\s+(gain|discover|proxy|--version|-V|--help)\b")
|
|
13
|
+
ALREADY_WRAPPED = re.compile(r"^\s*rtk\s+\S+")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
op, args = parse_cli()
|
|
18
|
+
cmd = (args.get("command") or args.get("cmd") or "").strip()
|
|
19
|
+
if not cmd or op.lower() != "bash":
|
|
20
|
+
emit(True, "not a Bash command")
|
|
21
|
+
|
|
22
|
+
first = cmd.split(maxsplit=1)[0].lstrip("(").lstrip("{")
|
|
23
|
+
if first == "rtk" and RTK_META.search(cmd):
|
|
24
|
+
emit(True, "rtk meta command")
|
|
25
|
+
if ALREADY_WRAPPED.match(cmd):
|
|
26
|
+
emit(True, "already wrapped")
|
|
27
|
+
|
|
28
|
+
# Inspect tokens (ignore env assignments like FOO=bar cmd)
|
|
29
|
+
tokens = [t for t in cmd.split() if "=" not in t.split("/")[0]]
|
|
30
|
+
for tok in tokens[:3]:
|
|
31
|
+
bin_name = tok.split("/")[-1]
|
|
32
|
+
if bin_name in WRAPPED:
|
|
33
|
+
emit(
|
|
34
|
+
False,
|
|
35
|
+
f"rtk-wrap: '{bin_name}' must be invoked via rtk. "
|
|
36
|
+
f"Use: rtk {cmd}",
|
|
37
|
+
bin=bin_name,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
emit(True, "no wrapped CLI in command")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""schema-diff-gate enforcer: schema/pool changes must pass uap schema-diff."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import re
|
|
5
|
+
import sqlite3
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
11
|
+
from _common import emit, parse_cli, repo_root, run, worktree_root # noqa: E402
|
|
12
|
+
|
|
13
|
+
WATCHED_RE = re.compile(
|
|
14
|
+
r"(migrations/.*\.sql|infra/postgres-spock/|infra/helm_charts/[^/]*pgdog|"
|
|
15
|
+
r"infra/helm_charts/[^/]*cnpg|infra/helm_charts/[^/]*redis|"
|
|
16
|
+
r"infra/helm_charts/[^/]*envoy|infra/helm_charts/[^/]*sentinel)",
|
|
17
|
+
re.I,
|
|
18
|
+
)
|
|
19
|
+
COMMIT_OPS = {"git-commit", "git commit", "Bash"}
|
|
20
|
+
RECENT_SEC = 3600
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def touched_watched_paths(root: Path) -> list[str]:
|
|
24
|
+
rc, out, _ = run(["git", "diff", "--name-only", "HEAD"], cwd=root)
|
|
25
|
+
if rc != 0:
|
|
26
|
+
return []
|
|
27
|
+
rc2, staged, _ = run(["git", "diff", "--name-only", "--cached"], cwd=root)
|
|
28
|
+
all_files = (out + "\n" + (staged if rc2 == 0 else "")).splitlines()
|
|
29
|
+
return [f for f in all_files if f and WATCHED_RE.search(f)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def schema_diff_ok(root: Path) -> bool:
|
|
33
|
+
db = root / "agents" / "data" / "memory" / "short_term.db"
|
|
34
|
+
if not db.exists():
|
|
35
|
+
return False
|
|
36
|
+
try:
|
|
37
|
+
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=1.0)
|
|
38
|
+
cur = con.execute(
|
|
39
|
+
"SELECT timestamp FROM session_memories "
|
|
40
|
+
"WHERE content LIKE '%schema-diff%pass%' "
|
|
41
|
+
"ORDER BY id DESC LIMIT 1"
|
|
42
|
+
)
|
|
43
|
+
row = cur.fetchone()
|
|
44
|
+
con.close()
|
|
45
|
+
if not row:
|
|
46
|
+
return False
|
|
47
|
+
try:
|
|
48
|
+
ts = time.mktime(time.strptime(row[0][:19], "%Y-%m-%dT%H:%M:%S"))
|
|
49
|
+
except Exception: # noqa: BLE001
|
|
50
|
+
return False
|
|
51
|
+
return (time.time() - ts) < RECENT_SEC
|
|
52
|
+
except sqlite3.Error:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main() -> None:
|
|
57
|
+
op, args = parse_cli()
|
|
58
|
+
cmd = (args.get("command") or "").lower()
|
|
59
|
+
is_commit = op in COMMIT_OPS or "git commit" in cmd or "git push" in cmd
|
|
60
|
+
if not is_commit:
|
|
61
|
+
emit(True, "not a commit/push gate point")
|
|
62
|
+
|
|
63
|
+
# git diff runs against the working tree; short_term.db lives in MAIN_ROOT
|
|
64
|
+
watched = touched_watched_paths(worktree_root())
|
|
65
|
+
if not watched:
|
|
66
|
+
emit(True, "no watched schema/pool paths in diff")
|
|
67
|
+
|
|
68
|
+
if schema_diff_ok(repo_root()):
|
|
69
|
+
emit(True, f"recent schema-diff pass covers: {', '.join(watched[:5])}")
|
|
70
|
+
|
|
71
|
+
emit(
|
|
72
|
+
False,
|
|
73
|
+
"schema-diff-gate: changes to "
|
|
74
|
+
+ ", ".join(watched[:5])
|
|
75
|
+
+ " require `uap schema-diff` to pass (within 1h). Run it and re-commit.",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""session-memory-write enforcer: code-changing sessions must write a lesson."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import sqlite3
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
9
|
+
from _common import emit, parse_cli, repo_root # noqa: E402
|
|
10
|
+
|
|
11
|
+
END_OPS = {"session-end", "stop", "terminate", "SessionEnd"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def recent_lesson(root: Path) -> bool:
|
|
15
|
+
db = root / "agents" / "data" / "memory" / "short_term.db"
|
|
16
|
+
if not db.exists():
|
|
17
|
+
return False
|
|
18
|
+
try:
|
|
19
|
+
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=1.0)
|
|
20
|
+
cur = con.execute(
|
|
21
|
+
"SELECT COUNT(*) FROM session_memories "
|
|
22
|
+
"WHERE type IN ('decision','lesson','pattern') "
|
|
23
|
+
"AND session_id='current'"
|
|
24
|
+
)
|
|
25
|
+
n = cur.fetchone()[0]
|
|
26
|
+
con.close()
|
|
27
|
+
return n > 0
|
|
28
|
+
except sqlite3.Error:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main() -> None:
|
|
33
|
+
op, args = parse_cli()
|
|
34
|
+
if op not in END_OPS:
|
|
35
|
+
emit(True, "not a session-end op")
|
|
36
|
+
|
|
37
|
+
code_changed = bool(args.get("code_changed"))
|
|
38
|
+
if not code_changed:
|
|
39
|
+
emit(True, "no code changes this session")
|
|
40
|
+
|
|
41
|
+
if recent_lesson(repo_root()):
|
|
42
|
+
emit(True, "lesson/decision recorded this session")
|
|
43
|
+
|
|
44
|
+
emit(
|
|
45
|
+
False,
|
|
46
|
+
"session-memory-write: code changed but no decision/lesson/pattern row in short_term.db. "
|
|
47
|
+
"Insert one before terminating.",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
main()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""task-required enforcer: a UAP task must be in_progress before mutating work.
|
|
3
|
+
|
|
4
|
+
Blocks Edit/Write/MultiEdit (outside exempt prefixes) and the ship actions
|
|
5
|
+
git commit / git push / gh pr create when no row in .uap/tasks/tasks.db has
|
|
6
|
+
status='in_progress'. Closes UAP protocol step 4 — which was previously
|
|
7
|
+
text-injection only and therefore skippable.
|
|
8
|
+
|
|
9
|
+
Fail-open: if UAP task tracking is not initialised (no tasks.db) or the DB is
|
|
10
|
+
unreadable, the operation is allowed — so non-UAP repos are unaffected.
|
|
11
|
+
Override: set UAP_NO_TASK=1 to bypass.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sqlite3
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
21
|
+
from _common import emit, parse_cli, repo_root, run # noqa: E402
|
|
22
|
+
|
|
23
|
+
EDIT_OPS = {"edit", "write", "multiedit"}
|
|
24
|
+
|
|
25
|
+
# Meta / infra / docs paths that do not require a task (mirror worktree_required,
|
|
26
|
+
# plus .policy-tools/ which is the policy-system's own runtime artifact dir).
|
|
27
|
+
EXEMPT_PREFIXES = (
|
|
28
|
+
".claude/",
|
|
29
|
+
".cursor/",
|
|
30
|
+
".opencode/",
|
|
31
|
+
".codex/",
|
|
32
|
+
".forge/",
|
|
33
|
+
".uap/",
|
|
34
|
+
".policy-tools/",
|
|
35
|
+
"src/policies/",
|
|
36
|
+
"scripts/",
|
|
37
|
+
"docs/",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Bash ship actions gated even though Bash itself is otherwise unrestricted.
|
|
41
|
+
SHIP_PATTERNS = (
|
|
42
|
+
re.compile(r"\bgit\s+(commit|push)\b"),
|
|
43
|
+
re.compile(r"\bgh\s+pr\s+create\b"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main_repo_root() -> Path:
|
|
48
|
+
"""Resolve the primary worktree root, even when invoked from a linked
|
|
49
|
+
worktree — `git rev-parse --git-common-dir` always points at the main
|
|
50
|
+
.git, whose parent is the primary checkout."""
|
|
51
|
+
rc, out, _ = run(["git", "rev-parse", "--git-common-dir"])
|
|
52
|
+
if rc == 0 and out.strip():
|
|
53
|
+
p = Path(out.strip())
|
|
54
|
+
if not p.is_absolute():
|
|
55
|
+
p = (Path.cwd() / p).resolve()
|
|
56
|
+
return p.parent
|
|
57
|
+
return repo_root()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def in_progress_task_state():
|
|
61
|
+
"""True if an in_progress task exists, False if none, None if UAP task
|
|
62
|
+
tracking is not set up / DB unreadable (caller treats None as allow)."""
|
|
63
|
+
db = main_repo_root() / ".uap" / "tasks" / "tasks.db"
|
|
64
|
+
if not db.exists():
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=2)
|
|
68
|
+
try:
|
|
69
|
+
n = con.execute(
|
|
70
|
+
"SELECT COUNT(*) FROM tasks WHERE status='in_progress'"
|
|
71
|
+
).fetchone()[0]
|
|
72
|
+
finally:
|
|
73
|
+
con.close()
|
|
74
|
+
return n > 0
|
|
75
|
+
except Exception: # noqa: BLE001
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main() -> None:
|
|
80
|
+
op, args = parse_cli()
|
|
81
|
+
|
|
82
|
+
if os.environ.get("UAP_NO_TASK") == "1":
|
|
83
|
+
emit(True, "UAP_NO_TASK override set")
|
|
84
|
+
|
|
85
|
+
op_l = op.lower()
|
|
86
|
+
is_edit = op_l in EDIT_OPS
|
|
87
|
+
is_bash = op_l == "bash"
|
|
88
|
+
if not is_edit and not is_bash:
|
|
89
|
+
emit(True, "not a mutating operation")
|
|
90
|
+
|
|
91
|
+
if is_bash:
|
|
92
|
+
cmd = args.get("command") or args.get("cmd") or ""
|
|
93
|
+
if not any(p.search(cmd) for p in SHIP_PATTERNS):
|
|
94
|
+
emit(True, "not a ship action")
|
|
95
|
+
gate_label = "ship action (git commit/push, gh pr create)"
|
|
96
|
+
else:
|
|
97
|
+
target = (
|
|
98
|
+
args.get("file_path")
|
|
99
|
+
or args.get("path")
|
|
100
|
+
or args.get("target")
|
|
101
|
+
or ""
|
|
102
|
+
)
|
|
103
|
+
if not target:
|
|
104
|
+
emit(True, "no file path in args")
|
|
105
|
+
root = repo_root()
|
|
106
|
+
try:
|
|
107
|
+
rel = str(Path(target).resolve().relative_to(root))
|
|
108
|
+
except ValueError:
|
|
109
|
+
emit(True, "target outside repo")
|
|
110
|
+
if any(rel.startswith(p) for p in EXEMPT_PREFIXES):
|
|
111
|
+
emit(True, f"exempt path: {rel}")
|
|
112
|
+
gate_label = f"edit of '{rel}'"
|
|
113
|
+
|
|
114
|
+
state = in_progress_task_state()
|
|
115
|
+
if state is None:
|
|
116
|
+
emit(True, "UAP task tracking not initialised — fail-open")
|
|
117
|
+
if state:
|
|
118
|
+
emit(True, "in_progress UAP task present")
|
|
119
|
+
|
|
120
|
+
emit(
|
|
121
|
+
False,
|
|
122
|
+
f"task-required: no in_progress UAP task — {gate_label} blocked. "
|
|
123
|
+
'Run: uap task create --type <task|bug|feature> --title "<desc>" '
|
|
124
|
+
"then: uap task update <id> --status in_progress "
|
|
125
|
+
"(or: uap task claim <id> to also spin a worktree). "
|
|
126
|
+
"Override for one-off meta-work: UAP_NO_TASK=1.",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
main()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""test-gate enforcer: changed services under services|apps need test deltas."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
9
|
+
from _common import emit, parse_cli, run, worktree_root # noqa: E402
|
|
10
|
+
|
|
11
|
+
PR_OPS_RE = re.compile(
|
|
12
|
+
r"\b(pr[-_ ]?ready|pr-create|gh pr create|signoff|ready[-_ ]for[-_ ]review|merge)\b",
|
|
13
|
+
re.I,
|
|
14
|
+
)
|
|
15
|
+
SVC_RE = re.compile(r"^(services|apps)/([^/]+)/")
|
|
16
|
+
TEST_RE = re.compile(
|
|
17
|
+
r"(/tests?/|__tests__/|\.test\.(ts|tsx|js|py)$|_test\.(py|go)$|\.spec\.(ts|tsx|js)$)"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main() -> None:
|
|
22
|
+
op, args = parse_cli()
|
|
23
|
+
cmd = (args.get("command") or "").lower()
|
|
24
|
+
if not (PR_OPS_RE.search(op) or PR_OPS_RE.search(cmd)):
|
|
25
|
+
emit(True, "not a PR-ready gate point")
|
|
26
|
+
|
|
27
|
+
root = worktree_root() # git diff must run against the working tree, not MAIN_ROOT
|
|
28
|
+
rc, out, _ = run(
|
|
29
|
+
["git", "diff", "--name-only", "origin/main...HEAD"], cwd=root, timeout=10
|
|
30
|
+
)
|
|
31
|
+
if rc != 0:
|
|
32
|
+
emit(True, "cannot compute diff vs origin/main")
|
|
33
|
+
|
|
34
|
+
changed = [l for l in out.splitlines() if l.strip()]
|
|
35
|
+
svcs_touched: set[str] = set()
|
|
36
|
+
for f in changed:
|
|
37
|
+
m = SVC_RE.match(f)
|
|
38
|
+
if m:
|
|
39
|
+
svcs_touched.add(f"{m.group(1)}/{m.group(2)}")
|
|
40
|
+
|
|
41
|
+
if not svcs_touched:
|
|
42
|
+
emit(True, "no services/apps changes in diff")
|
|
43
|
+
|
|
44
|
+
svcs_with_tests = {
|
|
45
|
+
s for s in svcs_touched if any(f.startswith(s) and TEST_RE.search(f) for f in changed)
|
|
46
|
+
}
|
|
47
|
+
missing = svcs_touched - svcs_with_tests
|
|
48
|
+
if missing:
|
|
49
|
+
emit(
|
|
50
|
+
False,
|
|
51
|
+
f"test-gate: the following services changed without test deltas: {', '.join(sorted(missing))}",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
emit(True, "all changed services include test deltas")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""validate-plan-before-build enforcer.
|
|
3
|
+
|
|
4
|
+
On the first mutating tool call after a plan is marked ready, block and require
|
|
5
|
+
the agent to run the `validate the plan` prompt. State tracked in .uap/plan_state.json.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
15
|
+
from _common import emit, parse_cli # noqa: E402
|
|
16
|
+
|
|
17
|
+
STATE = Path(os.environ.get("UAP_STATE_DIR", ".uap")) / "plan_state.json"
|
|
18
|
+
MUTATING_OPS = {"Edit", "Write", "MultiEdit", "edit", "write", "multiedit"}
|
|
19
|
+
BUILD_BASH_RE = (
|
|
20
|
+
"git commit",
|
|
21
|
+
"git push",
|
|
22
|
+
"kubectl apply",
|
|
23
|
+
"helm upgrade",
|
|
24
|
+
"helm install",
|
|
25
|
+
"terraform apply",
|
|
26
|
+
"npm run build",
|
|
27
|
+
"pnpm build",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_state() -> dict:
|
|
32
|
+
if not STATE.exists():
|
|
33
|
+
return {}
|
|
34
|
+
try:
|
|
35
|
+
return json.loads(STATE.read_text())
|
|
36
|
+
except json.JSONDecodeError:
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_state(s: dict) -> None:
|
|
41
|
+
STATE.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
STATE.write_text(json.dumps(s))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> None:
|
|
46
|
+
op, args = parse_cli()
|
|
47
|
+
state = load_state()
|
|
48
|
+
|
|
49
|
+
# Mutating?
|
|
50
|
+
cmd = (args.get("command") or "").lower()
|
|
51
|
+
mutating = op in MUTATING_OPS or any(p in cmd for p in BUILD_BASH_RE)
|
|
52
|
+
if not mutating:
|
|
53
|
+
emit(True, "not a build/mutation op")
|
|
54
|
+
|
|
55
|
+
plan_ready = bool(state.get("plan_ready"))
|
|
56
|
+
validated_at = float(state.get("validated_at", 0))
|
|
57
|
+
ready_at = float(state.get("ready_at", 0))
|
|
58
|
+
|
|
59
|
+
if not plan_ready:
|
|
60
|
+
emit(True, "no active plan-ready marker")
|
|
61
|
+
|
|
62
|
+
if validated_at and validated_at >= ready_at:
|
|
63
|
+
emit(True, "plan validated since marking ready")
|
|
64
|
+
|
|
65
|
+
emit(
|
|
66
|
+
False,
|
|
67
|
+
"validate-plan-before-build: plan is ready but not validated. "
|
|
68
|
+
"Run the prompt `validate the plan` before making changes. "
|
|
69
|
+
"After a pass, write {\"validated_at\": <epoch>} to .uap/plan_state.json.",
|
|
70
|
+
inject_prompt="validate the plan",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""worktree-required enforcer: Edit/Write must target a .worktrees/ path."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
9
|
+
from _common import emit, parse_cli, repo_root # noqa: E402
|
|
10
|
+
|
|
11
|
+
EXEMPT_PREFIXES = (
|
|
12
|
+
".claude/",
|
|
13
|
+
".cursor/",
|
|
14
|
+
".opencode/",
|
|
15
|
+
".codex/",
|
|
16
|
+
".forge/",
|
|
17
|
+
".uap/",
|
|
18
|
+
"src/policies/",
|
|
19
|
+
"scripts/",
|
|
20
|
+
"docs/",
|
|
21
|
+
)
|
|
22
|
+
EDIT_OPS = {"Edit", "Write", "MultiEdit", "edit", "write", "multiedit"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
op, args = parse_cli()
|
|
27
|
+
if op not in EDIT_OPS:
|
|
28
|
+
emit(True, "not a file-edit operation")
|
|
29
|
+
|
|
30
|
+
target = args.get("file_path") or args.get("path") or args.get("target") or ""
|
|
31
|
+
if not target:
|
|
32
|
+
emit(True, "no file path in args")
|
|
33
|
+
|
|
34
|
+
root = repo_root()
|
|
35
|
+
try:
|
|
36
|
+
rel = str(Path(target).resolve().relative_to(root))
|
|
37
|
+
except ValueError:
|
|
38
|
+
emit(True, "target outside repo")
|
|
39
|
+
|
|
40
|
+
if rel.startswith(".worktrees/"):
|
|
41
|
+
emit(True, "target inside a worktree")
|
|
42
|
+
|
|
43
|
+
if any(rel.startswith(p) for p in EXEMPT_PREFIXES):
|
|
44
|
+
emit(True, f"exempt path: {rel}")
|
|
45
|
+
|
|
46
|
+
if os.environ.get("UAP_NO_WORKTREE") == "1":
|
|
47
|
+
emit(True, "UAP_NO_WORKTREE override set")
|
|
48
|
+
|
|
49
|
+
emit(
|
|
50
|
+
False,
|
|
51
|
+
f"worktree-required: '{rel}' must be edited inside .worktrees/NNN-<slug>/. "
|
|
52
|
+
"Run: uap worktree create <slug>",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|