arcane-cli 0.20.1 → 0.21.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.
@@ -275,8 +275,9 @@ If an agent encounters a situation outside its defined parameters, it should:
275
275
 
276
276
  4. **Push the branch** to origin (after rebase).
277
277
 
278
- 5. **Merge or queue** based on your power level:
279
- - **Magus+ power level:** self-merge via `git merge --ff-only`, push main, delete branch.
278
+ 5. **Merge or queue** based on your power level — and merge by the mechanism your isolation primitive allows (ARC-028 R1):
279
+ - **Magus+ power level, in the primary checkout:** self-merge via `git merge --ff-only`, push main, delete branch.
280
+ - **Magus+ power level, in a linked worktree or a clone:** self-merge through the platform's PR merge instead. The authority is identical; only the mechanism changes, because a local ff-merge requires checking out `main` and another working tree already holds it. Power level decides *whether* you may merge without review; the isolation primitive decides *how*.
280
281
  - **Below Magus:** push the branch, report the branch name, and queue for human merge.
281
282
 
282
283
  6. **Delete the branch** (local and remote) after merge.
@@ -286,7 +287,7 @@ If an agent encounters a situation outside its defined parameters, it should:
286
287
  - The agent-slug prefix makes ownership obvious and prevents naming collisions.
287
288
  - Use standard commit types: `feat`, `fix`, `docs`, `refactor`, `chore`, etc.
288
289
 
289
- **What happens if ff-only fails:** Another actor merged to main first. This should not happen if you followed step 3. If it does, rebase and retry:
290
+ **What happens if ff-only fails:** Another actor merged to main first. This should not happen if you followed step 3. If it does, rebase and retry — **from the primary checkout**, since this block checks out `main`:
290
291
 
291
292
  ```bash
292
293
  git fetch origin
@@ -295,6 +296,8 @@ git checkout main
295
296
  git merge --ff-only {your-slug}/type/short-description
296
297
  ```
297
298
 
299
+ From a linked worktree or a clone, stop after the rebase and merge through the platform's PR instead (ARC-028 R1). `git checkout main` there fails with `fatal: 'main' is already used by worktree at '<path>'`.
300
+
298
301
  ### Multi-Agent Concurrency Rules
299
302
 
300
303
  When more than one agent may work in the same repository simultaneously:
@@ -204,7 +204,7 @@ type/short-description
204
204
  sessions/YYYY-MM-DD-topic-slug
205
205
  ```
206
206
 
207
- Create the session branch as the **first action** of any session, before any file edits or commits. All session commits land on this branch. At close, push and open a PR. After merge, return to `main`.
207
+ Create the session branch as the **first action** of any session, before any file edits or commits. All session commits land on this branch. At close, push and open a PR. After merge, return to `main` — **in the primary checkout only** (ARC-028 R8). A session running in a linked worktree ends with push → PR → worktree removal instead; it must not check out `main`, because the primary checkout already holds it and Git will refuse the second checkout.
208
208
 
209
209
  **Examples:**
210
210
 
@@ -257,7 +257,9 @@ The agent slug prefix makes branch ownership obvious in `git branch -r` output a
257
257
  ```bash
258
258
  git push origin docs/my-topic
259
259
  ```
260
- 4. **Merge to main via fast-forward** when done:
260
+ 4. **Merge to main via fast-forward** when done. **This step is primary-checkout work** (ARC-028 R1)
261
+ — it checks out `main`, which a linked worktree cannot do while the primary holds it. From a
262
+ worktree or a clone, open a PR and merge through the platform instead:
261
263
  ```bash
262
264
  git checkout main
263
265
  git pull origin main
@@ -266,7 +268,7 @@ The agent slug prefix makes branch ownership obvious in `git branch -r` output a
266
268
  git branch -d docs/my-topic
267
269
  git push origin --delete docs/my-topic
268
270
  ```
269
- 5. **If ff-only fails** (someone else merged first), rebase and retry:
271
+ 5. **If ff-only fails** (someone else merged first), rebase and retry — **primary checkout only**, same as step 4:
270
272
  ```bash
271
273
  git checkout docs/my-topic
272
274
  git rebase main
@@ -295,7 +297,11 @@ The agent slug prefix makes branch ownership obvious in `git branch -r` output a
295
297
  stale divergence. Resolve conflicts locally — never push a branch with known merge conflicts.
296
298
  4. **Push the branch to origin** (after rebase).
297
299
  5. **Merge or queue for review** based on power level:
298
- - **Magus+ agents:** self-merge via `git merge --ff-only`, then push main
300
+ - **Magus+ agents, working in the primary checkout:** self-merge via `git merge --ff-only`, then push main
301
+ - **Magus+ agents, working in a linked worktree or a clone:** self-merge through the platform's PR
302
+ merge instead (ARC-028 R1). The authority is the same; only the mechanism changes, because a
303
+ local ff-merge requires checking out `main` and the primary checkout already holds it. Power
304
+ level decides *whether* you may merge without review; the isolation primitive decides *how*.
299
305
  - **Below Magus:** push the branch, report branch name, and queue for human merge
300
306
  6. **Delete the branch** after merge.
301
307
 
@@ -354,16 +360,35 @@ Commit execution rights depend on the interaction context:
354
360
 
355
361
  ### Post-Merge Cleanup
356
362
 
357
- After every PR merge or local fast-forward merge, **always return to main and prune the branch**. Failing to do this leaves you on a stale branch where new work gets committed to the wrong place or stays unpushed.
363
+ After every PR merge or local fast-forward merge, **from the primary checkout, always return to main and prune the branch** (ARC-028 R8 — the worktree procedure is below, and differs). Failing to do this leaves you on a stale branch where new work gets committed to the wrong place or stays unpushed.
358
364
 
359
365
  ```bash
360
- # After PR merges on remote:
366
+ # After PR merges on remote — from the primary checkout:
361
367
  git checkout main
362
368
  git pull origin main
363
369
  git branch -d type/old-topic # delete local branch
364
370
  git push origin --delete type/old-topic # delete remote branch (safety net; ignore 'does not exist' errors if already auto-deleted)
365
371
  ```
366
372
 
373
+ **From a linked worktree, do not run the block above** (ARC-028 R8). `git checkout main` fails with
374
+ `fatal: 'main' is already used by worktree at <path>` when the primary checkout holds it, and
375
+ `git branch -d` fails with `error: cannot delete branch '<branch>' used by worktree at <path>` for
376
+ any branch still attached to a worktree — including your own, from inside it. Those are the exact
377
+ messages; recognising them is the point, because neither is a malfunction. A worktree session cleans
378
+ up like this instead:
379
+
380
+ ```bash
381
+ # From the worktree: push and let the PR merge land.
382
+ git push origin type/old-topic
383
+ # Then, from the PRIMARY checkout (R7 — a worktree cannot remove itself safely):
384
+ git worktree remove <path> # after the same-vantage-point check below
385
+ git fetch --prune origin # drops the remote-tracking ref
386
+ git push origin --delete type/old-topic # if the platform did not auto-delete
387
+ ```
388
+
389
+ Neither failure is an obstacle to work around: Git refusing to double-check-out a branch, and
390
+ refusing to delete a worktree-attached one, are the enforcement ARC-028 R3/R7 rely on.
391
+
367
392
  This applies to all actors — humans, interactive tools (Copilot, Claude), and autonomous agents. The `spell-commit-work` and `spell-close-session` prompts enforce this check.
368
393
 
369
394
  ### Same-Vantage-Point Check (EF-33 / ARC-028 R7)
@@ -423,7 +448,9 @@ az repos pr reviewer list --id <PR_ID> --org https://dev.azure.com/<org>
423
448
  # Allowed merge strategies: Merge (no fast forward) or Rebase+fast-forward
424
449
  az repos pr update --id <PR_ID> --org https://dev.azure.com/<org> --status completed --delete-source-branch true --squash false
425
450
 
426
- # 8. Pull merge commit and clean up (worktree-safe)
451
+ # 8. Pull merge commit and clean up — PRIMARY CHECKOUT ONLY (ARC-028 R8).
452
+ # From a linked worktree, `git checkout main` fails first and the rest of
453
+ # this block never runs; see Post-Merge Cleanup for the worktree procedure.
427
454
  git checkout main
428
455
  git pull origin main
429
456
  git branch -d <branch> # delete local branch (skip if branch is attached to an active worktree)
@@ -49,7 +49,8 @@ See also: [Hardening Checklist](hardening-checklist.md), [Authentication Strateg
49
49
  | Network exposure on the agent host | **Mitigated** | Gateway bound to loopback only. An encrypted tunnel/VPN is the only remote-access path. No ports open on the LAN. |
50
50
  | Supply chain (package installs) | **Mitigated** | Global package installs use a user-owned directory, not a system directory. No `sudo` during install. |
51
51
  | Inter-machine communication | **Mitigated** | Encrypted mesh tunnel/VPN. No direct unencrypted LAN communication. |
52
- | Token/credential exposure | **Mitigated** | Token stored as an env var in a `.env` file. Config uses a secret reference, not plaintext. State directory is mode `700`. |
52
+ | Token/credential exposure (at rest) | **Mitigated** | Token stored as an env var in a `.env` file. Config uses a secret reference, not plaintext. State directory is mode `700`. |
53
+ | Credential committed to version control | **Not mitigated** | Storage conventions are documented (secret references, `.gitignore` entries for `.env`/`*.pem`/`*.key`) but **no detection exists** — no scanner runs on the commit path, the push path, or in CI, and nothing verifies the conventions were followed. `spell-security-review`'s secrets step is an on-demand agent read, not a control. Treat every credential that reaches a commit as compromised and rotate it — history, existing clones, and remote backups all survive deleting the file (EF-35). |
53
54
 
54
55
  ---
55
56
 
@@ -151,11 +151,30 @@ Rules:
151
151
  - Skip this entire step for local-only and read-only sessions.
152
152
  - Resolve `<remote>` and `<trunk>` from observed Git/provider state: use the usable authenticated remote selected in step 9 and the merged PR's target branch (falling back to that remote's default branch). Never assume `origin` or `main`.
153
153
  - Verify through the detected provider that the PR is merged before changing branches.
154
+ - **Determine the isolation primitive first (ARC-028 R8).** Run both of these and compare:
155
+ ```bash
156
+ git rev-parse --path-format=absolute --git-common-dir
157
+ git rev-parse --path-format=absolute --git-dir
158
+ ```
159
+ Equal → **primary checkout**. Different → **linked worktree**, and the two paths below diverge. `spell-open-session` should already have named the primitive; re-derive it here rather than trusting the handoff, because the session may have moved.
160
+
161
+ **`--path-format=absolute` is required, not decorative.** Without it, `--git-dir` returns an absolute path while `--git-common-dir` stays relative whenever the current directory is *below* the repository root — so a plain comparison reports the primary checkout as a linked worktree from any subdirectory, which is most of the time. If your git predates `--path-format` (2.31), use `git rev-parse --git-dir` and test whether the result contains `/worktrees/` instead.
162
+
163
+ **Primary checkout (the default path):**
154
164
  - Run `git switch <trunk>` followed by `git pull --ff-only <remote> <trunk>`. Do not end a remote-backed session on a topic branch.
155
- - Delete the local topic branch: `git branch -d <branch>` — if the repository or its linked worktrees might be reached through more than one filesystem view, run the same-vantage-point check first (EF-33 / ARC-028 R7, [governance/git-conventions.md](../../.arcane/governance/git-conventions.md) Same-Vantage-Point Check section).
165
+ - Delete the local topic branch: `git branch -d <branch>` — if the repository or its linked worktrees might be reached through more than one filesystem view, run the same-vantage-point check first (EF-33 / ARC-028 R7, [governance/git-conventions.md](../../.arcane/governance/git-conventions.md) Same-Vantage-Point Check section).
156
166
  - Verify `git log --oneline -3` shows the merged change at HEAD.
157
- - If stale local branches exist (merged or older than 7 days), list them and suggest cleanup.
158
- - See [governance/git-conventions.md](../../.arcane/governance/git-conventions.md) Post-Merge Cleanup section.
167
+
168
+ **Linked worktree:**
169
+ - First establish whether any working tree currently holds `<trunk>`: run `git worktree list` and look for `[<trunk>]`. That, not the mere fact of being in a worktree, is what determines the two rules below — a **bare** repository with worktrees attached (a common agent-fleet layout: one bare clone, one worktree per agent) usually has no checkout holding `<trunk>` at all, and there `git switch <trunk>` and `git branch -d` both succeed normally. Verified: they do.
170
+ - **If a working tree holds `<trunk>`, do not run `git switch <trunk>`.** It will fail — Git refuses to check out one branch in two worktrees (`fatal: '<trunk>' is already used by worktree at '<path>'`). Ending on the session branch is correct here, not a lapse; the branch is already merged and the worktree is about to be removed.
171
+ - **Do not run `git branch -d <branch>` while this worktree is attached to it.** It will fail (`error: cannot delete branch '<branch>' used by worktree at '<path>'`), from inside the worktree and from the primary alike. Both refusals are the guardrail ARC-028 R3/R7 leans on, not an obstacle to work around — never reach for `-D`, `--force`, or manual deletion of `.git/worktrees/<name>` to get past them. (`-D` does not bypass this either; the refusal is about attachment, not about merge state.)
172
+ - Report the worktree path and the branch name, and name **where** removal must happen: `git worktree remove <path>` then `git fetch --prune <remote>`, run after the same-vantage-point check from a checkout that is not this one. A worktree cannot safely remove itself. If `git worktree list` shows a working tree holding `<trunk>`, that is the vantage point; if the repository is **bare**, run it from the bare repository itself (`git -C <bare> worktree remove <path>`) — do not report the work as belonging to a "primary checkout" that does not exist.
173
+ - If this worktree was created with `--detach` there is no branch to name; report the path and the detached HEAD sha instead.
174
+ - Verify the merge landed with `git log --oneline -3 <remote>/<trunk>` (the remote-tracking ref), since local `<trunk>` is not checked out here to fast-forward.
175
+
176
+ - Common to both: if stale local branches exist (merged or older than 7 days), list them and suggest cleanup.
177
+ - See [governance/git-conventions.md](../../.arcane/governance/git-conventions.md) Post-Merge Cleanup section.
159
178
 
160
179
  11. **Return a concise closure report.**
161
180
 
@@ -107,6 +107,12 @@ Execute the `spell-implement` workflow:
107
107
  git checkout main && git pull --ff-only
108
108
  ```
109
109
  If `pull --ff-only` fails, halt and report — manual rebase is needed.
110
+
111
+ **In a linked worktree, do not run the `checkout main` line** (ARC-028 R1/R8). It fails with `fatal: 'main' is already used by worktree at '<path>'` when another working tree holds `main`, and the halt instruction above only covers a `pull` failure — so an agent that runs it there has no branch to take. This matters especially here: the R4 rule below sends genuinely disjoint epics into parallel worktrees, so this pipeline reaches that state by design. Sync the remote-tracking ref instead:
112
+ ```bash
113
+ git fetch --prune origin # origin/main is now current
114
+ ```
115
+ and branch from `origin/main` in step 1. Check which primitive you are in with `git rev-parse --path-format=absolute --git-common-dir` vs `--git-dir` — equal means primary checkout. `--path-format=absolute` is required, or every primary checkout looks like a worktree from any subdirectory.
110
116
  1. Create the topic branch from `stories.json.branchName` (format: `{agent}/type/description`).
111
117
  2. Loop:
112
118
  a. Pick the next story where `passes: false`, ordered by priority.
@@ -200,3 +206,6 @@ After each phase completes, output a brief status line:
200
206
  - **Branch discipline.** All work on topic branches, never on main. Per ADR-048.
201
207
  - **Fresh context per story.** Do not carry assumptions between stories during Phase 3.
202
208
  - **Epic checkpointing.** When this spell is run as one epic in a multi-epic plan, recommend running `spell-commit-work` before starting the next epic.
209
+ - **Multi-epic runs serialize by default (ARC-028 R4).** Before running two or more epics concurrently in separate worktrees, compare their footprints: the file and directory globs each will touch, plus any **shared sequence** — database migration numbers, generated indexes and manifests, lockfiles, fixture IDs, anything where correctness depends on what another epic picked. If the footprints overlap on either axis, run the epics **sequentially in one workspace**, or re-scope them to disjoint footprints first. State the comparison and the resulting decision in the run's opening report; "they seemed unrelated" is not a footprint comparison.
210
+
211
+ This is a default derived from evidence, not caution for its own sake. A four-epic run with three executing in parallel isolated worktrees produced two epics independently claiming the same migration sequence number and two adding conflicting imports to the same file region — none of it visible until a human reached merge review. A three-epic re-run, strictly serialized in one workspace, produced zero collisions. Isolation prevents epics from *disturbing* each other; it does nothing to stop them *agreeing on the same answer* to a question neither knew the other was asking, and it delays the discovery until the most expensive moment. Serialization stays the default until re-derivation tooling exists (fresh-pull sequence re-derivation, shared numbering locks) — parallelism is available for genuinely disjoint footprints, and should be used there.
@@ -33,6 +33,13 @@ Workflow (loop until all stories pass):
33
33
  ```
34
34
  If `pull --ff-only` fails, halt and report — manual rebase is needed.
35
35
 
36
+ **In a linked worktree, do not run the `checkout main` line** (ARC-028 R1/R8). It fails with `fatal: 'main' is already used by worktree at '<path>'` when another working tree holds `main`, and the halt instruction above only covers a `pull` failure — so an agent that runs it there has no branch to take. Sync the remote-tracking ref instead, which is all this step actually needs:
37
+ ```bash
38
+ git fetch --prune origin # origin/main is now current
39
+ git rebase origin/main # or branch from origin/main directly in step 1
40
+ ```
41
+ Check with `git rev-parse --path-format=absolute --git-common-dir` vs `--git-dir`: equal means primary checkout, different means linked worktree. `--path-format=absolute` is required — without it the two differ from any subdirectory and every primary checkout looks like a worktree.
42
+
36
43
  1. **Load stories.json** — locate the feature folder from the current branch:
37
44
  ```bash
38
45
  # Derive feature folder from branch name (works for lafayette/feat/541-phase-2a or lafayette/feat/phase-2a)
@@ -71,6 +71,13 @@ Then produce output in this exact structure:
71
71
  Before anything else, check:
72
72
 
73
73
  - **Current branch:** Run `git branch --show-current` and `git worktree list`. Report the current state; do not switch during this read-only check. Branch creation/switching is owned by the lazy Mutation Guard. **Caveat (EF-33):** if this repository or its linked worktrees might be reached through more than one filesystem view (a bridged/remote mount, a container bind-mount), `git worktree list` can truthfully report a live, healthy worktree as `prunable` when read from a vantage point that can't resolve its registered path — this is a read-only report, not a reason to act; never run a destructive worktree/branch command on its output alone (see git-conventions.md's Same-Vantage-Point Check section before any such command elsewhere in this or later sessions).
74
+ - **Isolation primitive (ARC-028 R1–R5):** name where this session will run *before* the Mutation Guard creates anything, and state it in the session output. Every unit of work lives in exactly one of three primitives — the **primary checkout**, a **linked worktree**, or a **full clone**. Select from what the previous bullet's `git worktree list` and `git status` already showed, in this order:
75
+ - **Managing repository state** — reconciling uncommitted files, pruning branches, fast-forwarding `main`, cutting a release, or any `git worktree` lifecycle operation → **primary checkout** (R1). These are the only operations that require holding `main`, and only one working tree can.
76
+ - **The primary checkout is occupied** — another session's branch is checked out there, or it holds uncommitted work that is not yours → **linked worktree** (R3). At most one actor occupies the primary checkout at a time. Create it with `git worktree add <path> -b <session-branch>`; never switch or delete a branch attached to a worktree you are not in.
77
+ - **Unattended/automated git operations** (cron, daemon fleets, anything pulling or pushing without a human present) → **full clone** (R5). Linked worktrees share one physical `.git`, so concurrent unattended operations contend on the same locks.
78
+ - **Otherwise** → **primary checkout on a session branch** (R2). This is the default and it is deliberately the do-nothing path: one actor, one checkout, no new tooling.
79
+ - **Footprint overlap overrides the choice (R4).** If this session's work touches the same files or the same shared sequence (migration numbers, generated indexes, lockfiles) as work already running in another primitive, do **not** isolate it — serialize instead: wait, or re-scope to a disjoint footprint first. Isolation does not prevent those collisions, it hides them until merge review.
80
+ - Working in a linked worktree changes two things later in the session and nothing else: `spell-close-session` must not check out `main` (see git-conventions.md's Post-Merge Cleanup section), and the worktree contains no untracked tooling state — budget for `npm install` or equivalent in a code repo. Branch naming, attribution, and the PR gate are identical from every primitive (R8).
74
81
  - **Session branch naming compliance:** Branch names must be human-readable and policy-compliant:
75
82
  - Interactive session default: `sessions/YYYY-MM-DD-<topic-slug>` (kebab-case topic from current task/session objective).
76
83
  - Disallow random adjective-noun generator names (example: `ideal-disco`) as session defaults.
@@ -91,6 +91,7 @@ Workflow:
91
91
  - Tag release if applicable.
92
92
 
93
93
  9. **Post-merge branch cleanup** (after the PR merges) — the merge was the human gate, so cleanup is automatic:
94
+ - **First, which primitive are you in?** `git rev-parse --path-format=absolute --git-common-dir` vs `--git-dir` — equal means primary checkout, different means linked worktree. (`--path-format=absolute` is required; without it the two differ from any subdirectory and every primary checkout reads as a worktree.) **In a linked worktree, skip the two steps below** (ARC-028 R1/R8): `git checkout main` fails when another working tree holds `main`, and it fails *first*, so the rest of this cleanup block never runs and the remote branch deletion below is left half-done. Report the worktree path for removal from another vantage point instead, and go straight to the remote-branch and prune steps.
94
95
  - Switch to the main branch (e.g. `git checkout main`).
95
96
  - Pull the latest (e.g. `git pull`).
96
97
  - Delete the merged topic branch locally (e.g. `git branch -d <branch>`) — if the repository or its linked worktrees might be reached through more than one filesystem view, run the same-vantage-point check first (EF-33 / ARC-028 R7, [governance/git-conventions.md](../../.arcane/governance/git-conventions.md) Same-Vantage-Point Check section).
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ const require = __createRequire(import.meta.url);
6
6
  import { Command } from "commander";
7
7
  import { createRequire as createRequire2 } from "module";
8
8
  import { fileURLToPath } from "url";
9
- import { dirname as dirname3, join as join12 } from "path";
9
+ import { dirname as dirname4, join as join12 } from "path";
10
10
 
11
11
  // src/commands/init.ts
12
12
  import { select as select2, confirm, input as input2 } from "@inquirer/prompts";
@@ -1795,9 +1795,9 @@ async function runAgentsList(targetDir) {
1795
1795
  }
1796
1796
 
1797
1797
  // src/modules/push-safety.ts
1798
- import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5, chmod, access as access3, rm as rm2 } from "fs/promises";
1798
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5, chmod, access as access3, rm as rm2, readdir as readdir3 } from "fs/promises";
1799
1799
  import { constants } from "fs";
1800
- import { join as join4, resolve } from "path";
1800
+ import { join as join4, resolve, dirname, basename } from "path";
1801
1801
 
1802
1802
  // src/modules/git.ts
1803
1803
  import { execFile } from "child_process";
@@ -2042,11 +2042,11 @@ async function readHooksPath(cwd) {
2042
2042
  const matched = known.find((candidate) => candidate === scope);
2043
2043
  return { value, scope: matched ?? "unknown" };
2044
2044
  }
2045
- function isArcaneHooksPath(cwd, value) {
2045
+ function isArcaneHooksPath(root, value) {
2046
2046
  const windows = process.platform === "win32";
2047
2047
  const normalize = (p) => {
2048
2048
  const separated = windows ? p.replace(/\\/g, "/") : p;
2049
- const absolute = resolve(cwd, separated).replace(/\\/g, "/").replace(/\/+$/, "");
2049
+ const absolute = resolve(root, separated).replace(/\\/g, "/").replace(/\/+$/, "");
2050
2050
  return windows ? absolute.toLowerCase() : absolute;
2051
2051
  };
2052
2052
  return normalize(value) === normalize(ARCANE_HOOKS_DIR);
@@ -2085,34 +2085,81 @@ function describeConfigScope(scope) {
2085
2085
  return "scope unknown";
2086
2086
  }
2087
2087
  }
2088
- function hookFilePath(cwd) {
2089
- return join4(cwd, ARCANE_HOOKS_DIR, "pre-push");
2088
+ async function repositoryRoot(cwd) {
2089
+ try {
2090
+ const { stdout } = await runGit(cwd, [
2091
+ "rev-parse",
2092
+ "--path-format=absolute",
2093
+ "--git-common-dir"
2094
+ ]);
2095
+ const commonDir = stdout.trim();
2096
+ if (commonDir !== "") {
2097
+ const parent = dirname(commonDir);
2098
+ if (basename(commonDir) === ".git" && parent !== "") return parent;
2099
+ }
2100
+ } catch {
2101
+ }
2102
+ try {
2103
+ const { stdout } = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
2104
+ const top = stdout.trim();
2105
+ if (top !== "") return top;
2106
+ } catch {
2107
+ }
2108
+ return cwd;
2109
+ }
2110
+ async function hookFilePath(cwd) {
2111
+ return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, "pre-push");
2112
+ }
2113
+ async function hooksPathValue(cwd) {
2114
+ return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2090
2115
  }
2091
2116
  async function installPrePushHook(cwd) {
2117
+ const root = await repositoryRoot(cwd);
2092
2118
  const existing = await readHooksPath(cwd);
2093
2119
  if (existing?.unreadable === true) return { status: "refused-unreadable-config" };
2094
- if (existing !== void 0 && !isArcaneHooksPath(cwd, existing.value)) {
2120
+ if (existing !== void 0 && !isArcaneHooksPath(root, existing.value)) {
2095
2121
  return {
2096
2122
  status: "refused-foreign-hooks-path",
2097
2123
  existing: existing.value,
2098
2124
  scope: existing.scope
2099
2125
  };
2100
2126
  }
2101
- const hookPath = hookFilePath(cwd);
2127
+ if (existing === void 0) {
2128
+ const displaced = await defaultHooks(cwd);
2129
+ if (displaced.length > 0) {
2130
+ return { status: "refused-default-hooks", hooks: displaced };
2131
+ }
2132
+ }
2133
+ const hookPath = join4(root, ARCANE_HOOKS_DIR, "pre-push");
2102
2134
  if (existing !== void 0 && await hookBodyMatches(cwd)) return { status: "already-ours" };
2103
- await mkdir4(join4(cwd, ARCANE_HOOKS_DIR), { recursive: true });
2135
+ await mkdir4(join4(root, ARCANE_HOOKS_DIR), { recursive: true });
2104
2136
  await writeFile5(hookPath, HOOK_BODY, "utf-8");
2105
2137
  await chmod(hookPath, 493);
2106
- await runGit(cwd, ["config", "--local", "core.hooksPath", ARCANE_HOOKS_DIR]);
2138
+ await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2107
2139
  return { status: "installed", path: hookPath };
2108
2140
  }
2109
2141
  async function isHookEnforced(cwd) {
2110
2142
  const hooksPath = await readHooksPath(cwd);
2111
- if (hooksPath === void 0 || !isArcaneHooksPath(cwd, hooksPath.value)) return false;
2143
+ if (hooksPath === void 0 || hooksPath.unreadable === true) return false;
2144
+ if (!isArcaneHooksPath(await repositoryRoot(cwd), hooksPath.value)) return false;
2112
2145
  return hookBodyMatches(cwd);
2113
2146
  }
2147
+ async function defaultHooks(cwd) {
2148
+ let hooksDir;
2149
+ try {
2150
+ const { stdout } = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
2151
+ hooksDir = join4(resolve(cwd, stdout.trim()), "hooks");
2152
+ } catch {
2153
+ return [];
2154
+ }
2155
+ try {
2156
+ return (await readdir3(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
2157
+ } catch {
2158
+ return [];
2159
+ }
2160
+ }
2114
2161
  async function hookBodyMatches(cwd) {
2115
- const hookPath = hookFilePath(cwd);
2162
+ const hookPath = await hookFilePath(cwd);
2116
2163
  if (!await fileExists(hookPath)) return false;
2117
2164
  try {
2118
2165
  if (await readFile4(hookPath, "utf-8") !== HOOK_BODY) return false;
@@ -2130,7 +2177,7 @@ async function hookBodyMatches(cwd) {
2130
2177
  async function removePrePushHook(cwd) {
2131
2178
  const existing = await readHooksPath(cwd);
2132
2179
  if (existing === void 0 || existing.unreadable === true) return;
2133
- if (!isArcaneHooksPath(cwd, existing.value)) return;
2180
+ if (!isArcaneHooksPath(await repositoryRoot(cwd), existing.value)) return;
2134
2181
  if (existing.scope !== "local" && existing.scope !== "worktree") return;
2135
2182
  const scopeFlag = existing.scope === "worktree" ? "--worktree" : "--local";
2136
2183
  try {
@@ -2138,7 +2185,7 @@ async function removePrePushHook(cwd) {
2138
2185
  } catch {
2139
2186
  }
2140
2187
  try {
2141
- await rm2(hookFilePath(cwd), { force: true });
2188
+ await rm2(await hookFilePath(cwd), { force: true });
2142
2189
  } catch {
2143
2190
  }
2144
2191
  }
@@ -2183,11 +2230,11 @@ async function readScopedAll(cwd, key) {
2183
2230
  } catch {
2184
2231
  return [];
2185
2232
  }
2186
- return stdout.split("\n").map((line) => line.trimEnd()).filter(Boolean).map((line) => {
2233
+ return stdout.split("\n").filter((line) => line !== "").map((line) => {
2187
2234
  const separator = line.indexOf(" ");
2188
2235
  if (separator === -1) return { scope: "unknown", value: line.trim() };
2189
2236
  return { scope: line.slice(0, separator).trim(), value: line.slice(separator + 1).trim() };
2190
- }).filter((entry) => entry.value !== "");
2237
+ });
2191
2238
  }
2192
2239
  async function effectivePushUrls(cwd, remote) {
2193
2240
  const scoped = await readScopedAll(cwd, `remote.${remote}.pushurl`);
@@ -2206,13 +2253,18 @@ async function disablePushUrls(cwd) {
2206
2253
  }
2207
2254
  async function disableOneRemote(cwd, remote) {
2208
2255
  const scoped = await readScopedAll(cwd, `remote.${remote}.pushurl`);
2209
- const foreign = scoped.filter((entry) => entry.scope !== "local" && entry.value !== DISABLED_PUSH_URL);
2256
+ const OVERRIDABLE = /* @__PURE__ */ new Set(["local", "worktree"]);
2257
+ const foreign = scoped.filter(
2258
+ (entry) => !OVERRIDABLE.has(entry.scope) && entry.value !== DISABLED_PUSH_URL
2259
+ );
2210
2260
  if (foreign.length > 0) {
2211
- const where = [...new Set(foreign.map((entry) => entry.scope))].join(", ");
2261
+ const scopes = [...new Set(foreign.map((entry) => entry.scope))];
2262
+ const where = scopes.join(", ");
2263
+ const flag = scopes[0] === "unknown" ? "<scope>" : `--${scopes[0]}`;
2212
2264
  return {
2213
2265
  remote,
2214
2266
  status: "failed",
2215
- reason: `a push URL for this remote is configured at ${where} scope, which this repository cannot override \u2014 git would still deliver the push there. Remove it with \`git config --${where.split(", ")[0]} --unset-all remote.${remote}.pushurl\` and re-apply.`
2267
+ reason: `a push URL for this remote is configured at ${where} scope, which this repository cannot override \u2014 git would still deliver the push there. Remove it with \`git config ${flag} --unset-all remote.${remote}.pushurl\` and re-apply.`
2216
2268
  };
2217
2269
  }
2218
2270
  const currentPushUrls = scoped.map((entry) => entry.value);
@@ -2237,6 +2289,15 @@ async function disableOneRemote(cwd, remote) {
2237
2289
  `remote.${remote}.pushurl`,
2238
2290
  DISABLED_PUSH_URL
2239
2291
  ]);
2292
+ const after = await effectivePushUrls(cwd, remote);
2293
+ if (after.length !== 1 || after[0] !== DISABLED_PUSH_URL) {
2294
+ const live = after.filter((url) => url !== DISABLED_PUSH_URL);
2295
+ return {
2296
+ remote,
2297
+ status: "failed",
2298
+ reason: `the sentinel was written but git still resolves a live push URL for this remote (${live.join(", ")}). It most likely comes from a file this repository \`include\`s, which \`--replace-all\` cannot remove. Run \`git config --show-origin --get-all remote.${remote}.pushurl\` to find the file, and clear it there.`
2299
+ };
2300
+ }
2240
2301
  return { remote, status: "disabled" };
2241
2302
  }
2242
2303
  async function undisabledRemotes(cwd) {
@@ -2277,7 +2338,7 @@ async function restoreOneRemote(cwd, remote) {
2277
2338
  hadNone = false;
2278
2339
  }
2279
2340
  if (hadNone) {
2280
- await runGit(cwd, ["config", "--local", "--unset-all", `remote.${remote}.pushurl`]);
2341
+ await unsetIgnoringMissing(cwd, `remote.${remote}.pushurl`);
2281
2342
  } else {
2282
2343
  await runGit(cwd, [
2283
2344
  "config",
@@ -2292,12 +2353,19 @@ async function restoreOneRemote(cwd, remote) {
2292
2353
  }
2293
2354
  for (const staleKey of [key, NO_PUSH_URL_MARKER(remote), `arcane.originalPushUrl.${remote}`]) {
2294
2355
  try {
2295
- await runGit(cwd, ["config", "--local", "--unset-all", staleKey]);
2356
+ await unsetIgnoringMissing(cwd, staleKey);
2296
2357
  } catch {
2297
2358
  }
2298
2359
  }
2299
2360
  return { remote, status: "restored" };
2300
2361
  }
2362
+ async function unsetIgnoringMissing(cwd, key) {
2363
+ try {
2364
+ await runGit(cwd, ["config", "--local", "--unset-all", key]);
2365
+ } catch (error) {
2366
+ if (error.code !== 5) throw error;
2367
+ }
2368
+ }
2301
2369
  async function hadNoPushUrl(cwd, remote) {
2302
2370
  return (await readLocalAll(cwd, NO_PUSH_URL_MARKER(remote)))[0] === "true";
2303
2371
  }
@@ -2659,6 +2727,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2659
2727
  printWarning(
2660
2728
  "Did not install the pre-push hook: git could not report the current core.hooksPath (unreadable config, or a git too old for `--show-scope`). Installing without knowing whether another hook manager owns that setting could silently disable it. Fix the config and run `spell doctor`."
2661
2729
  );
2730
+ } else if (hook.status === "refused-default-hooks") {
2731
+ printWarning(
2732
+ `Did not install the pre-push hook: this repository has hooks in git's default directory (${hook.hooks.join(", ")}). Setting core.hooksPath would silently stop them running. Move them under \`${ARCANE_HOOKS_DIR}\` yourself and re-run, or add the push guard to your existing pre-push hook.`
2733
+ );
2662
2734
  } else if (hook.status === "refused-foreign-hooks-path") {
2663
2735
  printWarning(
2664
2736
  `Did not install the pre-push hook: core.hooksPath is already "${hook.existing}" (${describeConfigScope(hook.scope)}), so another hook manager owns it. Overwriting would silently disable those hooks. Chain an Arcane pre-push guard into that directory yourself, or unset core.hooksPath first.`
@@ -2819,7 +2891,7 @@ import { join as join8 } from "path";
2819
2891
 
2820
2892
  // src/modules/hub.ts
2821
2893
  import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
2822
- import { readdir as readdir3, writeFile as writeFile6 } from "fs/promises";
2894
+ import { readdir as readdir4, writeFile as writeFile6 } from "fs/promises";
2823
2895
  import { join as join7 } from "path";
2824
2896
  var MANIFEST_RETROFITS = [
2825
2897
  {
@@ -2963,7 +3035,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
2963
3035
  const businessRootPath = join7(targetDir, businessRoot);
2964
3036
  let ventureDirs;
2965
3037
  try {
2966
- const entries = await readdir3(businessRootPath, { withFileTypes: true });
3038
+ const entries = await readdir4(businessRootPath, { withFileTypes: true });
2967
3039
  ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
2968
3040
  } catch {
2969
3041
  return;
@@ -3170,7 +3242,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3170
3242
 
3171
3243
  // src/modules/version-check.ts
3172
3244
  import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir5 } from "fs/promises";
3173
- import { dirname, join as join9 } from "path";
3245
+ import { dirname as dirname2, join as join9 } from "path";
3174
3246
  import { homedir as homedir2 } from "os";
3175
3247
  import { createRequire } from "module";
3176
3248
  var require2 = createRequire(import.meta.url);
@@ -3199,7 +3271,7 @@ async function readCache(cachePath) {
3199
3271
  }
3200
3272
  }
3201
3273
  async function writeCache(cachePath, latest) {
3202
- await mkdir5(dirname(cachePath), { recursive: true });
3274
+ await mkdir5(dirname2(cachePath), { recursive: true });
3203
3275
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
3204
3276
  await writeFile7(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3205
3277
  }
@@ -3409,7 +3481,7 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
3409
3481
 
3410
3482
  // src/commands/doctor.ts
3411
3483
  import { access as access4, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3412
- import { join as join11, dirname as dirname2 } from "path";
3484
+ import { join as join11, dirname as dirname3 } from "path";
3413
3485
  import { execFile as execFile2 } from "child_process";
3414
3486
  import { promisify } from "util";
3415
3487
 
@@ -3502,8 +3574,8 @@ async function checkVSCodeExtension(id, label) {
3502
3574
  ];
3503
3575
  for (const dir of extDirs) {
3504
3576
  try {
3505
- const { readdir: readdir4 } = await import("fs/promises");
3506
- const entries = await readdir4(dir);
3577
+ const { readdir: readdir5 } = await import("fs/promises");
3578
+ const entries = await readdir5(dir);
3507
3579
  const prefix = id.toLowerCase() + "-";
3508
3580
  const found = entries.some(
3509
3581
  (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
@@ -3651,7 +3723,7 @@ async function fixSessionContinuity(targetDir, assetsDir) {
3651
3723
  await access4(destPath);
3652
3724
  } catch {
3653
3725
  const srcPath = join11(assetsDir, file);
3654
- await mkdir6(dirname2(destPath), { recursive: true });
3726
+ await mkdir6(dirname3(destPath), { recursive: true });
3655
3727
  await fsCopyFile2(srcPath, destPath);
3656
3728
  created.push(file);
3657
3729
  }
@@ -3762,7 +3834,7 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
3762
3834
  }
3763
3835
 
3764
3836
  // src/commands/unblock-push.ts
3765
- import { basename, resolve as resolve2 } from "path";
3837
+ import { basename as basename2, resolve as resolve2 } from "path";
3766
3838
  import { input as input4 } from "@inquirer/prompts";
3767
3839
  async function runUnblockPush(targetDir) {
3768
3840
  let manifest;
@@ -3777,10 +3849,16 @@ async function runUnblockPush(targetDir) {
3777
3849
  throw err;
3778
3850
  }
3779
3851
  const policy = manifest.push_policy ?? "open";
3780
- if (policy === "open") {
3852
+ const stillInForce = await isHookEnforced(targetDir) || (await blockedRemotes(targetDir)).length > 0;
3853
+ if (policy === "open" && !stillInForce) {
3781
3854
  printInfo("This repository is not push-blocked \u2014 nothing to undo.");
3782
3855
  return;
3783
3856
  }
3857
+ if (policy === "open") {
3858
+ printWarning(
3859
+ "The manifest says push_policy: open, but this repository still has Arcane's push block in force \u2014 most likely a previous unblock only partly succeeded. Continuing so it can be finished."
3860
+ );
3861
+ }
3784
3862
  if (!process.stdin.isTTY) {
3785
3863
  console.error(
3786
3864
  "spell unblock-push must be run from an interactive terminal.\nLifting a push block is a deliberate decision, so it cannot be scripted, piped, or run in CI."
@@ -3788,7 +3866,7 @@ async function runUnblockPush(targetDir) {
3788
3866
  process.exit(1);
3789
3867
  return;
3790
3868
  }
3791
- const repoName = basename(resolve2(targetDir));
3869
+ const repoName = basename2(resolve2(targetDir));
3792
3870
  if (repoName === "") {
3793
3871
  console.error(
3794
3872
  "Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
@@ -3857,7 +3935,7 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
3857
3935
  import { realpathSync } from "fs";
3858
3936
  var require3 = createRequire2(import.meta.url);
3859
3937
  var pkg = require3("../package.json");
3860
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join12(dirname3(fileURLToPath(import.meta.url)), "assets");
3938
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join12(dirname4(fileURLToPath(import.meta.url)), "assets");
3861
3939
  var program = new Command();
3862
3940
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
3863
3941
  await printWelcome(pkg.version, process.cwd());