@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.
Files changed (96) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/bin/cli.js +17 -0
  3. package/dist/bin/cli.js.map +1 -1
  4. package/dist/cli/deliver-defaults.d.ts +23 -0
  5. package/dist/cli/deliver-defaults.d.ts.map +1 -0
  6. package/dist/cli/deliver-defaults.js +121 -0
  7. package/dist/cli/deliver-defaults.js.map +1 -0
  8. package/dist/cli/hooks.d.ts.map +1 -1
  9. package/dist/cli/hooks.js +50 -2
  10. package/dist/cli/hooks.js.map +1 -1
  11. package/dist/cli/init.d.ts.map +1 -1
  12. package/dist/cli/init.js +29 -0
  13. package/dist/cli/init.js.map +1 -1
  14. package/dist/cli/react.d.ts +25 -0
  15. package/dist/cli/react.d.ts.map +1 -0
  16. package/dist/cli/react.js +59 -0
  17. package/dist/cli/react.js.map +1 -0
  18. package/dist/cli/setup.d.ts.map +1 -1
  19. package/dist/cli/setup.js +19 -0
  20. package/dist/cli/setup.js.map +1 -1
  21. package/dist/coordination/reactor.d.ts +38 -0
  22. package/dist/coordination/reactor.d.ts.map +1 -0
  23. package/dist/coordination/reactor.js +124 -0
  24. package/dist/coordination/reactor.js.map +1 -0
  25. package/dist/mcp-router/server.d.ts +2 -1
  26. package/dist/mcp-router/server.d.ts.map +1 -1
  27. package/dist/mcp-router/server.js +5 -2
  28. package/dist/mcp-router/server.js.map +1 -1
  29. package/dist/mcp-router/tools/react.d.ts +58 -0
  30. package/dist/mcp-router/tools/react.d.ts.map +1 -0
  31. package/dist/mcp-router/tools/react.js +57 -0
  32. package/dist/mcp-router/tools/react.js.map +1 -0
  33. package/dist/memory/model-router.d.ts +1 -1
  34. package/dist/memory/model-router.d.ts.map +1 -1
  35. package/dist/memory/model-router.js +27 -1
  36. package/dist/memory/model-router.js.map +1 -1
  37. package/dist/models/openai-compat-client.d.ts.map +1 -1
  38. package/dist/models/openai-compat-client.js +5 -0
  39. package/dist/models/openai-compat-client.js.map +1 -1
  40. package/dist/models/types.d.ts +8 -0
  41. package/dist/models/types.d.ts.map +1 -1
  42. package/dist/models/types.js +22 -0
  43. package/dist/models/types.js.map +1 -1
  44. package/dist/policies/policy-tools.d.ts +7 -0
  45. package/dist/policies/policy-tools.d.ts.map +1 -1
  46. package/dist/policies/policy-tools.js +24 -2
  47. package/dist/policies/policy-tools.js.map +1 -1
  48. package/dist/types/config.d.ts +12 -0
  49. package/dist/types/config.d.ts.map +1 -1
  50. package/docs/design/UAP_REACTOR.md +170 -0
  51. package/package.json +3 -1
  52. package/src/policies/enforcers/7ebbc721-7540-4e9f-879a-770e0213a09b_architecture_review.py +101 -0
  53. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  54. package/src/policies/enforcers/_common.py +100 -0
  55. package/src/policies/enforcers/artifact_hygiene.py +52 -0
  56. package/src/policies/enforcers/cluster_routing.py +63 -0
  57. package/src/policies/enforcers/codebase_read_before_plan.py +52 -0
  58. package/src/policies/enforcers/coord_overlap.py +81 -0
  59. package/src/policies/enforcers/delivery_enforcement.py +97 -0
  60. package/src/policies/enforcers/doc_live_over_report.py +50 -0
  61. package/src/policies/enforcers/expert_review_required.py +135 -0
  62. package/src/policies/enforcers/iac_parity.py +53 -0
  63. package/src/policies/enforcers/mcp_router_first.py +37 -0
  64. package/src/policies/enforcers/memory_before_plan.py +61 -0
  65. package/src/policies/enforcers/parallel_reads.py +50 -0
  66. package/src/policies/enforcers/rtk_wrap.py +44 -0
  67. package/src/policies/enforcers/schema_diff_gate.py +80 -0
  68. package/src/policies/enforcers/session_memory_write.py +52 -0
  69. package/src/policies/enforcers/task_required.py +131 -0
  70. package/src/policies/enforcers/test_gate.py +58 -0
  71. package/src/policies/enforcers/validate_plan_before_build.py +75 -0
  72. package/src/policies/enforcers/worktree_required.py +57 -0
  73. package/src/policies/schemas/policies/architecture-review.md +51 -0
  74. package/src/policies/schemas/policies/artifact-hygiene.md +29 -0
  75. package/src/policies/schemas/policies/cluster-routing.md +31 -0
  76. package/src/policies/schemas/policies/codebase-read-before-plan.md +30 -0
  77. package/src/policies/schemas/policies/coord-overlap.md +24 -0
  78. package/src/policies/schemas/policies/delivery-enforcement.md +45 -0
  79. package/src/policies/schemas/policies/doc-live-over-report.md +32 -0
  80. package/src/policies/schemas/policies/expert-review-required.md +60 -0
  81. package/src/policies/schemas/policies/iac-parity.md +31 -0
  82. package/src/policies/schemas/policies/mandatory-testing-deployment.md +147 -0
  83. package/src/policies/schemas/policies/mcp-router-first.md +24 -0
  84. package/src/policies/schemas/policies/memory-before-plan.md +24 -0
  85. package/src/policies/schemas/policies/merge-deploy-monitor-verify.md +145 -0
  86. package/src/policies/schemas/policies/parallel-reads.md +24 -0
  87. package/src/policies/schemas/policies/rtk-wrap.md +26 -0
  88. package/src/policies/schemas/policies/schema-diff-gate.md +30 -0
  89. package/src/policies/schemas/policies/session-memory-write.md +24 -0
  90. package/src/policies/schemas/policies/task-required.md +49 -0
  91. package/src/policies/schemas/policies/test-gate.md +24 -0
  92. package/src/policies/schemas/policies/validate-plan-before-build.md +28 -0
  93. package/src/policies/schemas/policies/worktree-required.md +28 -0
  94. package/templates/hooks/uap-policy-gate.sh +5 -0
  95. package/templates/hooks/uap-reactor-prompt.sh +44 -0
  96. 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()