arcane-cli 0.24.1 → 0.26.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.
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Use PROACTIVELY when naming anything new (a spell, a field, a product, an internal concept) before committing to it — clears a candidate name against both the outside world and this repository itself.
3
+ ---
4
+
5
+ # Scry
6
+
7
+ Invoke the Arcane `spell-scry` spell workflow.
8
+
9
+ See the full prompt at `.github/prompts/spell-scry.prompt.md` for the complete workflow definition.
10
+
11
+ ---
12
+
13
+ @.github/prompts/spell-scry.prompt.md
@@ -0,0 +1,123 @@
1
+ ---
2
+ name: Spell — Scry
3
+ description: Clear a candidate name before it ships — four outward checks (who coined it, estate still trading, same-audience giants, first-association salience) plus a mandatory inward repo-local collision pass. Use when naming anything new (a spell, a field, a product, an internal concept) before committing to it.
4
+ argument-hint: '<candidate name or term>'
5
+ agent: agent
6
+ ---
7
+
8
+ ## Executive Summary
9
+
10
+ - This spell clears a candidate name before it ships, checking both the outside world and this
11
+ repository itself.
12
+ - The inward pass runs first and is mandatory, not optional — a real naming decision (ARC-028's
13
+ `workspace`) was nearly settled on a clean external read alone, until a single `grep -ri` found the
14
+ word already load-bearing inside the repository in two other senses.
15
+ - Both passes classify hits with the same taxonomy (same-space / adjacent / out-of-space) and combine
16
+ into one verdict: pass / pass-with-disclosure / kill.
17
+ - `spell ward` is the opposite direction of this same concern — it finds what leaked **in**; this spell
18
+ clears what's about to go **out**. They do not call each other; ward's denylist scanning and scry's
19
+ single-candidate classification are different operations on the same underlying grep primitive.
20
+
21
+ ---
22
+
23
+ Clear the candidate name given in the prompt argument.
24
+
25
+ ## Step 1 — Resolve the candidate
26
+
27
+ Take the term verbatim from the argument. If none was given, ask for it — do not guess a name to check.
28
+
29
+ ## Step 2 — Inward pass (mandatory, run first)
30
+
31
+ Grep this repository for the candidate term before doing any outward research:
32
+
33
+ - **Prose**: markdown, comments, documentation.
34
+ - **Identifiers**: variable, function, type, and class names — weight these **above** prose, since
35
+ consumers already depend on code identifiers in a way they never depend on a sentence mentioning a
36
+ word.
37
+ - **Config keys and schema fields**: `.arcane.json` fields, YAML/JSON config keys, any validated schema.
38
+
39
+ Use word-boundary matching (the same principle `spell ward`'s denylist scan uses, expressed here as a
40
+ `grep -w` / IDE-search equivalent over the codebase) — a bare substring match on a short candidate term
41
+ will drown in false positives from unrelated words that merely contain it.
42
+
43
+ **Classify each internal hit with the same taxonomy the outward checks use below:**
44
+
45
+ - **Same-space**: the term is already load-bearing for a closely related concept (a config field, a
46
+ directory name, a class doing adjacent work) — a genuine internal collision.
47
+ - **Adjacent**: the term appears near the concept but for something distinguishable enough that a
48
+ reader would not confuse the two once told them apart.
49
+ - **Out-of-space**: incidental — a comment, an unrelated variable, coincidental substring overlap.
50
+
51
+ **Why this runs first:** a same-space internal collision is often cheaper to find than the outward
52
+ research below, and finding one early can save the effort of a full outward pass on a name that was
53
+ already going to be killed. This is not a formality — the ARC-028 `workspace` incident is the concrete
54
+ case: a clean external read had nearly settled the decision before a single `grep -ri` found the word
55
+ already meaning something else in two other places inside this exact repository.
56
+
57
+ ## Step 3 — Outward pass: the four checks
58
+
59
+ Research the candidate term against the outside world:
60
+
61
+ 1. **Who coined it** — search for the term's origin and earliest known usage in the relevant space
62
+ (software, apps, AI, games — whichever applies to this candidate).
63
+ 2. **Is an estate/trademark holder still trading** — distinguish an actively-used, enforced brand from
64
+ an abandoned or long-defunct one. A defunct mark carries much less collision risk than a live one.
65
+ 3. **Same-audience giants** — do major, well-known players who share this candidate's actual target
66
+ audience already use this name saliently? A collision with a giant outside the target audience
67
+ matters far less than one inside it.
68
+ 4. **First-association salience per market** — for someone in the target market, what does this term
69
+ bring to mind *first*? If the first association is something else entirely, that is a real cost even
70
+ without a formal trademark conflict.
71
+
72
+ Classify each outward finding with the **same** same-space / adjacent / out-of-space taxonomy as the
73
+ inward pass — never a separate scheme. Cite sources for every outward claim; do not assert a name's
74
+ history or salience from unverified general knowledge alone.
75
+
76
+ ## Step 4 — Combine into one verdict
77
+
78
+ A name that clears outward but collides inward is not a clean pass, and the reverse is equally true —
79
+ combine both passes into a single verdict, not two separate reports:
80
+
81
+ - **pass** — no same-space finding, inward or outward; adjacent/out-of-space findings, if any, are
82
+ noted but do not block.
83
+ - **pass-with-disclosure** — a same-space finding exists but is distant, low-traffic, or otherwise minor
84
+ enough that proceeding is reasonable with the collision stated explicitly, not discovered later.
85
+ - **kill** — a genuine, blocking same-space collision, inward or outward. Do not soften this to
86
+ pass-with-disclosure merely because the outward research took effort to produce.
87
+
88
+ ## Step 5 — Report
89
+
90
+ ```markdown
91
+ ## Scry verdict: <candidate> — PASS | PASS-WITH-DISCLOSURE | KILL
92
+
93
+ ### Inward pass
94
+ | Hit | Location | Classification |
95
+ |---|---|---|
96
+ | <term usage> | <file:line> | same-space / adjacent / out-of-space |
97
+
98
+ ### Outward pass
99
+ | Check | Finding | Classification | Source |
100
+ |---|---|---|---|
101
+ | Who coined it | ... | ... | <url or citation> |
102
+ | Estate still trading | ... | ... | <url or citation> |
103
+ | Same-audience giants | ... | ... | <url or citation> |
104
+ | First-association salience | ... | ... | <url or citation> |
105
+
106
+ ### Verdict rationale
107
+ <why this combination of inward + outward findings produced this verdict>
108
+ ```
109
+
110
+ ## Related Spells
111
+
112
+ - `spell ward` — the opposite direction: finds third-party identifiers that already leaked into the
113
+ repo, rather than clearing a candidate before it ships out.
114
+
115
+ ## Rules
116
+
117
+ - **Never skip the inward pass.** It is not optional, and it runs before the outward pass, not after.
118
+ - **Never assert an outward finding without a source.** "Based on general knowledge" is not a citation.
119
+ - **Never use a separate classification scheme for inward vs. outward findings.** Same-space / adjacent
120
+ / out-of-space applies to both, so a reader can compare them directly.
121
+ - **Never round pass-with-disclosure up to pass, or down to kill, to avoid stating a nuanced verdict.**
122
+ - This spell classifies and reports. It does not rename, alias, or otherwise resolve a collision it
123
+ finds — that decision belongs to whoever asked for the name cleared.
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 dirname4, join as join12 } from "path";
9
+ import { dirname as dirname4, join as join14 } from "path";
10
10
 
11
11
  // src/commands/init.ts
12
12
  import { select as select2, confirm, input as input2 } from "@inquirer/prompts";
@@ -551,7 +551,9 @@ var COMPONENTS = [
551
551
  ".github/prompts/spell-generate-bot-icons.prompt.md",
552
552
  ".claude/commands/spell-generate-bot-icons.md",
553
553
  ".github/prompts/spell-make-discoverable.prompt.md",
554
- ".claude/commands/spell-make-discoverable.md"
554
+ ".claude/commands/spell-make-discoverable.md",
555
+ ".github/prompts/spell-scry.prompt.md",
556
+ ".claude/commands/spell-scry.md"
555
557
  ]
556
558
  },
557
559
  {
@@ -3858,8 +3860,8 @@ async function checkVSCodeExtension(id, label) {
3858
3860
  ];
3859
3861
  for (const dir of extDirs) {
3860
3862
  try {
3861
- const { readdir: readdir5 } = await import("fs/promises");
3862
- const entries = await readdir5(dir);
3863
+ const { readdir: readdir7 } = await import("fs/promises");
3864
+ const entries = await readdir7(dir);
3863
3865
  const prefix = id.toLowerCase() + "-";
3864
3866
  const found = entries.some(
3865
3867
  (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
@@ -4194,6 +4196,215 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
4194
4196
  }
4195
4197
  }
4196
4198
 
4199
+ // src/modules/ward.ts
4200
+ import { readdir as readdir6, readFile as readFile8 } from "fs/promises";
4201
+ import { join as join13, relative as relative2 } from "path";
4202
+
4203
+ // src/modules/denylist-scan.ts
4204
+ import { readFile as readFile7, readdir as readdir5 } from "fs/promises";
4205
+ import { join as join12, relative } from "path";
4206
+ function escapeRegExp(value) {
4207
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4208
+ }
4209
+ function createDenylistRules(tokens, labelPrefix) {
4210
+ return tokens.map((token, index) => ({
4211
+ label: `${labelPrefix}-${index + 1}`,
4212
+ pattern: new RegExp(
4213
+ `(?:^|[^A-Za-z0-9])${escapeRegExp(token)}(?=$|[^A-Za-z0-9])`,
4214
+ "i"
4215
+ )
4216
+ }));
4217
+ }
4218
+ var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
4219
+ "node_modules",
4220
+ ".git",
4221
+ "dist",
4222
+ "coverage",
4223
+ ".vitest"
4224
+ ]);
4225
+ var DEFAULT_SCANNABLE_EXTENSIONS = [".md", ".ts", ".js", ".json", ".yml", ".yaml"];
4226
+ async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT_SCANNABLE_EXTENSIONS) {
4227
+ const collected = [];
4228
+ let entries;
4229
+ try {
4230
+ entries = await readdir5(root, { withFileTypes: true });
4231
+ } catch {
4232
+ return collected;
4233
+ }
4234
+ for (const entry of entries) {
4235
+ const absolutePath = join12(root, entry.name);
4236
+ if (entry.isDirectory()) {
4237
+ if (SKIP_DIRECTORIES.has(entry.name)) continue;
4238
+ collected.push(...await collectScannableFiles(absolutePath, repoRoot, extensions));
4239
+ } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
4240
+ const displayPath = relative(repoRoot, absolutePath).replace(/\\/g, "/");
4241
+ collected.push([absolutePath, displayPath]);
4242
+ }
4243
+ }
4244
+ return collected;
4245
+ }
4246
+
4247
+ // src/modules/ward.ts
4248
+ async function scanFileAllRules(absolutePath, displayPath, rules) {
4249
+ const findings = [];
4250
+ let content;
4251
+ try {
4252
+ content = await readFile8(absolutePath, "utf8");
4253
+ } catch {
4254
+ return findings;
4255
+ }
4256
+ const lines = content.split("\n");
4257
+ for (let index = 0; index < lines.length; index++) {
4258
+ const searchable = lines[index].replace(/\{[A-Z][A-Z0-9_]*\}/g, "");
4259
+ for (const rule of rules) {
4260
+ if (rule.pattern.test(searchable)) {
4261
+ findings.push({ file: displayPath, line: index + 1, rule: rule.label });
4262
+ }
4263
+ }
4264
+ }
4265
+ return findings;
4266
+ }
4267
+ var VENDOR_IDENTIFIER_PROTECTION_LIST = [
4268
+ "claude",
4269
+ "gpt-4",
4270
+ "gpt-3.5",
4271
+ "gemini",
4272
+ "llama",
4273
+ "mistral",
4274
+ "copilot",
4275
+ "anthropic",
4276
+ "openai"
4277
+ ];
4278
+ var GREP_PROOF_EXTENSIONS = [".gif", ".png", ".jpg", ".jpeg", ".mp4", ".mov", ".mp3", ".wav"];
4279
+ var WARD_TEXT_EXTENSIONS = [
4280
+ ".md",
4281
+ ".ts",
4282
+ ".tsx",
4283
+ ".js",
4284
+ ".jsx",
4285
+ ".json",
4286
+ ".yml",
4287
+ ".yaml",
4288
+ ".txt",
4289
+ ".mdx"
4290
+ ];
4291
+ function applyExclusions(findings, fileLines, exclusions) {
4292
+ if (exclusions.length === 0) return findings;
4293
+ return findings.filter((finding) => {
4294
+ const applicable = exclusions.filter((e) => e.rule === finding.rule);
4295
+ if (applicable.length === 0) return true;
4296
+ const lines = fileLines.get(finding.file);
4297
+ const lineText = lines?.[finding.line - 1] ?? "";
4298
+ return !applicable.some((e) => lineText.includes(e.context));
4299
+ });
4300
+ }
4301
+ async function scanFilenames(root, rules, repoRoot = root) {
4302
+ const findings = [];
4303
+ let entries;
4304
+ try {
4305
+ entries = await readdir6(root, { withFileTypes: true });
4306
+ } catch {
4307
+ return findings;
4308
+ }
4309
+ for (const entry of entries) {
4310
+ const absolutePath = join13(root, entry.name);
4311
+ if (entry.isDirectory()) {
4312
+ if (SKIP_DIRECTORIES.has(entry.name)) continue;
4313
+ findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
4314
+ continue;
4315
+ }
4316
+ const displayPath = relative2(repoRoot, absolutePath).replace(/\\/g, "/");
4317
+ for (const rule of rules) {
4318
+ if (rule.pattern.test(entry.name)) {
4319
+ findings.push({ file: displayPath, line: 0, rule: rule.label });
4320
+ break;
4321
+ }
4322
+ }
4323
+ }
4324
+ return findings;
4325
+ }
4326
+ async function flagGrepProofMedia(root, repoRoot = root) {
4327
+ const files = await collectScannableFiles(root, repoRoot, GREP_PROOF_EXTENSIONS);
4328
+ return files.map(([, displayPath]) => ({
4329
+ file: displayPath,
4330
+ reason: "binary media format -- text extraction is unreliable; review manually for embedded identifiers"
4331
+ }));
4332
+ }
4333
+ async function runWard(repoRoot, options = {}) {
4334
+ const userTerms = options.terms ?? [];
4335
+ const exclusions = options.exclusions ?? [];
4336
+ const extensions = [...WARD_TEXT_EXTENSIONS, ...options.binaryExtensions ?? []];
4337
+ const userRules = createDenylistRules(userTerms, "ward-term");
4338
+ const vendorRules = createDenylistRules([...VENDOR_IDENTIFIER_PROTECTION_LIST], "ward-vendor");
4339
+ const allRules = [...userRules, ...vendorRules];
4340
+ const contentFiles = await collectScannableFiles(repoRoot, repoRoot, extensions);
4341
+ const fileLines = /* @__PURE__ */ new Map();
4342
+ const contentFindings = [];
4343
+ for (const [absolutePath, displayPath] of contentFiles) {
4344
+ const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
4345
+ if (findings2.length > 0) {
4346
+ try {
4347
+ fileLines.set(displayPath, (await readFile8(absolutePath, "utf8")).split("\n"));
4348
+ } catch {
4349
+ }
4350
+ }
4351
+ contentFindings.push(...findings2);
4352
+ }
4353
+ const filenameFindings = await scanFilenames(repoRoot, allRules);
4354
+ const combined = [...contentFindings, ...filenameFindings];
4355
+ const excluded = applyExclusions(combined, fileLines, exclusions);
4356
+ const vendorLabels = new Set(vendorRules.map((r) => r.label));
4357
+ const findings = excluded.map((f) => ({
4358
+ ...f,
4359
+ category: vendorLabels.has(f.rule) ? "protected-vendor-identifier" : "leak"
4360
+ }));
4361
+ const mediaFlags = await flagGrepProofMedia(repoRoot);
4362
+ return { findings, mediaFlags };
4363
+ }
4364
+
4365
+ // src/commands/ward.ts
4366
+ function parseTerms(raw) {
4367
+ if (!raw) return [];
4368
+ return raw.split(",").map((t) => t.trim()).filter(Boolean);
4369
+ }
4370
+ async function runWardCli(targetDir, options = {}, exclusions = []) {
4371
+ const terms = parseTerms(options.terms);
4372
+ const report = await runWard(targetDir, { terms, exclusions });
4373
+ console.log("\nspell ward \u2014 scanning for leaked identifiers\n");
4374
+ const leaks = report.findings.filter((f) => f.category === "leak");
4375
+ const protectedHits = report.findings.filter((f) => f.category === "protected-vendor-identifier");
4376
+ if (leaks.length === 0 && protectedHits.length === 0) {
4377
+ console.log(" \u2713 No denylisted terms found.");
4378
+ }
4379
+ for (const finding of leaks) {
4380
+ const where = finding.line === 0 ? `${finding.file} (filename)` : `${finding.file}:${finding.line}`;
4381
+ console.log(` \u2717 [leak] ${where} \u2014 rule: ${finding.rule}`);
4382
+ }
4383
+ for (const finding of protectedHits) {
4384
+ const where = finding.line === 0 ? `${finding.file} (filename)` : `${finding.file}:${finding.line}`;
4385
+ console.log(
4386
+ ` \u26A0 [protected-vendor-identifier] ${where} \u2014 rule: ${finding.rule} \u2014 verify before any automated rename touches this line`
4387
+ );
4388
+ }
4389
+ if (report.mediaFlags.length > 0) {
4390
+ console.log("\n Grep-proof media flagged for manual review:");
4391
+ for (const flag of report.mediaFlags) {
4392
+ console.log(` ? ${flag.file} \u2014 ${flag.reason}`);
4393
+ }
4394
+ }
4395
+ console.log();
4396
+ if (options.gate && leaks.length > 0) {
4397
+ console.log(` ${leaks.length} leak(s) found. Failing (--gate mode).
4398
+ `);
4399
+ process.exitCode = 1;
4400
+ return;
4401
+ }
4402
+ if (leaks.length > 0) {
4403
+ console.log(` ${leaks.length} leak(s) found. Run with --gate to fail CI on this.
4404
+ `);
4405
+ }
4406
+ }
4407
+
4197
4408
  // src/commands/unblock-push.ts
4198
4409
  import { basename as basename2, resolve as resolve2 } from "path";
4199
4410
  import { input as input4 } from "@inquirer/prompts";
@@ -4296,7 +4507,7 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
4296
4507
  import { realpathSync } from "fs";
4297
4508
  var require3 = createRequire2(import.meta.url);
4298
4509
  var pkg = require3("../package.json");
4299
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join12(dirname4(fileURLToPath(import.meta.url)), "assets");
4510
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join14(dirname4(fileURLToPath(import.meta.url)), "assets");
4300
4511
  var program = new Command();
4301
4512
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
4302
4513
  if (program.args.length > 0) {
@@ -4362,6 +4573,9 @@ program.command("unblock-push").description("Lift this repository's push block (
4362
4573
  program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").action(async (opts) => {
4363
4574
  await runDoctor(process.cwd(), { fix: opts.fix }, ASSETS_DIR);
4364
4575
  });
4576
+ program.command("ward").description("Scan the repo for leaked third-party identifiers and trademarks (T18)").option("--terms <terms>", "Comma-separated denylist terms, in addition to the built-in vendor-identifier list").option("--gate", "Exit non-zero if any leak is found (for CI)").action(async (opts) => {
4577
+ await runWardCli(process.cwd(), opts);
4578
+ });
4365
4579
  var agentsCommand = program.command("agents").description("Manage the portable agent system (init, sync, list)");
4366
4580
  agentsCommand.command("init").description(
4367
4581
  "Interactive agent roster setup \u2014 choose profile, naming strategy, and install definitions"