arcane-cli 0.24.0 → 0.25.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.
|
@@ -10,7 +10,7 @@ agent: agent
|
|
|
10
10
|
- This prompt chains all Spell Loop phases into a single autonomous pipeline.
|
|
11
11
|
- Includes optional PRD enchantment (spell-enchant) between Plan and Architect for quality enhancement.
|
|
12
12
|
- Only one human touchpoint: approving the PR at ship time.
|
|
13
|
-
- Delegates each phase to the appropriate specialized agent role (research, architecture, build, QA, marketing review). Resolve concrete personas from [agent-policies](../../.arcane/governance/agent-policies.md) / [naming-conventions](../../.arcane/governance/naming-conventions.md); if neither is present, proceed with role names directly.
|
|
13
|
+
- Delegates each phase to the appropriate specialized agent role (research, architecture, build, QA, marketing review) — **in practice, a single agent switches hats per phase** unless a real per-tool subagent registry is configured (e.g. `.claude/agents/` for Claude Code); this framework ships no such registry by default, so "delegates" names the intended role model, not a literal handoff between separately running agents (2026-07-22 dogfooding finding). Resolve concrete personas from [agent-policies](../../.arcane/governance/agent-policies.md) / [naming-conventions](../../.arcane/governance/naming-conventions.md); if neither is present, proceed with role names directly.
|
|
14
14
|
- If any phase fails, the pipeline halts and reports the blocker — no silent failures.
|
|
15
15
|
|
|
16
16
|
---
|
|
@@ -58,8 +58,9 @@ Execute the `spell-plan` workflow:
|
|
|
58
58
|
|
|
59
59
|
1. Gather requirements from the feature description.
|
|
60
60
|
2. Check existing ADRs and business docs for constraints.
|
|
61
|
-
3.
|
|
62
|
-
4.
|
|
61
|
+
3. **If the feature description hands in a pre-diagnosed root cause** (a specific file/line/mechanism already named as the cause of a bug), re-verify it against current source before accepting it — a handed-in diagnosis can be stale (2026-07-22 dogfooding finding: a filed root-cause lead was already fixed and the real causes were elsewhere). Do not carry a stale diagnosis into the PRD or stories unexamined.
|
|
62
|
+
4. Produce `PRD.md` with requirements, acceptance criteria, constraints, and tracking configuration (`tracking_mode`, optional `external_provider`, optional `adoWorkItemId`/`githubIssueId`).
|
|
63
|
+
5. **Gate:** Validate every requirement has at least one testable acceptance criterion. If scope is too large for one sprint, halt and recommend splitting.
|
|
63
64
|
|
|
64
65
|
Store the PRD in the working directory. Proceed automatically to Phase 1.5.
|
|
65
66
|
|
|
@@ -118,6 +119,7 @@ Execute the `spell-implement` workflow:
|
|
|
118
119
|
2. Loop:
|
|
119
120
|
a. Pick the next story where `passes: false`, ordered by priority.
|
|
120
121
|
b. Implement the story — minimum code to satisfy acceptance criteria.
|
|
122
|
+
b1. **DB migration guard.** If `architecture.md`/the PRD did not name a database migration as part of this feature's scope, but implementing this story is about to produce one anyway, **halt and flag it** — do not write the migration file silently (2026-07-22 dogfooding finding: this is a scoped hard guard, not a blanket ban). If a migration genuinely is in scope, **re-derive its sequence number from a fresh `git pull --ff-only` of the target branch immediately before writing the file** — not once at branch-creation time in step 0 — so a concurrent epic that landed a migration in the meantime is reflected. This is the concrete fix for a real incident: two epics run in parallel worktrees each independently claimed the same migration sequence number, invisible until human merge review.
|
|
121
123
|
c. Write tests per `governance/testing-standards.md`.
|
|
122
124
|
d. Run tests. If passing:
|
|
123
125
|
- In `external/ado` mode, commit with `#{adoWorkItemId} type(scope): description` and agent attribution trailers (`Agent`, `Model`, `Provider`).
|
|
@@ -136,8 +138,9 @@ Execute the `spell-test` workflow:
|
|
|
136
138
|
1. Run the full test suite (not just per-story — full regression).
|
|
137
139
|
2. Validate coverage: 80% line minimum, 95% critical path.
|
|
138
140
|
3. Map each acceptance criterion from the PRD to a specific passing test.
|
|
139
|
-
4.
|
|
140
|
-
5.
|
|
141
|
+
4. **If this run produced a database migration:** a fresh/empty test database is not sufficient evidence by itself — this is a real incident's root cause (a unique index conflicted with legitimate duplicate rows the target app allows by design; every test passed against an empty DB, then broke the consumer's actual deploy). If the execution sandbox has access to a snapshot or copy of the target environment's real data, replay the migration against it and report the result. **If it does not** (the common case for an isolated sandbox), do not silently proceed as if this were validated — name the gap explicitly in the ship report's disclosure (see below) so a human reviews the migration against real data before it ships.
|
|
142
|
+
5. Update `stories.json` with consolidated test evidence.
|
|
143
|
+
6. **Gate:** If coverage is below threshold or any acceptance criterion lacks a test, loop back to Phase 3 to add missing tests. Maximum 2 coverage-fix loops before halting.
|
|
141
144
|
|
|
142
145
|
Proceed automatically to Phase 5.
|
|
143
146
|
|
|
@@ -159,12 +162,24 @@ Proceed automatically to Phase 6.
|
|
|
159
162
|
Execute the `spell-ship` workflow:
|
|
160
163
|
|
|
161
164
|
1. Verify all prior phases passed (PRD, architecture, all stories, tests, review).
|
|
165
|
+
1a. **PR strategy: one branch, one PR, per invocation.** This is the default and it is not optional to
|
|
166
|
+
restate per run — a 2026-07-22 dogfooding finding found this pattern had to be imposed by the
|
|
167
|
+
operator each time because the spell never stated it. If a later invocation would touch files an
|
|
168
|
+
earlier, still-open PR from a prior invocation already changed, do not open a second, conflicting PR
|
|
169
|
+
— rebase this branch onto the earlier PR's branch (or wait for it to merge, per this spell's own
|
|
170
|
+
multi-epic serialization default in the Rules section) before opening this one.
|
|
162
171
|
2. Run pre-deploy checklist:
|
|
163
172
|
- No merge conflicts with target branch.
|
|
164
173
|
- No secrets or credentials in the diff.
|
|
165
174
|
- DECISIONS.md updated if new ADRs were created.
|
|
166
175
|
- Documentation updated for user-facing changes.
|
|
167
176
|
3. Generate the ship report (phase status table, test evidence, review findings, recommendation).
|
|
177
|
+
**Mandatory disclosure section:** explicitly list anything this run could not verify in its own
|
|
178
|
+
execution sandbox — a real-data migration replay Phase 4 couldn't attempt, an external integration
|
|
179
|
+
it couldn't reach, a manual step it couldn't perform — never omit this section just because nothing
|
|
180
|
+
applies (state "Nothing withheld" in that case). Fold it into the consumer repo's own QA checklist
|
|
181
|
+
so a human reviewer sees exactly what still needs a human look, not just what passed (2026-07-22
|
|
182
|
+
dogfooding finding: honest disclosure of sandbox limits, not just of failures).
|
|
168
183
|
4. Create the PR:
|
|
169
184
|
- `external/ado` mode: `az repos pr create --repository {repo} --source-branch {branch} --target-branch main --title "{feature}" --work-items {adoWorkItemId}`
|
|
170
185
|
- `internal` mode (or provider TODO mode): create PR without `--work-items`.
|
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
|
|
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";
|
|
@@ -3858,8 +3858,8 @@ async function checkVSCodeExtension(id, label) {
|
|
|
3858
3858
|
];
|
|
3859
3859
|
for (const dir of extDirs) {
|
|
3860
3860
|
try {
|
|
3861
|
-
const { readdir:
|
|
3862
|
-
const entries = await
|
|
3861
|
+
const { readdir: readdir7 } = await import("fs/promises");
|
|
3862
|
+
const entries = await readdir7(dir);
|
|
3863
3863
|
const prefix = id.toLowerCase() + "-";
|
|
3864
3864
|
const found = entries.some(
|
|
3865
3865
|
(e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
|
|
@@ -4194,6 +4194,215 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
4194
4194
|
}
|
|
4195
4195
|
}
|
|
4196
4196
|
|
|
4197
|
+
// src/modules/ward.ts
|
|
4198
|
+
import { readdir as readdir6, readFile as readFile8 } from "fs/promises";
|
|
4199
|
+
import { join as join13, relative as relative2 } from "path";
|
|
4200
|
+
|
|
4201
|
+
// src/modules/denylist-scan.ts
|
|
4202
|
+
import { readFile as readFile7, readdir as readdir5 } from "fs/promises";
|
|
4203
|
+
import { join as join12, relative } from "path";
|
|
4204
|
+
function escapeRegExp(value) {
|
|
4205
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4206
|
+
}
|
|
4207
|
+
function createDenylistRules(tokens, labelPrefix) {
|
|
4208
|
+
return tokens.map((token, index) => ({
|
|
4209
|
+
label: `${labelPrefix}-${index + 1}`,
|
|
4210
|
+
pattern: new RegExp(
|
|
4211
|
+
`(?:^|[^A-Za-z0-9])${escapeRegExp(token)}(?=$|[^A-Za-z0-9])`,
|
|
4212
|
+
"i"
|
|
4213
|
+
)
|
|
4214
|
+
}));
|
|
4215
|
+
}
|
|
4216
|
+
var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
|
|
4217
|
+
"node_modules",
|
|
4218
|
+
".git",
|
|
4219
|
+
"dist",
|
|
4220
|
+
"coverage",
|
|
4221
|
+
".vitest"
|
|
4222
|
+
]);
|
|
4223
|
+
var DEFAULT_SCANNABLE_EXTENSIONS = [".md", ".ts", ".js", ".json", ".yml", ".yaml"];
|
|
4224
|
+
async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT_SCANNABLE_EXTENSIONS) {
|
|
4225
|
+
const collected = [];
|
|
4226
|
+
let entries;
|
|
4227
|
+
try {
|
|
4228
|
+
entries = await readdir5(root, { withFileTypes: true });
|
|
4229
|
+
} catch {
|
|
4230
|
+
return collected;
|
|
4231
|
+
}
|
|
4232
|
+
for (const entry of entries) {
|
|
4233
|
+
const absolutePath = join12(root, entry.name);
|
|
4234
|
+
if (entry.isDirectory()) {
|
|
4235
|
+
if (SKIP_DIRECTORIES.has(entry.name)) continue;
|
|
4236
|
+
collected.push(...await collectScannableFiles(absolutePath, repoRoot, extensions));
|
|
4237
|
+
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
4238
|
+
const displayPath = relative(repoRoot, absolutePath).replace(/\\/g, "/");
|
|
4239
|
+
collected.push([absolutePath, displayPath]);
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
return collected;
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4245
|
+
// src/modules/ward.ts
|
|
4246
|
+
async function scanFileAllRules(absolutePath, displayPath, rules) {
|
|
4247
|
+
const findings = [];
|
|
4248
|
+
let content;
|
|
4249
|
+
try {
|
|
4250
|
+
content = await readFile8(absolutePath, "utf8");
|
|
4251
|
+
} catch {
|
|
4252
|
+
return findings;
|
|
4253
|
+
}
|
|
4254
|
+
const lines = content.split("\n");
|
|
4255
|
+
for (let index = 0; index < lines.length; index++) {
|
|
4256
|
+
const searchable = lines[index].replace(/\{[A-Z][A-Z0-9_]*\}/g, "");
|
|
4257
|
+
for (const rule of rules) {
|
|
4258
|
+
if (rule.pattern.test(searchable)) {
|
|
4259
|
+
findings.push({ file: displayPath, line: index + 1, rule: rule.label });
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
}
|
|
4263
|
+
return findings;
|
|
4264
|
+
}
|
|
4265
|
+
var VENDOR_IDENTIFIER_PROTECTION_LIST = [
|
|
4266
|
+
"claude",
|
|
4267
|
+
"gpt-4",
|
|
4268
|
+
"gpt-3.5",
|
|
4269
|
+
"gemini",
|
|
4270
|
+
"llama",
|
|
4271
|
+
"mistral",
|
|
4272
|
+
"copilot",
|
|
4273
|
+
"anthropic",
|
|
4274
|
+
"openai"
|
|
4275
|
+
];
|
|
4276
|
+
var GREP_PROOF_EXTENSIONS = [".gif", ".png", ".jpg", ".jpeg", ".mp4", ".mov", ".mp3", ".wav"];
|
|
4277
|
+
var WARD_TEXT_EXTENSIONS = [
|
|
4278
|
+
".md",
|
|
4279
|
+
".ts",
|
|
4280
|
+
".tsx",
|
|
4281
|
+
".js",
|
|
4282
|
+
".jsx",
|
|
4283
|
+
".json",
|
|
4284
|
+
".yml",
|
|
4285
|
+
".yaml",
|
|
4286
|
+
".txt",
|
|
4287
|
+
".mdx"
|
|
4288
|
+
];
|
|
4289
|
+
function applyExclusions(findings, fileLines, exclusions) {
|
|
4290
|
+
if (exclusions.length === 0) return findings;
|
|
4291
|
+
return findings.filter((finding) => {
|
|
4292
|
+
const applicable = exclusions.filter((e) => e.rule === finding.rule);
|
|
4293
|
+
if (applicable.length === 0) return true;
|
|
4294
|
+
const lines = fileLines.get(finding.file);
|
|
4295
|
+
const lineText = lines?.[finding.line - 1] ?? "";
|
|
4296
|
+
return !applicable.some((e) => lineText.includes(e.context));
|
|
4297
|
+
});
|
|
4298
|
+
}
|
|
4299
|
+
async function scanFilenames(root, rules, repoRoot = root) {
|
|
4300
|
+
const findings = [];
|
|
4301
|
+
let entries;
|
|
4302
|
+
try {
|
|
4303
|
+
entries = await readdir6(root, { withFileTypes: true });
|
|
4304
|
+
} catch {
|
|
4305
|
+
return findings;
|
|
4306
|
+
}
|
|
4307
|
+
for (const entry of entries) {
|
|
4308
|
+
const absolutePath = join13(root, entry.name);
|
|
4309
|
+
if (entry.isDirectory()) {
|
|
4310
|
+
if (SKIP_DIRECTORIES.has(entry.name)) continue;
|
|
4311
|
+
findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
|
|
4312
|
+
continue;
|
|
4313
|
+
}
|
|
4314
|
+
const displayPath = relative2(repoRoot, absolutePath).replace(/\\/g, "/");
|
|
4315
|
+
for (const rule of rules) {
|
|
4316
|
+
if (rule.pattern.test(entry.name)) {
|
|
4317
|
+
findings.push({ file: displayPath, line: 0, rule: rule.label });
|
|
4318
|
+
break;
|
|
4319
|
+
}
|
|
4320
|
+
}
|
|
4321
|
+
}
|
|
4322
|
+
return findings;
|
|
4323
|
+
}
|
|
4324
|
+
async function flagGrepProofMedia(root, repoRoot = root) {
|
|
4325
|
+
const files = await collectScannableFiles(root, repoRoot, GREP_PROOF_EXTENSIONS);
|
|
4326
|
+
return files.map(([, displayPath]) => ({
|
|
4327
|
+
file: displayPath,
|
|
4328
|
+
reason: "binary media format -- text extraction is unreliable; review manually for embedded identifiers"
|
|
4329
|
+
}));
|
|
4330
|
+
}
|
|
4331
|
+
async function runWard(repoRoot, options = {}) {
|
|
4332
|
+
const userTerms = options.terms ?? [];
|
|
4333
|
+
const exclusions = options.exclusions ?? [];
|
|
4334
|
+
const extensions = [...WARD_TEXT_EXTENSIONS, ...options.binaryExtensions ?? []];
|
|
4335
|
+
const userRules = createDenylistRules(userTerms, "ward-term");
|
|
4336
|
+
const vendorRules = createDenylistRules([...VENDOR_IDENTIFIER_PROTECTION_LIST], "ward-vendor");
|
|
4337
|
+
const allRules = [...userRules, ...vendorRules];
|
|
4338
|
+
const contentFiles = await collectScannableFiles(repoRoot, repoRoot, extensions);
|
|
4339
|
+
const fileLines = /* @__PURE__ */ new Map();
|
|
4340
|
+
const contentFindings = [];
|
|
4341
|
+
for (const [absolutePath, displayPath] of contentFiles) {
|
|
4342
|
+
const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
|
|
4343
|
+
if (findings2.length > 0) {
|
|
4344
|
+
try {
|
|
4345
|
+
fileLines.set(displayPath, (await readFile8(absolutePath, "utf8")).split("\n"));
|
|
4346
|
+
} catch {
|
|
4347
|
+
}
|
|
4348
|
+
}
|
|
4349
|
+
contentFindings.push(...findings2);
|
|
4350
|
+
}
|
|
4351
|
+
const filenameFindings = await scanFilenames(repoRoot, allRules);
|
|
4352
|
+
const combined = [...contentFindings, ...filenameFindings];
|
|
4353
|
+
const excluded = applyExclusions(combined, fileLines, exclusions);
|
|
4354
|
+
const vendorLabels = new Set(vendorRules.map((r) => r.label));
|
|
4355
|
+
const findings = excluded.map((f) => ({
|
|
4356
|
+
...f,
|
|
4357
|
+
category: vendorLabels.has(f.rule) ? "protected-vendor-identifier" : "leak"
|
|
4358
|
+
}));
|
|
4359
|
+
const mediaFlags = await flagGrepProofMedia(repoRoot);
|
|
4360
|
+
return { findings, mediaFlags };
|
|
4361
|
+
}
|
|
4362
|
+
|
|
4363
|
+
// src/commands/ward.ts
|
|
4364
|
+
function parseTerms(raw) {
|
|
4365
|
+
if (!raw) return [];
|
|
4366
|
+
return raw.split(",").map((t) => t.trim()).filter(Boolean);
|
|
4367
|
+
}
|
|
4368
|
+
async function runWardCli(targetDir, options = {}, exclusions = []) {
|
|
4369
|
+
const terms = parseTerms(options.terms);
|
|
4370
|
+
const report = await runWard(targetDir, { terms, exclusions });
|
|
4371
|
+
console.log("\nspell ward \u2014 scanning for leaked identifiers\n");
|
|
4372
|
+
const leaks = report.findings.filter((f) => f.category === "leak");
|
|
4373
|
+
const protectedHits = report.findings.filter((f) => f.category === "protected-vendor-identifier");
|
|
4374
|
+
if (leaks.length === 0 && protectedHits.length === 0) {
|
|
4375
|
+
console.log(" \u2713 No denylisted terms found.");
|
|
4376
|
+
}
|
|
4377
|
+
for (const finding of leaks) {
|
|
4378
|
+
const where = finding.line === 0 ? `${finding.file} (filename)` : `${finding.file}:${finding.line}`;
|
|
4379
|
+
console.log(` \u2717 [leak] ${where} \u2014 rule: ${finding.rule}`);
|
|
4380
|
+
}
|
|
4381
|
+
for (const finding of protectedHits) {
|
|
4382
|
+
const where = finding.line === 0 ? `${finding.file} (filename)` : `${finding.file}:${finding.line}`;
|
|
4383
|
+
console.log(
|
|
4384
|
+
` \u26A0 [protected-vendor-identifier] ${where} \u2014 rule: ${finding.rule} \u2014 verify before any automated rename touches this line`
|
|
4385
|
+
);
|
|
4386
|
+
}
|
|
4387
|
+
if (report.mediaFlags.length > 0) {
|
|
4388
|
+
console.log("\n Grep-proof media flagged for manual review:");
|
|
4389
|
+
for (const flag of report.mediaFlags) {
|
|
4390
|
+
console.log(` ? ${flag.file} \u2014 ${flag.reason}`);
|
|
4391
|
+
}
|
|
4392
|
+
}
|
|
4393
|
+
console.log();
|
|
4394
|
+
if (options.gate && leaks.length > 0) {
|
|
4395
|
+
console.log(` ${leaks.length} leak(s) found. Failing (--gate mode).
|
|
4396
|
+
`);
|
|
4397
|
+
process.exitCode = 1;
|
|
4398
|
+
return;
|
|
4399
|
+
}
|
|
4400
|
+
if (leaks.length > 0) {
|
|
4401
|
+
console.log(` ${leaks.length} leak(s) found. Run with --gate to fail CI on this.
|
|
4402
|
+
`);
|
|
4403
|
+
}
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4197
4406
|
// src/commands/unblock-push.ts
|
|
4198
4407
|
import { basename as basename2, resolve as resolve2 } from "path";
|
|
4199
4408
|
import { input as input4 } from "@inquirer/prompts";
|
|
@@ -4296,7 +4505,7 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
|
|
|
4296
4505
|
import { realpathSync } from "fs";
|
|
4297
4506
|
var require3 = createRequire2(import.meta.url);
|
|
4298
4507
|
var pkg = require3("../package.json");
|
|
4299
|
-
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ??
|
|
4508
|
+
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join14(dirname4(fileURLToPath(import.meta.url)), "assets");
|
|
4300
4509
|
var program = new Command();
|
|
4301
4510
|
program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
|
|
4302
4511
|
if (program.args.length > 0) {
|
|
@@ -4362,6 +4571,9 @@ program.command("unblock-push").description("Lift this repository's push block (
|
|
|
4362
4571
|
program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").action(async (opts) => {
|
|
4363
4572
|
await runDoctor(process.cwd(), { fix: opts.fix }, ASSETS_DIR);
|
|
4364
4573
|
});
|
|
4574
|
+
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) => {
|
|
4575
|
+
await runWardCli(process.cwd(), opts);
|
|
4576
|
+
});
|
|
4365
4577
|
var agentsCommand = program.command("agents").description("Manage the portable agent system (init, sync, list)");
|
|
4366
4578
|
agentsCommand.command("init").description(
|
|
4367
4579
|
"Interactive agent roster setup \u2014 choose profile, naming strategy, and install definitions"
|