arcane-cli 0.16.1 → 0.16.3

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.
@@ -220,6 +220,13 @@ Create the session branch as the **first action** of any session, before any fil
220
220
  1. `git branch -m <old> <new>`
221
221
  2. `git push -u origin <new>`
222
222
  3. `git push origin --delete <old>` (skip this if an active PR still depends on `<old>`).
223
+ - **EF-20 hazard:** on some Windows/Git-for-Windows filesystems, a rename or delete can trigger
224
+ an interactive retry prompt (e.g. a file lock from an editor or antivirus scan) that blocks
225
+ indefinitely on a terminal with no non-interactive fallback. When running these commands
226
+ directly through a tool that supports a per-call timeout (an agent's shell tool, a CI step),
227
+ set one rather than assuming the command will return. Arcane's own TypeScript Git helper
228
+ (`src/modules/git.ts`) applies this as a standing contract — closed stdin plus a
229
+ command-class timeout — for every `git` invocation the CLI itself makes.
223
230
 
224
231
  **Agents:**
225
232
 
@@ -101,7 +101,8 @@ Rules:
101
101
 
102
102
  9. **Stage and commit session-close docs.**
103
103
 
104
- **Remote-capability check:** classify the state created by `spell-open-session` before branching, staging, push, or PR operations.
104
+ **Remote-capability check:** classify the state created by `spell-open-session` before branching, staging, push, or PR operations. Check for **not a repository at all** first — every other classification assumes a `.git` directory exists.
105
+ - **Not a Git repository (EF-05):** `git rev-parse --is-inside-work-tree` fails. Do not attempt any git operation (branch, add, commit, push). Fail closed with: `No Git repository detected. Run \`git init -b main\` first (explicit -b avoids landing on "master" on systems where that's the init.defaultBranch default), then re-run spell-close-session.` This mirrors `spell init`'s own next-steps guidance for the same state.
105
106
  - **Usable supported remote/merge path:** requires an authenticated supported provider (`github.com`, `dev.azure.com`, or `visualstudio.com`). Keep the current compliant session/PR/worktree branch, resolve the actual remote name and integration branch, and use the provider-neutral PR path below.
106
107
  - **No usable remote/merge path:** remain on the current trunk. Do not create a dead session branch, push, pull, or open a PR. Apply the EF-28 interactive commit approval gate, commit locally on trunk after approval, report `Local-only close: committed on <trunk>; no remote PR/pull performed`, and skip steps 9a, 9c, and 10's remote operations.
107
108
  - **Read-only session:** if no repository mutation occurred, do not create a branch or commit.
package/dist/index.js CHANGED
@@ -1599,15 +1599,140 @@ async function runAgentsList(targetDir) {
1599
1599
 
1600
1600
  // src/modules/git.ts
1601
1601
  import { execFile } from "child_process";
1602
- import { promisify } from "util";
1603
- var execFileAsync = promisify(execFile);
1604
- async function inspectGitRepository(cwd) {
1605
- try {
1606
- const { stdout: stdout2 } = await execFileAsync(
1602
+ var GitTimeoutError = class extends Error {
1603
+ constructor(message, options) {
1604
+ super(message, options);
1605
+ this.name = "GitTimeoutError";
1606
+ }
1607
+ };
1608
+ var NETWORK_SUBCOMMANDS = /* @__PURE__ */ new Set(["fetch", "pull", "push", "clone", "ls-remote", "send-email"]);
1609
+ var WRITE_SUBCOMMANDS = /* @__PURE__ */ new Set([
1610
+ "init",
1611
+ "commit",
1612
+ "add",
1613
+ "checkout",
1614
+ "switch",
1615
+ "merge",
1616
+ "rebase",
1617
+ "reset",
1618
+ "tag",
1619
+ "mv",
1620
+ "rm",
1621
+ "cherry-pick",
1622
+ "revert",
1623
+ "stash",
1624
+ "apply",
1625
+ "am",
1626
+ "restore",
1627
+ "submodule",
1628
+ "sparse-checkout",
1629
+ "maintenance",
1630
+ "notes"
1631
+ ]);
1632
+ var AMBIGUOUS_AS_WRITE = /* @__PURE__ */ new Set(["branch", "config", "remote", "worktree"]);
1633
+ var READ_SUBCOMMANDS = /* @__PURE__ */ new Set([
1634
+ "status",
1635
+ "rev-parse",
1636
+ "log",
1637
+ "diff",
1638
+ "show",
1639
+ "describe",
1640
+ "cat-file",
1641
+ "ls-files",
1642
+ "ls-tree",
1643
+ "symbolic-ref",
1644
+ "merge-base",
1645
+ "blame",
1646
+ "shortlog",
1647
+ "grep",
1648
+ "reflog",
1649
+ "diff-tree",
1650
+ "rev-list",
1651
+ "name-rev",
1652
+ "count-objects",
1653
+ "verify-commit",
1654
+ "verify-tag",
1655
+ "help",
1656
+ "version"
1657
+ ]);
1658
+ var GLOBAL_FLAGS_WITH_SEPARATE_VALUE = /* @__PURE__ */ new Set(["-c", "-C"]);
1659
+ var DEFAULT_TIMEOUTS_MS = {
1660
+ read: 15e3,
1661
+ write: 3e4,
1662
+ network: 12e4
1663
+ };
1664
+ function classifyGitCommand(args) {
1665
+ let subcommand;
1666
+ for (let i = 0; i < args.length; i++) {
1667
+ const arg = args[i];
1668
+ if (GLOBAL_FLAGS_WITH_SEPARATE_VALUE.has(arg)) {
1669
+ i++;
1670
+ continue;
1671
+ }
1672
+ if (arg.startsWith("-")) continue;
1673
+ subcommand = arg;
1674
+ break;
1675
+ }
1676
+ if (!subcommand) return "read";
1677
+ if (NETWORK_SUBCOMMANDS.has(subcommand)) return "network";
1678
+ if (WRITE_SUBCOMMANDS.has(subcommand) || AMBIGUOUS_AS_WRITE.has(subcommand)) return "write";
1679
+ if (READ_SUBCOMMANDS.has(subcommand)) return "read";
1680
+ return "write";
1681
+ }
1682
+ function buildGitEnv() {
1683
+ return {
1684
+ ...process.env,
1685
+ GIT_TERMINAL_PROMPT: "0",
1686
+ GCM_INTERACTIVE: "Never",
1687
+ GIT_OPTIONAL_LOCKS: "0"
1688
+ };
1689
+ }
1690
+ function runGit(cwd, args, options = {}) {
1691
+ const commandClass = options.commandClass ?? classifyGitCommand(args);
1692
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
1693
+ return new Promise((resolve, reject) => {
1694
+ const child = execFile(
1607
1695
  "git",
1608
- ["rev-parse", "--is-inside-work-tree"],
1609
- { cwd }
1696
+ args,
1697
+ {
1698
+ cwd,
1699
+ env: buildGitEnv(),
1700
+ timeout: timeoutMs,
1701
+ // SIGTERM (Node's default) rather than SIGKILL: gives git a chance
1702
+ // to run its own cleanup on POSIX, relevant to EF-20's own concern
1703
+ // about a killed operation leaving refs/locks in an uncertain state.
1704
+ // Moot on Windows -- Node docs: both map to an abrupt TerminateProcess
1705
+ // there -- but this module isn't Windows-gated.
1706
+ killSignal: "SIGTERM",
1707
+ windowsHide: true,
1708
+ encoding: "utf8"
1709
+ },
1710
+ (error, stdout, stderr) => {
1711
+ if (error) {
1712
+ if (error.killed) {
1713
+ reject(
1714
+ new GitTimeoutError(
1715
+ // NOTE: embeds the raw args, including any secrets a future
1716
+ // network-class caller might pass (e.g. a credentialed
1717
+ // clone URL). No current caller does; redact before one does.
1718
+ `git ${args.join(" ")} timed out after ${timeoutMs}ms (class: ${commandClass})`,
1719
+ { cause: error }
1720
+ )
1721
+ );
1722
+ return;
1723
+ }
1724
+ reject(error);
1725
+ return;
1726
+ }
1727
+ resolve({ stdout, stderr });
1728
+ }
1610
1729
  );
1730
+ child.stdin?.end();
1731
+ });
1732
+ }
1733
+ async function inspectGitRepository(cwd) {
1734
+ try {
1735
+ const { stdout: stdout2 } = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
1611
1736
  if (stdout2.trim() !== "true") {
1612
1737
  return { status: "not-repository" };
1613
1738
  }
@@ -1615,28 +1740,61 @@ async function inspectGitRepository(cwd) {
1615
1740
  return { status: "not-repository" };
1616
1741
  }
1617
1742
  try {
1618
- await execFileAsync("git", ["rev-parse", "--verify", "HEAD"], { cwd });
1743
+ await runGit(cwd, ["rev-parse", "--verify", "HEAD"]);
1619
1744
  } catch {
1620
1745
  return { status: "no-commits" };
1621
1746
  }
1622
- const { stdout } = await execFileAsync(
1623
- "git",
1624
- ["status", "--porcelain", "-uall"],
1625
- { cwd }
1626
- );
1747
+ const { stdout } = await runGit(cwd, ["status", "--porcelain", "-uall"]);
1627
1748
  const lines = stdout.trim();
1628
1749
  return {
1629
1750
  status: "ready",
1630
1751
  uncommittedChanges: lines.length === 0 ? 0 : lines.split("\n").length
1631
1752
  };
1632
1753
  }
1633
- async function countUncommittedChanges(cwd) {
1754
+ async function correctUnbornMasterDefault(cwd) {
1755
+ let current;
1756
+ try {
1757
+ const { stdout } = await runGit(cwd, ["symbolic-ref", "--short", "HEAD"]);
1758
+ current = stdout.trim();
1759
+ } catch {
1760
+ return { corrected: false, to: "main" };
1761
+ }
1762
+ if (current !== "master") {
1763
+ return { corrected: false, to: "main" };
1764
+ }
1765
+ try {
1766
+ await runGit(cwd, ["rev-parse", "--verify", "HEAD"]);
1767
+ return { corrected: false, to: "main" };
1768
+ } catch {
1769
+ }
1634
1770
  try {
1635
- const { stdout } = await execFileAsync("git", ["status", "--porcelain", "-uall"], { cwd });
1636
- const lines = stdout.trim();
1637
- return lines.length === 0 ? 0 : lines.split("\n").length;
1771
+ await runGit(cwd, ["rev-parse", "--verify", "refs/heads/main"]);
1772
+ return { corrected: false, to: "main" };
1638
1773
  } catch {
1639
- return 0;
1774
+ }
1775
+ await runGit(cwd, ["symbolic-ref", "HEAD", "refs/heads/main"]);
1776
+ return { corrected: true, from: current, to: "main" };
1777
+ }
1778
+ async function ensureLocalPullRebase(cwd) {
1779
+ let rawLocalValue;
1780
+ try {
1781
+ const { stdout } = await runGit(cwd, ["config", "--local", "--get", "pull.rebase"]);
1782
+ rawLocalValue = stdout.trim();
1783
+ } catch {
1784
+ rawLocalValue = void 0;
1785
+ }
1786
+ if (rawLocalValue === void 0) {
1787
+ await runGit(cwd, ["config", "--local", "pull.rebase", "true"]);
1788
+ return { action: "set" };
1789
+ }
1790
+ try {
1791
+ const { stdout } = await runGit(cwd, ["config", "--local", "--type=bool", "--get", "pull.rebase"]);
1792
+ if (stdout.trim() === "false") {
1793
+ return { action: "explicit-false-preserved" };
1794
+ }
1795
+ return { action: "already-set" };
1796
+ } catch {
1797
+ return { action: "already-set" };
1640
1798
  }
1641
1799
  }
1642
1800
 
@@ -1716,21 +1874,42 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
1716
1874
  console.log(chalk2.hex("#a855f7")(`
1717
1875
  \u2726 Arcane v${packageVersion}
1718
1876
  `));
1877
+ let repoNotFound = false;
1719
1878
  if (!options.dryRun) {
1720
- const dirtyCount = await countUncommittedChanges(targetDir);
1721
- if (dirtyCount > 0) {
1722
- printWarning(`This repo has ${dirtyCount} uncommitted change${dirtyCount === 1 ? "" : "s"}.`);
1723
- printInfo("Running spell init will add new files to your working tree.");
1724
- console.log();
1725
- const continueAnyway = await confirm({
1726
- message: "Continue anyway?",
1727
- default: true
1728
- });
1729
- if (!continueAnyway) {
1730
- console.log("\nCancelled. Commit or stash your changes first.");
1731
- return;
1879
+ const gitState = await inspectGitRepository(targetDir);
1880
+ if (gitState.status === "not-repository") {
1881
+ repoNotFound = true;
1882
+ } else {
1883
+ if (gitState.status === "no-commits") {
1884
+ const correction = await correctUnbornMasterDefault(targetDir);
1885
+ if (correction.corrected) {
1886
+ printInfo(
1887
+ `Repointed the unborn branch from "${correction.from}" to "${correction.to}" (avoids the Git for Windows default-branch trap).`
1888
+ );
1889
+ }
1890
+ }
1891
+ if (gitState.status === "ready" && gitState.uncommittedChanges > 0) {
1892
+ printWarning(
1893
+ `This repo has ${gitState.uncommittedChanges} uncommitted change${gitState.uncommittedChanges === 1 ? "" : "s"}.`
1894
+ );
1895
+ printInfo("Running spell init will add new files to your working tree.");
1896
+ console.log();
1897
+ const continueAnyway = await confirm({
1898
+ message: "Continue anyway?",
1899
+ default: true
1900
+ });
1901
+ if (!continueAnyway) {
1902
+ console.log("\nCancelled. Commit or stash your changes first.");
1903
+ return;
1904
+ }
1905
+ console.log();
1906
+ }
1907
+ const rebaseResult = await ensureLocalPullRebase(targetDir);
1908
+ if (rebaseResult.action === "explicit-false-preserved") {
1909
+ printWarning(
1910
+ "This repo's pull.rebase is explicitly set to false, but Arcane's governance mandates rebase-and-fast-forward (see .arcane/governance/git-conventions.md). Left as-is since you set it explicitly."
1911
+ );
1732
1912
  }
1733
- console.log();
1734
1913
  }
1735
1914
  }
1736
1915
  let profile = options.profile;
@@ -1879,6 +2058,12 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
1879
2058
  "Review .arcane/governance/ docs and customize for your project"
1880
2059
  );
1881
2060
  }
2061
+ if (repoNotFound) {
2062
+ printNextStep(
2063
+ step++,
2064
+ 'Initialize Git for this project: `git init -b main` (avoids defaulting to "master" on systems where that\'s the system default)'
2065
+ );
2066
+ }
1882
2067
  printNextStep(
1883
2068
  step++,
1884
2069
  "Commit the new files: git add .arcane .github .claude AGENTS.md CLAUDE.md .arcane.json"
@@ -2391,7 +2576,7 @@ async function runUninstall(options, targetDir) {
2391
2576
  import { access as access3, readFile as readFile5, mkdir as mkdir5, copyFile as fsCopyFile2 } from "fs/promises";
2392
2577
  import { join as join10, dirname as dirname2 } from "path";
2393
2578
  import { execFile as execFile2 } from "child_process";
2394
- import { promisify as promisify2 } from "util";
2579
+ import { promisify } from "util";
2395
2580
 
2396
2581
  // src/config/incidents.ts
2397
2582
  var INCIDENT_QUEUE = [
@@ -2459,7 +2644,7 @@ function evaluateIncidentGate(records) {
2459
2644
  }
2460
2645
 
2461
2646
  // src/commands/doctor.ts
2462
- var execFileAsync2 = promisify2(execFile2);
2647
+ var execFileAsync = promisify(execFile2);
2463
2648
  async function checkNodeVersion() {
2464
2649
  const name = "Node.js version";
2465
2650
  const raw = process.version;
@@ -2497,7 +2682,7 @@ async function checkVSCodeExtension(id, label) {
2497
2682
  let installBin = "code";
2498
2683
  for (const bin of ["code", "code-insiders"]) {
2499
2684
  try {
2500
- await execFileAsync2(bin, ["--version"]);
2685
+ await execFileAsync(bin, ["--version"]);
2501
2686
  installBin = bin;
2502
2687
  break;
2503
2688
  } catch {
@@ -2559,6 +2744,29 @@ async function checkArcaneManifest(targetDir) {
2559
2744
  };
2560
2745
  }
2561
2746
  }
2747
+ async function checkPullRebase(targetDir) {
2748
+ const name = "Git pull.rebase (rebase-and-fast-forward mandate)";
2749
+ try {
2750
+ const { stdout } = await runGit(targetDir, ["config", "--type=bool", "--get", "pull.rebase"]);
2751
+ const value = stdout.trim();
2752
+ if (value === "true") {
2753
+ return { name, passed: true, message: "pull.rebase=true" };
2754
+ }
2755
+ return {
2756
+ name,
2757
+ passed: false,
2758
+ message: `pull.rebase resolves to "${value}", not "true" -- a bare \`git pull\` will create merge commits, contradicting git-conventions.md's rebase-and-fast-forward mandate. Run: git config --local pull.rebase true`,
2759
+ blocking: false
2760
+ };
2761
+ } catch {
2762
+ return {
2763
+ name,
2764
+ passed: false,
2765
+ message: "pull.rebase is unset, not a recognized boolean, or this isn't a Git repository -- effective behavior may not be rebase-and-fast-forward, and Git for Windows defaults this to false when unset. Run: git config --local pull.rebase true",
2766
+ blocking: false
2767
+ };
2768
+ }
2769
+ }
2562
2770
  function checkIncidentReleaseGate() {
2563
2771
  const result = evaluateIncidentGate(INCIDENT_QUEUE);
2564
2772
  if (result.blocked) {
@@ -2621,7 +2829,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
2621
2829
  checkNodeVersion(),
2622
2830
  checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)"),
2623
2831
  checkArcaneManifest(targetDir),
2624
- Promise.resolve(checkIncidentReleaseGate())
2832
+ Promise.resolve(checkIncidentReleaseGate()),
2833
+ checkPullRebase(targetDir)
2625
2834
  ]);
2626
2835
  const sessionResults = await checkSessionContinuity(targetDir);
2627
2836
  results.push(...sessionResults);