arcane-cli 1.17.0 → 1.17.2

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.
@@ -34,7 +34,7 @@ See also: [[README]], [[DECISIONS]], [[governance/git-conventions|Git Convention
34
34
 
35
35
  7. **Use wiki-links for cross-references. Enforcement: explicitly advisory prose (ARC-023) — no link-checker validates wiki-link usage or resolution across this repo's docs.** (e.g., `[[DECISIONS#ADR-NNN|Short Title]]`, `[[journal/YYYY-MM-DD-topic-slug|Session Label]]`). This enables knowledge graph visualization.
36
36
 
37
- 8. **Use Mermaid for diagrams. Enforcement: explicitly advisory prose (ARC-023) — no lint validates diagram block type or the generated-state-diagrams determinism convention.** (` ```mermaid ` blocks) for all flow charts, architecture diagrams, and sequence diagrams. Directory trees stay as plain code blocks. Spell output describing state the spell already computed — not authored, freehand explanatory content — additionally follows the **generated state diagrams** convention ([ARC-036](https://github.com/codemagicianhq/arcane/blob/main/DECISIONS.md#arc-036--generated-state-diagrams-deterministic-mermaid-for-computed-spell-state)): a deterministic, data-derived Mermaid diagram built only from values already in scope, never modeled or invented. Skip it for one-line outputs, speed-rule spells, or any state with only a single reading — the diagram exists to make relationships between multiple already-computed values legible, not to decorate simple output.
37
+ 8. **Use Mermaid for diagrams. Enforcement: explicitly advisory prose (ARC-023) — no lint validates diagram block type or the generated-state-diagrams determinism convention.** (` ```mermaid ` blocks) for all flow charts, architecture diagrams, and sequence diagrams. Directory trees stay as plain code blocks. Spell output describing state the spell already computed — not authored, freehand explanatory content — additionally follows the **generated state diagrams** convention ([ARC-036](https://github.com/codemagicianhq/arcane/blob/main/DECISIONS.md#arc-036--generated-state-diagrams-deterministic-mermaid-for-computed-spell-state)): a deterministic, data-derived Mermaid diagram built only from values already in scope, never modeled or invented. Skip it for one-line outputs, speed-rule spells, or any state with only a single reading — the diagram exists to make relationships between multiple already-computed values legible, not to decorate simple output. A `gitGraph` that draws commits labels every commit `<short-sha> <summary>`: the short SHA, then two to five words taken from the commit subject already in scope, never the SHA alone, so the diagram reads without the log beside it.
38
38
 
39
39
  9. **Journal files use date-prefix naming. Enforcement: explicitly advisory prose (ARC-023) — `checkSessionContinuity` only verifies `journal/.gitkeep` exists, not that individual journal filenames follow the date-prefix pattern.** `journal/YYYY-MM-DD-topic-slug.md` for chronological sorting.
40
40
 
@@ -266,11 +266,12 @@ Output format:
266
266
 
267
267
  ```mermaid
268
268
  gitGraph
269
- commit id: "<short-sha 1> succeeded"
270
- commit id: "<short-sha 2> pending"
269
+ commit id: "<short-sha 1> <summary 1> succeeded"
270
+ commit id: "<short-sha 2> <summary 2> pending"
271
271
  ```
272
272
 
273
- One commit per session commit, in order, `id` carrying its short SHA and step 1b's classification —
273
+ One commit per session commit, in order, `id` carrying its short SHA, a two-to-five-word summary of its
274
+ subject (rule 8) and step 1b's classification —
274
275
  never claim `succeeded` for anything step 1b did not confirm as such.
275
276
 
276
277
  ## Journal Updates
@@ -5,7 +5,7 @@ tagline: Conventional commits, written for you
5
5
  claude_description: Use PROACTIVELY whenever committing work during a session, even if the user just says 'commit this'.
6
6
  argument-hint: Optional focus (e.g., security, prompts, agents, infrastructure)
7
7
  agent: agent
8
- last_updated: 2026-07-05
8
+ last_updated: 2026-11-04
9
9
  ---
10
10
 
11
11
  ## Executive Summary
@@ -312,11 +312,13 @@ trunk) or no usable remote (nothing to compare against):
312
312
  gitGraph
313
313
  commit id: "<target HEAD short-sha>"
314
314
  branch <branch-name>
315
- commit id: "<short-sha 1>"
316
- commit id: "<short-sha 2>"
315
+ commit id: "<short-sha 1> <summary 1>"
316
+ commit id: "<short-sha 2> <summary 2>"
317
317
  ```
318
318
 
319
- One commit per entry in the gathered commit list, in the same order; omit the diagram (not just the
320
- commits) if that list is empty. Use `<target>`'s short SHA as the fork-point commit id.
319
+ One commit per entry in the gathered commit list, in the same order, each `id` carrying the short SHA and
320
+ then a two-to-five-word summary of that commit's subject (`<short-sha> <summary>`, rule 8): the SHA
321
+ identifies the point, the words say what it is, so the diagram reads without the log beside it. Omit the
322
+ diagram (not just the commits) if that list is empty. Use `<target>`'s short SHA as the fork-point commit id.
321
323
 
322
324
  **Next:** Continue work or run `spell-close-session` to finalize journal and session docs.
@@ -5,7 +5,7 @@ tagline: Open a PR with full context
5
5
  claude_description: Use PROACTIVELY whenever opening a pull request for the current branch, even if the user just says 'open a PR'.
6
6
  argument-hint: '[--draft] [--reviewers <name,...>] [--target <branch>] [--docs-only]'
7
7
  agent: agent
8
- last_updated: 2026-07-05
8
+ last_updated: 2026-11-04
9
9
  ---
10
10
 
11
11
  ## Executive Summary
@@ -263,12 +263,13 @@ so they need no edit.
263
263
  gitGraph
264
264
  commit id: "<target HEAD short-sha>"
265
265
  branch <branch-name>
266
- commit id: "<short-sha 1>"
267
- commit id: "<short-sha 2>"
266
+ commit id: "<short-sha 1> <summary 1>"
267
+ commit id: "<short-sha 2> <summary 2>"
268
268
  ```
269
269
 
270
270
  Same shape `spell-commit-work` uses for the identical concept — reference it rather than re-deriving
271
- the rule (D8): one commit per gathered log entry, target's short SHA as the fork point.
271
+ the rule (D8): one commit per gathered log entry labelled `<short-sha> <summary>` (rule 8), target's short SHA
272
+ as the fork point.
272
273
 
273
274
  ## Rules
274
275
 
@@ -5,7 +5,7 @@ tagline: Conventional commits, written for you
5
5
  claude_description: Use PROACTIVELY whenever committing work during a session, even if the user just says 'commit this'.
6
6
  argument-hint: Optional focus (e.g., security, prompts, agents, infrastructure)
7
7
  agent: agent
8
- last_updated: 2026-07-05
8
+ last_updated: 2026-11-04
9
9
  ---
10
10
 
11
11
  This prompt is the Arcane `spell-commit-work` spell. Read [`.arcane/spells/spell-commit-work.md`](../../.arcane/spells/spell-commit-work.md) and follow it as the complete workflow.
@@ -5,7 +5,7 @@ tagline: Open a PR with full context
5
5
  claude_description: Use PROACTIVELY whenever opening a pull request for the current branch, even if the user just says 'open a PR'.
6
6
  argument-hint: '[--draft] [--reviewers <name,...>] [--target <branch>] [--docs-only]'
7
7
  agent: agent
8
- last_updated: 2026-07-05
8
+ last_updated: 2026-11-04
9
9
  ---
10
10
 
11
11
  This prompt is the Arcane `spell-create-pull-request` spell. Read [`.arcane/spells/spell-create-pull-request.md`](../../.arcane/spells/spell-create-pull-request.md) and follow it as the complete workflow.
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { dirname as dirname5, join as join8 } from "path";
14
14
  import chalk2 from "chalk";
15
15
 
16
16
  // src/modules/copier.ts
17
- import { lstatSync } from "fs";
17
+ import { lstatSync, readlinkSync } from "fs";
18
18
  import {
19
19
  mkdir,
20
20
  access,
@@ -406,6 +406,44 @@ function validateRealTargetDirectory(targetDir, relativeDir) {
406
406
  const escape = findLinkedEscape(targetDir, relativeDir);
407
407
  if (escape !== null) throw new LinkedDirectoryError(relativeDir, escape, targetDir);
408
408
  }
409
+ var LinkedFileError = class extends LinkedDirectoryError {
410
+ constructor(relativePath, escape, targetDir) {
411
+ super(relativePath, escape, targetDir);
412
+ this.message = `Linked file: "${relativePath}" is a link to "${escape.resolvesTo}" outside target directory "${targetDir}". Arcane writes managed files only inside the target directory: replace the link with a regular file, then run the command again.`;
413
+ this.name = "LinkedFileError";
414
+ }
415
+ };
416
+ var MAX_LINK_HOPS = 40;
417
+ function resolveWriteDestination(absPath) {
418
+ let current = absPath;
419
+ for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
420
+ let isLink;
421
+ try {
422
+ isLink = lstatSync(current).isSymbolicLink();
423
+ } catch {
424
+ return resolveRealPath(current);
425
+ }
426
+ if (!isLink) return resolveRealPath(current);
427
+ current = path.resolve(resolveRealPath(path.dirname(current)), readlinkSync(current));
428
+ }
429
+ return null;
430
+ }
431
+ function findLinkedFileEscape(targetDir, relativePath) {
432
+ const abs = path.resolve(targetDir, relativePath);
433
+ try {
434
+ if (!lstatSync(abs).isSymbolicLink()) return null;
435
+ } catch {
436
+ return null;
437
+ }
438
+ const destination = resolveWriteDestination(abs);
439
+ if (destination !== null && isWithin(resolveRealPath(path.resolve(targetDir)), destination)) return null;
440
+ return { link: relativePath.replace(/\\/g, "/"), resolvesTo: destination ?? "a loop of links" };
441
+ }
442
+ function validateRealTargetFile(targetDir, relativePath) {
443
+ validateRealTargetPath(targetDir, relativePath);
444
+ const escape = findLinkedFileEscape(targetDir, relativePath);
445
+ if (escape !== null) throw new LinkedFileError(relativePath, escape, targetDir);
446
+ }
409
447
  function findLinkedEscapes(targetDir, files) {
410
448
  const byDirectory = /* @__PURE__ */ new Map();
411
449
  const byLink = /* @__PURE__ */ new Map();
@@ -709,8 +747,9 @@ async function resolveSecretsScanExcludePrefixes(targetDir) {
709
747
  }
710
748
  return [];
711
749
  }
712
- async function writeManifest(targetDir, manifest) {
750
+ async function writeManifest(targetDir, manifest, opts = {}) {
713
751
  const filePath = manifestPath(targetDir);
752
+ if (!opts.followLinks) validateRealTargetFile(targetDir, path2.relative(path2.resolve(targetDir), filePath));
714
753
  let existing = "";
715
754
  try {
716
755
  existing = await readFile2(filePath, "utf-8");
@@ -2326,6 +2365,39 @@ function validateAgentDefinition(value, source) {
2326
2365
  if (issues.length > 0) throw new AgentConfigValidationError(source, issues);
2327
2366
  return definition;
2328
2367
  }
2368
+ function hasControlCharacter(value) {
2369
+ for (let i = 0; i < value.length; i++) {
2370
+ const code = value.charCodeAt(i);
2371
+ if (code < 32 || code === 127) return true;
2372
+ }
2373
+ return false;
2374
+ }
2375
+ var OPENCLAW_HOME = "~/.openclaw";
2376
+ function requirePathSegment(value, field, issues) {
2377
+ if (typeof value !== "string") {
2378
+ requireString(value, field, issues);
2379
+ return;
2380
+ }
2381
+ const trimmed = value.trim();
2382
+ if (trimmed === "" || trimmed === "." || trimmed === "..") {
2383
+ issues.push(`${field} must name something other than "${value}"`);
2384
+ } else if (hasControlCharacter(value)) {
2385
+ issues.push(`${field} must not contain a control character such as a newline`);
2386
+ } else if (/[\\/]/.test(value)) {
2387
+ issues.push(`${field} must not contain "/" or "\\"; it becomes a file name`);
2388
+ }
2389
+ }
2390
+ function requireOpenClawRoot(value, field, issues) {
2391
+ if (typeof value !== "string") {
2392
+ requireString(value, field, issues);
2393
+ return;
2394
+ }
2395
+ const segments = value.split("/");
2396
+ const insideHome = (value === OPENCLAW_HOME || value.startsWith(`${OPENCLAW_HOME}/`)) && !hasControlCharacter(value) && !value.includes("\\") && !segments.slice(2).some((s) => s === "." || s === "..");
2397
+ if (!insideHome) {
2398
+ issues.push(`${field} must be "${OPENCLAW_HOME}" or a folder inside it, not "${value}"`);
2399
+ }
2400
+ }
2329
2401
  function validateAgentRoster(value, source) {
2330
2402
  const issues = [];
2331
2403
  const parsed = requireRecord(value, "roster", issues);
@@ -2352,7 +2424,7 @@ function validateAgentRoster(value, source) {
2352
2424
  const openclaw = requireRecord(roster.openclaw, "openclaw", issues);
2353
2425
  if (openclaw) {
2354
2426
  requireBoolean(openclaw.enabled, "openclaw.enabled", issues);
2355
- requireString(openclaw.workspace_root, "openclaw.workspace_root", issues);
2427
+ requireOpenClawRoot(openclaw.workspace_root, "openclaw.workspace_root", issues);
2356
2428
  }
2357
2429
  if (!Array.isArray(roster.roster)) {
2358
2430
  issues.push("roster must be an array");
@@ -2361,9 +2433,14 @@ function validateAgentRoster(value, source) {
2361
2433
  const item = requireRecord(entry, `roster[${index}]`, issues);
2362
2434
  if (!item) return;
2363
2435
  requireString(item.definition, `roster[${index}].definition`, issues);
2364
- if (item.name !== null) requireString(item.name, `roster[${index}].name`, issues);
2365
- requireString(item.id, `roster[${index}].id`, issues);
2366
- if (item.epithet !== void 0) requireString(item.epithet, `roster[${index}].epithet`, issues);
2436
+ if (item.name !== null) requirePathSegment(item.name, `roster[${index}].name`, issues);
2437
+ requirePathSegment(item.id, `roster[${index}].id`, issues);
2438
+ if (item.epithet !== void 0) {
2439
+ requireString(item.epithet, `roster[${index}].epithet`, issues);
2440
+ if (typeof item.epithet === "string" && hasControlCharacter(item.epithet)) {
2441
+ issues.push(`roster[${index}].epithet must not contain a control character such as a newline`);
2442
+ }
2443
+ }
2367
2444
  });
2368
2445
  }
2369
2446
  if (issues.length > 0) throw new AgentConfigValidationError(source, issues);
@@ -2453,7 +2530,7 @@ var MalformedMarkersError = class extends Error {
2453
2530
  }
2454
2531
  };
2455
2532
  async function mergeIntoFile(targetDir, relativePath, content, opts = {}) {
2456
- validateRealTargetPath(targetDir, relativePath);
2533
+ validateRealTargetFile(targetDir, relativePath);
2457
2534
  const destPath = path3.resolve(targetDir, relativePath);
2458
2535
  let existing;
2459
2536
  try {
@@ -2513,7 +2590,7 @@ ${MARKER_END}
2513
2590
  return true;
2514
2591
  }
2515
2592
  async function stripMarkerSection(targetDir, relativePath, opts = {}) {
2516
- validateRealTargetPath(targetDir, relativePath);
2593
+ validateRealTargetFile(targetDir, relativePath);
2517
2594
  const destPath = path3.resolve(targetDir, relativePath);
2518
2595
  let existing;
2519
2596
  try {
@@ -2538,6 +2615,7 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
2538
2615
  }
2539
2616
 
2540
2617
  // src/modules/agent-generator.ts
2618
+ var OPENCLAW_WORKSPACE_FILES = ["IDENTITY.md", "SOUL.md", "TOOLS.md"];
2541
2619
  function renderIdentity(def, name) {
2542
2620
  const mottosLine = def.persona.catchphrases && def.persona.catchphrases.length > 0 ? `
2543
2621
  ## Mottos
@@ -2727,6 +2805,12 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
2727
2805
  if (!def.clients.openclaw) continue;
2728
2806
  const workspaceName = entry.id === "main" ? "workspace" : `workspace-${entry.id}`;
2729
2807
  const workspaceDir = join4(openclawRoot, workspaceName);
2808
+ if (!options.dryRun) {
2809
+ validateRealTargetDirectory(openclawRoot, workspaceName);
2810
+ for (const file of OPENCLAW_WORKSPACE_FILES) {
2811
+ validateRealTargetFile(openclawRoot, `${workspaceName}/${file}`);
2812
+ }
2813
+ }
2730
2814
  try {
2731
2815
  if (!options.dryRun) {
2732
2816
  await mkdir3(workspaceDir, { recursive: true });
@@ -2769,6 +2853,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
2769
2853
  const generatedDir = join4(targetDir, ".arcane", "generated");
2770
2854
  if (!options.dryRun) {
2771
2855
  validateRealTargetDirectory(targetDir, ".arcane/generated");
2856
+ validateRealTargetFile(targetDir, ".arcane/generated/openclaw-roster.json");
2772
2857
  await mkdir3(generatedDir, { recursive: true });
2773
2858
  await writeFile5(
2774
2859
  join4(generatedDir, "openclaw-roster.json"),
@@ -2801,7 +2886,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
2801
2886
  }
2802
2887
  if (repoOptedOut) continue;
2803
2888
  if (!options.dryRun) {
2804
- validateRealTargetPath(targetDir, `.github/agents/${fileName}`);
2889
+ validateRealTargetFile(targetDir, `.github/agents/${fileName}`);
2805
2890
  await writeFile5(join4(agentsDir, fileName), content, "utf8");
2806
2891
  } else {
2807
2892
  console.log(` [dry-run] Would write: .github/agents/${fileName}`);
@@ -2919,7 +3004,7 @@ async function performAgentSync(targetDir, assetsDir, roster, options) {
2919
3004
  console.log(` ${line}`);
2920
3005
  }
2921
3006
  if (!options.dryRun) {
2922
- await writeManifest(targetDir, { ...manifest, fanout: fanout.record });
3007
+ await writeManifest(targetDir, { ...manifest, fanout: fanout.record }, { followLinks: true });
2923
3008
  }
2924
3009
  }
2925
3010
  if (result.unmanagedAgentFiles.length > 0) {
@@ -3041,7 +3126,7 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
3041
3126
  if (!options.dryRun) {
3042
3127
  if (scope === "repo") {
3043
3128
  validateRealTargetDirectory(targetDir, ".arcane/agents");
3044
- validateRealTargetPath(targetDir, ".arcane/agents.yaml");
3129
+ validateRealTargetFile(targetDir, ".arcane/agents.yaml");
3045
3130
  }
3046
3131
  await mkdir4(arcaneDir, { recursive: true });
3047
3132
  await writeFile6(join5(baseDir, "agents.yaml"), stringify(roster), "utf8");
@@ -3052,6 +3137,7 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
3052
3137
  const src = join5(assetsDir, "agents", `${roleId}.yaml`);
3053
3138
  const dest = join5(arcaneDir, `${roleId}.yaml`);
3054
3139
  if (!options.dryRun) {
3140
+ if (scope === "repo") validateRealTargetFile(targetDir, `.arcane/agents/${roleId}.yaml`);
3055
3141
  try {
3056
3142
  await copyFile2(src, dest);
3057
3143
  } catch {
@@ -3685,6 +3771,7 @@ async function installHook(cwd, hookName, body) {
3685
3771
  return { status: "already-ours" };
3686
3772
  }
3687
3773
  validateRealTargetDirectory(root, ARCANE_HOOKS_DIR);
3774
+ validateRealTargetFile(root, `${ARCANE_HOOKS_DIR}/${hookName}`);
3688
3775
  await mkdir5(join7(root, ARCANE_HOOKS_DIR), { recursive: true });
3689
3776
  await writeFile8(hookPath, body, "utf-8");
3690
3777
  await chmod(hookPath, 493);
@@ -4665,7 +4752,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
4665
4752
  scope: "user",
4666
4753
  fanout: fanout.record
4667
4754
  };
4668
- await writeManifest(storeRoot, manifest);
4755
+ await writeManifest(storeRoot, manifest, { followLinks: true });
4669
4756
  printStep(`\u2728 ${fileCount} Spells installed to ${storeRoot}/spells/`);
4670
4757
  for (const line of describeFanoutOutcomes(fanout.outcomes)) {
4671
4758
  if (line.startsWith("!")) printWarning(line.slice(2));
@@ -5656,7 +5743,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5656
5743
  for (const line of describeFanoutOutcomes(fanout.outcomes, options.dryRun)) console.log(` ${line}`);
5657
5744
  const scopePatch = defaultScopePatch(options, manifest);
5658
5745
  if (!options.dryRun && (JSON.stringify(fanout.record) !== JSON.stringify(manifest.fanout ?? {}) || scopePatch !== void 0)) {
5659
- await writeManifest(targetDir, { ...manifest, ...scopePatch, fanout: fanout.record });
5746
+ await writeManifest(targetDir, { ...manifest, ...scopePatch, fanout: fanout.record }, { followLinks: scope === "user" });
5660
5747
  }
5661
5748
  }
5662
5749
  return;
@@ -5994,7 +6081,7 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
5994
6081
  ...fanoutRecord !== void 0 ? { fanout: fanoutRecord } : {},
5995
6082
  ...defaultScopePatch(options, manifest)
5996
6083
  };
5997
- await writeManifest(targetDir, updated);
6084
+ await writeManifest(targetDir, updated, { followLinks: scope === "user" });
5998
6085
  console.log(
5999
6086
  `
6000
6087
  \u2713 Updated ${fileCount} files.`
@@ -8185,6 +8272,13 @@ async function fixSessionContinuity(targetDir, assetsDir) {
8185
8272
  );
8186
8273
  continue;
8187
8274
  }
8275
+ const fileEscape = findLinkedFileEscape(targetDir, file);
8276
+ if (fileEscape !== null) {
8277
+ console.log(
8278
+ ` ! Not created: ${file} \u2014 it is a link to "${fileEscape.resolvesTo}" outside "${resolve7(targetDir)}".`
8279
+ );
8280
+ continue;
8281
+ }
8188
8282
  const srcPath = join22(assetsDir, file);
8189
8283
  await mkdir7(dirname11(destPath), { recursive: true });
8190
8284
  await fsCopyFile(srcPath, destPath);