@tyroneross/build-loop 0.30.3 → 0.35.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 (171) hide show
  1. package/.agents/plugins/marketplace.json +2 -2
  2. package/.claude-plugin/marketplace.json +4 -4
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/AGENTS.md +11 -2
  6. package/README.md +8 -7
  7. package/agents/advisor.md +111 -0
  8. package/agents/alignment-checker.md +11 -0
  9. package/agents/assessment-orchestrator.md +1 -1
  10. package/agents/build-orchestrator.md +28 -33
  11. package/agents/fact-checker.md +1 -1
  12. package/agents/fix-critique.md +1 -1
  13. package/agents/independent-auditor.md +1 -1
  14. package/agents/overfitting-reviewer.md +1 -1
  15. package/agents/plan-critic.md +8 -6
  16. package/agents/promotion-reviewer.md +1 -1
  17. package/agents/retrospective-synthesizer.md +20 -3
  18. package/agents/scope-auditor.md +1 -1
  19. package/agents/security-reviewer.md +1 -1
  20. package/agents/self-improvement-architect.md +1 -1
  21. package/commands/{handoff.md → compose-handoff.md} +4 -4
  22. package/commands/{optimize.md → optimize-run.md} +1 -1
  23. package/commands/{agent-rally-point.md → rally-point.md} +3 -3
  24. package/commands/{research.md → research-run.md} +1 -1
  25. package/commands/setup-memory.md +32 -0
  26. package/commands/{plan-verify.md → verify-plan.md} +1 -1
  27. package/docs/agent-surface-policy.md +38 -20
  28. package/docs/memory-setup.md +19 -14
  29. package/hooks/_resolve_python.sh +28 -0
  30. package/hooks/closeout.sh +58 -0
  31. package/hooks/git/pre-push +69 -6
  32. package/hooks/hooks.json +34 -13
  33. package/hooks/post-push-closeout.sh +73 -0
  34. package/hooks/session-start-closeout.sh +69 -0
  35. package/hooks/session-start-memory.sh +27 -0
  36. package/hooks/session-start-plugin-heal.sh +33 -0
  37. package/hooks/test_closeout.sh +124 -0
  38. package/package.json +7 -4
  39. package/plugin-artifacts/codex/.codex-plugin/plugin.json +41 -0
  40. package/plugin-artifacts/codex/AGENTS.md +507 -0
  41. package/plugin-artifacts/codex/BUILD-ARTIFACT.md +5 -0
  42. package/plugin-artifacts/codex/LICENSE +202 -0
  43. package/plugin-artifacts/codex/README.md +404 -0
  44. package/plugin-artifacts/codex/docs/agent-surface-policy.md +63 -0
  45. package/plugin-artifacts/codex/references/advisor-dispatch-ladder.md +62 -0
  46. package/plugin-artifacts/codex/references/agent-role-taxonomy.md +135 -0
  47. package/plugin-artifacts/codex/references/autonomous-and-per-commit-modes.md +161 -0
  48. package/plugin-artifacts/codex/references/autonomy-config.md +231 -0
  49. package/plugin-artifacts/codex/references/capability-routing.md +182 -0
  50. package/plugin-artifacts/codex/references/codex-subagents.md +106 -0
  51. package/plugin-artifacts/codex/references/coordination-file-template.md +181 -0
  52. package/plugin-artifacts/codex/references/coordination-rules.md +283 -0
  53. package/plugin-artifacts/codex/references/dogfood-reload-checkpoint.md +112 -0
  54. package/plugin-artifacts/codex/references/halt-and-ask-protocol.md +102 -0
  55. package/plugin-artifacts/codex/references/implementer-envelope-schema.md +302 -0
  56. package/plugin-artifacts/codex/references/intent-capability-pack.md +257 -0
  57. package/plugin-artifacts/codex/references/intent-exploration-prompts.md +96 -0
  58. package/plugin-artifacts/codex/references/leadership.md +72 -0
  59. package/plugin-artifacts/codex/references/memory-systems.md +238 -0
  60. package/plugin-artifacts/codex/references/memory.md +299 -0
  61. package/plugin-artifacts/codex/references/model-tier-mapping.md +257 -0
  62. package/plugin-artifacts/codex/references/modular-systems-pack.md +96 -0
  63. package/plugin-artifacts/codex/references/phase-1-assess.md +231 -0
  64. package/plugin-artifacts/codex/references/phase-2-plan.md +78 -0
  65. package/plugin-artifacts/codex/references/phase-3-execute.md +49 -0
  66. package/plugin-artifacts/codex/references/phase-4-review.md +322 -0
  67. package/plugin-artifacts/codex/references/phase-5-iterate.md +71 -0
  68. package/plugin-artifacts/codex/references/phase-6-learn.md +56 -0
  69. package/plugin-artifacts/codex/references/recent-design-structures.md +274 -0
  70. package/plugin-artifacts/codex/references/research-trigger-policy.md +140 -0
  71. package/plugin-artifacts/codex/references/runtime-smoke-triggers.md +42 -0
  72. package/plugin-artifacts/codex/references/self-review.md +234 -0
  73. package/plugin-artifacts/codex/references/single-writer-commit-protocol.md +90 -0
  74. package/plugin-artifacts/codex/references/task-capture-policy.md +68 -0
  75. package/plugin-artifacts/codex/references/ui-io-contract.md +116 -0
  76. package/plugin-artifacts/codex/references/ui-spotcheck-protocol.md +65 -0
  77. package/plugin-artifacts/codex/skills/build-loop/SKILL.md +372 -0
  78. package/plugin-artifacts/codex/skills/build-loop/detect-plugins.mjs +82 -0
  79. package/plugin-artifacts/codex/skills/build-loop/eval-guide.md +65 -0
  80. package/plugin-artifacts/codex/skills/build-loop/fallbacks.md +549 -0
  81. package/plugin-artifacts/codex/skills/build-loop/phases/fact-check.md +42 -0
  82. package/plugin-artifacts/codex/skills/build-loop/phases/ui-validation.md +267 -0
  83. package/plugin-artifacts/codex/skills/build-loop/references/advisor-dispatch-ladder.md +62 -0
  84. package/plugin-artifacts/codex/skills/build-loop/references/apple-native-planning.md +439 -0
  85. package/plugin-artifacts/codex/skills/build-loop/references/autonomous-and-per-commit-modes.md +161 -0
  86. package/plugin-artifacts/codex/skills/build-loop/references/capability-routing.md +182 -0
  87. package/plugin-artifacts/codex/skills/build-loop/references/codex-subagents.md +106 -0
  88. package/plugin-artifacts/codex/skills/build-loop/references/coordination.md +161 -0
  89. package/plugin-artifacts/codex/skills/build-loop/references/correction-aware-capture.md +177 -0
  90. package/plugin-artifacts/codex/skills/build-loop/references/independent-auditor.md +72 -0
  91. package/plugin-artifacts/codex/skills/build-loop/references/intent-capability-pack.md +257 -0
  92. package/plugin-artifacts/codex/skills/build-loop/references/intent-exploration-prompts.md +96 -0
  93. package/plugin-artifacts/codex/skills/build-loop/references/leadership.md +72 -0
  94. package/plugin-artifacts/codex/skills/build-loop/references/memory.md +299 -0
  95. package/plugin-artifacts/codex/skills/build-loop/references/modular-systems-pack.md +96 -0
  96. package/plugin-artifacts/codex/skills/build-loop/references/output-style.md +129 -0
  97. package/plugin-artifacts/codex/skills/build-loop/references/pay-it-forward-arch.md +98 -0
  98. package/plugin-artifacts/codex/skills/build-loop/references/phase-1-assess.md +231 -0
  99. package/plugin-artifacts/codex/skills/build-loop/references/phase-2-plan.md +78 -0
  100. package/plugin-artifacts/codex/skills/build-loop/references/phase-3-execute.md +49 -0
  101. package/plugin-artifacts/codex/skills/build-loop/references/phase-4-review.md +322 -0
  102. package/plugin-artifacts/codex/skills/build-loop/references/phase-5-iterate.md +71 -0
  103. package/plugin-artifacts/codex/skills/build-loop/references/phase-6-learn.md +56 -0
  104. package/plugin-artifacts/codex/skills/build-loop/references/recent-design-structures.md +274 -0
  105. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/ASSESSMENT.md +85 -0
  106. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/STANDALONE_TEST_RUN.md +149 -0
  107. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/01-simple-bugfix.md +32 -0
  108. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/02-ui-build-with-iteration.md +48 -0
  109. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/03-multi-failure-escalation.md +60 -0
  110. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/04-ui-build-ibr-absent.md +51 -0
  111. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/05-refactor-navgator-absent.md +71 -0
  112. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/06-recurring-bug-debugger-absent.md +52 -0
  113. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/trace-comparison.md +202 -0
  114. package/plugin-artifacts/codex/skills/build-loop/references/self-recursive-dev.md +77 -0
  115. package/plugin-artifacts/codex/skills/build-loop/references/self-review.md +234 -0
  116. package/plugin-artifacts/codex/skills/build-loop/references/ui-io-contract.md +116 -0
  117. package/plugin-artifacts/codex/skills/build-loop/scanners/audit-design-rules.mjs +476 -0
  118. package/plugin-artifacts/codex/skills/build-loop/scanners/require-visual-evidence.mjs +239 -0
  119. package/plugin-artifacts/codex/skills/build-loop/templates/backlog-item.md +35 -0
  120. package/plugin-artifacts/codex/skills/build-loop/templates/codex-worker-prompt.md +100 -0
  121. package/plugin-artifacts/codex/skills/build-loop/templates/ui-subagent-prompt.md +179 -0
  122. package/plugin-artifacts/codex/skills/build-loop/templates/ux-fix-plan.md +40 -0
  123. package/scripts/_paths.py +59 -13
  124. package/scripts/build_codex_plugin_artifact.py +314 -0
  125. package/scripts/check_cache_sync.py +1 -1
  126. package/scripts/install_memory.py +11 -3
  127. package/scripts/prune_plugin_cache.py +225 -11
  128. package/scripts/sync_navgator_lessons.py +25 -0
  129. package/scripts/sync_plugin_cache.py +66 -36
  130. package/skills/agent-rally-point/SKILL.md +1 -1
  131. package/skills/agent-rally-watcher/SKILL.md +1 -1
  132. package/skills/build-loop/SKILL.md +11 -155
  133. package/skills/build-loop/references/advisor-dispatch-ladder.md +62 -0
  134. package/skills/build-loop/references/autonomous-and-per-commit-modes.md +161 -0
  135. package/skills/build-loop/references/capability-routing.md +4 -3
  136. package/skills/build-loop/references/codex-subagents.md +8 -0
  137. package/skills/build-loop/references/coordination.md +1 -1
  138. package/skills/build-loop/references/intent-capability-pack.md +88 -1
  139. package/skills/build-loop/references/memory.md +17 -2
  140. package/skills/build-loop/references/phase-1-assess.md +13 -0
  141. package/skills/build-loop/references/phase-2-plan.md +2 -1
  142. package/skills/build-loop/references/phase-3-execute.md +4 -2
  143. package/skills/build-loop/references/phase-4-review.md +12 -0
  144. package/skills/build-loop/references/phase-6-learn.md +1 -1
  145. package/skills/build-loop/references/self-review.md +4 -2
  146. package/skills/build-loop/templates/codex-worker-prompt.md +45 -34
  147. package/skills/handoff/SKILL.md +3 -3
  148. package/skills/loop-builder/SKILL.md +98 -0
  149. package/skills/loop-builder/presets/active-project-evidence.yaml +97 -0
  150. package/skills/loop-builder/presets/generic-artifact-loop.yaml +95 -0
  151. package/skills/loop-builder/presets/presentation-audit.yaml +96 -0
  152. package/skills/loop-builder/presets/research-synthesis.yaml +96 -0
  153. package/skills/loop-builder/presets/source-ingestion-raw-data-audit.yaml +96 -0
  154. package/skills/loop-builder/references/spec-format.md +80 -0
  155. package/skills/loop-builder/scripts/loop_builder.py +346 -0
  156. package/skills/model-tiering/SKILL.md +65 -40
  157. package/skills/optimize/SKILL.md +2 -2
  158. package/skills/plan-verify/SKILL.md +1 -1
  159. package/skills/plugin-builder/references/distribution.md +8 -0
  160. package/skills/research/SKILL.md +33 -6
  161. package/skills/runtime-parity-verification/SKILL.md +51 -0
  162. package/skills/spec-writing/SKILL.md +19 -0
  163. package/templates/memory/README.md +65 -0
  164. package/templates/memory/charter.md.template +46 -0
  165. package/templates/memory/manifest.json +24 -4
  166. package/codex-skills/debug-loop/SKILL.md +0 -18
  167. package/codex-skills/handoff/SKILL.md +0 -22
  168. package/codex-skills/knowledge/SKILL.md +0 -21
  169. package/codex-skills/optimize/SKILL.md +0 -18
  170. package/codex-skills/research/SKILL.md +0 -18
  171. /package/commands/{knowledge-review.md → review-knowledge.md} +0 -0
@@ -17,8 +17,11 @@ from __future__ import annotations
17
17
  import argparse
18
18
  import json
19
19
  import os
20
+ import re
20
21
  import shutil
22
+ import subprocess
21
23
  import sys
24
+ from datetime import datetime, timezone
22
25
  from pathlib import Path
23
26
  from typing import Any, Literal
24
27
 
@@ -31,6 +34,16 @@ Host = Literal["codex", "claude"]
31
34
  # "Plugin directory does not exist".
32
35
  IN_USE_ENV_VARS: tuple[str, ...] = ("CLAUDE_PLUGIN_ROOT", "CODEX_PLUGIN_ROOT")
33
36
 
37
+ # Archive layout: pruned version dirs are MOVED, never deleted, so the
38
+ # session-start healer (scripts/hooks/plugin_dir_heal.py) can move them
39
+ # back if a peer session was pinned to that version. The healer scans
40
+ # <plugins>/removed/<tag>/<plugin>/<version>/ (and the shallower
41
+ # <plugins>/removed/<tag>/<version>/ shape) — `find_archived_match()` —
42
+ # so the layout below MUST stay grandparent-of-cache = parent-of-removed.
43
+ ARCHIVE_PREFIX = "buildloop-prune-"
44
+ ARCHIVE_RETENTION = 5
45
+ REMOVED_DIR_NAME = "removed"
46
+
34
47
  HOST_CONFIG: dict[Host, dict[str, str]] = {
35
48
  "codex": {
36
49
  "manifest": ".codex-plugin/plugin.json",
@@ -91,14 +104,27 @@ def iter_version_dirs(
91
104
  return sorted(versions)
92
105
 
93
106
 
94
- def detect_in_use_versions(*, plugin_name: str) -> set[str]:
107
+ def detect_in_use_versions(*, plugin_name: str, scan_peers: bool = True) -> set[str]:
95
108
  """Cache version dir name(s) a live host session is currently loaded from.
96
109
 
97
- Read from the host plugin-root env vars. The path is intentionally NOT
98
- resolved: an in-use version dir may itself be a symlink (e.g. a local-dev
99
- override pointing at a working tree), and we must protect its *cache* name,
100
- not the symlink target's name. Only a path whose parent is this plugin's
101
- cache dir counts, so an unrelated env value can't over-protect.
110
+ Reads:
111
+ 1. THIS process's env vars (CLAUDE_PLUGIN_ROOT / CODEX_PLUGIN_ROOT)
112
+ original behaviour; always on.
113
+ 2. PEER live host processes' env (when ``scan_peers``, default on)
114
+ covers the multi-session case (cross-terminal Claude + Claude, or
115
+ Claude + Codex) where another session is bound to a different
116
+ version of the same plugin. The cross-user fix for
117
+ `bl-plugin-cache-gc-selfheal`.
118
+
119
+ The path is intentionally NOT resolved: an in-use version dir may itself
120
+ be a symlink (e.g. a local-dev override pointing at a working tree), and
121
+ we must protect its *cache* name, not the symlink target's name. Only a
122
+ path whose parent is this plugin's cache dir counts, so an unrelated env
123
+ value can't over-protect.
124
+
125
+ Peer scan is best-effort and fail-open: if ``ps -E`` / ``/proc`` are
126
+ unavailable or return nothing, we fall back to the current-process scan.
127
+ Never raises.
102
128
  """
103
129
  names: set[str] = set()
104
130
  for var in IN_USE_ENV_VARS:
@@ -108,9 +134,117 @@ def detect_in_use_versions(*, plugin_name: str) -> set[str]:
108
134
  p = Path(root)
109
135
  if p.parent.name == plugin_name:
110
136
  names.add(p.name)
137
+ if scan_peers:
138
+ try:
139
+ names.update(_detect_peer_in_use_versions(plugin_name=plugin_name))
140
+ except Exception: # noqa: BLE001 - fail-open on ANY error
141
+ pass
111
142
  return names
112
143
 
113
144
 
145
+ # Matches IN_USE_ENV_VARS values in a ps -E / /proc/environ blob. Captures
146
+ # the path up to the first whitespace or NUL byte. Env-var values can't
147
+ # legally contain whitespace via shell export, so this is safe and avoids
148
+ # bleeding into an adjacent var.
149
+ _ENV_VAR_PATTERN = re.compile(
150
+ r"(?:^|[ \t\0])(" + "|".join(re.escape(v) for v in IN_USE_ENV_VARS) + r")=([^\s\0]+)"
151
+ )
152
+
153
+
154
+ def _detect_peer_in_use_versions(*, plugin_name: str) -> set[str]:
155
+ """Scan peer live processes' environment for CLAUDE_PLUGIN_ROOT /
156
+ CODEX_PLUGIN_ROOT pinned to ``plugin_name``'s cache. Returns the version
157
+ dir names found.
158
+
159
+ Strategy by platform:
160
+ - Linux: read /proc/<pid>/environ for every same-uid process.
161
+ - macOS: ``ps -E`` (BSD) prints env vars after the command, restricted
162
+ to the current user.
163
+
164
+ Best-effort. Returns ``set()`` on any failure.
165
+ """
166
+ names: set[str] = set()
167
+ # Linux fast-path: /proc is the most reliable read.
168
+ proc_root = Path("/proc")
169
+ if proc_root.is_dir():
170
+ try:
171
+ my_uid = os.getuid()
172
+ except AttributeError:
173
+ my_uid = None
174
+ try:
175
+ entries = list(proc_root.iterdir())
176
+ except OSError:
177
+ return names
178
+ for entry in entries:
179
+ if not entry.name.isdigit():
180
+ continue
181
+ if entry.name == str(os.getpid()):
182
+ continue
183
+ if my_uid is not None:
184
+ try:
185
+ if entry.stat().st_uid != my_uid:
186
+ continue
187
+ except OSError:
188
+ continue
189
+ environ_path = entry / "environ"
190
+ try:
191
+ blob = environ_path.read_bytes()
192
+ except OSError:
193
+ continue
194
+ try:
195
+ text = blob.decode("utf-8", errors="replace")
196
+ except Exception: # noqa: BLE001
197
+ continue
198
+ for match in _ENV_VAR_PATTERN.finditer(text):
199
+ _name = _version_from_root(match.group(2), plugin_name)
200
+ if _name:
201
+ names.add(_name)
202
+ return names
203
+
204
+ # macOS / BSD: `ps -E` shows env after the command. Restrict to current
205
+ # user (-u $USER); env-only for other users requires root.
206
+ user = os.environ.get("USER") or ""
207
+ cmd: list[str]
208
+ if user:
209
+ cmd = ["ps", "-E", "-o", "pid=,command=", "-u", user]
210
+ else:
211
+ cmd = ["ps", "-E", "-o", "pid=,command=", "-ax"]
212
+ try:
213
+ result = subprocess.run(
214
+ cmd, capture_output=True, text=True, timeout=2, check=False,
215
+ )
216
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
217
+ return names
218
+ my_pid = str(os.getpid())
219
+ for line in result.stdout.splitlines():
220
+ line = line.strip()
221
+ if not line:
222
+ continue
223
+ parts = line.split(None, 1)
224
+ if not parts:
225
+ continue
226
+ if parts[0] == my_pid:
227
+ continue
228
+ for match in _ENV_VAR_PATTERN.finditer(line):
229
+ _name = _version_from_root(match.group(2), plugin_name)
230
+ if _name:
231
+ names.add(_name)
232
+ return names
233
+
234
+
235
+ def _version_from_root(root_value: str, plugin_name: str) -> str | None:
236
+ """Extract the version dir name from an env-var value when it points at
237
+ a cache dir for ``plugin_name``. Same un-resolved match rule as the
238
+ current-process detector — protect by NAME, not resolved target.
239
+ """
240
+ if not root_value:
241
+ return None
242
+ p = Path(root_value)
243
+ if p.parent.name == plugin_name:
244
+ return p.name
245
+ return None
246
+
247
+
114
248
  def installed_pinned_versions(*, plugin_name: str, host: Host) -> set[str]:
115
249
  """Cache version dir names the host has pinned for this plugin across ALL
116
250
  scopes/projects, read from its installed_plugins.json. These belong to other
@@ -164,11 +298,75 @@ def classify_versions(
164
298
  return keep, stale, skipped
165
299
 
166
300
 
167
- def remove_cache_entry(path: Path) -> None:
301
+ def archive_root_for(cache_root: Path) -> Path:
302
+ """Return the `removed/` sibling of cache_root, matching the layout the
303
+ SessionStart healer scans. cache_root is `<host>/plugins/cache`, so the
304
+ removed dir is `<host>/plugins/removed` — one level up + sibling.
305
+ """
306
+ return cache_root.parent / REMOVED_DIR_NAME
307
+
308
+
309
+ def _archive_tag() -> str:
310
+ # Microsecond precision so multiple prunes inside one second don't collide.
311
+ return ARCHIVE_PREFIX + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
312
+
313
+
314
+ def _gc_old_archives(archive_root: Path, retention: int) -> None:
315
+ """Keep only the newest `retention` `buildloop-prune-*` dirs under
316
+ archive_root; rmtree older ones. Fail-open: any unreadable entry skipped."""
317
+ if retention < 0 or not archive_root.is_dir():
318
+ return
319
+ try:
320
+ entries = [p for p in archive_root.iterdir()
321
+ if p.is_dir() and p.name.startswith(ARCHIVE_PREFIX)]
322
+ except OSError:
323
+ return
324
+ if len(entries) <= retention:
325
+ return
326
+ entries.sort(key=lambda p: p.stat().st_mtime, reverse=True)
327
+ for stale in entries[retention:]:
328
+ try:
329
+ shutil.rmtree(stale)
330
+ except OSError:
331
+ continue
332
+
333
+
334
+ def remove_cache_entry(
335
+ path: Path,
336
+ *,
337
+ plugin_name: str,
338
+ archive_root: Path,
339
+ retention: int = ARCHIVE_RETENTION,
340
+ ) -> None:
341
+ """Retire a cache entry safely.
342
+
343
+ Symlinks (local-dev / heal symlinks) → `unlink()` only. The link target is
344
+ a real dir the host may still need; following or archiving it would risk
345
+ nuking a live install. The link itself is cheap to recreate.
346
+
347
+ Real version dirs → MOVED into
348
+ `archive_root/buildloop-prune-<UTC-ts>/<plugin>/<basename>/` via atomic
349
+ `os.rename` (same-fs); cross-fs falls back to `copytree(symlinks=True)` +
350
+ `rmtree`. Hard delete is removed entirely so the SessionStart healer can
351
+ restore-from-removed when a peer session was pinned to the version.
352
+
353
+ After a successful archive, prune old `buildloop-prune-*` dirs under
354
+ archive_root down to `retention` so `removed/` does not grow unbounded.
355
+ """
168
356
  if path.is_symlink():
169
357
  path.unlink()
170
358
  return
171
- shutil.rmtree(path)
359
+ tag_dir = archive_root / _archive_tag() / plugin_name
360
+ dest = tag_dir / path.name
361
+ tag_dir.mkdir(parents=True, exist_ok=True)
362
+ try:
363
+ os.rename(path, dest)
364
+ except OSError:
365
+ # Cross-filesystem move (or other rename refusal) — copy then remove.
366
+ # symlinks=True preserves any inner symlinks verbatim.
367
+ shutil.copytree(path, dest, symlinks=True)
368
+ shutil.rmtree(path)
369
+ _gc_old_archives(archive_root, retention)
172
370
 
173
371
 
174
372
  def prune_host(
@@ -181,6 +379,7 @@ def prune_host(
181
379
  keep_version_override: str | None,
182
380
  protect: list[str] | None,
183
381
  detect_in_use: bool,
382
+ scan_peers: bool,
184
383
  protect_installed: bool,
185
384
  apply: bool,
186
385
  ) -> tuple[dict[str, Any], int]:
@@ -189,7 +388,9 @@ def prune_host(
189
388
  keep_version = keep_version_override or manifest["version"]
190
389
  protected_names: set[str] = set(protect or [])
191
390
  if detect_in_use:
192
- protected_names.update(detect_in_use_versions(plugin_name=plugin_name))
391
+ protected_names.update(
392
+ detect_in_use_versions(plugin_name=plugin_name, scan_peers=scan_peers)
393
+ )
193
394
  if protect_installed:
194
395
  protected_names.update(installed_pinned_versions(plugin_name=plugin_name, host=host))
195
396
  version_dirs = iter_version_dirs(
@@ -207,12 +408,17 @@ def prune_host(
207
408
 
208
409
  deleted: list[Path] = []
209
410
  errors: list[dict[str, str]] = []
411
+ archive_root = archive_root_for(cache_root)
210
412
  if apply:
211
413
  for path in stale:
212
414
  try:
213
- remove_cache_entry(path)
415
+ remove_cache_entry(
416
+ path,
417
+ plugin_name=plugin_name,
418
+ archive_root=archive_root,
419
+ )
214
420
  deleted.append(path)
215
- except OSError as exc:
421
+ except (OSError, shutil.Error) as exc:
216
422
  errors.append({"path": str(path), "error": str(exc)})
217
423
 
218
424
  return {
@@ -251,6 +457,13 @@ def parse_args() -> argparse.Namespace:
251
457
  help="Disable auto-protecting the version dir the running host session is "
252
458
  "loaded from (read from CLAUDE_PLUGIN_ROOT / CODEX_PLUGIN_ROOT).",
253
459
  )
460
+ p.add_argument(
461
+ "--no-scan-peers",
462
+ action="store_true",
463
+ help="Don't scan PEER live host processes' env for in-use versions. "
464
+ "Default-on peer scan protects multi-session pins (Claude + Claude, "
465
+ "Claude + Codex) so a prune in one session doesn't kill another's hooks.",
466
+ )
254
467
  p.add_argument(
255
468
  "--protect-installed",
256
469
  action="store_true",
@@ -338,6 +551,7 @@ def main() -> int:
338
551
  keep_version_override=args.keep_version,
339
552
  protect=args.protect,
340
553
  detect_in_use=not args.no_detect_in_use,
554
+ scan_peers=not args.no_scan_peers,
341
555
  protect_installed=args.protect_installed,
342
556
  apply=args.apply,
343
557
  )
@@ -113,6 +113,26 @@ TOOL = "navgator"
113
113
  SOURCE = "migration" # one-way external import per write_decision taxonomy
114
114
  SYNC_ERRORS_LOG = ".build-loop/sync_errors.log"
115
115
 
116
+ # One-time-per-process guard for the psycopg-missing warning. The Postgres
117
+ # mirror is OPTIONAL (SQLite is the source of truth); a missing psycopg should
118
+ # degrade gracefully, but it had been failing SILENTLY — only a line in
119
+ # .build-loop/sync_errors.log, which recurred 2026-05-05..2026-06-03 unseen.
120
+ # Surface it ONCE on stderr with the actionable install hint, then stay quiet.
121
+ _PSYCOPG_WARNED = False
122
+
123
+
124
+ def _warn_psycopg_missing_once() -> None:
125
+ global _PSYCOPG_WARNED
126
+ if _PSYCOPG_WARNED:
127
+ return
128
+ _PSYCOPG_WARNED = True
129
+ _log(
130
+ "psycopg not installed — skipping the optional Postgres mirror "
131
+ "(SQLite remains the source of truth). Install with "
132
+ "`uv pip install -e .[db]` to enable the mirror. "
133
+ "(This notice prints once per run; details in .build-loop/sync_errors.log.)"
134
+ )
135
+
116
136
  # Make scripts/ importable as a sibling module (mirrors capture_arch_violation.py).
117
137
  HERE = Path(__file__).resolve().parent
118
138
  if str(HERE) not in sys.path:
@@ -597,6 +617,11 @@ def main(argv: list[str] | None = None) -> int:
597
617
  _append_sync_error(workdir, msg)
598
618
  errors.append("postgres_unavailable")
599
619
  conn = None
620
+ # A MISSING psycopg module (vs. a reachable-but-down server) is the
621
+ # silent recurring case. Surface it visibly, once per run, with the
622
+ # install hint — don't bury it in the log file again.
623
+ if isinstance(exc, ModuleNotFoundError) or "psycopg" in str(exc).lower():
624
+ _warn_psycopg_missing_once()
600
625
 
601
626
  if conn is not None:
602
627
  try:
@@ -33,7 +33,7 @@ if str(HERE) not in sys.path:
33
33
  import check_cache_sync as cache_sync # type: ignore # noqa: E402
34
34
 
35
35
 
36
- EXCLUDE_DIRS = {".git", "node_modules", "__pycache__", ".venv", ".build-loop"}
36
+ EXCLUDE_DIRS = {".git", "node_modules", "__pycache__", ".venv", ".build-loop", ".rally"}
37
37
  HOOK_NAMES = ("post-commit", "post-merge", "post-checkout", "post-rewrite")
38
38
  HOOK_MARKER = "# --- BEGIN build-loop plugin-cache-sync ---"
39
39
  HOOK_MARKER_END = "# --- END build-loop plugin-cache-sync ---"
@@ -80,9 +80,15 @@ def safe_extract_tar(tar_bytes: bytes, target: Path) -> None:
80
80
 
81
81
  def materialize_head(source: Path) -> tempfile.TemporaryDirectory[str]:
82
82
  require_git_head(source)
83
+ top = Path(run_git(source, ["rev-parse", "--show-toplevel"])).resolve()
84
+ try:
85
+ rel = source.resolve().relative_to(top)
86
+ except ValueError as exc:
87
+ raise SyncError(f"{source} is not under git toplevel {top}") from exc
88
+ treeish = "HEAD" if str(rel) == "." else f"HEAD:{rel.as_posix()}"
83
89
  proc = subprocess.run(
84
- ["git", "archive", "--format=tar", "HEAD"],
85
- cwd=str(source),
90
+ ["git", "archive", "--format=tar", treeish],
91
+ cwd=str(top),
86
92
  stdout=subprocess.PIPE,
87
93
  stderr=subprocess.PIPE,
88
94
  check=False,
@@ -324,33 +330,28 @@ def print_human(results: list[dict[str, Any]], source_mode: str, materialized_so
324
330
  print(f" [{diff.get('status')}] {diff.get('path')}")
325
331
 
326
332
 
327
- def main(argv: list[str] | None = None) -> int:
328
- args = parse_args(argv)
329
- source = Path(args.source).expanduser().resolve()
330
- if not source.is_dir():
331
- print(f"error: source is not a directory: {source}", file=sys.stderr)
332
- return 2
333
+ def emit_hook_install(installed: dict[str, Any], args: argparse.Namespace) -> None:
334
+ if args.json:
335
+ print(json.dumps(installed, indent=2, sort_keys=True))
336
+ elif not args.quiet:
337
+ print(f"installed plugin cache sync hooks for {args.host}:")
338
+ for hook in installed["hooks"]:
339
+ print(f" {hook}")
333
340
 
334
- try:
335
- if args.install_git_hooks:
336
- installed = install_git_hooks(source, args.host)
337
- if args.json:
338
- print(json.dumps(installed, indent=2, sort_keys=True))
339
- elif not args.quiet:
340
- print(f"installed plugin cache sync hooks for {args.host}:")
341
- for hook in installed["hooks"]:
342
- print(f" {hook}")
343
- return 0
344
341
 
345
- tmp: tempfile.TemporaryDirectory[str] | None = None
346
- if args.dirty:
347
- source_for_sync = source
348
- source_mode = "dirty"
349
- else:
350
- tmp = materialize_head(source)
351
- source_for_sync = Path(tmp.name)
352
- source_mode = "head"
342
+ def materialize_source_for_sync(
343
+ source: Path,
344
+ args: argparse.Namespace,
345
+ ) -> tuple[Path, str, tempfile.TemporaryDirectory[str] | None]:
346
+ if args.dirty:
347
+ return source, "dirty", None
348
+ tmp = materialize_head(source)
349
+ return Path(tmp.name), "head", tmp
353
350
 
351
+
352
+ def build_sync_payload(source: Path, args: argparse.Namespace) -> dict[str, Any]:
353
+ source_for_sync, source_mode, tmp = materialize_source_for_sync(source, args)
354
+ try:
354
355
  explicit_host = args.host != "all"
355
356
  results = [
356
357
  sync_one_host(
@@ -362,25 +363,54 @@ def main(argv: list[str] | None = None) -> int:
362
363
  )
363
364
  for host in host_list(args.host)
364
365
  ]
365
- payload = {
366
+ return {
366
367
  "ok": all(result.get("ok", True) for result in results),
367
368
  "source": str(source),
368
369
  "source_mode": source_mode,
369
370
  "materialized_source": str(source_for_sync),
370
371
  "results": results,
371
372
  }
372
- if args.json:
373
- print(json.dumps(payload, indent=2, sort_keys=True))
374
- elif not args.quiet:
375
- print_human(results, source_mode, source_for_sync)
373
+ finally:
376
374
  if tmp is not None:
377
375
  tmp.cleanup()
376
+
377
+
378
+ def emit_sync_payload(payload: dict[str, Any], args: argparse.Namespace) -> None:
379
+ if args.json:
380
+ print(json.dumps(payload, indent=2, sort_keys=True))
381
+ elif not args.quiet:
382
+ print_human(
383
+ payload["results"],
384
+ payload["source_mode"],
385
+ Path(payload["materialized_source"]),
386
+ )
387
+
388
+
389
+ def emit_sync_error(exc: SyncError, args: argparse.Namespace) -> None:
390
+ if args.json:
391
+ print(json.dumps({"ok": False, "error": str(exc)}, indent=2, sort_keys=True))
392
+ else:
393
+ print(f"error: {exc}", file=sys.stderr)
394
+
395
+
396
+ def main(argv: list[str] | None = None) -> int:
397
+ args = parse_args(argv)
398
+ source = Path(args.source).expanduser().resolve()
399
+ if not source.is_dir():
400
+ print(f"error: source is not a directory: {source}", file=sys.stderr)
401
+ return 2
402
+
403
+ try:
404
+ if args.install_git_hooks:
405
+ installed = install_git_hooks(source, args.host)
406
+ emit_hook_install(installed, args)
407
+ return 0
408
+
409
+ payload = build_sync_payload(source, args)
410
+ emit_sync_payload(payload, args)
378
411
  return 0 if payload["ok"] else 1
379
412
  except SyncError as exc:
380
- if args.json:
381
- print(json.dumps({"ok": False, "error": str(exc)}, indent=2, sort_keys=True))
382
- else:
383
- print(f"error: {exc}", file=sys.stderr)
413
+ emit_sync_error(exc, args)
384
414
  return 2
385
415
 
386
416
 
@@ -19,7 +19,7 @@ is mechanical later.
19
19
  |---|---|
20
20
  | Substrate package | `${CLAUDE_PLUGIN_ROOT}/scripts/rally_point/` |
21
21
  | Host-neutral CLI | `${CLAUDE_PLUGIN_ROOT}/scripts/agent_rally.py` |
22
- | Slash command | `${CLAUDE_PLUGIN_ROOT}/commands/agent-rally-point.md` |
22
+ | Slash command | `${CLAUDE_PLUGIN_ROOT}/commands/rally-point.md` |
23
23
  | Hook adapters | `${CLAUDE_PLUGIN_ROOT}/hooks/session-start-rally-point.sh`, `${CLAUDE_PLUGIN_ROOT}/hooks/pre-edit-rally-point.sh` |
24
24
  | Build-loop adapters | `${CLAUDE_PLUGIN_ROOT}/scripts/coordination_status.py`, `${CLAUDE_PLUGIN_ROOT}/scripts/coordination_rally.py`, `${CLAUDE_PLUGIN_ROOT}/scripts/coordination_bootstrap.py` |
25
25
  | Boundary manifest | `${CLAUDE_PLUGIN_ROOT}/scripts/rally_point/plugin_boundary.json` |
@@ -20,7 +20,7 @@ entrypoints thin.
20
20
  | Watcher package | `${CLAUDE_PLUGIN_ROOT}/scripts/agent_rally_watcher/` |
21
21
  | Compatibility CLI | `${CLAUDE_PLUGIN_ROOT}/scripts/coordination_watch.py` |
22
22
  | Status dependency | `${CLAUDE_PLUGIN_ROOT}/scripts/coordination_status.py` |
23
- | Manual command | `${CLAUDE_PLUGIN_ROOT}/commands/agent-rally-point.md` `watch` subcommand |
23
+ | Manual command | `${CLAUDE_PLUGIN_ROOT}/commands/rally-point.md` `watch` subcommand |
24
24
  | Boundary manifest | `${CLAUDE_PLUGIN_ROOT}/scripts/rally_point/plugin_boundary.json` |
25
25
 
26
26
  ## Operating Rules