@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,664 @@
1
+ """Canonical, cooperatively locked planning-file CAS publication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import ctypes
7
+ import hashlib
8
+ import os
9
+ import re
10
+ import secrets
11
+ import stat
12
+ import tempfile
13
+ import time
14
+
15
+ MAX_CONTENT_BYTES = 92_160
16
+ LOCK_WAIT_SECONDS = 2.0
17
+ SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$")
18
+ HASH_RE = re.compile(r"^[a-f0-9]{64}$")
19
+ KINDS = {"spec": "specs", "plan": "plans"}
20
+ _REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
21
+ _WINDOWS_PARENT_HANDLES = {}
22
+
23
+
24
+ class PlanFileError(RuntimeError):
25
+ pass
26
+
27
+
28
+ def _is_reparse(info):
29
+ return stat.S_ISLNK(info.st_mode) or bool(getattr(info, "st_file_attributes", 0) & _REPARSE)
30
+
31
+
32
+ def _identity(info):
33
+ return info.st_dev, info.st_ino
34
+
35
+
36
+ def _version(info):
37
+ return (_identity(info), info.st_size, getattr(info, "st_mtime_ns", 0),
38
+ getattr(info, "st_ctime_ns", 0), info.st_nlink, info.st_mode)
39
+
40
+
41
+ def _regular_single(info):
42
+ return stat.S_ISREG(info.st_mode) and not _is_reparse(info) and info.st_nlink == 1
43
+
44
+
45
+ def _directory(info):
46
+ return stat.S_ISDIR(info.st_mode) and not _is_reparse(info)
47
+
48
+
49
+ def _aliases(directory, name):
50
+ names = os.listdir(directory)
51
+ return [item for item in names if item.casefold() == name.casefold()]
52
+
53
+
54
+ def _exact_absolute_child(parent, name):
55
+ if _aliases(parent, name) != [name]:
56
+ raise PlanFileError("path_alias")
57
+ path = os.path.join(parent, name)
58
+ info = os.lstat(path)
59
+ if not _directory(info) or os.path.realpath(path) != os.path.abspath(path):
60
+ raise PlanFileError("path_type")
61
+ return path, info
62
+
63
+
64
+ def _request_path(root, slug, kind):
65
+ if not isinstance(root, str) or not os.path.isabs(root) \
66
+ or os.path.realpath(root) != os.path.abspath(root):
67
+ raise PlanFileError("root")
68
+ if not isinstance(slug, str) or not SLUG_RE.fullmatch(slug) or kind not in KINDS:
69
+ raise PlanFileError("request")
70
+ return os.path.join(root, ".codearbiter", KINDS[kind], f"{slug}.md")
71
+
72
+
73
+ def _windows_handle_identity(handle):
74
+ from ctypes import wintypes
75
+
76
+ class ByHandleInfo(ctypes.Structure):
77
+ _fields_ = [
78
+ ("FileAttributes", wintypes.DWORD), ("CreationTime", wintypes.FILETIME),
79
+ ("LastAccessTime", wintypes.FILETIME), ("LastWriteTime", wintypes.FILETIME),
80
+ ("VolumeSerialNumber", wintypes.DWORD), ("FileSizeHigh", wintypes.DWORD),
81
+ ("FileSizeLow", wintypes.DWORD), ("NumberOfLinks", wintypes.DWORD),
82
+ ("FileIndexHigh", wintypes.DWORD), ("FileIndexLow", wintypes.DWORD),
83
+ ]
84
+
85
+ info = ByHandleInfo()
86
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
87
+ if not kernel32.GetFileInformationByHandle(handle, ctypes.byref(info)):
88
+ raise PlanFileError("ancestor_handle")
89
+ return (info.VolumeSerialNumber, (info.FileIndexHigh << 32) | info.FileIndexLow,
90
+ info.FileAttributes)
91
+
92
+
93
+ def _windows_open_directory(path, deny_delete):
94
+ from ctypes import wintypes
95
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
96
+ create = kernel32.CreateFileW
97
+ create.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
98
+ wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
99
+ create.restype = wintypes.HANDLE
100
+ sharing = 0x00000001 | 0x00000002
101
+ if not deny_delete:
102
+ sharing |= 0x00000004
103
+ handle = create(path, 0x0080 | 0x00100000, sharing, None, 3,
104
+ 0x02000000 | 0x00200000, None)
105
+ if handle == ctypes.c_void_p(-1).value:
106
+ raise PlanFileError("ancestor_handle")
107
+ return handle
108
+
109
+
110
+ def _windows_close(handle):
111
+ ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(handle)
112
+
113
+
114
+ def _windows_snapshot(path):
115
+ handle = _windows_open_directory(path, False)
116
+ try:
117
+ return _windows_handle_identity(handle)
118
+ finally:
119
+ _windows_close(handle)
120
+
121
+
122
+ def _validated_windows_path(root, slug, kind):
123
+ target = _request_path(root, slug, kind)
124
+ root_info = os.lstat(root)
125
+ if not _directory(root_info):
126
+ raise PlanFileError("root")
127
+ state_path, state_info = _exact_absolute_child(root, ".codearbiter")
128
+ parent_path, parent_info = _exact_absolute_child(state_path, KINDS[kind])
129
+ leaf = f"{slug}.md"
130
+ aliases = _aliases(parent_path, leaf)
131
+ if aliases not in ([], [leaf]):
132
+ raise PlanFileError("path_alias")
133
+ paths = (root, state_path, parent_path)
134
+ infos = (root_info, state_info, parent_info)
135
+ snapshots = tuple(_windows_snapshot(item) for item in paths)
136
+ for info, snapshot in zip(infos, snapshots):
137
+ if info.st_ino != snapshot[1] or snapshot[2] & _REPARSE or not snapshot[2] & 0x10:
138
+ raise PlanFileError("ancestor_identity")
139
+ return {"root": root, "state": state_path, "parent": parent_path, "target": target,
140
+ "leaf": leaf, "kind": kind, "identities": snapshots, "parent_fd": None}
141
+
142
+
143
+ def _open_posix_directory(name, parent_fd=None, expected=None):
144
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
145
+ fd = os.open(name, flags, dir_fd=parent_fd)
146
+ opened = os.fstat(fd)
147
+ if not _directory(opened) or expected is not None and _identity(opened) != _identity(expected):
148
+ os.close(fd)
149
+ raise PlanFileError("ancestor_identity")
150
+ return fd, opened
151
+
152
+
153
+ def _exact_fd_child(parent_fd, name):
154
+ if _aliases(parent_fd, name) != [name]:
155
+ raise PlanFileError("path_alias")
156
+ info = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
157
+ if not _directory(info):
158
+ raise PlanFileError("path_type")
159
+ fd, opened = _open_posix_directory(name, parent_fd, info)
160
+ return fd, opened
161
+
162
+
163
+ @contextlib.contextmanager
164
+ def _held_path(root, slug, kind, fault=None):
165
+ if os.name == "nt":
166
+ path = _validated_windows_path(root, slug, kind)
167
+ if fault:
168
+ fault("after_validate", path)
169
+ handles = []
170
+ try:
171
+ for directory, expected in zip((path["root"], path["state"], path["parent"]),
172
+ path["identities"]):
173
+ handle = _windows_open_directory(directory, True)
174
+ actual = _windows_handle_identity(handle)
175
+ if actual != expected:
176
+ _windows_close(handle)
177
+ raise PlanFileError("ancestor_identity")
178
+ handles.append(handle)
179
+ _WINDOWS_PARENT_HANDLES[path["parent"]] = handles[-1]
180
+ yield path
181
+ finally:
182
+ _WINDOWS_PARENT_HANDLES.pop(path["parent"], None)
183
+ for handle in reversed(handles):
184
+ with contextlib.suppress(Exception):
185
+ _windows_close(handle)
186
+ return
187
+
188
+ _request_path(root, slug, kind)
189
+ root_before = os.lstat(root)
190
+ if not _directory(root_before):
191
+ raise PlanFileError("root")
192
+ root_fd = state_fd = parent_fd = None
193
+ try:
194
+ root_fd, root_info = _open_posix_directory(root, expected=root_before)
195
+ state_fd, state_info = _exact_fd_child(root_fd, ".codearbiter")
196
+ parent_fd, parent_info = _exact_fd_child(state_fd, KINDS[kind])
197
+ leaf = f"{slug}.md"
198
+ aliases = _aliases(parent_fd, leaf)
199
+ if aliases not in ([], [leaf]):
200
+ raise PlanFileError("path_alias")
201
+ path = {"root": root, "state": os.path.join(root, ".codearbiter"),
202
+ "parent": os.path.join(root, ".codearbiter", KINDS[kind]),
203
+ "target": os.path.join(root, ".codearbiter", KINDS[kind], leaf),
204
+ "leaf": leaf, "kind": kind, "root_fd": root_fd, "state_fd": state_fd,
205
+ "parent_fd": parent_fd,
206
+ "identities": (_identity(root_info), _identity(state_info), _identity(parent_info))}
207
+ if fault:
208
+ fault("after_validate", path)
209
+ yield path
210
+ finally:
211
+ for fd in (parent_fd, state_fd, root_fd):
212
+ if fd is not None:
213
+ with contextlib.suppress(Exception):
214
+ os.close(fd)
215
+
216
+
217
+ def _chain_current(path):
218
+ if os.name == "nt":
219
+ return True
220
+ try:
221
+ state = os.stat(".codearbiter", dir_fd=path["root_fd"], follow_symlinks=False)
222
+ parent = os.stat(KINDS[path["kind"]], dir_fd=path["state_fd"], follow_symlinks=False)
223
+ return _identity(state) == path["identities"][1] and _identity(parent) == path["identities"][2]
224
+ except (OSError, ValueError):
225
+ return False
226
+
227
+
228
+ def _lock_name(target):
229
+ return hashlib.sha256(os.path.normcase(os.path.abspath(target)).encode("utf-8")).hexdigest() + ".lock"
230
+
231
+
232
+ @contextlib.contextmanager
233
+ def _windows_mutex(target):
234
+ name = "Local\\CodeArbiterPiPlan-" + _lock_name(target)[:-5]
235
+ handle = _windows_create_mutex(name)
236
+ if not handle:
237
+ raise PlanFileError("lock_create")
238
+ acquired = False
239
+ try:
240
+ outcome = _windows_wait_mutex(handle, int(LOCK_WAIT_SECONDS * 1000))
241
+ if outcome not in (0x00000000, 0x00000080):
242
+ raise PlanFileError("lock_timeout" if outcome == 0x00000102 else "lock_wait")
243
+ acquired = True # WAIT_ABANDONED is ownership, not failure.
244
+ yield
245
+ finally:
246
+ if acquired:
247
+ with contextlib.suppress(Exception):
248
+ _windows_release_mutex(handle)
249
+ with contextlib.suppress(Exception):
250
+ _windows_close(handle)
251
+
252
+
253
+ def _windows_create_mutex(name):
254
+ from ctypes import wintypes
255
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
256
+ create = kernel32.CreateMutexW
257
+ create.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR]
258
+ create.restype = wintypes.HANDLE
259
+ return create(None, False, name) # Default user DACL; no replaceable lock pathname.
260
+
261
+
262
+ def _windows_wait_mutex(handle, timeout_ms):
263
+ return ctypes.WinDLL("kernel32", use_last_error=True).WaitForSingleObject(handle, timeout_ms)
264
+
265
+
266
+ def _windows_release_mutex(handle):
267
+ if not ctypes.WinDLL("kernel32", use_last_error=True).ReleaseMutex(handle):
268
+ raise OSError(ctypes.get_last_error(), "ReleaseMutex")
269
+
270
+
271
+ @contextlib.contextmanager
272
+ def _posix_lock(target, lock_root):
273
+ root = lock_root or os.path.join(tempfile.gettempdir(), "codearbiter-pi-plan-locks")
274
+ try:
275
+ os.mkdir(root, 0o700)
276
+ except FileExistsError:
277
+ pass
278
+ before = os.lstat(root)
279
+ if not _directory(before) or stat.S_IMODE(before.st_mode) != 0o700 \
280
+ or hasattr(os, "getuid") and before.st_uid != os.getuid():
281
+ raise PlanFileError("lock_root")
282
+ root_fd, opened_root = _open_posix_directory(root, expected=before)
283
+ lock_fd = None
284
+ try:
285
+ name = _lock_name(target)
286
+ flags = os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)
287
+ try:
288
+ leaf_before = os.stat(name, dir_fd=root_fd, follow_symlinks=False)
289
+ lock_fd = os.open(name, flags, dir_fd=root_fd)
290
+ except FileNotFoundError:
291
+ lock_fd = os.open(name, flags | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=root_fd)
292
+ leaf_before = os.stat(name, dir_fd=root_fd, follow_symlinks=False)
293
+ opened = os.fstat(lock_fd)
294
+ if not _regular_single(opened) or _identity(opened) != _identity(leaf_before) \
295
+ or stat.S_IMODE(opened.st_mode) != 0o600 \
296
+ or hasattr(os, "getuid") and opened.st_uid != os.getuid():
297
+ raise PlanFileError("lock_path")
298
+ import fcntl
299
+ deadline = time.monotonic() + LOCK_WAIT_SECONDS
300
+ while True:
301
+ try:
302
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
303
+ break
304
+ except (OSError, BlockingIOError):
305
+ if time.monotonic() >= deadline:
306
+ raise PlanFileError("lock_timeout")
307
+ time.sleep(0.005)
308
+ current = os.stat(name, dir_fd=root_fd, follow_symlinks=False)
309
+ if _identity(current) != _identity(opened) or _version(current) != _version(opened):
310
+ raise PlanFileError("lock_race")
311
+ yield
312
+ finally:
313
+ if lock_fd is not None:
314
+ with contextlib.suppress(Exception):
315
+ import fcntl
316
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
317
+ with contextlib.suppress(Exception):
318
+ os.close(lock_fd)
319
+ with contextlib.suppress(Exception):
320
+ os.close(root_fd)
321
+
322
+
323
+ def _lock_for(target, lock_root=None):
324
+ return _windows_mutex(target) if os.name == "nt" else _posix_lock(target, lock_root)
325
+
326
+
327
+ def _target_stat(path):
328
+ aliases = _aliases(path["parent_fd"] if os.name != "nt" else path["parent"], path["leaf"])
329
+ if aliases == []:
330
+ return None
331
+ if aliases != [path["leaf"]]:
332
+ raise PlanFileError("path_alias")
333
+ return (os.stat(path["leaf"], dir_fd=path["parent_fd"], follow_symlinks=False)
334
+ if os.name != "nt" else os.lstat(path["target"]))
335
+
336
+
337
+ def _read_pass(fd):
338
+ os.lseek(fd, 0, os.SEEK_SET)
339
+ chunks, total = [], 0
340
+ while True:
341
+ chunk = os.read(fd, min(65_536, MAX_CONTENT_BYTES + 1 - total))
342
+ if not chunk:
343
+ break
344
+ chunks.append(chunk)
345
+ total += len(chunk)
346
+ if total > MAX_CONTENT_BYTES:
347
+ raise PlanFileError("content_overflow")
348
+ return b"".join(chunks)
349
+
350
+
351
+ def _open_temp(path, temp_name):
352
+ if os.name != "nt":
353
+ flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
354
+ return os.open(temp_name, flags, 0o600, dir_fd=path["parent_fd"])
355
+ from ctypes import wintypes
356
+ import msvcrt
357
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
358
+ create = kernel32.CreateFileW
359
+ create.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
360
+ wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
361
+ create.restype = wintypes.HANDLE
362
+ handle = create(os.path.join(path["parent"], temp_name),
363
+ 0x80000000 | 0x40000000 | 0x00010000 | 0x00100000,
364
+ 0x00000001 | 0x00000002 | 0x00000004, None, 1,
365
+ 0x00000080 | 0x00200000, None)
366
+ if handle == ctypes.c_void_p(-1).value:
367
+ raise PlanFileError("temp_create")
368
+ try:
369
+ return msvcrt.open_osfhandle(handle, os.O_RDWR | getattr(os, "O_BINARY", 0))
370
+ except Exception:
371
+ _windows_close(handle)
372
+ raise
373
+
374
+
375
+ def _verify_owned_temp(path, temp_name, fd, owned_identity, data):
376
+ opened_before = os.fstat(fd)
377
+ first = _read_pass(fd)
378
+ opened_middle = os.fstat(fd)
379
+ second = _read_pass(fd)
380
+ opened_after = os.fstat(fd)
381
+ current = (os.stat(temp_name, dir_fd=path["parent_fd"], follow_symlinks=False)
382
+ if os.name != "nt" else os.lstat(os.path.join(path["parent"], temp_name)))
383
+ if not _regular_single(opened_after) or _identity(opened_after) != owned_identity \
384
+ or _identity(current) != owned_identity \
385
+ or not (_version(opened_before) == _version(opened_middle) == _version(opened_after)) \
386
+ or current.st_size != opened_after.st_size or current.st_mtime_ns != opened_after.st_mtime_ns \
387
+ or first != second or first != data:
388
+ raise PlanFileError("temp_race")
389
+
390
+
391
+ def _read_target(path, fault=None, allow_links=1):
392
+ before = _target_stat(path)
393
+ if before is None:
394
+ return None
395
+ if not stat.S_ISREG(before.st_mode) or _is_reparse(before) or before.st_nlink != allow_links:
396
+ raise PlanFileError("target_type")
397
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
398
+ fd = (os.open(path["leaf"], flags, dir_fd=path["parent_fd"])
399
+ if os.name != "nt" else os.open(path["target"], flags))
400
+ try:
401
+ opened = os.fstat(fd)
402
+ if not stat.S_ISREG(opened.st_mode) or _is_reparse(opened) or opened.st_nlink != allow_links \
403
+ or _identity(opened) != _identity(before):
404
+ raise PlanFileError("target_race")
405
+ first_before = _version(opened)
406
+ first = _read_pass(fd)
407
+ first_after = _version(os.fstat(fd))
408
+ if fault:
409
+ fault("read_between", path)
410
+ second_before = _version(os.fstat(fd))
411
+ second = _read_pass(fd)
412
+ second_after = _version(os.fstat(fd))
413
+ current = _target_stat(path)
414
+ path_stable = current is not None and _identity(current) == _identity(opened) \
415
+ and current.st_size == opened.st_size and current.st_nlink == opened.st_nlink \
416
+ and current.st_mtime_ns == opened.st_mtime_ns
417
+ if not path_stable or not _chain_current(path) \
418
+ or not (first_before == first_after == second_before == second_after) \
419
+ or first != second:
420
+ raise PlanFileError("target_race")
421
+ text = first.decode("utf-8", "strict")
422
+ return {"content": text, "hash": hashlib.sha256(first).hexdigest(),
423
+ "identity": _identity(opened), "version": first_before}
424
+ finally:
425
+ os.close(fd)
426
+
427
+
428
+ def _write_all(fd, data):
429
+ offset = 0
430
+ while offset < len(data):
431
+ written = os.write(fd, data[offset:])
432
+ if written <= 0:
433
+ raise PlanFileError("temp_write")
434
+ offset += written
435
+
436
+
437
+ def _sync_temp(fd):
438
+ os.fsync(fd)
439
+
440
+
441
+ def _close_temp(fd):
442
+ os.close(fd)
443
+
444
+
445
+ def _sync_parent(path):
446
+ if os.name == "nt":
447
+ return False
448
+ os.fsync(path["parent_fd"])
449
+ return True
450
+
451
+
452
+ def _windows_rename_status(handle, parent_handle, leaf, replace):
453
+ from ctypes import wintypes
454
+
455
+ class RenameInfo(ctypes.Structure):
456
+ _fields_ = [("ReplaceIfExists", wintypes.BOOLEAN), ("RootDirectory", wintypes.HANDLE),
457
+ ("FileNameLength", wintypes.DWORD), ("FileName", wintypes.WCHAR * 1)]
458
+
459
+ class IoStatusBlock(ctypes.Structure):
460
+ _fields_ = [("Status", ctypes.c_void_p), ("Information", ctypes.c_size_t)]
461
+
462
+ encoded = leaf.encode("utf-16-le")
463
+ size = ctypes.sizeof(RenameInfo) + len(encoded)
464
+ buffer = ctypes.create_string_buffer(size)
465
+ info = ctypes.cast(buffer, ctypes.POINTER(RenameInfo)).contents
466
+ info.ReplaceIfExists = replace
467
+ info.RootDirectory = parent_handle
468
+ info.FileNameLength = len(encoded)
469
+ ctypes.memmove(ctypes.addressof(buffer) + RenameInfo.FileName.offset, encoded, len(encoded))
470
+ iosb = IoStatusBlock()
471
+ ntdll = ctypes.WinDLL("ntdll")
472
+ function = ntdll.NtSetInformationFile
473
+ function.argtypes = [wintypes.HANDLE, ctypes.POINTER(IoStatusBlock), wintypes.LPVOID,
474
+ wintypes.ULONG, ctypes.c_int]
475
+ function.restype = wintypes.LONG
476
+ return int(function(handle, ctypes.byref(iosb), buffer, size, 10))
477
+
478
+
479
+ def _windows_publish(path, temp_fd, replace):
480
+ import msvcrt
481
+ parent_handle = _WINDOWS_PARENT_HANDLES.get(path["parent"])
482
+ if parent_handle is None:
483
+ raise PlanFileError("ancestor_handle")
484
+ handle = msvcrt.get_osfhandle(temp_fd)
485
+ status = _windows_rename_status(handle, parent_handle, path["leaf"], replace)
486
+ if status < 0: # NT_SUCCESS includes success and informational statuses.
487
+ if not replace and (status & 0xFFFFFFFF) in (0xC0000035, 0xC00000BA):
488
+ raise FileExistsError(path["leaf"])
489
+ raise PlanFileError("publish")
490
+
491
+
492
+ def _posix_publish(path, temp_name, replace):
493
+ if replace:
494
+ os.replace(temp_name, path["leaf"], src_dir_fd=path["parent_fd"],
495
+ dst_dir_fd=path["parent_fd"])
496
+ return
497
+ os.link(temp_name, path["leaf"], src_dir_fd=path["parent_fd"],
498
+ dst_dir_fd=path["parent_fd"], follow_symlinks=False)
499
+
500
+
501
+ def _publish(path, temp_name, temp_fd, replace):
502
+ if os.name == "nt":
503
+ _windows_publish(path, temp_fd, replace)
504
+ else:
505
+ _posix_publish(path, temp_name, replace)
506
+
507
+
508
+ def _unlink_owned(path, name, owned_identity):
509
+ try:
510
+ current = (os.stat(name, dir_fd=path["parent_fd"], follow_symlinks=False)
511
+ if os.name != "nt" else os.lstat(os.path.join(path["parent"], name)))
512
+ if _identity(current) != owned_identity:
513
+ return
514
+ if os.name == "nt":
515
+ os.unlink(os.path.join(path["parent"], name))
516
+ else:
517
+ os.unlink(name, dir_fd=path["parent_fd"])
518
+ except FileNotFoundError:
519
+ pass
520
+
521
+
522
+ def _cleanup_committed_temp(path, name, owned_identity):
523
+ """Best-effort postcommit cleanup; never lets fallible name work escape."""
524
+ try:
525
+ _unlink_owned(path, name, owned_identity)
526
+ try:
527
+ current = (os.stat(name, dir_fd=path["parent_fd"], follow_symlinks=False)
528
+ if os.name != "nt" else os.lstat(os.path.join(path["parent"], name)))
529
+ except FileNotFoundError:
530
+ return True
531
+ return _identity(current) != owned_identity
532
+ except Exception:
533
+ return False
534
+
535
+
536
+ def _same_snapshot(left, right):
537
+ if left is None or right is None:
538
+ return left is right
539
+ return left["identity"] == right["identity"] and left["version"] == right["version"] \
540
+ and left["hash"] == right["hash"]
541
+
542
+
543
+ def _result(status, **fields):
544
+ return {"status": status, **fields}
545
+
546
+
547
+ def _committed_result(path, data, content, fault=None, initial_diagnostic=None):
548
+ durable = False
549
+ diagnostic = initial_diagnostic
550
+ if fault:
551
+ with contextlib.suppress(Exception):
552
+ fault("after_publish", path)
553
+ try:
554
+ durable = _sync_parent(path) and diagnostic is None
555
+ if not durable and diagnostic is None:
556
+ diagnostic = diagnostic or "directory_durability_unavailable"
557
+ except Exception:
558
+ diagnostic = diagnostic or "directory_sync_failed"
559
+ try:
560
+ try:
561
+ final = _read_target(path)
562
+ except PlanFileError:
563
+ if initial_diagnostic != "postcommit_cleanup_failed":
564
+ raise
565
+ final = _read_target(path, allow_links=2)
566
+ wanted_hash = hashlib.sha256(data).hexdigest()
567
+ exists = final is not None
568
+ observed_hash = final["hash"] if final is not None else None
569
+ observed_content = final["content"] if final is not None else ""
570
+ if not exists or observed_hash != wanted_hash or observed_content != content:
571
+ diagnostic = "postcommit_changed"
572
+ durable = False
573
+ except Exception:
574
+ return _result("committed", observed=False, exists=None, hash=None, content=None,
575
+ directoryDurable=False, postCommitDiagnostic="postcommit_unobserved")
576
+ result = _result("committed", observed=True, exists=exists, hash=observed_hash,
577
+ content=observed_content, directoryDurable=durable)
578
+ if diagnostic:
579
+ result["postCommitDiagnostic"] = diagnostic
580
+ return result
581
+
582
+
583
+ def plan_file_operation(root, request, *, lock_root=None, fault=None):
584
+ """Run a bounded CAS. The lock serializes cooperating codeArbiter Pi writers only."""
585
+ try:
586
+ if not isinstance(request, dict) or set(request) - {"slug", "kind", "action", "expectedHash", "content"}:
587
+ raise PlanFileError("request")
588
+ action = request.get("action")
589
+ if action == "read" and set(request) != {"slug", "kind", "action"}:
590
+ raise PlanFileError("request")
591
+ if action == "replace" and set(request) != {"slug", "kind", "action", "expectedHash", "content"}:
592
+ raise PlanFileError("request")
593
+ if action not in ("read", "replace"):
594
+ raise PlanFileError("request")
595
+ target = _request_path(root, request.get("slug"), request.get("kind"))
596
+ with _lock_for(target, lock_root), _held_path(root, request["slug"], request["kind"], fault) as path:
597
+ chosen = _read_target(path, fault)
598
+ if action == "read":
599
+ if chosen is None:
600
+ return _result("unchanged", exists=False, hash=None, content="")
601
+ return _result("unchanged", exists=True, hash=chosen["hash"], content=chosen["content"])
602
+ expected = request["expectedHash"]
603
+ if expected is not None and (not isinstance(expected, str) or not HASH_RE.fullmatch(expected)):
604
+ raise PlanFileError("request")
605
+ if (chosen is None) != (expected is None) or chosen is not None and chosen["hash"] != expected:
606
+ return _result("conflict")
607
+ content = request["content"]
608
+ if not isinstance(content, str):
609
+ raise PlanFileError("request")
610
+ data = content.encode("utf-8", "strict")
611
+ if len(data) > MAX_CONTENT_BYTES:
612
+ raise PlanFileError("content_overflow")
613
+ temp_name = f".{path['leaf']}.ca-plan-tmp-{secrets.token_hex(16)}"
614
+ fd = None
615
+ temp_identity = None
616
+ committed = False
617
+ try:
618
+ fd = _open_temp(path, temp_name)
619
+ temp_info = os.fstat(fd)
620
+ if not _regular_single(temp_info):
621
+ raise PlanFileError("temp_type")
622
+ temp_identity = _identity(temp_info)
623
+ _write_all(fd, data)
624
+ _sync_temp(fd)
625
+ for phase in ("before_publish", "publish"):
626
+ if fault:
627
+ fault(phase, path)
628
+ try:
629
+ latest = _read_target(path)
630
+ except Exception:
631
+ return _result("conflict")
632
+ if not _same_snapshot(chosen, latest) or not _chain_current(path):
633
+ return _result("conflict")
634
+ _verify_owned_temp(path, temp_name, fd, temp_identity, data)
635
+ try:
636
+ _publish(path, temp_name, fd, chosen is not None)
637
+ except FileExistsError:
638
+ return _result("conflict")
639
+ committed = True # The successful atomic rename is the irreversible commit point.
640
+ if os.name == "nt" or chosen is not None:
641
+ temp_identity = None
642
+ else:
643
+ cleanup_ok = _cleanup_committed_temp(path, temp_name, temp_identity)
644
+ if cleanup_ok:
645
+ temp_identity = None
646
+ cleanup_diagnostic = None if cleanup_ok else "postcommit_cleanup_failed"
647
+ if os.name == "nt" or chosen is not None:
648
+ cleanup_diagnostic = None
649
+ published_fd = fd
650
+ fd = None
651
+ with contextlib.suppress(Exception):
652
+ _close_temp(published_fd)
653
+ return _committed_result(path, data, content, fault, cleanup_diagnostic)
654
+ finally:
655
+ if fd is not None:
656
+ with contextlib.suppress(Exception):
657
+ _close_temp(fd)
658
+ if temp_identity is not None:
659
+ with contextlib.suppress(Exception):
660
+ _unlink_owned(path, temp_name, temp_identity)
661
+ except PlanFileError as exc:
662
+ return _result("error", code=str(exc) if str(exc) else "operation")
663
+ except Exception:
664
+ return _result("error", code="operation")