@arbiterforge/ca-pi 0.14.8 → 0.14.10
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/README.md +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +21 -0
- package/plugins/ca-pi/SPRINT.md +45 -5
- package/plugins/ca-pi/arbiter.md +39 -51
- package/plugins/ca-pi/hooks/_githooks.py +368 -39
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/doctor.py +3 -1
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/includes/redirect.md +32 -63
- package/plugins/ca-pi/includes/routing-table.md +7 -5
- package/plugins/ca-pi/includes/safety-core.md +8 -4
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/INDEX.md +1 -1
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +19 -7
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces.
|
|
|
10
10
|
<img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
|
|
11
11
|
<img alt="Codex plugin" src="https://img.shields.io/badge/OpenAI_Codex-plugin-10a37f">
|
|
12
12
|
<img alt="Pi Feature Forge preview" src="https://img.shields.io/badge/ca--pi-Feature_Forge_preview-d97757">
|
|
13
|
-
<img alt="version 2.21.
|
|
13
|
+
<img alt="version 2.21.10" src="https://img.shields.io/badge/version-2.21.10-2b7489">
|
|
14
14
|
<img alt="core lanes" src="https://img.shields.io/badge/core_lanes-18-555">
|
|
15
15
|
<img alt="skills" src="https://img.shields.io/badge/skills-23-555">
|
|
16
16
|
<img alt="agents" src="https://img.shields.io/badge/agents-19-555">
|
package/package.json
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to `ca-pi` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.14.10] - 2026-09-22
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Resolve cross-host git-hook path-form races (ADR-0038): the shared registry
|
|
12
|
+
and its generated pre-commit/pre-push shim now translate between
|
|
13
|
+
Windows-native, Git-Bash/MSYS, and WSL drvfs path spellings; the installer's
|
|
14
|
+
two-phase shim write is lock-serialized and rolls back on a partial
|
|
15
|
+
failure; a foreign-spelled trusted identity now produces a diagnostic
|
|
16
|
+
instead of a silent exit.
|
|
17
|
+
|
|
18
|
+
## [0.14.9] - 2026-09-22
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Route understood requests to the existing workflow without compulsory
|
|
23
|
+
command syntax; keep questions and draft-only requests non-mutating.
|
|
24
|
+
- Recover ordinary quality failures inside an approved sprint through
|
|
25
|
+
the original gates and current typed evidence, without adding routine
|
|
26
|
+
user checkpoints or expanding authority. HTML farm remains disabled.
|
|
27
|
+
|
|
7
28
|
## [0.14.8] - 2026-09-22
|
|
8
29
|
|
|
9
30
|
### Fixed
|
package/plugins/ca-pi/SPRINT.md
CHANGED
|
@@ -54,10 +54,11 @@ installed engine `identity`, `index`, and symbol-scoped `read` operations. For a
|
|
|
54
54
|
Markdown pair, use the existing Markdown status ledger. Resume either exact format
|
|
55
55
|
without creating or consulting a shadow artifact; the workflow must not create,
|
|
56
56
|
rename, or convert its counterpart. Do not re-brainstorm — confirm the resume
|
|
57
|
-
with the user and re-enter per `/feature`'s Resume ladder (approved spec + plan with
|
|
58
|
-
tasks → straight to Phase 2, executing only
|
|
59
|
-
`status` column or the typed engine's execution ledger is authoritative for its
|
|
60
|
-
|
|
57
|
+
with the user and re-enter per `/feature`'s Resume ladder (approved spec + plan with remaining
|
|
58
|
+
dependency-clean `PENDING` tasks → straight to Phase 2, executing only those tasks; the legacy
|
|
59
|
+
plan's `status` column or the typed engine's execution ledger is authoritative for its own format).
|
|
60
|
+
`BLOCKED` tasks remain non-selectable until their owning reconciliation path records a supported
|
|
61
|
+
transition. An interrupted sprint is re-entered, never restarted.
|
|
61
62
|
|
|
62
63
|
Route to `brainstorming` (`<plugin-root>/routines/brainstorming/SKILL.md`), scoped to a **sprint** — a coherent chunk of work (several features or one
|
|
63
64
|
goal), not a single feature. Drive it to a concrete sprint spec at the
|
|
@@ -134,7 +135,9 @@ NEVER auto-decided. Halt and surface to the user:
|
|
|
134
135
|
- Anything in `security-controls.md`; auth, crypto, secrets, or a trust-boundary change.
|
|
135
136
|
- An irreversible operation — data loss, a destructive migration, anything that cannot be rolled back.
|
|
136
137
|
- `/override` or hotfix territory — a gate bypass is never taken autonomously.
|
|
137
|
-
- A
|
|
138
|
+
- A security CRITICAL finding, or a `[CONFIRM-NN]` that requires a missing user decision.
|
|
139
|
+
An ordinary failing TDD/verification result instead takes Recovery within the approved sprint;
|
|
140
|
+
it remains blocked from acceptance until the original gate and required reviews pass.
|
|
138
141
|
- An auto-decision that would contradict an accepted ADR or a recorded deferral rationale
|
|
139
142
|
(ADR-0025). The valve, none of it auto-decided: a collision pre-ruled at the Phase 1 gate cites
|
|
140
143
|
the ruling and proceeds (that ruling was a user decision); a deferral whose recorded
|
|
@@ -147,6 +150,41 @@ Hard gates are rare BY DESIGN. If they trip repeatedly in one sprint, that is a
|
|
|
147
150
|
too thin, confidence was misplaced, or the record itself is stale — route a repeatedly-tripping
|
|
148
151
|
ADR to supersession in the summary; surface it, do not silently grind through.
|
|
149
152
|
|
|
153
|
+
## Recovery within the approved sprint
|
|
154
|
+
|
|
155
|
+
A failed TDD, coverage, lint, or fresh-verification result blocks acceptance, not authorized repair.
|
|
156
|
+
This section applies only after initial spec-and-plan approval and only inside that approved scope.
|
|
157
|
+
Classify the failure before acting: an implementation defect or stale proof takes the owning repair
|
|
158
|
+
or reconciliation path; a real authority or security block takes the named Hard gates path.
|
|
159
|
+
|
|
160
|
+
Read the diagnostics and current state, give the author a corrective brief containing the failed
|
|
161
|
+
obligation and relevant evidence, and rerun the original gate plus every invalidated review.
|
|
162
|
+
Never redispatch merely to evade a failing gate. Do not weaken tests, invent an exemption, broaden
|
|
163
|
+
scope, or accept a self-reported pass to continue. Use SMARTS and the existing sprint log for material
|
|
164
|
+
method choices, not another approval interview for an already-authorized correction.
|
|
165
|
+
|
|
166
|
+
For HTML, load `<plugin-root>/includes/artifacts.md` and use the exact current identity,
|
|
167
|
+
`eligible`, and applicable `task-reconcile` or `scope-reconcile` operations before redispatch.
|
|
168
|
+
Obtain a fresh complete context ticket and capture real policy events for every required receipt.
|
|
169
|
+
Never manufacture an approval or a receipt, edit execution cells, or create a shadow ledger.
|
|
170
|
+
An invalidated proof needs the appropriate fresh verification/review, not automatic reimplementation.
|
|
171
|
+
|
|
172
|
+
Unchanged failure plus unchanged inputs is not progress. After two consecutive corrective attempts
|
|
173
|
+
with the same failure and no new evidence, stop that retry strategy. Choose a different authorized
|
|
174
|
+
approach supported by a new diagnosis, or persist the stop before continuing independently eligible work.
|
|
175
|
+
For HTML, apply `task-block` with the exact current revision and model hash, then rerun `eligible`;
|
|
176
|
+
the blocked task and invalidated dependents must not be redispatched. For Markdown,
|
|
177
|
+
record the bounded blocked-task report in the authoritative plan and change that task's status to `BLOCKED`;
|
|
178
|
+
select only dependency-clean `PENDING` tasks whose dependencies are `ACCEPTED`.
|
|
179
|
+
`BLOCKED` tasks must not be redispatched until the owning plan/reconciliation workflow records a
|
|
180
|
+
supported transition. This is not automatically another user checkpoint. Ask only for the specific
|
|
181
|
+
missing fact or authority when that is the blocker; do not report the incomplete plan as accepted.
|
|
182
|
+
|
|
183
|
+
A `commit-gate` refusal returns to its named repair/reconciliation prerequisite. Recovery does not grant commit, provider, spending, disclosure, or publication authority. Security CRITICAL findings,
|
|
184
|
+
unresolved `[CONFIRM-NN]` decisions, irreversible operations, and all named Hard gates remain stops.
|
|
185
|
+
HTML farm remains disabled. This recovery path does not change farm-only intent or authorize a
|
|
186
|
+
silent premium fallback, another provider, or more spending after a farm circuit-breaker abort.
|
|
187
|
+
|
|
150
188
|
## Phase 3 — Land & summarize · gate: BLOCK
|
|
151
189
|
|
|
152
190
|
On plan completion, route the branch through `commit-gate`, then `finishing-a-development-branch` —
|
|
@@ -169,4 +207,6 @@ never on a no-op run.
|
|
|
169
207
|
- MUST NOT merge to the default branch or discard autonomously — auto-select open-PR only.
|
|
170
208
|
- MUST NOT inherit `decision-variance`'s Rule 1 — `/sprint` decides as the user, reusing only the SMARTS scoring.
|
|
171
209
|
- MUST surface a repeated hard-gate-trip pattern as a planning/confidence signal, not grind past it.
|
|
210
|
+
- MUST diagnose and repair ordinary in-scope quality failures through Recovery within the approved sprint;
|
|
211
|
+
never auto-pass a gate or treat recovery as additional authority.
|
|
172
212
|
- MUST, at sprint close, run the follow-up harvest (`<plugin-root>/includes/harvest.md`) in autonomous mode — auto-promote `confidence: low` `sprint-log.md` decisions and any open `[NEEDS-TRIAGE]` to `open-tasks.md` (work) / `open-questions.md` (decisions), each promotion SMARTS-scored and logged. A blocking decision is never auto-promoted — it escalates.
|
package/plugins/ca-pi/arbiter.md
CHANGED
|
@@ -9,10 +9,9 @@ map, and skill/routine bodies load on demand from <plugin-root>/. -->
|
|
|
9
9
|
|
|
10
10
|
# codeArbiter
|
|
11
11
|
|
|
12
|
-
You are codeArbiter.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
decisions.
|
|
12
|
+
You are codeArbiter. Interpret the user's goal, select the existing owner, and preserve its scope,
|
|
13
|
+
authorization, evidence, and delivery gates. Explicit commands are optional entry points, not
|
|
14
|
+
prerequisites for understanding a request. Be decisive about methods and precise about uncertainty.
|
|
16
15
|
|
|
17
16
|
**Register.** Terse by default: state the rule, hold the line, move on. At a *close* (a shipped
|
|
18
17
|
branch, a sprint wrap) or a *genuine caught finding the user then fixed*, you MAY add **exactly one**
|
|
@@ -35,29 +34,18 @@ no emojis, no flattery.
|
|
|
35
34
|
|
|
36
35
|
## §0 — Non-negotiables
|
|
37
36
|
|
|
38
|
-
Route
|
|
39
|
-
|
|
40
|
-
(`/ca-btw` is the only exception). safety-core's anti-circumvention rule governs this
|
|
41
|
-
document too: it binds by what it protects, not by its spelling.
|
|
37
|
+
Route through an existing owner; natural-language requests retain its scope, authorization, and
|
|
38
|
+
evidence requirements. Command-free is not off-channel. Safety-core binds by protection, not spelling.
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
not
|
|
45
|
-
|
|
46
|
-
| excuse | reality |
|
|
47
|
-
|---|---|
|
|
48
|
-
| "It looks good." | Looking good is not permission — the gate's evidence is. |
|
|
49
|
-
| "Too small for the lane." | Small is a lane parameter, not an exemption — triage exists to say so on the record. |
|
|
50
|
-
| "The user is in a hurry." | Hurry compresses the asking, never the gate: decide more, batch harder, skip nothing. |
|
|
51
|
-
| "I already know what the reviewer will find." | Then the dispatch is cheap, and the record still needs it. Prediction is not review. |
|
|
52
|
-
| "The suite was green earlier." | Freshness beats memory: rerun the instrument, don't recall it (safety-core). |
|
|
53
|
-
| "No command owns this." | A routing gap is surfaced, never papered over with `/ca-override`. |
|
|
40
|
+
Small work still follows its selected lane. Urgency never waives a gate. A predicted review or
|
|
41
|
+
previously green suite is not current evidence; a missing owner is a routing gap, not an override.
|
|
54
42
|
|
|
55
43
|
---
|
|
56
44
|
|
|
57
45
|
## §0.1 — Terminology lock
|
|
58
46
|
|
|
59
47
|
- **skill** — an orchestrator routine with **phases**; routed to. **agent** — a reviewer/author; **dispatched** by a skill. **phase** — a step inside a skill. **stage** — a project maturity value in `.codearbiter/CONTEXT.md`. **gate** — a phase exit condition (STOP/BLOCK). **severity** — a finding class (CRITICAL/HIGH/MEDIUM/LOW), separate from gate action.
|
|
60
|
-
- The user **invokes** `$ca-command`; the orchestrator **routes** to
|
|
48
|
+
- The user states a goal or **invokes** `$ca-command`; the orchestrator **routes** to its owner; a skill **dispatches** agents.
|
|
61
49
|
- Hard-rule modals: **MUST / MUST NOT / MAY / SHOULD** only. Exactly two bracketed markers exist: `[CONFIRM-NN]` (an unresolved unknown only the user can answer; numbered, lives in `open-questions.md`) and `[NEEDS-TRIAGE]` (an out-of-scope finding set aside inline, never acted on in place).
|
|
62
50
|
|
|
63
51
|
**Paths.** Framework: `<plugin-root>/` (`arbiter.md`, `skills/` — the user-invocable
|
|
@@ -89,33 +77,39 @@ never loads it on this session's behalf.
|
|
|
89
77
|
Before acting on a scope-touch (auth/crypto/secrets, dependencies, migrations, telemetry,
|
|
90
78
|
decisions), read the governing `.codearbiter/*.md` doc first and route to the owning skill/agent.
|
|
91
79
|
The full reference map and routing table live at `<plugin-root>/includes/reference-map.md`
|
|
92
|
-
and `<plugin-root>/includes/routing-table.md` — load them
|
|
80
|
+
and `<plugin-root>/includes/routing-table.md` — load them when a scope-touch or route needs them,
|
|
93
81
|
not every turn. `<plugin-root>/COMMANDS.md` is the command catalog.
|
|
94
82
|
|
|
95
83
|
---
|
|
96
84
|
|
|
97
85
|
## §6 — User interaction
|
|
98
86
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
87
|
+
Select by requested outcome and active workflow state, not by command spelling. Keep the three
|
|
88
|
+
routing tiers (ADR-0022), distinguishing uncertainty about the goal from choice of internal method:
|
|
89
|
+
|
|
90
|
+
1. **Unambiguous and non-destructive** — select and load the existing owner, then apply its gates.
|
|
91
|
+
Name the route briefly when doing work; do not ask the user to select or retype it.
|
|
92
|
+
2. **Probable** — a material part of the requested outcome or required authority is missing.
|
|
93
|
+
Inspect available evidence first, then ask once about that missing fact or decision.
|
|
94
|
+
3. **Genuinely unclear** — use `<plugin-root>/includes/redirect.md` to ask about the intended
|
|
95
|
+
outcome. Offer meaningful alternatives, not a compulsory command menu.
|
|
96
|
+
|
|
97
|
+
**Multiple reasonable internal methods are not ambiguous user intent.** Choose among them within
|
|
98
|
+
safety-core's decision-authority limits. A second plausible route alone is not a reason to ask.
|
|
99
|
+
Do not invent a product decision, resolve a `[CONFIRM-NN]`, or assume missing authorization.
|
|
100
|
+
|
|
101
|
+
**Questions are not mutation authority.** Explain a commit without creating one; draft a commit
|
|
102
|
+
message without staging or committing when asked to draft only. A question about whether to add a
|
|
103
|
+
feature is not an implementation request. Conversely, "can you commit these changes?" can request
|
|
104
|
+
an action: classify the intended outcome and explicit restrictions, not punctuation alone. Use
|
|
105
|
+
read-only evidence gathering for answers; do not select a procedure's writing close by accident.
|
|
106
|
+
|
|
107
|
+
**Load the owner, not another ceremony.** Direct skill/resource references may replace a redundant
|
|
108
|
+
entry hop only when its arguments, modes, prerequisites, and gates are preserved. If the wrapper
|
|
109
|
+
owns distinct behavior, load it too. A description is a discovery hint, never the complete procedure.
|
|
110
|
+
Keep private procedures path-loaded and avoid bulk context reads. Respect the active call extent:
|
|
111
|
+
return a scoped result, forward a completed authorized stage, or branch for an allowed prerequisite.
|
|
112
|
+
Neither universal return-to-caller nor unconditional commit-to-PR is correct.
|
|
119
113
|
|
|
120
114
|
**Clarity and risk are separate axes.** Tier 1 requires BOTH unambiguous intent AND a non-destructive
|
|
121
115
|
command. Anything irreversible or gate-bypassing drops to tier 2 and asks, even when the intent is
|
|
@@ -134,18 +128,12 @@ A deterministic mode-token flip (`mode --dangerous`, `mode --ops`) is friction,
|
|
|
134
128
|
not in this set — ADR-0030 supersedes ADR-0022's tier-2 confirmation clause for dangerous-mode entry
|
|
135
129
|
alone; the other four members are unchanged.
|
|
136
130
|
|
|
137
|
-
**
|
|
138
|
-
|
|
139
|
-
recommendation AND the strongest consideration against it — a bare recommendation anchors; the
|
|
140
|
-
counter-case is what makes the choice real. Batch independent questions into one round. Safety-core's
|
|
141
|
-
decision-authority rule governs which parameters are yours to decide alone; an uncertain
|
|
142
|
-
classification is still a fork, and forks are asked.
|
|
131
|
+
**Ask user decisions fully, once.** Give a recommendation and its strongest counter-consideration;
|
|
132
|
+
batch independent questions. Ask for missing outcomes, facts, or authority, not internal methods.
|
|
143
133
|
|
|
144
|
-
|
|
145
|
-
command; it does not improvise the operation. When no command owns an operation, that is a
|
|
146
|
-
routing gap to surface.
|
|
134
|
+
The orchestrator does not improvise unowned operations: surface a routing gap, never an override.
|
|
147
135
|
|
|
148
|
-
**`/ca-btw "question"`**
|
|
136
|
+
**`/ca-btw "question"`** remains an explicit Q&A convenience, not a prerequisite for asking.
|
|
149
137
|
|
|
150
138
|
---
|
|
151
139
|
|
|
@@ -270,6 +270,146 @@ def _shell_path(path):
|
|
|
270
270
|
return path.replace("\\", "/")
|
|
271
271
|
|
|
272
272
|
|
|
273
|
+
# ADR-0038: a bounded, finite translator between the three known spellings of
|
|
274
|
+
# an absolute path on a Windows drive letter -- Windows-native (`C:/...`),
|
|
275
|
+
# Git-Bash/MSYS (`/c/...`), and WSL drvfs (`/mnt/c/...`). This is the ONE
|
|
276
|
+
# place cross-host path-form resolution happens; every caller (the Python
|
|
277
|
+
# registry checks below, and the shell text `_CX_RESOLVE_SH` embedded into
|
|
278
|
+
# the generated shim) must implement this SAME grammar, never re-derive it.
|
|
279
|
+
# Scope is deliberately closed: a path matching none of the three patterns
|
|
280
|
+
# (e.g. a WSL-native /home/... path with no Windows-drive equivalent) is left
|
|
281
|
+
# as its own sole candidate -- this is not a general host-layout search.
|
|
282
|
+
_WIN_DRIVE = re.compile(r'^([A-Za-z]):[/\\](.*)$')
|
|
283
|
+
_GITBASH_DRIVE = re.compile(r'^/([A-Za-z])/(.*)$')
|
|
284
|
+
_WSL_DRIVE = re.compile(r'^/mnt/([A-Za-z])/(.*)$')
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _path_form_candidates(path):
|
|
288
|
+
"""Every spelling of `path` worth testing for existence (ADR-0038): the
|
|
289
|
+
host-native absolute form first, then the other translated forms when
|
|
290
|
+
`path` matches one of the three known Windows-drive spellings. Pure -- no
|
|
291
|
+
filesystem access.
|
|
292
|
+
|
|
293
|
+
A native Windows-drive spelling ("C:/...") is only ever offered as a
|
|
294
|
+
checkable candidate when this interpreter is itself native Windows
|
|
295
|
+
(`os.name == "nt"`): on a genuine POSIX interpreter (real WSL, Linux) that
|
|
296
|
+
spelling is not absolute at all and a caller testing it with
|
|
297
|
+
`os.path.isfile` would have it silently reinterpreted as relative to the
|
|
298
|
+
current directory -- a foreign-spelled entry could then resolve to an
|
|
299
|
+
unrelated, attacker-plantable relative path instead of correctly falling
|
|
300
|
+
through to the POSIX-absolute translated forms. Git-Bash resolves the
|
|
301
|
+
same file via its own "/<drive>/..." candidate below regardless, so
|
|
302
|
+
nothing is lost by withholding the native spelling there."""
|
|
303
|
+
normalized = _shell_path(path)
|
|
304
|
+
on_windows = os.name == "nt"
|
|
305
|
+
m = _WSL_DRIVE.match(normalized)
|
|
306
|
+
if m:
|
|
307
|
+
drive, rest = m.group(1).lower(), m.group(2)
|
|
308
|
+
if on_windows:
|
|
309
|
+
return [f"{drive.upper()}:/{rest}", normalized, f"/{drive}/{rest}"]
|
|
310
|
+
return [normalized, f"/{drive}/{rest}"]
|
|
311
|
+
m = _GITBASH_DRIVE.match(normalized)
|
|
312
|
+
if m:
|
|
313
|
+
drive, rest = m.group(1).lower(), m.group(2)
|
|
314
|
+
if on_windows:
|
|
315
|
+
return [f"{drive.upper()}:/{rest}", normalized, f"/mnt/{drive}/{rest}"]
|
|
316
|
+
return [normalized, f"/mnt/{drive}/{rest}"]
|
|
317
|
+
m = _WIN_DRIVE.match(normalized)
|
|
318
|
+
if m:
|
|
319
|
+
drive, rest = m.group(1).lower(), m.group(2)
|
|
320
|
+
candidates = [f"/{drive}/{rest}", f"/mnt/{drive}/{rest}"]
|
|
321
|
+
if on_windows:
|
|
322
|
+
candidates.insert(0, normalized)
|
|
323
|
+
return candidates
|
|
324
|
+
return [normalized]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _resolve_live(path):
|
|
328
|
+
"""The first candidate spelling of `path` (ADR-0038) naming an existing
|
|
329
|
+
regular file, or None if none does. The ONE Python-side entry point for
|
|
330
|
+
cross-host file resolution -- callers must never re-derive candidates."""
|
|
331
|
+
for candidate in _path_form_candidates(path):
|
|
332
|
+
if os.path.isfile(candidate):
|
|
333
|
+
return candidate
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _resolve_live_dir(path):
|
|
338
|
+
"""Directory analogue of `_resolve_live` (ADR-0038) -- used to recognize
|
|
339
|
+
that a sibling host's differently-spelled drop-in-dir string names the
|
|
340
|
+
SAME real directory this host resolves, so a shim differing only in that
|
|
341
|
+
spelling is not treated as stale (B3/#684 churn)."""
|
|
342
|
+
for candidate in _path_form_candidates(path):
|
|
343
|
+
if os.path.isdir(candidate):
|
|
344
|
+
return candidate
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# The bounded, finite shell-side translator (ADR-0038), using POSIX path
|
|
349
|
+
# semantics like `_path_form_candidates` on POSIX -- embedded verbatim into
|
|
350
|
+
# every generated shim so the REAL git-hook execution path (not just Python
|
|
351
|
+
# diagnostics) resolves a foreign-but-translatable spelling instead of
|
|
352
|
+
# failing closed on it. Pure POSIX sh (`case`, `cut`, `tr`, parameter
|
|
353
|
+
# expansion) -- no extra process spawn beyond the cheap utilities already
|
|
354
|
+
# used elsewhere in this shim. Prints the resolved path and returns 0 on the
|
|
355
|
+
# first candidate that exists; returns 1 with nothing printed when NONE of
|
|
356
|
+
# the (as-is, plus up to two translated) candidates resolves -- callers must
|
|
357
|
+
# treat that as "does not exist," preserving ADR-0014's fail-closed contract.
|
|
358
|
+
_CX_RESOLVE_SH = (
|
|
359
|
+
"_cx_resolve() {\n"
|
|
360
|
+
" P=$1\n"
|
|
361
|
+
" K=-f\n"
|
|
362
|
+
' [ "${2:-}" = dir ] && K=-d\n'
|
|
363
|
+
' case "$P" in\n'
|
|
364
|
+
" /mnt/[A-Za-z]/*)\n"
|
|
365
|
+
' [ "$K" "$P" ] && { printf \'%s\' "$P"; return 0; }\n'
|
|
366
|
+
' REST=${P#/mnt/?/}\n'
|
|
367
|
+
' DR=$(printf \'%s\' "$P" | cut -c6)\n'
|
|
368
|
+
' DRL=$(printf \'%s\' "$DR" | tr \'A-Z\' \'a-z\')\n'
|
|
369
|
+
' DRU=$(printf \'%s\' "$DR" | tr \'a-z\' \'A-Z\')\n'
|
|
370
|
+
' A1="/$DRL/$REST"; A2="$DRU:/$REST"\n'
|
|
371
|
+
" ;;\n"
|
|
372
|
+
" /[A-Za-z]/*)\n"
|
|
373
|
+
' [ "$K" "$P" ] && { printf \'%s\' "$P"; return 0; }\n'
|
|
374
|
+
' REST=${P#/?/}\n'
|
|
375
|
+
' DR=$(printf \'%s\' "$P" | cut -c2)\n'
|
|
376
|
+
' DRL=$(printf \'%s\' "$DR" | tr \'A-Z\' \'a-z\')\n'
|
|
377
|
+
' DRU=$(printf \'%s\' "$DR" | tr \'a-z\' \'A-Z\')\n'
|
|
378
|
+
' A1="/mnt/$DRL/$REST"; A2="$DRU:/$REST"\n'
|
|
379
|
+
" ;;\n"
|
|
380
|
+
" [A-Za-z]:/*)\n"
|
|
381
|
+
" # Deliberately do NOT test \"$P\" as-is here (unlike the two\n"
|
|
382
|
+
" # branches above): on a genuine POSIX sh (real WSL, Linux), a\n"
|
|
383
|
+
" # drive-letter-native spelling like \"C:/...\" is not absolute at\n"
|
|
384
|
+
" # all -- it is silently reinterpreted as relative to CWD, which\n"
|
|
385
|
+
" # could select an attacker-plantable file at ./C:/... instead of\n"
|
|
386
|
+
" # correctly falling through to the translated candidates below.\n"
|
|
387
|
+
" # Git-Bash resolves this same spelling too, but via A1 (\"/<drive>/\n"
|
|
388
|
+
" # ...\"), which Git-Bash ALSO resolves -- so nothing is lost by\n"
|
|
389
|
+
" # withholding the raw as-is test for this one grammar.\n"
|
|
390
|
+
' DR=$(printf \'%s\' "$P" | cut -c1 | tr \'A-Z\' \'a-z\')\n'
|
|
391
|
+
" # ??: (two wildcards, literal colon) never matches a drive-letter\n"
|
|
392
|
+
" # path like C:/... (its 3rd char is / , not the required literal\n"
|
|
393
|
+
" # ':'), so it left REST completely unstripped -- a confirmed\n"
|
|
394
|
+
" # security-review regression (fixed before merge, never released).\n"
|
|
395
|
+
" # ?:/ (one wildcard drive letter, literal ':', literal '/') is the\n"
|
|
396
|
+
" # correct 3-char match for this branch's own case pattern.\n"
|
|
397
|
+
' REST=${P#?:/}\n'
|
|
398
|
+
' REST=${REST#/}\n'
|
|
399
|
+
' A1="/$DR/$REST"; A2="/mnt/$DR/$REST"\n'
|
|
400
|
+
" ;;\n"
|
|
401
|
+
" *)\n"
|
|
402
|
+
' [ "$K" "$P" ] && { printf \'%s\' "$P"; return 0; }\n'
|
|
403
|
+
" return 1\n"
|
|
404
|
+
" ;;\n"
|
|
405
|
+
" esac\n"
|
|
406
|
+
' [ "$K" "$A1" ] && { printf \'%s\' "$A1"; return 0; }\n'
|
|
407
|
+
' [ "$K" "$A2" ] && { printf \'%s\' "$A2"; return 0; }\n'
|
|
408
|
+
" return 1\n"
|
|
409
|
+
"}\n"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
273
413
|
def _path_entry_current(dropin_dir, plugin, enforcer):
|
|
274
414
|
"""True iff this plugin's own drop-in entry already names `enforcer`."""
|
|
275
415
|
existing = _read(_path_entry_file(dropin_dir, plugin))
|
|
@@ -389,6 +529,37 @@ _FRESHNESS_PY = (
|
|
|
389
529
|
"except OSError:\n"
|
|
390
530
|
" names = []\n"
|
|
391
531
|
"legacy = re.compile(r'^[0-9]+\\.[0-9]+\\.[0-9]+$')\n"
|
|
532
|
+
# ADR-0038: mirrors `_path_form_candidates`/`_resolve_live` exactly (a
|
|
533
|
+
# foreign-spelled but resolvable entry must not be treated as absent
|
|
534
|
+
# here, or a stale sibling would go unmarked). A native Windows-drive
|
|
535
|
+
# spelling ("C:/...") is only offered as a checkable candidate when this
|
|
536
|
+
# interpreter itself is native Windows -- on a genuine POSIX shell/
|
|
537
|
+
# interpreter that spelling is not absolute and would otherwise be
|
|
538
|
+
# silently reinterpreted as relative to CWD.
|
|
539
|
+
"def _resolve(p):\n"
|
|
540
|
+
" p = p.replace('\\\\', '/')\n"
|
|
541
|
+
" m = re.match(r'^/mnt/([A-Za-z])/(.*)$', p)\n"
|
|
542
|
+
" if m:\n"
|
|
543
|
+
" dr, rest = m.group(1).lower(), m.group(2)\n"
|
|
544
|
+
" cands = [p, '/' + dr + '/' + rest]\n"
|
|
545
|
+
" if os.name == 'nt':\n"
|
|
546
|
+
" cands.append(dr.upper() + ':/' + rest)\n"
|
|
547
|
+
" return any(os.path.isfile(c) for c in cands)\n"
|
|
548
|
+
" m = re.match(r'^/([A-Za-z])/(.*)$', p)\n"
|
|
549
|
+
" if m:\n"
|
|
550
|
+
" dr, rest = m.group(1).lower(), m.group(2)\n"
|
|
551
|
+
" cands = [p, '/mnt/' + dr + '/' + rest]\n"
|
|
552
|
+
" if os.name == 'nt':\n"
|
|
553
|
+
" cands.append(dr.upper() + ':/' + rest)\n"
|
|
554
|
+
" return any(os.path.isfile(c) for c in cands)\n"
|
|
555
|
+
" m = re.match(r'^([A-Za-z]):[/\\\\](.*)$', p)\n"
|
|
556
|
+
" if m:\n"
|
|
557
|
+
" dr, rest = m.group(1).lower(), m.group(2)\n"
|
|
558
|
+
" cands = ['/' + dr + '/' + rest, '/mnt/' + dr + '/' + rest]\n"
|
|
559
|
+
" if os.name == 'nt':\n"
|
|
560
|
+
" cands.insert(0, p)\n"
|
|
561
|
+
" return any(os.path.isfile(c) for c in cands)\n"
|
|
562
|
+
" return os.path.isfile(p)\n"
|
|
392
563
|
"def _rd(p):\n"
|
|
393
564
|
" try:\n"
|
|
394
565
|
" with open(p, encoding='utf-8', errors='replace') as f:\n"
|
|
@@ -403,7 +574,7 @@ _FRESHNESS_PY = (
|
|
|
403
574
|
" if legacy.fullmatch(plugin):\n"
|
|
404
575
|
" continue\n"
|
|
405
576
|
" path_val = _rd(os.path.join(d, n))\n"
|
|
406
|
-
" if not path_val or not
|
|
577
|
+
" if not path_val or not _resolve(path_val.strip()):\n"
|
|
407
578
|
" continue\n"
|
|
408
579
|
" seen_file = os.path.join(d, plugin + '.seen')\n"
|
|
409
580
|
# `.seen` only counts as a confirmation of what's registered RIGHT NOW when
|
|
@@ -478,7 +649,7 @@ def live_registered_plugins(dropin_dir):
|
|
|
478
649
|
if legacy.fullmatch(plugin):
|
|
479
650
|
continue
|
|
480
651
|
path_val = _read(os.path.join(dropin_dir, name))
|
|
481
|
-
if path_val and
|
|
652
|
+
if path_val and _resolve_live(path_val.strip()):
|
|
482
653
|
live.append(plugin)
|
|
483
654
|
return live
|
|
484
655
|
|
|
@@ -498,9 +669,11 @@ def _read_trusted_identity(dropin_dir):
|
|
|
498
669
|
if len(lines) != 3 or not lines[2]:
|
|
499
670
|
return None
|
|
500
671
|
python_path, git_path, owner = lines
|
|
501
|
-
|
|
672
|
+
resolved_python = _resolve_live(python_path)
|
|
673
|
+
resolved_git = _resolve_live(git_path)
|
|
674
|
+
if not resolved_python or not resolved_git:
|
|
502
675
|
return None
|
|
503
|
-
return
|
|
676
|
+
return resolved_python, resolved_git, owner
|
|
504
677
|
|
|
505
678
|
|
|
506
679
|
def _refresh_trusted_identity(dropin_dir, plugin):
|
|
@@ -583,17 +756,33 @@ def _shim(dropin_dir, phase):
|
|
|
583
756
|
"#!/bin/sh\n"
|
|
584
757
|
f"{SENTINEL}\n"
|
|
585
758
|
f"{SHIM_NOTICE}\n"
|
|
759
|
+
f"{_CX_RESOLVE_SH}"
|
|
586
760
|
f"D={quote(_shell_path(dropin_dir))}\n"
|
|
761
|
+
'D=$(_cx_resolve "$D" dir) || {\n'
|
|
762
|
+
' echo "codeArbiter: hook registry directory could not be resolved '
|
|
763
|
+
'across host path forms -- failing CLOSED." >&2\n'
|
|
764
|
+
' exit 1\n'
|
|
765
|
+
'}\n'
|
|
587
766
|
f'if [ -e "$D/{_TRUSTED_IDENTITY_FILE}" ] || [ -L "$D/{_TRUSTED_IDENTITY_FILE}" ]; then\n'
|
|
588
767
|
f' [ -f "$D/{_TRUSTED_IDENTITY_FILE}" ] || exit 1\n'
|
|
589
768
|
f' exec 3< "$D/{_TRUSTED_IDENTITY_FILE}" || exit 1\n'
|
|
590
|
-
' IFS= read -r
|
|
591
|
-
' IFS= read -r
|
|
769
|
+
' IFS= read -r PY_RAW <&3 || exit 1\n'
|
|
770
|
+
' IFS= read -r G_RAW <&3 || exit 1\n'
|
|
592
771
|
' IFS= read -r IDENTITY_OWNER <&3 || exit 1\n'
|
|
593
772
|
" IDENTITY_EXTRA=''\n"
|
|
594
773
|
' if IFS= read -r IDENTITY_EXTRA <&3 || [ -n "$IDENTITY_EXTRA" ]; then exit 1; fi\n'
|
|
595
774
|
' exec 3<&-\n'
|
|
596
|
-
' [ -n "$IDENTITY_OWNER" ]
|
|
775
|
+
' [ -n "$IDENTITY_OWNER" ] || exit 1\n'
|
|
776
|
+
' PY=$(_cx_resolve "$PY_RAW") || {\n'
|
|
777
|
+
' echo "codeArbiter: trusted python executable \\"$PY_RAW\\" could not be '
|
|
778
|
+
'resolved (tried cross-host path-form candidates, ADR-0038) -- failing CLOSED." >&2\n'
|
|
779
|
+
" exit 1\n"
|
|
780
|
+
" }\n"
|
|
781
|
+
' G=$(_cx_resolve "$G_RAW") || {\n'
|
|
782
|
+
' echo "codeArbiter: trusted git executable \\"$G_RAW\\" could not be '
|
|
783
|
+
'resolved (tried cross-host path-form candidates, ADR-0038) -- failing CLOSED." >&2\n'
|
|
784
|
+
" exit 1\n"
|
|
785
|
+
" }\n"
|
|
597
786
|
' export CODEARBITER_GIT_EXECUTABLE="$G"\n'
|
|
598
787
|
' export CODEARBITER_PYTHON_EXECUTABLE="$PY"\n'
|
|
599
788
|
"else\n"
|
|
@@ -619,7 +808,7 @@ def _shim(dropin_dir, phase):
|
|
|
619
808
|
' case "$N" in [0-9]*.[0-9]*.[0-9]*.path) continue ;; esac\n'
|
|
620
809
|
' case " $SKIP " in *" ${N%.path} "*) continue ;; esac\n'
|
|
621
810
|
' IFS= read -r E < "$c" || continue\n'
|
|
622
|
-
'
|
|
811
|
+
' E=$(_cx_resolve "$E") || continue\n'
|
|
623
812
|
' SEEN=1\n'
|
|
624
813
|
f' {invoke}'
|
|
625
814
|
' RC=$?\n'
|
|
@@ -786,14 +975,82 @@ def _hooks_current(hd, dropin_dir):
|
|
|
786
975
|
plugin's own enforcer path. So a plugin-version bump that only changes
|
|
787
976
|
`_enforcer_path()` does NOT make this return False; install() refreshes
|
|
788
977
|
the plugin's OWN drop-in `.path` entry unconditionally every call,
|
|
789
|
-
independent of whether this check short-circuits the shim-file rewrite.
|
|
978
|
+
independent of whether this check short-circuits the shim-file rewrite.
|
|
979
|
+
|
|
980
|
+
B3/#684 (ADR-0038): the embedded `D=` line is host-LOCAL by necessity —
|
|
981
|
+
each host's shell needs a spelling IT can actually glob the drop-in dir
|
|
982
|
+
with, so two hosts sharing one repo legitimately compute two different
|
|
983
|
+
(but equally correct) `D=` strings for the very same directory. A raw
|
|
984
|
+
whole-body string compare therefore always disagreed across alternating
|
|
985
|
+
SessionStarts, reinstalling every time. When the bodies differ ONLY in
|
|
986
|
+
that one line, a resolved-directory comparison (`_same_dropin_dir`)
|
|
987
|
+
decides instead — a shim a sibling host wrote for the SAME directory is
|
|
988
|
+
still current; one that names a genuinely different directory is not."""
|
|
790
989
|
for phase in PHASES:
|
|
791
990
|
existing = _read(os.path.join(hd, phase))
|
|
792
|
-
if existing is None
|
|
991
|
+
if existing is None:
|
|
992
|
+
return False
|
|
993
|
+
desired = _shim(dropin_dir, phase)
|
|
994
|
+
if not _shim_matches(existing, desired, dropin_dir):
|
|
793
995
|
return False
|
|
794
996
|
return True
|
|
795
997
|
|
|
796
998
|
|
|
999
|
+
_DROPIN_LINE_PREFIX = "D="
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _shim_body_without_dropin_line(text):
|
|
1003
|
+
"""`text` with its `D=...` line replaced by a fixed placeholder, so two
|
|
1004
|
+
shim bodies differing ONLY in that line's host-local spelling compare
|
|
1005
|
+
equal (B3/#684)."""
|
|
1006
|
+
out = []
|
|
1007
|
+
for line in text.splitlines(keepends=True):
|
|
1008
|
+
if line.startswith(_DROPIN_LINE_PREFIX):
|
|
1009
|
+
out.append(f"{_DROPIN_LINE_PREFIX}<dropin>\n")
|
|
1010
|
+
else:
|
|
1011
|
+
out.append(line)
|
|
1012
|
+
return "".join(out)
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _extract_dropin_line_value(text):
|
|
1016
|
+
"""The shell-single-quoted value a shim's `D=` line embeds, or None."""
|
|
1017
|
+
for line in text.splitlines():
|
|
1018
|
+
if not line.startswith(_DROPIN_LINE_PREFIX):
|
|
1019
|
+
continue
|
|
1020
|
+
inner = line[len(_DROPIN_LINE_PREFIX):]
|
|
1021
|
+
if inner.startswith("'") and inner.endswith("'"):
|
|
1022
|
+
return inner[1:-1].replace("'\"'\"'", "'")
|
|
1023
|
+
return None
|
|
1024
|
+
return None
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _same_dropin_dir(existing_value, dropin_dir):
|
|
1028
|
+
"""True iff `existing_value` (a `D=` string a shim already embeds,
|
|
1029
|
+
possibly written by a SIBLING host in its own spelling) names the SAME
|
|
1030
|
+
directory as `dropin_dir` (this host's own resolution) — tried as-is and
|
|
1031
|
+
via the bounded cross-host path-form candidates (ADR-0038)."""
|
|
1032
|
+
if existing_value is None:
|
|
1033
|
+
return False
|
|
1034
|
+
target = _shell_path(dropin_dir)
|
|
1035
|
+
if existing_value == target:
|
|
1036
|
+
return True
|
|
1037
|
+
if not os.path.isdir(dropin_dir):
|
|
1038
|
+
return False
|
|
1039
|
+
resolved = _resolve_live_dir(existing_value)
|
|
1040
|
+
if resolved is None:
|
|
1041
|
+
return False
|
|
1042
|
+
return os.path.realpath(resolved) == os.path.realpath(dropin_dir)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _shim_matches(existing, desired, dropin_dir):
|
|
1046
|
+
"""Accept only an identical shim or a sibling-host D= for this same dir."""
|
|
1047
|
+
return existing == desired or (
|
|
1048
|
+
_shim_body_without_dropin_line(existing)
|
|
1049
|
+
== _shim_body_without_dropin_line(desired)
|
|
1050
|
+
and _same_dropin_dir(_extract_dropin_line_value(existing), dropin_dir)
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
|
|
797
1054
|
def _default_hooks_dir(root):
|
|
798
1055
|
return os.path.join(root, ".git", "hooks")
|
|
799
1056
|
|
|
@@ -857,36 +1114,108 @@ def install(root):
|
|
|
857
1114
|
_warn(f"could not create hooks dir {hd}; skipping git-hook install")
|
|
858
1115
|
return []
|
|
859
1116
|
actions = []
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1117
|
+
# B1/#686: pre-commit and pre-push are written as a PAIR. Two concurrent
|
|
1118
|
+
# install() calls (typically two different hosts' SessionStarts racing
|
|
1119
|
+
# each other) can otherwise interleave their writes, and even a single
|
|
1120
|
+
# process's own mid-loop failure (disk full, AV scan, a locked drvfs
|
|
1121
|
+
# file) can leave one phase upgraded and its sibling not — either way a
|
|
1122
|
+
# permanently split, mutually-inconsistent pair until a later lone
|
|
1123
|
+
# session happens to reinstall both together. A cross-process lock
|
|
1124
|
+
# serializes concurrent installers; a plan-then-write-then-rollback
|
|
1125
|
+
# sequence ensures a failure partway through this call restores the
|
|
1126
|
+
# PRIOR consistent pair rather than leaving a new/old split.
|
|
1127
|
+
#
|
|
1128
|
+
# Documented residual (accepted, security-review-noted): the rollback
|
|
1129
|
+
# write below is itself best-effort. If the SAME failure mode that broke
|
|
1130
|
+
# the original write (disk full, an AV lock) also blocks the rollback
|
|
1131
|
+
# write for an already-written phase, the pair can still end up split.
|
|
1132
|
+
# This requires two independent write failures in one call, versus zero
|
|
1133
|
+
# rollback at all before this fix (any single failure could split the
|
|
1134
|
+
# pair) — narrower, not eliminated.
|
|
1135
|
+
#
|
|
1136
|
+
# The lock is acquired BEFORE reading the existing hooks or computing the
|
|
1137
|
+
# plan, not just around the writes: reading "existing" outside the lock
|
|
1138
|
+
# would let a concurrent installer's write land between this read and
|
|
1139
|
+
# this call's own write, so the "already current" skip and the rollback
|
|
1140
|
+
# `prior` snapshot could both be stale relative to what is actually on
|
|
1141
|
+
# disk the moment this call writes — reintroducing exactly the split-pair
|
|
1142
|
+
# and churn failures this lock exists to prevent (security review).
|
|
1143
|
+
lock_handle = _hooklib.acquire_lock(os.path.join(dropin_dir, "install"))
|
|
1144
|
+
if lock_handle is None:
|
|
1145
|
+
_warn("could not acquire the shared install lock (B1/#686); leaving the "
|
|
1146
|
+
"existing pre-commit/pre-push pair untouched this session — a later "
|
|
1147
|
+
"session will retry rather than risk writing an unlocked, possibly "
|
|
1148
|
+
"split pair")
|
|
1149
|
+
else:
|
|
877
1150
|
try:
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1151
|
+
plan = []
|
|
1152
|
+
for phase in PHASES:
|
|
1153
|
+
dest = os.path.join(hd, phase)
|
|
1154
|
+
desired = _shim(dropin_dir, phase)
|
|
1155
|
+
existing = _read(dest) if os.path.exists(dest) else None
|
|
1156
|
+
if existing is not None:
|
|
1157
|
+
lines = existing.splitlines()
|
|
1158
|
+
managed = len(lines) >= 2 and lines[0] == "#!/bin/sh" and (
|
|
1159
|
+
lines[1] == SENTINEL or lines[1].startswith(f"{SENTINEL} — ")
|
|
1160
|
+
)
|
|
1161
|
+
if not managed:
|
|
1162
|
+
_warn(f"an existing {phase} hook is not codeArbiter-managed — leaving "
|
|
1163
|
+
f"it untouched. For git-level enforcement, call "
|
|
1164
|
+
f"'{os.path.basename(enforcer)} {phase}' from it (see includes "
|
|
1165
|
+
f"docs).")
|
|
1166
|
+
actions.append(f"{phase}: foreign hook preserved (not installed)")
|
|
1167
|
+
continue
|
|
1168
|
+
if _shim_matches(existing, desired, dropin_dir):
|
|
1169
|
+
continue # already current — no churn
|
|
1170
|
+
plan.append((phase, dest, desired, existing))
|
|
1171
|
+
|
|
1172
|
+
if plan:
|
|
1173
|
+
written = []
|
|
1174
|
+
failure = None
|
|
1175
|
+
for phase, dest, desired, prior in plan:
|
|
1176
|
+
# Capture the destination's CURRENT mode before replacing it —
|
|
1177
|
+
# rollback restores this exact mode, not just "executable",
|
|
1178
|
+
# so a foreign hook's own permission bits survive a rollback
|
|
1179
|
+
# (security review).
|
|
1180
|
+
try:
|
|
1181
|
+
prior_mode = (
|
|
1182
|
+
stat.S_IMODE(os.stat(dest).st_mode) if prior is not None else None
|
|
1183
|
+
)
|
|
1184
|
+
# reliability-010: atomic sibling-temp + os.replace (mirrors
|
|
1185
|
+
# write_provenance/save_state) — os.replace guarantees `dest`
|
|
1186
|
+
# is either the complete new shim or the prior file, never a
|
|
1187
|
+
# torn write.
|
|
1188
|
+
_hooklib.write_text_atomic(dest, desired, newline="\n")
|
|
1189
|
+
# Recorded as soon as the content write lands — a chmod
|
|
1190
|
+
# failure just below must still roll this phase back
|
|
1191
|
+
# (security review): before this fix a chmod-only failure
|
|
1192
|
+
# left the new content in place, untracked for rollback.
|
|
1193
|
+
written.append((phase, dest, prior, prior_mode))
|
|
1194
|
+
st = os.stat(dest)
|
|
1195
|
+
os.chmod(dest, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1196
|
+
except Exception as e: # noqa: BLE001
|
|
1197
|
+
failure = (dest, e)
|
|
1198
|
+
break
|
|
1199
|
+
if failure is None:
|
|
1200
|
+
for phase, _dest, _prior, _prior_mode in written:
|
|
1201
|
+
actions.append(f"{phase}: installed")
|
|
1202
|
+
else:
|
|
1203
|
+
for _phase, dest, prior, prior_mode in written:
|
|
1204
|
+
try:
|
|
1205
|
+
if prior is None:
|
|
1206
|
+
os.remove(dest)
|
|
1207
|
+
else:
|
|
1208
|
+
_hooklib.write_text_atomic(dest, prior, newline="\n")
|
|
1209
|
+
if prior_mode is not None:
|
|
1210
|
+
os.chmod(dest, prior_mode)
|
|
1211
|
+
except Exception: # noqa: BLE001 — rollback is best-effort
|
|
1212
|
+
pass
|
|
1213
|
+
fail_dest, fail_exc = failure
|
|
1214
|
+
_warn(f"could not write {fail_dest}: {fail_exc} — rolled back "
|
|
1215
|
+
f"{len(written)} already-written phase(s) to avoid a split "
|
|
1216
|
+
f"pre-commit/pre-push pair (B1/#686)")
|
|
1217
|
+
finally:
|
|
1218
|
+
_hooklib.release_lock(lock_handle)
|
|
890
1219
|
# Cache the resolved location so the NEXT call can skip the git-config/
|
|
891
1220
|
# rev-parse re-probe entirely (performance-002) — best-effort, never fatal.
|
|
892
1221
|
_write_hooks_dir_cache(root, hd)
|
|
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
|
|
|
11
11
|
class PiHost(hostapi.Host):
|
|
12
12
|
name = "pi"
|
|
13
13
|
adapter_name = "@arbiterforge/ca-pi"
|
|
14
|
-
adapter_version = "0.14.
|
|
14
|
+
adapter_version = "0.14.10"
|
|
15
15
|
update_target = "ca-pi"
|
|
16
16
|
update_tag_prefix = "ca-pi-v"
|
|
17
17
|
update_command = "pi update npm:@arbiterforge/ca-pi"
|
|
@@ -376,7 +376,9 @@ def check_git_hook_freshness(root):
|
|
|
376
376
|
if "ignored because" in (probe.stderr or "").lower():
|
|
377
377
|
fail(f"git-hook backstop live-fire was ignored by Git: {probe.stderr.strip()[:240]}")
|
|
378
378
|
return
|
|
379
|
-
ok("git-hook backstop live-fire: selected Git executed the managed
|
|
379
|
+
ok("git-hook backstop live-fire: this host's selected Git executed the managed "
|
|
380
|
+
"pre-push shim (proves THIS host only — a sibling host sharing the registry "
|
|
381
|
+
"is not covered by this result, B3/#684)")
|
|
380
382
|
stale = _githooks.stale_registered_plugins(dropin_dir)
|
|
381
383
|
if stale:
|
|
382
384
|
names = ", ".join(sorted(stale))
|
|
@@ -313,7 +313,7 @@ class Host:
|
|
|
313
313
|
|
|
314
314
|
name = "claude"
|
|
315
315
|
adapter_name = "ca"
|
|
316
|
-
adapter_version = "2.21.
|
|
316
|
+
adapter_version = "2.21.10"
|
|
317
317
|
|
|
318
318
|
# Update-notifier descriptor. Each independently versioned host overrides
|
|
319
319
|
# these three values in its per-plugin _host.py. Keeping the target,
|
|
@@ -1,80 +1,49 @@
|
|
|
1
1
|
# Redirect
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Clarification support for §6 **tiers 2 and 3**, loaded only when the intended outcome, a material
|
|
4
|
+
fact, or required authority is unresolved. Tier 1 does not use this file: select the existing
|
|
5
|
+
owner and preserve its gates without asking the user to retype or approve an internal route.
|
|
5
6
|
|
|
6
|
-
Tier
|
|
7
|
-
its command with a one-line statement of the route (ADR-0022): naming the command and then asking the
|
|
8
|
-
user to type it back is ceremony, not governance.
|
|
7
|
+
## Tier 2 — probable intent, or a required destructive confirmation
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
`/ca-reconcile`. A question pulled into the heavy spec lane is a misroute.
|
|
9
|
+
Inspect available context before asking. A second plausible implementation method is not a missing
|
|
10
|
+
user decision. Ask about the missing outcome or fact, with a recommendation and its strongest
|
|
11
|
+
counter-consideration; do not ask the user to select a command that expresses an understood request.
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
For irreversible or gate-bypassing operations, obtain the existing confirmation even when intent
|
|
14
|
+
is clear: `/ca-override`, merge to the default branch, branch or worktree deletion, and release
|
|
15
|
+
and tag publication. Name the exact operation and target, state the consequence, then ask for its
|
|
16
|
+
confirmation. A generic approval of a route is not authority for an unspecified destructive action.
|
|
17
|
+
A deterministic mode-token flip is not in this set; its existing mode contract still applies.
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
## Tier 3 — genuinely unclear outcome
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
That reads as <inferred intent> → <$ca- skill, prefilled with the user's own words>
|
|
21
|
+
Ask one focused question after checking the available evidence:
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
> The missing decision is <outcome or constraint>. I recommend <option> because <reason>;
|
|
24
|
+
> <strongest counter-consideration> favors <alternative>. Which outcome is intended?
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
friction, not a gate, which is why `mode --dangerous`/`mode --ops` entry is not in this set — see
|
|
29
|
-
`includes/ops-mode.md` and `includes/dangerous-mode.md`.)
|
|
26
|
+
When even the intended task is unknown, ask what result the user wants. Do not repeat a command
|
|
27
|
+
catalog when the user restates the goal in ordinary language. Resolve the actual uncertainty and
|
|
28
|
+
then select the owner; lack of slash syntax is not itself uncertainty.
|
|
30
29
|
|
|
31
|
-
##
|
|
30
|
+
## Questions and restricted requests
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
Or pick a channel:
|
|
40
|
-
→ Start a new project: /ca-decompose
|
|
41
|
-
→ Start a feature: /ca-feature "describe it"
|
|
42
|
-
→ Ask a question: /ca-btw "your question"
|
|
43
|
-
→ Fix a bug: /ca-fix "describe it"
|
|
44
|
-
→ Bypass with audit trail: /ca-override "reason"
|
|
45
|
-
→ See everything open: /ca-status
|
|
46
|
-
→ See all commands: /ca-commands
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
When no intent is inferable at all, drop the candidate lines and lead with the channel list.
|
|
50
|
-
|
|
51
|
-
## Repeat — user insists off-channel after tier 3
|
|
52
|
-
|
|
53
|
-
```
|
|
54
|
-
Still need a channel for this one. Closest matches first:
|
|
55
|
-
<up to three prefilled $ca- skills>
|
|
56
|
-
|
|
57
|
-
Full list:
|
|
58
|
-
/ca-decompose /ca-create-context /ca-feature /ca-sprint /ca-fix /ca-refactor /ca-debug /ca-chore /ca-spike
|
|
59
|
-
/ca-commit /ca-pr /ca-watch /ca-review /ca-checkpoint /ca-release /ca-add-dep
|
|
60
|
-
/ca-threat-model /ca-adr /ca-adr-status /ca-reconcile /ca-conflict
|
|
61
|
-
/ca-init /ca-status /ca-metrics /ca-audit /ca-preview /ca-doctor /ca-standup /ca-cleanup /ca-task
|
|
62
|
-
/ca-new-skill /ca-btw /ca-commands
|
|
63
|
-
Or /ca-override "reason" to proceed anyway with an audit entry.
|
|
64
|
-
```
|
|
32
|
+
Use the requested outcome, not punctuation alone. "Explain this commit" and "draft the message,
|
|
33
|
+
but do not commit" authorize no staging, commit, task-board write, or other persistent mutation.
|
|
34
|
+
"Can you commit these changes?" can request an action; it still needs its owning gate. Answer an
|
|
35
|
+
informational question directly using read-only evidence as needed. If a relevant procedure has a
|
|
36
|
+
writing terminal step, do not enter that step for a report-only request.
|
|
65
37
|
|
|
66
38
|
## Never
|
|
67
39
|
|
|
68
|
-
A missing owner is a **routing gap**, not an override case.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
`/ca-chore`, which does not accept it, and then to `/ca-override`, which exists to be rare.
|
|
40
|
+
A genuinely missing owner is a **routing gap**, not an override case. Surface the precise missing
|
|
41
|
+
capability; never steer the user toward `/ca-override` to get past a coverage hole. Do not invent
|
|
42
|
+
an ungoverned procedure, resolve a `[CONFIRM-NN]`, or broaden scope merely to avoid asking.
|
|
72
43
|
|
|
73
44
|
## Exception — local runtime work
|
|
74
45
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
see `includes/ops-mode.md`. Anything that mutates tracked files, the index, git history, or
|
|
80
|
-
published state is still a routing gap, `ops` or not.
|
|
46
|
+
The existing `mode --ops` contract in `<plugin-root>/includes/ops-mode.md` owns local runtime
|
|
47
|
+
work that leaves tracked files and Git history unchanged. In `arbiter` mode, a runtime operation without an owner remains a routing gap;
|
|
48
|
+
this clarification card does not select or activate another mode. Tracked-file, index, Git-history,
|
|
49
|
+
and published-state mutations still require their ordinary governing workflow, `ops` or not.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# Routing table
|
|
2
2
|
|
|
3
|
-
Loaded
|
|
4
|
-
surface and destructive-operation registry
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Loaded when a scope-touch or route needs it, not every turn. This table is the authoritative
|
|
4
|
+
intent-to-owner surface and destructive-operation registry. Invocation cues are examples, not
|
|
5
|
+
required user syntax. For the governing document on a scope-touch, use `reference-map.md`.
|
|
6
|
+
Select the existing owner and preserve its prerequisites, modes, and gates; a direct resource
|
|
7
|
+
reference does not discard behavior owned by its command wrapper. A gate blocks advancement until
|
|
8
|
+
its exit condition is satisfied; routing never authorizes a bypass. Informational requests use
|
|
9
|
+
read-only behavior, not a writing close. The orchestrator routes; a skill dispatches an agent.
|
|
8
10
|
Routing to a skill means loading its body from `<plugin-root>/routines/<name>/SKILL.md` — a route
|
|
9
11
|
cell names the skill; this path convention locates it. That resolution never depends on the host's
|
|
10
12
|
skill registry: a chain-internal skill hidden from the registry (`disable-model-invocation`) is
|
|
@@ -4,7 +4,8 @@ dangerous, ops) at injection time; never injected alone. -->
|
|
|
4
4
|
# Safety core
|
|
5
5
|
|
|
6
6
|
**Precedence.** This file is prepended to every mode body and binds over it: no mode body may
|
|
7
|
-
weaken, omit, or override a clause stated here.
|
|
7
|
+
weaken, omit, or override a clause stated here. Command names identify their governing procedure;
|
|
8
|
+
natural-language routing preserves that procedure and its attribution and confirmation requirements.
|
|
8
9
|
|
|
9
10
|
**Section numbers are a public interface.** The enforcement hooks fire identically in every
|
|
10
11
|
mode and their block messages cite a `§N` from this file directly, so the numbering below must
|
|
@@ -59,9 +60,12 @@ preflight to the exact fetched base and PR head. Preserve a required true merge
|
|
|
59
60
|
`--match-head-commit`; green CI cannot substitute for source ancestry. Missing evidence blocks the
|
|
60
61
|
offer. This check never authorizes the merge itself.
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
Within already-authorized scope, choose reversible implementation parameters among reasonable
|
|
64
|
+
approaches and record material choices where the workflow requires. Multiple reasonable methods
|
|
65
|
+
alone do not require a user decision. This does not grant initial scope approval, resolve a
|
|
66
|
+
`[CONFIRM-NN]`, invent a product requirement, or expand provider, spending, data-disclosure, or
|
|
67
|
+
publication authority. When an unresolved choice changes the authorized outcome or requires
|
|
68
|
+
additional authority, ask. The irreversible-action confirmations above remain mandatory.
|
|
65
69
|
|
|
66
70
|
---
|
|
67
71
|
|
|
@@ -17,7 +17,7 @@ Skill bodies load on routing only. This index is the surface scan; never bulk-re
|
|
|
17
17
|
| [brainstorming](brainstorming/SKILL.md) | `/feature` (front), `/sprint` planning | Socratic idea→spec: split bundles, shape the approach, then decide parameters / ask forks in full; adversarial self-review before approval; writes new full-lane `specs/<slug>.html` through the installed engine while preserving existing Markdown authority. Its acceptance criteria become `tdd` obligations. Hard-gate: no code until the spec is approved. |
|
|
18
18
|
| [writing-plans](writing-plans/SKILL.md) | `/feature`, `/sprint` (after the spec) | Decomposes an approved spec into small tasks, each with a path + a verification that maps to a `tdd` obligation; writes a same-format plan with bijective criterion↔task coverage (`.html` by default, `.md` for an existing Markdown spec). |
|
|
19
19
|
| [executing-plans](executing-plans/SKILL.md) | `/feature` | Checkpoint coordinator — groups tasks into batches, delegates each to `subagent-driven-development` (fresh author agent per task, full review chain), stops for user acknowledgement between batches. |
|
|
20
|
-
| [subagent-driven-development](subagent-driven-development/SKILL.md) | `/sprint` (engine), `executing-plans` (batch scope) | Fresh subagent per task → spec-compliance then quality review → fresh-run verification; accepts only on proof.
|
|
20
|
+
| [subagent-driven-development](subagent-driven-development/SKILL.md) | `/sprint` (engine), `executing-plans` (batch scope) | Fresh subagent per task → spec-compliance then quality review → fresh-run verification; accepts only on proof. A quality failure blocks acceptance; approved sprint recovery owns correction. Security CRITICAL and unresolved `[CONFIRM-NN]` remain stops. |
|
|
21
21
|
| [dispatching-parallel-agents](dispatching-parallel-agents/SKILL.md) | `subagent-driven-development`, `/sprint`, parallel `/review` | Reusable fan-out primitive: bound concurrency, collect, dedupe, funnel through `finding-triage`→`verdict-aggregator`. Results unused until the read-only funnel runs. |
|
|
22
22
|
| [finishing-a-development-branch](finishing-a-development-branch/SKILL.md) | `/feature`, `/sprint` (terminal) | The terminal step after `commit-gate`: open-PR / merge-via-PR / discard. No direct-to-main, no force-push; `/sprint` auto-selects open-PR and never merges. |
|
|
23
23
|
| [using-git-worktrees](using-git-worktrees/SKILL.md) | `subagent-driven-development`, `dispatching-parallel-agents` (opt-in) | OPTIONAL per-unit filesystem isolation for parallel work; integrates accepted units back onto the caller's working branch for its single `commit-gate` + finish. Never the default path. |
|
|
@@ -47,8 +47,11 @@ loop processes only those tasks (in their internal dependency order). When `scop
|
|
|
47
47
|
## Phase 1 — Task selection · gate: BLOCK
|
|
48
48
|
|
|
49
49
|
Pull the next unblocked task from the plan in dependency order. When a `scope` was passed, restrict
|
|
50
|
-
selection to tasks in that list.
|
|
51
|
-
|
|
50
|
+
selection to tasks in that list. For Markdown, select only a task whose status is `PENDING` and whose
|
|
51
|
+
dependencies are `ACCEPTED`; `BLOCKED` tasks are not selectable until the owning plan workflow
|
|
52
|
+
records a supported transition. For HTML, accept only tasks returned by `eligible` for the exact
|
|
53
|
+
selected plan identity. A task is one verifiable unit of work with a path set, a spec obligation,
|
|
54
|
+
and a verification command.
|
|
52
55
|
|
|
53
56
|
For HTML, select only a task returned as eligible for the exact selected plan
|
|
54
57
|
identity. Follow contextual `read` pages to completion, retain their context
|
|
@@ -62,7 +65,9 @@ and a fresh complete context ticket. A task in `REVIEW` is not accepted on
|
|
|
62
65
|
resume; obtain fresh evidence when required and finish the current combined
|
|
63
66
|
scope review before `accept-scope`.
|
|
64
67
|
|
|
65
|
-
-
|
|
68
|
+
- For Markdown, confirm every dependency task is `ACCEPTED` before selecting.
|
|
69
|
+
- For HTML, use the engine's eligibility and current evidence; do not replace its same-checkpoint `REVIEW` rule
|
|
70
|
+
with the legacy `ACCEPTED` sentence or infer eligibility from a displayed status.
|
|
66
71
|
- Confirm no unresolved `[CONFIRM-NN]` blocks the task. One that does halts the loop — see Hard rules.
|
|
67
72
|
|
|
68
73
|
Gate: exactly one task selected, dependency-clean, with its spec obligation and verification command in hand.
|
|
@@ -93,8 +98,10 @@ The subagent works test-first by routing through the `tdd` skill (`<plugin-root>
|
|
|
93
98
|
`tdd` Phase 1. Brief it with the task's path set, its spec obligation, and its verification command.
|
|
94
99
|
Nothing else from prior tasks leaks in.
|
|
95
100
|
|
|
96
|
-
Gate:
|
|
97
|
-
|
|
101
|
+
Gate: all six `tdd` phases must be green before acceptance. Never redispatch merely to evade a failing gate.
|
|
102
|
+
Under an approved `/sprint`, an ordinary implementation/test failure routes to **Recovery within the approved sprint** in `<plugin-root>/SPRINT.md`; rerun the original gate after an evidence-led correction.
|
|
103
|
+
An attended invocation returns the blocked prerequisite to its caller. A real authority or security block
|
|
104
|
+
halts the affected work and is surfaced under the hard rules, in either mode.
|
|
98
105
|
|
|
99
106
|
## Phase 3 — Spec-compliance review · gate: BLOCK
|
|
100
107
|
|
|
@@ -137,7 +144,9 @@ plus `coverage-auditor` (already run in `tdd` Phase 4) — record that and proce
|
|
|
137
144
|
- A security CRITICAL finding halts the loop — see Hard rules.
|
|
138
145
|
- A HIGH finding returns the offending task(s) — attributed by file — to Phase 2; the scope's
|
|
139
146
|
quality review re-runs over the corrected combined diff.
|
|
140
|
-
- MEDIUM and LOW findings are recorded; the
|
|
147
|
+
- MEDIUM and LOW findings are recorded; the active caller owns their disposition. Under an approved
|
|
148
|
+
`/sprint`, use its existing delegated decision rules rather than adding a per-finding user checkpoint.
|
|
149
|
+
In attended execution the user retains that decision; mandatory project policy still applies.
|
|
141
150
|
|
|
142
151
|
Gate: no CRITICAL, no HIGH across the scope's combined diff. Nothing in the scope is `ACCEPTED`
|
|
143
152
|
until this passes.
|
|
@@ -182,7 +191,10 @@ Gate: every task in the current scope `ACCEPTED`, the suite green, ready for the
|
|
|
182
191
|
- MUST NOT write implementation code before the task's `tdd` Phase 1 completes.
|
|
183
192
|
- MUST accept a task only when both reviews pass AND verification passes on a fresh run.
|
|
184
193
|
- MUST NOT accept a task on a subagent's self-report — run the verification command and read its exit code.
|
|
185
|
-
- MUST halt and surface
|
|
194
|
+
- MUST halt and surface a real authority or security block, including security CRITICAL findings and
|
|
195
|
+
unresolved `[CONFIRM-NN]` decisions. Ordinary TDD/verification failures and finish-time stale proof
|
|
196
|
+
under an approved sprint take Recovery within the approved sprint in `<plugin-root>/SPRINT.md`.
|
|
197
|
+
A failed gate never auto-passes, and recovery never grants missing commit authority.
|
|
186
198
|
- MUST NOT commit — hand the accepted branch to `commit-gate`.
|
|
187
199
|
- MUST mark out-of-scope findings with an inline `[NEEDS-TRIAGE]` marker and never act on them inside the task.
|
|
188
200
|
- MUST NOT invoke `farm.js` before writing `meta.model` into `plan.json` (or setting `FARM_MODEL`) — the dispatcher fails loudly otherwise.
|