@arbiterforge/ca-pi 0.6.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 (206) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +558 -0
  3. package/package.json +35 -0
  4. package/plugins/ca-pi/CHANGELOG.md +1030 -0
  5. package/plugins/ca-pi/COMMANDS.md +90 -0
  6. package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
  7. package/plugins/ca-pi/SKILLS.md +47 -0
  8. package/plugins/ca-pi/SPRINT.md +142 -0
  9. package/plugins/ca-pi/agents/INDEX.md +31 -0
  10. package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
  11. package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
  12. package/plugins/ca-pi/agents/backend-author.md +60 -0
  13. package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
  14. package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
  15. package/plugins/ca-pi/agents/decision-challenger.md +116 -0
  16. package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
  17. package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
  18. package/plugins/ca-pi/agents/finding-triage.md +86 -0
  19. package/plugins/ca-pi/agents/frontend-author.md +64 -0
  20. package/plugins/ca-pi/agents/grader.md +173 -0
  21. package/plugins/ca-pi/agents/infra-author.md +64 -0
  22. package/plugins/ca-pi/agents/map-deps.md +35 -0
  23. package/plugins/ca-pi/agents/map-structure.md +37 -0
  24. package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
  25. package/plugins/ca-pi/agents/scout.md +127 -0
  26. package/plugins/ca-pi/agents/security-reviewer.md +72 -0
  27. package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
  28. package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
  29. package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
  30. package/plugins/ca-pi/generated/command-catalog.json +197 -0
  31. package/plugins/ca-pi/generated/roles.json +213 -0
  32. package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
  33. package/plugins/ca-pi/hooks/_activationlib.py +196 -0
  34. package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
  35. package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
  36. package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
  37. package/plugins/ca-pi/hooks/_boxlib.py +131 -0
  38. package/plugins/ca-pi/hooks/_colorlib.py +304 -0
  39. package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
  40. package/plugins/ca-pi/hooks/_entrylib.py +41 -0
  41. package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
  42. package/plugins/ca-pi/hooks/_gitexec.py +45 -0
  43. package/plugins/ca-pi/hooks/_githooks.py +920 -0
  44. package/plugins/ca-pi/hooks/_gitlib.py +110 -0
  45. package/plugins/ca-pi/hooks/_hooklib.py +595 -0
  46. package/plugins/ca-pi/hooks/_host.py +115 -0
  47. package/plugins/ca-pi/hooks/_intentlib.py +242 -0
  48. package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
  49. package/plugins/ca-pi/hooks/_metricslib.py +709 -0
  50. package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
  51. package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
  52. package/plugins/ca-pi/hooks/_previewlib.py +193 -0
  53. package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
  54. package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
  55. package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
  56. package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
  57. package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
  58. package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
  59. package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
  60. package/plugins/ca-pi/hooks/_scopelib.py +262 -0
  61. package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
  62. package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
  63. package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
  64. package/plugins/ca-pi/hooks/_sloplib.py +244 -0
  65. package/plugins/ca-pi/hooks/_standuplib.py +214 -0
  66. package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
  67. package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
  68. package/plugins/ca-pi/hooks/_updatelib.py +278 -0
  69. package/plugins/ca-pi/hooks/babysit.py +47 -0
  70. package/plugins/ca-pi/hooks/boardsync.py +129 -0
  71. package/plugins/ca-pi/hooks/doctor.py +420 -0
  72. package/plugins/ca-pi/hooks/git-enforce.py +325 -0
  73. package/plugins/ca-pi/hooks/hostapi.py +460 -0
  74. package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
  75. package/plugins/ca-pi/hooks/metrics.py +62 -0
  76. package/plugins/ca-pi/hooks/migration-pass.py +129 -0
  77. package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
  78. package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
  79. package/plugins/ca-pi/hooks/pre-bash.py +90 -0
  80. package/plugins/ca-pi/hooks/pre-edit.py +284 -0
  81. package/plugins/ca-pi/hooks/pre-read.py +81 -0
  82. package/plugins/ca-pi/hooks/pre-write.py +217 -0
  83. package/plugins/ca-pi/hooks/preview.py +69 -0
  84. package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
  85. package/plugins/ca-pi/hooks/releasehash.py +216 -0
  86. package/plugins/ca-pi/hooks/security-pass.py +139 -0
  87. package/plugins/ca-pi/hooks/session-start.py +1218 -0
  88. package/plugins/ca-pi/hooks/statusline.py +736 -0
  89. package/plugins/ca-pi/hooks/taskwrite.py +351 -0
  90. package/plugins/ca-pi/hooks/update-refresh.py +51 -0
  91. package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
  92. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
  93. package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
  94. package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
  95. package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
  96. package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
  97. package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
  98. package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
  99. package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
  100. package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
  101. package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
  102. package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
  103. package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
  104. package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
  105. package/plugins/ca-pi/includes/compaction-charter.md +16 -0
  106. package/plugins/ca-pi/includes/cut-docs.md +16 -0
  107. package/plugins/ca-pi/includes/dev-mode.md +30 -0
  108. package/plugins/ca-pi/includes/farm.md +237 -0
  109. package/plugins/ca-pi/includes/fresh-verification.md +14 -0
  110. package/plugins/ca-pi/includes/harvest.md +69 -0
  111. package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
  112. package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
  113. package/plugins/ca-pi/includes/redirect.md +69 -0
  114. package/plugins/ca-pi/includes/reference-map.md +22 -0
  115. package/plugins/ca-pi/includes/review-matrix.md +14 -0
  116. package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
  117. package/plugins/ca-pi/includes/routing-table.md +47 -0
  118. package/plugins/ca-pi/includes/security-gate-record.md +22 -0
  119. package/plugins/ca-pi/includes/smarts/core.md +90 -0
  120. package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
  121. package/plugins/ca-pi/routines/INDEX.md +32 -0
  122. package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
  123. package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
  124. package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
  125. package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
  126. package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
  127. package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
  128. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
  129. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
  130. package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
  131. package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
  132. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
  133. package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
  134. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
  135. package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
  136. package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
  137. package/plugins/ca-pi/routines/release/SKILL.md +315 -0
  138. package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
  139. package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
  140. package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
  141. package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
  142. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
  143. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
  144. package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
  145. package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
  146. package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
  147. package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
  148. package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
  149. package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
  150. package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
  151. package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
  152. package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
  153. package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
  154. package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
  155. package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
  156. package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
  157. package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
  158. package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
  159. package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
  160. package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
  161. package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
  162. package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
  163. package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
  164. package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
  165. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
  166. package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
  167. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
  168. package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
  169. package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
  170. package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
  171. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
  172. package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
  173. package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
  174. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
  175. package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
  176. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
  177. package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
  178. package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
  179. package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
  180. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
  181. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
  182. package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
  183. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
  184. package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
  185. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
  186. package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
  187. package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
  188. package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
  189. package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
  190. package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
  191. package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
  192. package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
  193. package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
  194. package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
  195. package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
  196. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
  197. package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
  198. package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
  199. package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
  200. package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
  201. package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
  202. package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
  203. package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
  204. package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
  205. package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
  206. package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
@@ -0,0 +1,543 @@
1
+ #!/usr/bin/env python3
2
+ """Bounded one-request Pi codec for exactly one shared core entry."""
3
+
4
+ import base64
5
+ import binascii
6
+ import contextlib
7
+ import importlib.util
8
+ import io
9
+ import json
10
+ import math
11
+ import os
12
+ import re
13
+ import sys
14
+ import uuid
15
+
16
+ HERE = os.path.dirname(os.path.abspath(__file__))
17
+ sys.path.insert(0, HERE)
18
+ from _host import PiHost # noqa: E402
19
+ from _prunepolicy import PrunePolicy, SemanticEntry, plan_prune # noqa: E402
20
+ import _arbiterstatelib # noqa: E402
21
+ import _hooklib # noqa: E402
22
+ import _ledgerlib # noqa: E402
23
+ import _planfilelib # noqa: E402
24
+ import _segmentslib # noqa: E402
25
+ import _taskboardlib # noqa: E402
26
+
27
+ MAX_REQUEST_BYTES = 262_144
28
+ MAX_PLAN_CONTENT_BYTES = 92_160
29
+ MAX_CAPTURE_CHARS = 1_048_576
30
+ ALLOWED_KEYS = frozenset({"version", "event", "cwd", "sessionId", "tool", "input", "result", "correlation"})
31
+ # Opaque join key for one originating tool call: a SHA-256 digest in lowercase hex,
32
+ # never the raw host tool-call id and never derived from parameters or prompt text.
33
+ CORRELATION_RE = re.compile(r"\A[0-9a-f]{64}\Z")
34
+ EVENT_KEYS = {
35
+ "session_start": (frozenset({"version", "event", "cwd"}), frozenset({"version", "event", "cwd", "sessionId"})),
36
+ "before_agent_start": (frozenset({"version", "event", "cwd"}), frozenset({"version", "event", "cwd", "sessionId"})),
37
+ "tool_call": (frozenset({"version", "event", "cwd", "tool", "input"}), frozenset({"version", "event", "cwd", "sessionId", "tool", "input", "correlation"})),
38
+ "tool_result": (frozenset({"version", "event", "cwd", "tool", "input", "result"}), frozenset({"version", "event", "cwd", "sessionId", "tool", "input", "result", "correlation"})),
39
+ "prune_plan": (frozenset({"version", "event", "cwd", "input"}), frozenset({"version", "event", "cwd", "input"})),
40
+ "footer_usage_update": (frozenset({"version", "event", "cwd", "input"}), frozenset({"version", "event", "cwd", "input"})),
41
+ "footer_status_snapshot": (frozenset({"version", "event", "cwd"}), frozenset({"version", "event", "cwd", "sessionId"})),
42
+ "plan_file": (frozenset({"version", "event", "cwd", "input"}), frozenset({"version", "event", "cwd", "input"})),
43
+ }
44
+ ENTRY_BY_EVENT = {
45
+ ("session_start", None): "session-start.py",
46
+ ("tool_call", "EXEC"): "pre-bash.py",
47
+ ("tool_call", "WRITE"): "pre-write.py",
48
+ ("tool_call", "EDIT"): "pre-edit.py",
49
+ ("tool_call", "READ"): "pre-read.py",
50
+ ("tool_result", "WRITE"): "post-write-edit.py",
51
+ ("tool_result", "EDIT"): "post-write-edit.py",
52
+ }
53
+ RULE_RE = re.compile(r"\[((?:H|PI)-[A-Za-z0-9]+)\]")
54
+ ANSI_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]?|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-_])")
55
+ FOOTER_USAGE_KEYS = frozenset({"sessionKey", "scanStart", "scanEnd", "facts"})
56
+ FOOTER_USAGE_RESULT_KEYS = frozenset({
57
+ "status", "session", "today", "acceptedThrough", "highWater",
58
+ })
59
+ FOOTER_USAGE_TOTAL_KEYS = frozenset({
60
+ "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costUsd",
61
+ })
62
+ FOOTER_USAGE_STATUSES = frozenset({"ok", "invalid", "corrupt", "lock_failed", "write_failed"})
63
+ FOOTER_MAX_COUNT = 1_000_000
64
+ FOOTER_MAX_STAGE = 128
65
+ FOOTER_MAX_PRUNE = 256
66
+
67
+
68
+ class ProtocolError(ValueError):
69
+ pass
70
+
71
+
72
+ class BoundedText(io.TextIOBase):
73
+ """Text writer that never retains more than its UTF-8 byte budget."""
74
+
75
+ def __init__(self, max_bytes):
76
+ self.max_bytes = max_bytes
77
+ self.parts = []
78
+ self.size = 0
79
+ self.overflowed = False
80
+
81
+ def writable(self):
82
+ return True
83
+
84
+ def write(self, value):
85
+ if not isinstance(value, str):
86
+ raise TypeError("text writer requires str")
87
+ encoded = value.encode("utf-8", "replace")
88
+ remaining = max(0, self.max_bytes - self.size)
89
+ if remaining:
90
+ self.parts.append(encoded[:remaining])
91
+ self.size += min(len(encoded), remaining)
92
+ if len(encoded) > remaining:
93
+ self.overflowed = True
94
+ return len(value)
95
+
96
+ def flush(self):
97
+ return None
98
+
99
+ def getvalue(self):
100
+ return b"".join(self.parts).decode("utf-8", "replace")
101
+
102
+
103
+ def _json_value(value, depth=0):
104
+ if depth > 16:
105
+ raise ProtocolError("JSON nesting exceeds limit")
106
+ if value is None or isinstance(value, (bool, int, str)):
107
+ return
108
+ if isinstance(value, float):
109
+ if not math.isfinite(value):
110
+ raise ProtocolError("JSON number is not finite")
111
+ return
112
+ if isinstance(value, list):
113
+ if len(value) > 10_000:
114
+ raise ProtocolError("JSON list exceeds limit")
115
+ for item in value:
116
+ _json_value(item, depth + 1)
117
+ return
118
+ if isinstance(value, dict):
119
+ if len(value) > 1_000 or any(not isinstance(key, str) for key in value):
120
+ raise ProtocolError("JSON object exceeds limit")
121
+ for item in value.values():
122
+ _json_value(item, depth + 1)
123
+ return
124
+ raise ProtocolError("unsupported JSON value")
125
+
126
+
127
+ def _request(raw):
128
+ def unique_object(pairs):
129
+ output = {}
130
+ for key, item in pairs:
131
+ if key in output:
132
+ raise ProtocolError("duplicate JSON object key")
133
+ output[key] = item
134
+ return output
135
+
136
+ try:
137
+ value = json.loads(
138
+ raw.decode("utf-8", "strict"),
139
+ parse_constant=lambda token: (_ for _ in ()).throw(ProtocolError(f"invalid JSON constant {token}")),
140
+ object_pairs_hook=unique_object,
141
+ )
142
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
143
+ raise ProtocolError("request is not one UTF-8 JSON object") from exc
144
+ if not isinstance(value, dict) or set(value) - ALLOWED_KEYS:
145
+ raise ProtocolError("request keys are invalid")
146
+ if type(value.get("version")) is not int or value["version"] != 1:
147
+ raise ProtocolError("request version is invalid")
148
+ for key in ("event", "cwd"):
149
+ if not isinstance(value.get(key), str) or not value[key] or len(value[key]) > 32_768:
150
+ raise ProtocolError(f"request {key} is invalid")
151
+ schema = EVENT_KEYS.get(value["event"])
152
+ if schema is None:
153
+ raise ProtocolError("request event is invalid")
154
+ required, allowed = schema
155
+ if not required.issubset(value) or not set(value).issubset(allowed):
156
+ raise ProtocolError("request shape is invalid for event")
157
+ if not os.path.isabs(value["cwd"]) or not os.path.isdir(value["cwd"]):
158
+ raise ProtocolError("request cwd is not an absolute directory")
159
+ for key in ("sessionId", "tool"):
160
+ if key in value and (not isinstance(value[key], str) or len(value[key]) > 1_024):
161
+ raise ProtocolError(f"request {key} is invalid")
162
+ if "correlation" in value and (
163
+ not isinstance(value["correlation"], str) or not CORRELATION_RE.match(value["correlation"])
164
+ ):
165
+ raise ProtocolError("request correlation is invalid")
166
+ for key in ("input", "result"):
167
+ if key in value:
168
+ if not isinstance(value[key], dict):
169
+ raise ProtocolError(f"request {key} must be an object")
170
+ _json_value(value[key])
171
+ return value
172
+
173
+
174
+ def _load_entry(filename):
175
+ path = os.path.realpath(os.path.join(HERE, filename))
176
+ if os.path.commonpath((path, os.path.realpath(HERE))) != os.path.realpath(HERE):
177
+ raise ProtocolError("entry escapes package")
178
+ spec = importlib.util.spec_from_file_location("ca_pi_entry_" + uuid.uuid4().hex, path)
179
+ if spec is None or spec.loader is None:
180
+ raise RuntimeError("shared entry cannot be loaded")
181
+ module = importlib.util.module_from_spec(spec)
182
+ spec.loader.exec_module(module)
183
+ if not callable(getattr(module, "run", None)):
184
+ raise RuntimeError("shared entry has no run seam")
185
+ return module
186
+
187
+
188
+ def _payload(request, host):
189
+ tool = request.get("tool", "")
190
+ tool_input = request.get("input", {})
191
+ if request["event"] == "tool_call" and host.normalize_tool(tool) == "READ":
192
+ tool_input = host.normalize_tool_input(tool, tool_input)
193
+ return {
194
+ "cwd": request["cwd"],
195
+ "session_id": request.get("sessionId", ""),
196
+ "hook_event_name": request["event"],
197
+ "tool_name": tool,
198
+ "tool_input": tool_input,
199
+ "tool_response": request.get("result", {}),
200
+ }
201
+
202
+
203
+ def _run_entry(request, filename, host):
204
+ module = _load_entry(filename)
205
+ stdin = io.StringIO(json.dumps(_payload(request, host), ensure_ascii=False))
206
+ stdout = BoundedText(MAX_CAPTURE_CHARS)
207
+ stderr = BoundedText(MAX_CAPTURE_CHARS)
208
+ previous = sys.stdin
209
+ code = 0
210
+ try:
211
+ sys.stdin = stdin
212
+ with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
213
+ try:
214
+ result = module.run(host)
215
+ code = int(result or 0)
216
+ except SystemExit as exc:
217
+ code = int(exc.code or 0) if isinstance(exc.code, (int, type(None))) else 1
218
+ finally:
219
+ sys.stdin = previous
220
+ out = stdout.getvalue()
221
+ err = stderr.getvalue()
222
+ if stdout.overflowed or stderr.overflowed:
223
+ raise RuntimeError("shared entry output overflow")
224
+ return code, out, err
225
+
226
+
227
+ def _rule(text):
228
+ match = RULE_RE.search(text)
229
+ return match.group(1) if match else None
230
+
231
+
232
+ def _prune_plan(request):
233
+ value = request.get("input")
234
+ if not isinstance(value, dict) or set(value) != {"entries", "policy"}:
235
+ raise ProtocolError("prune plan input is invalid")
236
+ rows, raw_policy = value["entries"], value["policy"]
237
+ if not isinstance(rows, list) or not rows or len(rows) > 10_000:
238
+ raise ProtocolError("prune plan entries are invalid")
239
+ if not isinstance(raw_policy, dict) or set(raw_policy) != {"tier", "keepRecent", "maxBytes"}:
240
+ raise ProtocolError("prune policy is invalid")
241
+ tier = raw_policy["tier"]
242
+ keep_recent = raw_policy["keepRecent"]
243
+ max_bytes = raw_policy["maxBytes"]
244
+ if tier not in ("gentle", "standard", "aggressive") \
245
+ or type(keep_recent) is not int or not 0 <= keep_recent <= 1000 \
246
+ or type(max_bytes) is not int or not 1 <= max_bytes <= MAX_REQUEST_BYTES:
247
+ raise ProtocolError("prune policy is invalid")
248
+ entries = []
249
+ entry_ids = set()
250
+ expected = {"id", "ordinal", "role", "kind", "byteSize", "toolBearing", "marked"}
251
+ for row in rows:
252
+ if not isinstance(row, dict) or set(row) != expected:
253
+ raise ProtocolError("semantic entry is invalid")
254
+ if not isinstance(row["id"], str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,1023}", row["id"]) \
255
+ or row["id"] in entry_ids \
256
+ or type(row["ordinal"]) is not int or row["ordinal"] != len(entries) \
257
+ or row["role"] not in ("user", "assistant", "tool", "system", "other") \
258
+ or row["kind"] not in ("message", "tool-result", "compaction", "metadata") \
259
+ or type(row["byteSize"]) is not int or not 0 <= row["byteSize"] <= MAX_REQUEST_BYTES \
260
+ or type(row["toolBearing"]) is not bool or type(row["marked"]) is not bool:
261
+ raise ProtocolError("semantic entry is invalid")
262
+ entry_ids.add(row["id"])
263
+ entries.append(SemanticEntry(
264
+ id=row["id"], ordinal=row["ordinal"], role=row["role"], kind=row["kind"],
265
+ byte_size=row["byteSize"], tool_bearing=row["toolBearing"], marked=row["marked"],
266
+ ))
267
+ plan = plan_prune(entries, PrunePolicy(
268
+ tier=tier, keep_recent=keep_recent, max_bytes=max_bytes,
269
+ ))
270
+ if plan.first_kept_id is None:
271
+ raise ProtocolError("prune plan has no kept boundary")
272
+ return {
273
+ "version": 1,
274
+ "outcome": "notice",
275
+ "auditCode": "PI_PRUNE_PLAN",
276
+ "resultPatch": {"prunePlan": {
277
+ "firstKeptEntryId": plan.first_kept_id,
278
+ "protectedIds": list(plan.protected_ids),
279
+ "actions": [{"entryId": entry_id, "action": action}
280
+ for entry_id, action in plan.actions],
281
+ "metrics": {
282
+ "entriesBefore": plan.metrics["entries_before"],
283
+ "candidateEntries": plan.metrics["candidate_entries"],
284
+ "protectedEntries": plan.metrics["protected_entries"],
285
+ "markedCandidates": plan.metrics["marked_candidates"],
286
+ },
287
+ "auditCodes": list(plan.audit_codes),
288
+ "fingerprint": plan.fingerprint,
289
+ }},
290
+ }
291
+
292
+
293
+ def _footer_usage_totals(value):
294
+ if not isinstance(value, dict) or set(value) != FOOTER_USAGE_TOTAL_KEYS:
295
+ return None
296
+ for key in FOOTER_USAGE_TOTAL_KEYS - {"costUsd"}:
297
+ amount = value[key]
298
+ if type(amount) is not int or not 0 <= amount <= _ledgerlib.PI_MAX_TOKENS:
299
+ return None
300
+ cost = value["costUsd"]
301
+ if type(cost) not in (int, float) or not math.isfinite(cost) \
302
+ or not 0 <= cost <= _ledgerlib.PI_MAX_COST_USD:
303
+ return None
304
+ return {
305
+ "inputTokens": value["inputTokens"],
306
+ "outputTokens": value["outputTokens"],
307
+ "cacheReadTokens": value["cacheReadTokens"],
308
+ "cacheWriteTokens": value["cacheWriteTokens"],
309
+ "costUsd": round(float(cost), 9),
310
+ }
311
+
312
+
313
+ def _footer_usage_result(value):
314
+ if not isinstance(value, dict) or set(value) != FOOTER_USAGE_RESULT_KEYS \
315
+ or value.get("status") not in FOOTER_USAGE_STATUSES:
316
+ return None
317
+ session = _footer_usage_totals(value.get("session"))
318
+ today = _footer_usage_totals(value.get("today"))
319
+ accepted_through = value.get("acceptedThrough")
320
+ high_water = value.get("highWater")
321
+ if session is None or today is None or type(accepted_through) is not int \
322
+ or not -1 <= accepted_through <= _ledgerlib.PI_MAX_POSITION \
323
+ or type(high_water) is not int \
324
+ or not -1 <= high_water <= _ledgerlib.PI_MAX_POSITION:
325
+ return None
326
+ return {
327
+ "status": value["status"],
328
+ "session": session,
329
+ "today": today,
330
+ "acceptedThrough": accepted_through,
331
+ "highWater": high_water,
332
+ }
333
+
334
+
335
+ def _footer_usage_update(request):
336
+ """Project-independent bounded crossing into the shared user-global ledger."""
337
+ value = request.get("input")
338
+ if not isinstance(value, dict) or set(value) != FOOTER_USAGE_KEYS:
339
+ result = None
340
+ else:
341
+ try:
342
+ result = _footer_usage_result(_ledgerlib.pi_ledger_update(
343
+ value["sessionKey"], value["scanStart"], value["scanEnd"], value["facts"]
344
+ ))
345
+ except Exception: # noqa: BLE001 - footer accounting is advisory and fail-soft
346
+ result = None
347
+ if result is None:
348
+ blank = {
349
+ "inputTokens": 0,
350
+ "outputTokens": 0,
351
+ "cacheReadTokens": 0,
352
+ "cacheWriteTokens": 0,
353
+ "costUsd": 0.0,
354
+ }
355
+ result = {
356
+ "status": "corrupt",
357
+ "session": dict(blank),
358
+ "today": dict(blank),
359
+ "acceptedThrough": -1,
360
+ "highWater": -1,
361
+ }
362
+ return {
363
+ "version": 1,
364
+ "outcome": "notice",
365
+ "auditCode": "PI_FOOTER_USAGE",
366
+ "resultPatch": {"footerUsage": result},
367
+ }
368
+
369
+
370
+ def _bounded_footer_text(value, maximum):
371
+ if not isinstance(value, str):
372
+ return None
373
+ clean = ANSI_RE.sub("", value)
374
+ clean = "".join(char for char in clean if ord(char) >= 32 and not 127 <= ord(char) <= 159)
375
+ return "".join(list(clean)[:maximum]) or None
376
+
377
+
378
+ def _footer_status_snapshot(request):
379
+ """Read the existing shared status sources after the TypeScript trust gate."""
380
+ unavailable = {"version": 1, "outcome": "allow", "auditCode": "PI_FOOTER_STATUS_UNAVAILABLE"}
381
+ try:
382
+ state = _arbiterstatelib.arbiter_state(
383
+ request["cwd"],
384
+ _taskboardlib.count_in_flight,
385
+ _taskboardlib.read_board,
386
+ _hooklib.frontmatter_enabled,
387
+ )
388
+ except Exception: # noqa: BLE001 - one unavailable segment never breaks the footer
389
+ state = None
390
+ if not isinstance(state, dict):
391
+ return unavailable
392
+ stage = _bounded_footer_text(state.get("stage"), FOOTER_MAX_STAGE)
393
+ counts = (state.get("tasks"), state.get("q"), state.get("over"))
394
+ sprint = state.get("sprint")
395
+ if stage is None or any(type(value) is not int or not 0 <= value <= FOOTER_MAX_COUNT
396
+ for value in counts) or type(sprint) is not bool:
397
+ return unavailable
398
+ session_id = request.get("sessionId")
399
+ prune = None
400
+ if isinstance(session_id, str) and session_id:
401
+ try:
402
+ prune = _bounded_footer_text(_segmentslib.seg_prune({}, session_id), FOOTER_MAX_PRUNE)
403
+ except Exception: # noqa: BLE001 - prune is an independent optional segment
404
+ prune = None
405
+ try:
406
+ dev = _arbiterstatelib.dev_active(request["cwd"])
407
+ except Exception: # noqa: BLE001 - dev is a fail-soft display fact
408
+ return unavailable
409
+ if type(dev) is not bool:
410
+ return unavailable
411
+ return {
412
+ "version": 1,
413
+ "outcome": "notice",
414
+ "auditCode": "PI_FOOTER_STATUS",
415
+ "resultPatch": {"footerStatus": {
416
+ "status": "ok",
417
+ "stage": stage,
418
+ "tasks": counts[0],
419
+ "questions": counts[1],
420
+ "overrides": counts[2],
421
+ "sprint": sprint,
422
+ "dev": dev,
423
+ "prune": prune,
424
+ }},
425
+ }
426
+
427
+
428
+ def _plan_file_request(value):
429
+ if not isinstance(value, dict) or value.get("action") not in ("read", "replace"):
430
+ raise ProtocolError("plan file input is invalid")
431
+ if value["action"] == "read":
432
+ if set(value) != {"slug", "kind", "action"}:
433
+ raise ProtocolError("plan file input is invalid")
434
+ return value
435
+ if set(value) != {"slug", "kind", "action", "expectedHash", "contentBase64"}:
436
+ raise ProtocolError("plan file input is invalid")
437
+ encoded = value["contentBase64"]
438
+ if not isinstance(encoded, str) or len(encoded) > ((MAX_PLAN_CONTENT_BYTES + 2) // 3) * 4:
439
+ raise ProtocolError("plan file content is invalid")
440
+ try:
441
+ raw = encoded.encode("ascii", "strict")
442
+ decoded = base64.b64decode(raw, validate=True)
443
+ except (UnicodeEncodeError, binascii.Error) as exc:
444
+ raise ProtocolError("plan file content is invalid") from exc
445
+ if base64.b64encode(decoded) != raw or len(decoded) > MAX_PLAN_CONTENT_BYTES:
446
+ raise ProtocolError("plan file content is invalid")
447
+ try:
448
+ content = decoded.decode("utf-8", "strict")
449
+ except UnicodeDecodeError as exc:
450
+ raise ProtocolError("plan file content is invalid") from exc
451
+ return {"slug": value.get("slug"), "kind": value.get("kind"), "action": "replace",
452
+ "expectedHash": value.get("expectedHash"), "content": content}
453
+
454
+
455
+ def _plan_file_response(result):
456
+ output = dict(result)
457
+ if "content" in output:
458
+ content = output.pop("content")
459
+ if content is None:
460
+ output["contentBase64"] = None
461
+ elif isinstance(content, str):
462
+ raw = content.encode("utf-8", "strict")
463
+ if len(raw) > MAX_PLAN_CONTENT_BYTES:
464
+ raise ProtocolError("plan file result is invalid")
465
+ output["contentBase64"] = base64.b64encode(raw).decode("ascii")
466
+ else:
467
+ raise ProtocolError("plan file result is invalid")
468
+ return output
469
+
470
+
471
+ def dispatch(request):
472
+ if request["event"] == "plan_file":
473
+ result = _planfilelib.plan_file_operation(request["cwd"], _plan_file_request(request["input"]))
474
+ return {
475
+ "version": 1,
476
+ "outcome": "notice",
477
+ "auditCode": "PI_PLAN_FILE",
478
+ "resultPatch": {"planFile": _plan_file_response(result)},
479
+ }
480
+ if request["event"] == "footer_usage_update":
481
+ return _footer_usage_update(request)
482
+ if request["event"] == "footer_status_snapshot":
483
+ return _footer_status_snapshot(request)
484
+ if request["event"] == "prune_plan":
485
+ return _prune_plan(request)
486
+ host = PiHost(request["cwd"])
487
+ category = host.normalize_tool(request.get("tool", ""))
488
+ if request["event"] == "tool_call" and category == "OTHER":
489
+ return {
490
+ "version": 1,
491
+ "outcome": "block",
492
+ "ruleId": "PI-UNKNOWN",
493
+ "message": "Unknown Pi tool is potentially mutating and is blocked; classify it or run /ca-doctor.",
494
+ "auditCode": "PI_UNKNOWN_BLOCK",
495
+ }
496
+ filename = ENTRY_BY_EVENT.get((request["event"], category))
497
+ if filename is None:
498
+ filename = ENTRY_BY_EVENT.get((request["event"], None))
499
+ if filename is None:
500
+ return {"version": 1, "outcome": "allow", "auditCode": "PI_NO_ROUTE"}
501
+ code, stdout, stderr = _run_entry(request, filename, host)
502
+ if code == 2:
503
+ message = stderr.strip() or "shared core blocked the operation"
504
+ response = {"version": 1, "outcome": "block", "message": message, "auditCode": "PI_CORE_BLOCK"}
505
+ rule = _rule(message)
506
+ if rule:
507
+ response["ruleId"] = rule
508
+ return response
509
+ if code != 0:
510
+ raise RuntimeError(stderr.strip() or f"shared entry exited {code}")
511
+ if request["event"] == "tool_call" and category == "READ" and stdout.strip():
512
+ try:
513
+ hook_output = json.loads(stdout)
514
+ context = hook_output["hookSpecificOutput"]["additionalContext"]
515
+ except (KeyError, TypeError, json.JSONDecodeError) as exc:
516
+ raise RuntimeError("shared read output is malformed") from exc
517
+ return {"version": 1, "outcome": "notice", "context": context, "auditCode": "PI_CORE_NOTICE"}
518
+ message = stderr.strip()
519
+ if message:
520
+ response = {"version": 1, "outcome": "notice", "message": message, "auditCode": "PI_CORE_NOTICE"}
521
+ rule = _rule(message)
522
+ if rule:
523
+ response["ruleId"] = rule
524
+ return response
525
+ if stdout:
526
+ return {"version": 1, "outcome": "notice", "context": stdout, "auditCode": "PI_CORE_CONTEXT"}
527
+ return {"version": 1, "outcome": "allow", "auditCode": "PI_CORE_ALLOW"}
528
+
529
+
530
+ def main():
531
+ raw = sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1)
532
+ if len(raw) > MAX_REQUEST_BYTES:
533
+ raise ProtocolError("request overflow")
534
+ response = dispatch(_request(raw))
535
+ sys.stdout.write(json.dumps(response, ensure_ascii=False, separators=(",", ":")))
536
+
537
+
538
+ if __name__ == "__main__":
539
+ try:
540
+ main()
541
+ except Exception as exc: # transport reports only a fixed class, never request data
542
+ sys.stderr.write(f"PI bridge error ({type(exc).__name__})\n")
543
+ sys.exit(1)