@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.
- package/.agents/plugins/marketplace.json +2 -2
- package/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +11 -2
- package/README.md +8 -7
- package/agents/advisor.md +111 -0
- package/agents/alignment-checker.md +11 -0
- package/agents/assessment-orchestrator.md +1 -1
- package/agents/build-orchestrator.md +28 -33
- package/agents/fact-checker.md +1 -1
- package/agents/fix-critique.md +1 -1
- package/agents/independent-auditor.md +1 -1
- package/agents/overfitting-reviewer.md +1 -1
- package/agents/plan-critic.md +8 -6
- package/agents/promotion-reviewer.md +1 -1
- package/agents/retrospective-synthesizer.md +20 -3
- package/agents/scope-auditor.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/self-improvement-architect.md +1 -1
- package/commands/{handoff.md → compose-handoff.md} +4 -4
- package/commands/{optimize.md → optimize-run.md} +1 -1
- package/commands/{agent-rally-point.md → rally-point.md} +3 -3
- package/commands/{research.md → research-run.md} +1 -1
- package/commands/setup-memory.md +32 -0
- package/commands/{plan-verify.md → verify-plan.md} +1 -1
- package/docs/agent-surface-policy.md +38 -20
- package/docs/memory-setup.md +19 -14
- package/hooks/_resolve_python.sh +28 -0
- package/hooks/closeout.sh +58 -0
- package/hooks/git/pre-push +69 -6
- package/hooks/hooks.json +34 -13
- package/hooks/post-push-closeout.sh +73 -0
- package/hooks/session-start-closeout.sh +69 -0
- package/hooks/session-start-memory.sh +27 -0
- package/hooks/session-start-plugin-heal.sh +33 -0
- package/hooks/test_closeout.sh +124 -0
- package/package.json +7 -4
- package/plugin-artifacts/codex/.codex-plugin/plugin.json +41 -0
- package/plugin-artifacts/codex/AGENTS.md +507 -0
- package/plugin-artifacts/codex/BUILD-ARTIFACT.md +5 -0
- package/plugin-artifacts/codex/LICENSE +202 -0
- package/plugin-artifacts/codex/README.md +404 -0
- package/plugin-artifacts/codex/docs/agent-surface-policy.md +63 -0
- package/plugin-artifacts/codex/references/advisor-dispatch-ladder.md +62 -0
- package/plugin-artifacts/codex/references/agent-role-taxonomy.md +135 -0
- package/plugin-artifacts/codex/references/autonomous-and-per-commit-modes.md +161 -0
- package/plugin-artifacts/codex/references/autonomy-config.md +231 -0
- package/plugin-artifacts/codex/references/capability-routing.md +182 -0
- package/plugin-artifacts/codex/references/codex-subagents.md +106 -0
- package/plugin-artifacts/codex/references/coordination-file-template.md +181 -0
- package/plugin-artifacts/codex/references/coordination-rules.md +283 -0
- package/plugin-artifacts/codex/references/dogfood-reload-checkpoint.md +112 -0
- package/plugin-artifacts/codex/references/halt-and-ask-protocol.md +102 -0
- package/plugin-artifacts/codex/references/implementer-envelope-schema.md +302 -0
- package/plugin-artifacts/codex/references/intent-capability-pack.md +257 -0
- package/plugin-artifacts/codex/references/intent-exploration-prompts.md +96 -0
- package/plugin-artifacts/codex/references/leadership.md +72 -0
- package/plugin-artifacts/codex/references/memory-systems.md +238 -0
- package/plugin-artifacts/codex/references/memory.md +299 -0
- package/plugin-artifacts/codex/references/model-tier-mapping.md +257 -0
- package/plugin-artifacts/codex/references/modular-systems-pack.md +96 -0
- package/plugin-artifacts/codex/references/phase-1-assess.md +231 -0
- package/plugin-artifacts/codex/references/phase-2-plan.md +78 -0
- package/plugin-artifacts/codex/references/phase-3-execute.md +49 -0
- package/plugin-artifacts/codex/references/phase-4-review.md +322 -0
- package/plugin-artifacts/codex/references/phase-5-iterate.md +71 -0
- package/plugin-artifacts/codex/references/phase-6-learn.md +56 -0
- package/plugin-artifacts/codex/references/recent-design-structures.md +274 -0
- package/plugin-artifacts/codex/references/research-trigger-policy.md +140 -0
- package/plugin-artifacts/codex/references/runtime-smoke-triggers.md +42 -0
- package/plugin-artifacts/codex/references/self-review.md +234 -0
- package/plugin-artifacts/codex/references/single-writer-commit-protocol.md +90 -0
- package/plugin-artifacts/codex/references/task-capture-policy.md +68 -0
- package/plugin-artifacts/codex/references/ui-io-contract.md +116 -0
- package/plugin-artifacts/codex/references/ui-spotcheck-protocol.md +65 -0
- package/plugin-artifacts/codex/skills/build-loop/SKILL.md +372 -0
- package/plugin-artifacts/codex/skills/build-loop/detect-plugins.mjs +82 -0
- package/plugin-artifacts/codex/skills/build-loop/eval-guide.md +65 -0
- package/plugin-artifacts/codex/skills/build-loop/fallbacks.md +549 -0
- package/plugin-artifacts/codex/skills/build-loop/phases/fact-check.md +42 -0
- package/plugin-artifacts/codex/skills/build-loop/phases/ui-validation.md +267 -0
- package/plugin-artifacts/codex/skills/build-loop/references/advisor-dispatch-ladder.md +62 -0
- package/plugin-artifacts/codex/skills/build-loop/references/apple-native-planning.md +439 -0
- package/plugin-artifacts/codex/skills/build-loop/references/autonomous-and-per-commit-modes.md +161 -0
- package/plugin-artifacts/codex/skills/build-loop/references/capability-routing.md +182 -0
- package/plugin-artifacts/codex/skills/build-loop/references/codex-subagents.md +106 -0
- package/plugin-artifacts/codex/skills/build-loop/references/coordination.md +161 -0
- package/plugin-artifacts/codex/skills/build-loop/references/correction-aware-capture.md +177 -0
- package/plugin-artifacts/codex/skills/build-loop/references/independent-auditor.md +72 -0
- package/plugin-artifacts/codex/skills/build-loop/references/intent-capability-pack.md +257 -0
- package/plugin-artifacts/codex/skills/build-loop/references/intent-exploration-prompts.md +96 -0
- package/plugin-artifacts/codex/skills/build-loop/references/leadership.md +72 -0
- package/plugin-artifacts/codex/skills/build-loop/references/memory.md +299 -0
- package/plugin-artifacts/codex/skills/build-loop/references/modular-systems-pack.md +96 -0
- package/plugin-artifacts/codex/skills/build-loop/references/output-style.md +129 -0
- package/plugin-artifacts/codex/skills/build-loop/references/pay-it-forward-arch.md +98 -0
- package/plugin-artifacts/codex/skills/build-loop/references/phase-1-assess.md +231 -0
- package/plugin-artifacts/codex/skills/build-loop/references/phase-2-plan.md +78 -0
- package/plugin-artifacts/codex/skills/build-loop/references/phase-3-execute.md +49 -0
- package/plugin-artifacts/codex/skills/build-loop/references/phase-4-review.md +322 -0
- package/plugin-artifacts/codex/skills/build-loop/references/phase-5-iterate.md +71 -0
- package/plugin-artifacts/codex/skills/build-loop/references/phase-6-learn.md +56 -0
- package/plugin-artifacts/codex/skills/build-loop/references/recent-design-structures.md +274 -0
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/ASSESSMENT.md +85 -0
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/STANDALONE_TEST_RUN.md +149 -0
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/01-simple-bugfix.md +32 -0
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/02-ui-build-with-iteration.md +48 -0
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/03-multi-failure-escalation.md +60 -0
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/04-ui-build-ibr-absent.md +51 -0
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/05-refactor-navgator-absent.md +71 -0
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/06-recurring-bug-debugger-absent.md +52 -0
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/trace-comparison.md +202 -0
- package/plugin-artifacts/codex/skills/build-loop/references/self-recursive-dev.md +77 -0
- package/plugin-artifacts/codex/skills/build-loop/references/self-review.md +234 -0
- package/plugin-artifacts/codex/skills/build-loop/references/ui-io-contract.md +116 -0
- package/plugin-artifacts/codex/skills/build-loop/scanners/audit-design-rules.mjs +476 -0
- package/plugin-artifacts/codex/skills/build-loop/scanners/require-visual-evidence.mjs +239 -0
- package/plugin-artifacts/codex/skills/build-loop/templates/backlog-item.md +35 -0
- package/plugin-artifacts/codex/skills/build-loop/templates/codex-worker-prompt.md +100 -0
- package/plugin-artifacts/codex/skills/build-loop/templates/ui-subagent-prompt.md +179 -0
- package/plugin-artifacts/codex/skills/build-loop/templates/ux-fix-plan.md +40 -0
- package/scripts/_paths.py +59 -13
- package/scripts/build_codex_plugin_artifact.py +314 -0
- package/scripts/check_cache_sync.py +1 -1
- package/scripts/install_memory.py +11 -3
- package/scripts/prune_plugin_cache.py +225 -11
- package/scripts/sync_navgator_lessons.py +25 -0
- package/scripts/sync_plugin_cache.py +66 -36
- package/skills/agent-rally-point/SKILL.md +1 -1
- package/skills/agent-rally-watcher/SKILL.md +1 -1
- package/skills/build-loop/SKILL.md +11 -155
- package/skills/build-loop/references/advisor-dispatch-ladder.md +62 -0
- package/skills/build-loop/references/autonomous-and-per-commit-modes.md +161 -0
- package/skills/build-loop/references/capability-routing.md +4 -3
- package/skills/build-loop/references/codex-subagents.md +8 -0
- package/skills/build-loop/references/coordination.md +1 -1
- package/skills/build-loop/references/intent-capability-pack.md +88 -1
- package/skills/build-loop/references/memory.md +17 -2
- package/skills/build-loop/references/phase-1-assess.md +13 -0
- package/skills/build-loop/references/phase-2-plan.md +2 -1
- package/skills/build-loop/references/phase-3-execute.md +4 -2
- package/skills/build-loop/references/phase-4-review.md +12 -0
- package/skills/build-loop/references/phase-6-learn.md +1 -1
- package/skills/build-loop/references/self-review.md +4 -2
- package/skills/build-loop/templates/codex-worker-prompt.md +45 -34
- package/skills/handoff/SKILL.md +3 -3
- package/skills/loop-builder/SKILL.md +98 -0
- package/skills/loop-builder/presets/active-project-evidence.yaml +97 -0
- package/skills/loop-builder/presets/generic-artifact-loop.yaml +95 -0
- package/skills/loop-builder/presets/presentation-audit.yaml +96 -0
- package/skills/loop-builder/presets/research-synthesis.yaml +96 -0
- package/skills/loop-builder/presets/source-ingestion-raw-data-audit.yaml +96 -0
- package/skills/loop-builder/references/spec-format.md +80 -0
- package/skills/loop-builder/scripts/loop_builder.py +346 -0
- package/skills/model-tiering/SKILL.md +65 -40
- package/skills/optimize/SKILL.md +2 -2
- package/skills/plan-verify/SKILL.md +1 -1
- package/skills/plugin-builder/references/distribution.md +8 -0
- package/skills/research/SKILL.md +33 -6
- package/skills/runtime-parity-verification/SKILL.md +51 -0
- package/skills/spec-writing/SKILL.md +19 -0
- package/templates/memory/README.md +65 -0
- package/templates/memory/charter.md.template +46 -0
- package/templates/memory/manifest.json +24 -4
- package/codex-skills/debug-loop/SKILL.md +0 -18
- package/codex-skills/handoff/SKILL.md +0 -22
- package/codex-skills/knowledge/SKILL.md +0 -21
- package/codex-skills/optimize/SKILL.md +0 -18
- package/codex-skills/research/SKILL.md +0 -18
- /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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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",
|
|
85
|
-
cwd=str(
|
|
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
|
|
328
|
-
args
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
print(f"
|
|
332
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|