arcane-cli 1.18.0 → 1.20.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.
Files changed (30) hide show
  1. package/README.md +4 -3
  2. package/dist/assets/.arcane/governance/spell-authoring-standards.md +34 -22
  3. package/dist/assets/.arcane/governance/threat-model.md +4 -4
  4. package/dist/assets/.arcane/spells/_fragments/action-required.md +27 -0
  5. package/dist/assets/.arcane/spells/_fragments/presence-report.md +1 -1
  6. package/dist/assets/.arcane/spells/spell-address-review.md +21 -8
  7. package/dist/assets/.arcane/spells/spell-adopt-docs.md +21 -8
  8. package/dist/assets/.arcane/spells/spell-app-store-accounts.md +22 -9
  9. package/dist/assets/.arcane/spells/spell-architect.md +21 -8
  10. package/dist/assets/.arcane/spells/spell-close-session.md +26 -13
  11. package/dist/assets/.arcane/spells/spell-commit-work.md +22 -9
  12. package/dist/assets/.arcane/spells/spell-create-pull-request.md +22 -9
  13. package/dist/assets/.arcane/spells/spell-eas-store-deploy.md +21 -8
  14. package/dist/assets/.arcane/spells/spell-full-cycle.md +21 -8
  15. package/dist/assets/.arcane/spells/spell-implement.md +1 -1
  16. package/dist/assets/.arcane/spells/spell-make-discoverable.md +21 -8
  17. package/dist/assets/.arcane/spells/spell-open-session.md +24 -10
  18. package/dist/assets/.arcane/spells/spell-parallel-workers.md +23 -10
  19. package/dist/assets/.arcane/spells/spell-plan.md +21 -8
  20. package/dist/assets/.arcane/spells/spell-review-batch.md +21 -8
  21. package/dist/assets/.arcane/spells/spell-review.md +22 -9
  22. package/dist/assets/.arcane/spells/spell-scope.md +21 -8
  23. package/dist/assets/.arcane/spells/spell-ship.md +21 -8
  24. package/dist/assets/.arcane/spells/spell-sync-pull-request.md +22 -9
  25. package/dist/assets/ai-context/system-prompt-context.md +1 -1
  26. package/dist/assets/schemas/presence.schema.json +28 -9
  27. package/dist/index.js +2157 -1829
  28. package/dist/index.js.map +1 -1
  29. package/package.json +1 -1
  30. package/dist/assets/.arcane/spells/_fragments/needs-you.md +0 -14
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { createRequire as __createRequire } from "node:module";
3
3
  const require = __createRequire(import.meta.url);
4
4
 
5
5
  // src/index.ts
6
- import { Command } from "commander";
6
+ import { Command, Option } from "commander";
7
7
  import { fileURLToPath } from "url";
8
8
  import { dirname as dirname15, join as join31 } from "path";
9
9
 
@@ -7639,7 +7639,7 @@ async function uninstallUserTier(options, storeRoot, manifest) {
7639
7639
 
7640
7640
  // src/commands/doctor.ts
7641
7641
  import { access as access4, readdir as readdir11, readFile as readFile19, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
7642
- import { join as join22, dirname as dirname11, resolve as resolve7 } from "path";
7642
+ import { join as join24, dirname as dirname12, resolve as resolve7 } from "path";
7643
7643
 
7644
7644
  // src/config/incidents.ts
7645
7645
  var INCIDENT_QUEUE = [
@@ -8086,1280 +8086,1831 @@ async function scanGovernancePlaceholders(governanceDir) {
8086
8086
  return { status: "scanned", runtimeTokens, activeDocs, templateDocs, findings };
8087
8087
  }
8088
8088
 
8089
- // src/commands/doctor.ts
8090
- function isRecord7(value) {
8091
- return typeof value === "object" && value !== null && !Array.isArray(value);
8092
- }
8093
- async function checkNodeVersion() {
8094
- const name = "Node.js version";
8095
- const raw = process.version;
8096
- const major = parseInt(raw.replace(/^v/, "").split(".")[0] ?? "0", 10);
8097
- if (major >= 18) {
8098
- return { name, passed: true, message: `${raw} (>=18 required)` };
8089
+ // src/modules/presence/client.ts
8090
+ import { request } from "http";
8091
+
8092
+ // src/modules/presence/store.ts
8093
+ import { randomBytes as randomBytes2 } from "crypto";
8094
+ import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
8095
+ import { join as join23, relative as relative2 } from "path";
8096
+
8097
+ // src/modules/session-store.ts
8098
+ import { execFileSync } from "child_process";
8099
+ import { mkdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync, existsSync as existsSync2 } from "fs";
8100
+ import { userInfo } from "os";
8101
+ import { dirname as dirname11, join as join22 } from "path";
8102
+
8103
+ // src/modules/credential-store.ts
8104
+ import { createHash as createHash4, randomBytes } from "crypto";
8105
+ var CHUNK_SIZE = 1e3;
8106
+ var MAX_CHUNKS = 64;
8107
+ var INDEX_NAME = "session/index";
8108
+ var CHUNK_PREFIX = "session/";
8109
+ var INDEX_VERSION = 1;
8110
+ var KeychainUnavailableError = class extends Error {
8111
+ constructor(operation, cause) {
8112
+ super(`The OS keychain is not available (${operation} failed).`);
8113
+ this.name = "KeychainUnavailableError";
8114
+ this.cause = cause;
8099
8115
  }
8100
- return {
8101
- name,
8102
- passed: false,
8103
- message: `${raw} \u2014 Node.js 18 or later is required`
8104
- };
8116
+ };
8117
+ function sha256Hex(value) {
8118
+ return createHash4("sha256").update(value, "utf8").digest("hex");
8105
8119
  }
8106
- async function checkVSCodeExtension(id, label2, homeDir = resolveHomeDir()) {
8107
- const name = `VS Code extension: ${label2}`;
8108
- const flavours = [
8109
- { dir: join22(homeDir, ".vscode", "extensions"), bin: "code" },
8110
- { dir: join22(homeDir, ".vscode-insiders", "extensions"), bin: "code-insiders" }
8111
- ];
8112
- let installBin = null;
8113
- for (const flavour of flavours) {
8114
- let entries;
8115
- try {
8116
- entries = await readdir11(flavour.dir);
8117
- } catch {
8118
- continue;
8119
- }
8120
- installBin ??= flavour.bin;
8121
- const prefix = id.toLowerCase() + "-";
8122
- const found = entries.some(
8123
- (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
8124
- );
8125
- if (found) {
8126
- return { name, passed: true, message: `${id} is installed` };
8120
+ function chunkName(gen, n) {
8121
+ return `${CHUNK_PREFIX}${gen}/${n}`;
8122
+ }
8123
+ function splitSecret(secret, size = CHUNK_SIZE) {
8124
+ const chunks = [];
8125
+ let start = 0;
8126
+ while (start < secret.length) {
8127
+ let end = Math.min(start + size, secret.length);
8128
+ if (end < secret.length) {
8129
+ const last = secret.charCodeAt(end - 1);
8130
+ if (last >= 55296 && last <= 56319) end -= 1;
8127
8131
  }
8132
+ chunks.push(secret.slice(start, end));
8133
+ start = end;
8128
8134
  }
8129
- return {
8130
- name,
8131
- passed: false,
8132
- message: `${id} not found. Install via: ${installBin ?? "code"} --install-extension ${id}`,
8133
- blocking: false
8134
- };
8135
+ return chunks;
8135
8136
  }
8136
- async function checkArcaneManifest(targetDir) {
8137
- const name = "Arcane manifest (.arcane.json)";
8138
- const manifestPath2 = join22(targetDir, ".arcane.json");
8137
+ function parseIndex(raw) {
8138
+ let parsed;
8139
8139
  try {
8140
- await access4(manifestPath2);
8140
+ parsed = JSON.parse(raw);
8141
8141
  } catch {
8142
- const selfHostedManifestPath = join22(targetDir, "src", "assets", ".arcane.json");
8142
+ return null;
8143
+ }
8144
+ if (typeof parsed !== "object" || parsed === null) return null;
8145
+ const o = parsed;
8146
+ if (o["v"] !== INDEX_VERSION || typeof o["gen"] !== "string" || !/^[0-9a-f]{16}$/.test(o["gen"]) || typeof o["count"] !== "number" || !Number.isInteger(o["count"]) || o["count"] < 1 || o["count"] > MAX_CHUNKS || typeof o["sha256"] !== "string" || !/^[0-9a-f]{64}$/.test(o["sha256"])) {
8147
+ return null;
8148
+ }
8149
+ return { v: o["v"], gen: o["gen"], count: o["count"], sha256: o["sha256"] };
8150
+ }
8151
+ var ChunkedSecretStore = class {
8152
+ constructor(backend) {
8153
+ this.backend = backend;
8154
+ }
8155
+ backend;
8156
+ guard(operation, fn) {
8143
8157
  try {
8144
- const raw = await readFile19(selfHostedManifestPath, "utf8");
8145
- const manifest = JSON.parse(raw);
8146
- if (isRecord7(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
8147
- return {
8148
- name,
8149
- passed: true,
8150
- message: "self-hosted source tree \u2014 committed source manifest is authoritative"
8151
- };
8152
- }
8153
- } catch {
8158
+ return fn();
8159
+ } catch (error2) {
8160
+ throw new KeychainUnavailableError(operation, error2);
8154
8161
  }
8155
- return {
8156
- name,
8157
- passed: false,
8158
- message: "No .arcane.json found. Run `spell init` to initialize Arcane in this project.",
8159
- blocking: false
8160
- };
8161
8162
  }
8162
- try {
8163
- const raw = await readFile19(manifestPath2, "utf8");
8164
- const manifest = JSON.parse(raw);
8165
- if (!isRecord7(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
8166
- return {
8167
- name,
8168
- passed: false,
8169
- message: ".arcane.json is present but missing required fields (version, components). Re-run `spell init`."
8170
- };
8163
+ /** Reads the stored secret. Never throws for bad data; throws only when the keychain itself fails. */
8164
+ load() {
8165
+ const raw = this.guard("read", () => this.backend.get(INDEX_NAME));
8166
+ if (raw === null) return { state: "signed-out" };
8167
+ const index = parseIndex(raw);
8168
+ if (index === null) return { state: "corrupt", reason: "the index entry is not valid" };
8169
+ const parts = [];
8170
+ for (let n = 0; n < index.count; n += 1) {
8171
+ const part = this.guard("read", () => this.backend.get(chunkName(index.gen, n)));
8172
+ if (part === null) return { state: "corrupt", reason: `chunk ${n} of ${index.count} is missing` };
8173
+ parts.push(part);
8171
8174
  }
8172
- return {
8173
- name,
8174
- passed: true,
8175
- message: `v${manifest["version"]} \u2014 ${manifest["components"].length} component(s) installed`
8176
- };
8177
- } catch {
8178
- return {
8179
- name,
8180
- passed: false,
8181
- message: ".arcane.json is present but contains invalid JSON. Re-run `spell init`."
8182
- };
8175
+ const secret = parts.join("");
8176
+ if (sha256Hex(secret) !== index.sha256) {
8177
+ return { state: "corrupt", reason: "the stored pieces do not match their checksum" };
8178
+ }
8179
+ return { state: "ok", secret };
8183
8180
  }
8184
- }
8185
- async function checkPullRebase(targetDir) {
8186
- const name = "Git pull.rebase (rebase-and-fast-forward mandate)";
8187
- try {
8188
- const { stdout } = await runGit(targetDir, ["config", "--type=bool", "--get", "pull.rebase"]);
8189
- const value = stdout.trim();
8190
- if (value === "true") {
8191
- return { name, passed: true, message: "pull.rebase=true" };
8181
+ /**
8182
+ * Replaces the stored secret. The previous session stays readable until the
8183
+ * new index is written; a failure before that leaves it untouched.
8184
+ */
8185
+ save(secret) {
8186
+ if (secret.length === 0) throw new Error("Refusing to store an empty secret.");
8187
+ const gen = randomBytes(8).toString("hex");
8188
+ const chunks = splitSecret(secret);
8189
+ if (chunks.length > MAX_CHUNKS) {
8190
+ throw new Error(`Refusing to store a secret longer than ${MAX_CHUNKS * CHUNK_SIZE} characters.`);
8191
+ }
8192
+ chunks.forEach((chunk, n) => {
8193
+ this.guard("write", () => this.backend.set(chunkName(gen, n), chunk));
8194
+ });
8195
+ const index = { v: INDEX_VERSION, gen, count: chunks.length, sha256: sha256Hex(secret) };
8196
+ this.guard("write", () => this.backend.set(INDEX_NAME, JSON.stringify(index)));
8197
+ try {
8198
+ this.sweep(gen);
8199
+ } catch {
8192
8200
  }
8193
- return {
8194
- name,
8195
- passed: false,
8196
- 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`,
8197
- blocking: false
8198
- };
8199
- } catch {
8200
- return {
8201
- name,
8202
- passed: false,
8203
- 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",
8204
- blocking: false
8205
- };
8206
8201
  }
8207
- }
8208
- function checkIncidentReleaseGate() {
8209
- const result = evaluateIncidentGate(INCIDENT_QUEUE);
8210
- if (result.blocked) {
8211
- return {
8212
- name: "Incident release gate (ARC-024)",
8213
- passed: false,
8214
- message: result.blockers.join("; ")
8215
- };
8202
+ /** Deletes everything this store wrote, strays included. Succeeds when nothing is stored. */
8203
+ clear() {
8204
+ this.guard("delete", () => this.backend.delete(INDEX_NAME));
8205
+ this.sweep(null);
8216
8206
  }
8217
- const accepted = result.acceptedRisks.length > 0 ? `; ${result.acceptedRisks.length} explicitly deferred` : "";
8207
+ /** Removes every chunk that does not belong to `keepGen` (all chunks when it is null). */
8208
+ sweep(keepGen) {
8209
+ const names = this.guard("list", () => this.backend.list());
8210
+ for (const name of names) {
8211
+ if (name === INDEX_NAME || !name.startsWith(CHUNK_PREFIX)) continue;
8212
+ if (keepGen !== null && name.startsWith(`${CHUNK_PREFIX}${keepGen}/`)) continue;
8213
+ this.guard("delete", () => this.backend.delete(name));
8214
+ }
8215
+ }
8216
+ };
8217
+
8218
+ // src/modules/keychain-backend.ts
8219
+ import { createRequire as createRequire2 } from "module";
8220
+ var KEYCHAIN_SERVICE = "arcane-cli";
8221
+ function createKeychainBackend(service = KEYCHAIN_SERVICE) {
8222
+ let loaded = null;
8223
+ const keyring = () => {
8224
+ loaded ??= createRequire2(import.meta.url)("@napi-rs/keyring");
8225
+ return loaded;
8226
+ };
8227
+ const entry = (name) => new (keyring()).Entry(service, name);
8218
8228
  return {
8219
- name: "Incident release gate (ARC-024)",
8220
- passed: true,
8221
- message: `${result.checked} incidents classified${accepted}`
8229
+ get: (name) => entry(name).getPassword() ?? null,
8230
+ set: (name, value) => {
8231
+ entry(name).setPassword(value);
8232
+ },
8233
+ delete: (name) => {
8234
+ entry(name).deletePassword();
8235
+ },
8236
+ list: () => keyring().findCredentials(service).map((credential) => credential.account)
8222
8237
  };
8223
8238
  }
8224
- async function checkSecrets(targetDir) {
8225
- const name = "Secrets scan (ARC-037)";
8226
- const excludePrefixes = await resolveSecretsScanExcludePrefixes(targetDir);
8227
- const findings = await scanRepository(targetDir, SECRETS_RULES, void 0, excludePrefixes);
8228
- if (findings.length === 0) {
8229
- return { name, passed: true, message: "no credential-shaped patterns found" };
8239
+
8240
+ // src/modules/session-store.ts
8241
+ var INSECURE_SESSION_FILE_NAME = "session-insecure.json";
8242
+ var SessionClearError = class extends Error {
8243
+ constructor(keychainError, removed) {
8244
+ super(keychainError.message);
8245
+ this.keychainError = keychainError;
8246
+ this.removed = removed;
8247
+ this.name = "SessionClearError";
8248
+ this.cause = keychainError;
8230
8249
  }
8231
- const summary = findings.map((f) => `${f.file}:${f.line} [${f.rule}]`).join("; ");
8232
- return {
8233
- name,
8234
- passed: false,
8235
- message: `${findings.length} credential-shaped pattern(s) found: ${summary}`
8236
- };
8250
+ keychainError;
8251
+ removed;
8252
+ };
8253
+ function isIsoDate(value) {
8254
+ return typeof value === "string" && !Number.isNaN(Date.parse(value));
8237
8255
  }
8238
- var SESSION_CONTINUITY_FILES = [
8239
- "TODO.md",
8240
- "DECISIONS.md",
8241
- "ai-context/system-prompt-context.md",
8242
- "journal/.gitkeep"
8243
- ];
8244
- async function checkSessionContinuity(targetDir) {
8245
- const results = [];
8246
- for (const file of SESSION_CONTINUITY_FILES) {
8247
- const name = `Session file: ${file}`;
8248
- const filePath = join22(targetDir, file);
8249
- try {
8250
- await access4(filePath);
8251
- results.push({ name, passed: true, message: "present" });
8252
- } catch {
8253
- results.push({
8254
- name,
8255
- passed: false,
8256
- message: `Missing. Required for spell-close-session. Run \`spell doctor --fix\` to create it.`,
8257
- blocking: false
8258
- });
8259
- }
8256
+ function parseSessionRecord(raw) {
8257
+ let parsed;
8258
+ try {
8259
+ parsed = JSON.parse(raw);
8260
+ } catch {
8261
+ return null;
8260
8262
  }
8261
- return results;
8262
- }
8263
- async function fixSessionContinuity(targetDir, assetsDir) {
8264
- const created = [];
8265
- for (const file of SESSION_CONTINUITY_FILES) {
8266
- const destPath = join22(targetDir, file);
8267
- try {
8268
- await access4(destPath);
8269
- } catch {
8270
- const escape = findLinkedEscape(targetDir, dirname11(file));
8271
- if (escape !== null) {
8272
- console.log(
8273
- ` ! Not created: ${file} \u2014 it is under "${escape.link}", a link to "${escape.resolvesTo}" outside "${resolve7(targetDir)}".`
8274
- );
8275
- continue;
8276
- }
8277
- const fileEscape = findLinkedFileEscape(targetDir, file);
8278
- if (fileEscape !== null) {
8279
- console.log(
8280
- ` ! Not created: ${file} \u2014 it is a link to "${fileEscape.resolvesTo}" outside "${resolve7(targetDir)}".`
8281
- );
8282
- continue;
8283
- }
8284
- const srcPath = join22(assetsDir, file);
8285
- await mkdir7(dirname11(destPath), { recursive: true });
8286
- await fsCopyFile(srcPath, destPath);
8287
- created.push(file);
8288
- }
8263
+ if (typeof parsed !== "object" || parsed === null) return null;
8264
+ const o = parsed;
8265
+ if (o["v"] !== 1 || o["environment"] !== "production" && o["environment"] !== "development" || typeof o["sub"] !== "string" || o["sub"] === "" || !(typeof o["email"] === "string" || o["email"] === null) || typeof o["refreshToken"] !== "string" || o["refreshToken"] === "" || !isIsoDate(o["obtainedAt"]) || !isIsoDate(o["expiresAt"])) {
8266
+ return null;
8289
8267
  }
8290
- return created;
8268
+ return {
8269
+ v: 1,
8270
+ environment: o["environment"],
8271
+ sub: o["sub"],
8272
+ email: o["email"],
8273
+ refreshToken: o["refreshToken"],
8274
+ obtainedAt: o["obtainedAt"],
8275
+ expiresAt: o["expiresAt"]
8276
+ };
8291
8277
  }
8292
- async function checkUserTier(homeDir = resolveHomeDir(), cliVersion) {
8293
- const name = "User tier (ARC-045)";
8294
- const storeRoot = userTierRoot(homeDir);
8295
- let manifest;
8296
- try {
8297
- manifest = await readManifest(storeRoot);
8298
- } catch (err) {
8299
- if (err instanceof ManifestNotFoundError) {
8300
- return {
8301
- name,
8302
- passed: true,
8303
- blocking: false,
8304
- message: "no user tier installed (optional \u2014 `spell init --user` shares every spell across the repositories on this machine)"
8305
- };
8306
- }
8307
- return {
8308
- name,
8309
- passed: false,
8310
- blocking: false,
8311
- message: `could not read ${storeRoot}/.arcane.json (${err instanceof Error ? err.message : String(err)}) \u2014 remove it and re-run \`spell init --user\``
8312
- };
8278
+ var FileSecretBackend = class {
8279
+ constructor(filePath, restrict = restrictToCurrentUser) {
8280
+ this.filePath = filePath;
8281
+ this.restrict = restrict;
8313
8282
  }
8314
- try {
8315
- if (typeof manifest.version !== "string" || manifest.version.length === 0) {
8316
- return {
8317
- name,
8318
- passed: false,
8319
- blocking: false,
8320
- message: `${storeRoot}/.arcane.json has no version field \u2014 remove the store and re-run \`spell init --user\``
8321
- };
8283
+ filePath;
8284
+ restrict;
8285
+ exists() {
8286
+ return existsSync2(this.filePath);
8287
+ }
8288
+ read() {
8289
+ if (!existsSync2(this.filePath)) return {};
8290
+ const parsed = JSON.parse(readFileSync2(this.filePath, "utf8"));
8291
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
8292
+ throw new Error("the session file is not an object");
8322
8293
  }
8323
- const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
8324
- const problems = [];
8325
- if (cliVersion !== void 0 && majorMinor(manifest.version) !== majorMinor(cliVersion)) {
8326
- problems.push(`installed at v${manifest.version}, this CLI is v${cliVersion}`);
8294
+ const out = {};
8295
+ for (const [k, v] of Object.entries(parsed)) {
8296
+ if (typeof v === "string") out[k] = v;
8327
8297
  }
8328
- const health = await inspectUserTierFanout(homeDir, manifest.fanout);
8329
- if (health.missing.length > 0) {
8330
- problems.push(`${health.missing.length} of ${health.total} client file(s) missing`);
8298
+ return out;
8299
+ }
8300
+ write(entries) {
8301
+ if (Object.keys(entries).length === 0) {
8302
+ rmSync(this.filePath, { force: true });
8303
+ return;
8331
8304
  }
8332
- if (problems.length > 0) {
8333
- return {
8334
- name,
8335
- passed: false,
8336
- blocking: false,
8337
- message: `${problems.join("; ")} \u2014 run \`spell update --user\` (regenerates missing files; never overwrites edited ones)`
8338
- };
8305
+ mkdirSync(dirname11(this.filePath), { recursive: true });
8306
+ const tmp = `${this.filePath}.${process.pid}.tmp`;
8307
+ writeFileSync(tmp, JSON.stringify(entries), { encoding: "utf8", mode: 384 });
8308
+ try {
8309
+ this.restrict(tmp);
8310
+ } catch (error2) {
8311
+ rmSync(tmp, { force: true });
8312
+ throw new Error(
8313
+ "Could not restrict the session file to your account, so it was not written.",
8314
+ { cause: error2 }
8315
+ );
8339
8316
  }
8340
- const customized = health.customized.length > 0 ? `, ${health.customized.length} customized` : "";
8341
- return {
8342
- name,
8343
- passed: true,
8344
- blocking: false,
8345
- message: `v${manifest.version} at ${storeRoot} \u2014 ${health.total} client file(s) in place${customized}`
8346
- };
8347
- } catch (err) {
8348
- return {
8349
- name,
8350
- passed: false,
8351
- blocking: false,
8352
- message: `could not inspect the user tier at ${storeRoot} (${err instanceof Error ? err.message : String(err)}) \u2014 run \`spell update --user\`; if that fails too, remove the store and re-run \`spell init --user\``
8353
- };
8354
- }
8355
- }
8356
- async function checkPushPolicy(targetDir) {
8357
- const name = "Push policy (EF-09)";
8358
- let manifest;
8359
- try {
8360
- manifest = await readManifest(targetDir);
8361
- } catch {
8362
- return { name, passed: true, message: "not initialized \u2014 nothing to check" };
8363
- }
8364
- const policy = manifest.push_policy ?? "open";
8365
- if (policy === "open") {
8366
- return { name, passed: true, message: "open \u2014 pushes allowed" };
8317
+ renameSync(tmp, this.filePath);
8367
8318
  }
8368
- if (policy === "guarded") {
8369
- const targets = await pushTargets(targetDir);
8370
- const where = targets.length === 0 ? "none configured" : targets.map(({ remote, urls }) => `${remote} \u2192 ${urls.length > 0 ? urls.join(", ") : "unknown"}`).join("; ");
8371
- return {
8372
- name,
8373
- passed: false,
8374
- blocking: false,
8375
- message: `guarded \u2014 no technical control installed. Push targets: ${where}. Confirm those are the remotes you intend before pushing.`
8376
- };
8319
+ get(name) {
8320
+ return this.read()[name] ?? null;
8377
8321
  }
8378
- const hookActive = await isHookEnforced(targetDir);
8379
- const remotes = await listRemotes(targetDir);
8380
- const open = await undisabledRemotes(targetDir);
8381
- if (hookActive && open.length === 0) {
8382
- if (remotes.length === 0) {
8383
- return {
8384
- name,
8385
- passed: false,
8386
- blocking: false,
8387
- message: "blocked \u2014 pre-push hook in place, but no remote is configured, so no push URL is disabled. If a remote is added later, run `spell doctor` again: a --no-verify push to it would succeed until the URL is disabled too."
8388
- };
8389
- }
8390
- return {
8391
- name,
8392
- passed: true,
8393
- message: `blocked \u2014 pre-push hook in place and push URL disabled for ${remotes.join(", ")}`
8394
- };
8322
+ set(name, value) {
8323
+ const entries = this.read();
8324
+ entries[name] = value;
8325
+ this.write(entries);
8395
8326
  }
8396
- const missing = [
8397
- hookActive ? null : "pre-push hook (config or hook file absent)",
8398
- open.length > 0 ? `push URL still live for: ${open.join(", ")}` : null
8399
- ].filter(Boolean);
8400
- return {
8401
- name,
8402
- passed: false,
8403
- blocking: false,
8404
- // ARC-049 decision 3: name the command that closes the gap.
8405
- message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have. Run \`spell block-push\` to install the controls.`
8406
- };
8407
- }
8408
- async function checkGovernancePlaceholders(targetDir) {
8409
- const name = "Governance placeholders (ARC-051)";
8410
- let scan;
8411
- try {
8412
- scan = await scanGovernancePlaceholders(join22(targetDir, ".arcane", "governance"));
8413
- } catch (err) {
8414
- return {
8415
- name,
8416
- passed: false,
8417
- blocking: false,
8418
- message: `could not scan .arcane/governance (${err instanceof Error ? err.message : String(err)})`
8419
- };
8327
+ delete(name) {
8328
+ const entries = this.read();
8329
+ if (!(name in entries)) return;
8330
+ delete entries[name];
8331
+ this.write(entries);
8420
8332
  }
8421
- if (scan.status === "skipped") {
8422
- return { name, passed: true, blocking: false, skipped: true, message: `skipped \u2014 ${scan.reason}` };
8333
+ list() {
8334
+ return Object.keys(this.read());
8423
8335
  }
8424
- if (scan.findings.length === 0) {
8425
- return {
8426
- name,
8427
- passed: true,
8428
- blocking: false,
8429
- message: `${scan.activeDocs.length} active doc(s) scanned, no unknown placeholders`
8430
- };
8431
- }
8432
- const listed = scan.findings.map(({ file, tokens }) => `.arcane/governance/${file}: ${tokens.join(", ")}`);
8433
- return {
8434
- name,
8435
- passed: false,
8436
- blocking: false,
8437
- message: `unknown placeholder(s) in status: active doc(s) \u2014 ${listed.join("; ")}. Fill each one in; or, if an agent resolves it at use time, add it to the runtime-placeholders list in .arcane/governance/${PLACEHOLDER_STANDARD_FILE}; or, if the document is meant to be filled in by hand, mark it \`status: template\`.`
8438
- };
8439
- }
8440
- var UNOWNED_EXAMPLE_SCOPE = "@example/";
8441
- var RETIRED_SCAFFOLD_PLACEHOLDER = "<your-mcp-server-package>";
8442
- async function checkComponentRequires(targetDir) {
8443
- const name = "Component prerequisites";
8444
- let manifest;
8445
- try {
8446
- manifest = await readManifest(targetDir);
8447
- } catch {
8448
- return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no readable .arcane.json" };
8449
- }
8450
- if (manifest.scope === "user") {
8451
- return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 the user tier holds spells only" };
8452
- }
8453
- if (!Array.isArray(manifest.components)) {
8454
- return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 .arcane.json lists no components" };
8455
- }
8456
- const missing = findMissingRequires(manifest.components, manifest.profile);
8457
- if (missing.length === 0) {
8458
- return { name, passed: true, blocking: false, message: "every installed component's prerequisites are installed" };
8336
+ /** Removes the file itself, strays and all. */
8337
+ remove() {
8338
+ rmSync(this.filePath, { force: true });
8459
8339
  }
8340
+ };
8341
+ function restrictToCurrentUser(filePath) {
8342
+ if (process.platform !== "win32") return;
8343
+ const account = userInfo().username;
8344
+ execFileSync("icacls", [filePath, "/inheritance:r", "/grant:r", `${account}:F`], {
8345
+ stdio: "ignore",
8346
+ windowsHide: true
8347
+ });
8348
+ }
8349
+ function insecureSessionFilePath(homeDir) {
8350
+ return join22(userTierRoot(homeDir), INSECURE_SESSION_FILE_NAME);
8351
+ }
8352
+ function createSessionStorage(options = {}) {
8353
+ const keychain = new ChunkedSecretStore(options.keychainBackend ?? createKeychainBackend());
8354
+ const fileBackend = options.fileBackend ?? new FileSecretBackend(insecureSessionFilePath());
8355
+ const file = new ChunkedSecretStore(fileBackend);
8356
+ const interpret = (result, source) => {
8357
+ if (result.state === "signed-out") return null;
8358
+ if (result.state === "corrupt") return { state: "corrupt", source, reason: result.reason };
8359
+ const record = parseSessionRecord(result.secret);
8360
+ if (record === null) return { state: "corrupt", source, reason: "the stored session is not valid" };
8361
+ return { state: "ok", source, record };
8362
+ };
8460
8363
  return {
8461
- name,
8462
- passed: false,
8463
- blocking: false,
8464
- message: missing.map(({ name: required, requiredBy }) => `${requiredBy.join(", ")} cites ${required}, which is not installed \u2014 ${getComponent(required).initOnly ? "" : "`spell update` installs it, or "}\`spell add ${required}\``).join("; ")
8364
+ load() {
8365
+ let keychainError;
8366
+ try {
8367
+ const fromKeychain = interpret(keychain.load(), "keychain");
8368
+ if (fromKeychain) return fromKeychain;
8369
+ } catch (error2) {
8370
+ if (!(error2 instanceof KeychainUnavailableError)) throw error2;
8371
+ keychainError = error2;
8372
+ }
8373
+ if (fileBackend.exists()) {
8374
+ const fromFile = interpret(file.load(), "file");
8375
+ if (fromFile) return fromFile;
8376
+ }
8377
+ return keychainError ? { state: "signed-out", keychainError } : { state: "signed-out" };
8378
+ },
8379
+ save(record, { insecure }) {
8380
+ const serialized = JSON.stringify(record);
8381
+ if (insecure) {
8382
+ file.save(serialized);
8383
+ try {
8384
+ keychain.clear();
8385
+ } catch {
8386
+ }
8387
+ return;
8388
+ }
8389
+ keychain.save(serialized);
8390
+ fileBackend.remove();
8391
+ },
8392
+ clear() {
8393
+ const removed = [];
8394
+ let keychainError;
8395
+ try {
8396
+ const hadKeychainSession = keychain.load().state !== "signed-out";
8397
+ keychain.clear();
8398
+ if (hadKeychainSession) removed.push("keychain");
8399
+ } catch (error2) {
8400
+ keychainError = error2;
8401
+ }
8402
+ if (fileBackend.exists()) {
8403
+ fileBackend.remove();
8404
+ removed.push("file");
8405
+ }
8406
+ if (keychainError instanceof KeychainUnavailableError) {
8407
+ throw new SessionClearError(keychainError, removed);
8408
+ }
8409
+ if (keychainError) throw keychainError;
8410
+ return { removed };
8411
+ }
8465
8412
  };
8466
8413
  }
8467
- async function checkNewlyAvailableComponents(targetDir) {
8468
- const name = "Newly available components";
8469
- let manifest;
8470
- try {
8471
- manifest = await readManifest(targetDir);
8472
- } catch {
8473
- return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no readable .arcane.json" };
8414
+
8415
+ // src/modules/presence/types.ts
8416
+ var PRESENCE_CONTRACT = "v0";
8417
+ var DEFAULT_PORT = 27226;
8418
+ var PRESENCE_ENV = { port: "ARCANE_PRESENCE_PORT", token: "ARCANE_PRESENCE_TOKEN" };
8419
+ var LOOPBACK_HOST = "127.0.0.1";
8420
+ var MAX_BODY_BYTES = 65536;
8421
+ var MAX_LINE_CHARS = 256;
8422
+ var MAX_REQUIRED_ACTIONS = 20;
8423
+ var MAX_SUBSCRIBERS = 32;
8424
+ var MAX_SUBSCRIBER_BUFFER_BYTES = 1048576;
8425
+ var MAX_CONNECTIONS = 128;
8426
+ var MAX_SESSIONS = 256;
8427
+ var MAX_ALIASES = 32;
8428
+ var CONNECTIONS_CHECK_MS = 1e3;
8429
+ var HEADERS_TIMEOUT_MS = 5e3;
8430
+ var REQUEST_TIMEOUT_MS = 1e4;
8431
+ var STALE_AFTER_MS = 9e4;
8432
+ var EXPIRE_AFTER_MS = 864e5;
8433
+ var KEEPALIVE_MS = 15e3;
8434
+ var SWEEP_MS = 5e3;
8435
+ var HARNESSES = ["claude-code", "copilot", "codex", "openclaw", "cron", "other"];
8436
+ var REPORT_STATUSES = ["working", "waiting", "blocked", "idle"];
8437
+ var ALIAS_CONTEXTS = ["openclaw", "slug"];
8438
+ var SESSION_EVENTS = [
8439
+ "session.started",
8440
+ "session.updated",
8441
+ "session.action_required",
8442
+ "session.stale",
8443
+ "session.resumed",
8444
+ "session.ended",
8445
+ "session.expired"
8446
+ ];
8447
+ var HELLO_EVENT = "server.hello";
8448
+ function presenceCapabilities() {
8449
+ return { observe: true, actions: [] };
8450
+ }
8451
+ function nullAgentBlock() {
8452
+ return { ref: null, slug: null, name: null, origin: null, definition_revision: null, aliases: [] };
8453
+ }
8454
+
8455
+ // src/modules/presence/store.ts
8456
+ var PRESENCE_DIR_NAME = "presence";
8457
+ var STORE_FILES = { token: "token", serverInfo: "server.json" };
8458
+ var PresenceStoreError = class extends Error {
8459
+ };
8460
+ function home(options) {
8461
+ return options.home ?? resolveHomeDir();
8462
+ }
8463
+ function presenceDir(options = {}) {
8464
+ return join23(userTierRoot(home(options)), PRESENCE_DIR_NAME);
8465
+ }
8466
+ function tokenPath(options = {}) {
8467
+ return join23(presenceDir(options), STORE_FILES.token);
8468
+ }
8469
+ function serverInfoPath(options = {}) {
8470
+ return join23(presenceDir(options), STORE_FILES.serverInfo);
8471
+ }
8472
+ function ensureStore(options = {}) {
8473
+ const root = userTierRoot(home(options));
8474
+ const dir = presenceDir(options);
8475
+ mkdirSync2(root, { recursive: true });
8476
+ const realRoot = resolveRealPath(root);
8477
+ if (relative2(realRoot, resolveRealPath(dir)) !== PRESENCE_DIR_NAME) {
8478
+ throw new PresenceStoreError(`Refusing to use ${dir}: it resolves outside ${root}.`);
8474
8479
  }
8475
- if (manifest.scope === "user" || !Array.isArray(manifest.components)) {
8476
- return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no repository install to compare" };
8480
+ if (relative2(realRoot, resolveRealPath(tokenPath(options))) !== join23(PRESENCE_DIR_NAME, STORE_FILES.token)) {
8481
+ throw new PresenceStoreError(`Refusing to use ${tokenPath(options)}: it resolves outside ${dir}.`);
8477
8482
  }
8478
- const installed = manifest.components.flatMap(
8479
- (c) => (LEGACY_COMPONENT_MIGRATIONS[c.name] ?? [c.name]).map((n) => ({ ...c, name: n }))
8480
- );
8481
- const prerequisites = findMissingRequires(manifest.components, manifest.profile).map((m) => m.name);
8482
- const fresh = (await findNewlyAvailable(targetDir, manifest.profile, installed, effectiveSpellScope(manifest))).filter((c) => !prerequisites.includes(c.name));
8483
- if (fresh.length === 0) {
8484
- return { name, passed: true, blocking: false, message: "the profile has nothing this install lacks" };
8483
+ mkdirSync2(dir, { recursive: true, mode: 448 });
8484
+ chmodSync(dir, 448);
8485
+ return dir;
8486
+ }
8487
+ function writeRestricted(target, content, options) {
8488
+ const restrict = options.restrict ?? restrictToCurrentUser;
8489
+ const tmp = `${target}.${process.pid}.tmp`;
8490
+ writeFileSync2(tmp, content, { encoding: "utf8", mode: 384 });
8491
+ try {
8492
+ restrict(tmp);
8493
+ } catch (error2) {
8494
+ rmSync2(tmp, { force: true });
8495
+ throw new PresenceStoreError(`Could not restrict ${target} to your account, so it was not written.`, { cause: error2 });
8485
8496
  }
8486
- const offerable = fresh.filter((c) => !getComponent(c.name).initOnly).map((c) => c.name);
8487
- const byHand = fresh.filter((c) => getComponent(c.name).initOnly).map((c) => c.name);
8488
- const parts = [];
8489
- if (offerable.length > 0) {
8490
- parts.push(`${offerable.join(", ")} \u2014 \`spell update --add-new\` installs ${offerable.length === 1 ? "it" : "them"}`);
8497
+ renameSync2(tmp, target);
8498
+ chmodSync(target, 384);
8499
+ }
8500
+ function generateToken() {
8501
+ return randomBytes2(32).toString("base64url");
8502
+ }
8503
+ function readTokenFile(options) {
8504
+ let raw;
8505
+ try {
8506
+ raw = readFileSync3(tokenPath(options), "utf8");
8507
+ } catch {
8508
+ return null;
8491
8509
  }
8492
- if (byHand.length > 0) {
8493
- parts.push(`${byHand.join(", ")} \u2014 initOnly, so only ${byHand.map((n) => `\`spell add ${n}\``).join(", ")}`);
8510
+ const token = raw.replace(/[\r\n]+$/, "").trim();
8511
+ return token.length > 0 ? token : null;
8512
+ }
8513
+ function ensureToken(options = {}) {
8514
+ ensureStore(options);
8515
+ const existing = options.rotate === true ? null : readTokenFile(options);
8516
+ if (existing !== null) {
8517
+ chmodSync(tokenPath(options), 384);
8518
+ (options.restrict ?? restrictToCurrentUser)(tokenPath(options));
8519
+ return existing;
8494
8520
  }
8495
- return { name, passed: false, blocking: false, message: `newly available for your profile, not installed: ${parts.join("; ")}` };
8521
+ const token = generateToken();
8522
+ writeRestricted(tokenPath(options), `${token}
8523
+ `, options);
8524
+ return token;
8496
8525
  }
8497
- async function checkMcpConfig(targetDir) {
8498
- const name = "MCP config (per-server timeout, I12)";
8499
- const filePath = join22(targetDir, ".mcp.json");
8500
- let raw;
8526
+ function readToken(options = {}) {
8527
+ const fromEnv = (options.env ?? process.env)[PRESENCE_ENV.token];
8528
+ if (fromEnv !== void 0 && fromEnv.replace(/[\r\n]+$/, "") !== "") return fromEnv.replace(/[\r\n]+$/, "");
8529
+ return readTokenFile(options);
8530
+ }
8531
+ function writeServerInfo(info, options = {}) {
8532
+ ensureStore(options);
8533
+ writeRestricted(serverInfoPath(options), `${JSON.stringify(info, null, 2)}
8534
+ `, options);
8535
+ }
8536
+ function readServerInfo(options = {}) {
8537
+ let parsed;
8501
8538
  try {
8502
- raw = await readFile19(filePath, "utf8");
8539
+ parsed = JSON.parse(readFileSync3(serverInfoPath(options), "utf8"));
8503
8540
  } catch {
8504
- return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
8541
+ return null;
8505
8542
  }
8506
- let rawParsed;
8543
+ if (typeof parsed !== "object" || parsed === null) return null;
8544
+ const o = parsed;
8545
+ const generator = o["generator"];
8546
+ const valid = typeof o["url"] === "string" && Number.isInteger(o["pid"]) && o["pid"] > 0 && Number.isInteger(o["port"]) && o["port"] >= 1 && o["port"] <= 65535 && typeof o["started_at"] === "string" && typeof generator === "object" && generator !== null && generator["name"] === "arcane-cli" && typeof generator["version"] === "string";
8547
+ if (!valid) return null;
8548
+ return {
8549
+ url: o["url"],
8550
+ pid: o["pid"],
8551
+ port: o["port"],
8552
+ started_at: o["started_at"],
8553
+ generator: { name: "arcane-cli", version: generator["version"] }
8554
+ };
8555
+ }
8556
+ function removeServerInfo(options = {}) {
8557
+ rmSync2(serverInfoPath(options), { force: true });
8558
+ }
8559
+ function storeExists(options = {}) {
8560
+ return existsSync3(presenceDir(options));
8561
+ }
8562
+
8563
+ // src/modules/presence/client.ts
8564
+ var CONNECT_TIMEOUT_MS = 1e3;
8565
+ var RESPONSE_TIMEOUT_MS = 1500;
8566
+ function pidAlive(pid) {
8507
8567
  try {
8508
- rawParsed = JSON.parse(raw);
8568
+ process.kill(pid, 0);
8569
+ return true;
8509
8570
  } catch {
8510
- return {
8511
- name,
8512
- passed: false,
8513
- blocking: false,
8514
- message: ".mcp.json exists but contains invalid JSON"
8515
- };
8571
+ return false;
8516
8572
  }
8517
- if (!isRecord7(rawParsed)) {
8518
- return {
8519
- name,
8520
- passed: false,
8521
- blocking: false,
8522
- message: ".mcp.json exists but is not a JSON object"
8573
+ }
8574
+ function resolveEndpoint(options = {}) {
8575
+ const info = readServerInfo(options);
8576
+ if (info === null || !pidAlive(info.pid)) return null;
8577
+ const token = readToken({ ...options, env: options.env ?? process.env });
8578
+ if (token === null) return null;
8579
+ return { info, token };
8580
+ }
8581
+ function errorFields(body) {
8582
+ const e = body?.error;
8583
+ return { code: typeof e?.code === "string" ? e.code : null, field: typeof e?.field === "string" ? e.field : null };
8584
+ }
8585
+ function send(port, token, method, path5, body, options = {}) {
8586
+ const connectTimeout = options.connectTimeoutMs ?? CONNECT_TIMEOUT_MS;
8587
+ const responseTimeout = options.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS;
8588
+ const payload = body === void 0 ? void 0 : JSON.stringify(body);
8589
+ return new Promise((resolve10) => {
8590
+ let settled = false;
8591
+ const done = (result) => {
8592
+ if (settled) return;
8593
+ settled = true;
8594
+ clearTimeout(connectTimer);
8595
+ clearTimeout(responseTimer);
8596
+ resolve10(result);
8523
8597
  };
8524
- }
8525
- const parsed = rawParsed;
8526
- const servers = parsed.mcpServers ?? {};
8527
- const serverNames = Object.keys(servers);
8528
- if (serverNames.length === 0) {
8529
- return { name, passed: true, blocking: false, message: "no MCP servers configured" };
8530
- }
8531
- const exampleHits = [];
8532
- const placeholderHits = [];
8533
- for (const n of serverNames) {
8534
- const args = servers[n]?.args;
8535
- if (!Array.isArray(args)) continue;
8536
- for (const a of args) {
8537
- if (typeof a !== "string") continue;
8538
- if (a.startsWith(UNOWNED_EXAMPLE_SCOPE)) exampleHits.push(`${n} (${a})`);
8539
- if (a === RETIRED_SCAFFOLD_PLACEHOLDER) placeholderHits.push(n);
8598
+ const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };
8599
+ if (payload !== void 0) {
8600
+ headers["Content-Type"] = "application/json";
8601
+ headers["Content-Length"] = Buffer.byteLength(payload);
8540
8602
  }
8603
+ const req = request({ host: LOOPBACK_HOST, port, method, path: path5, headers, agent: false }, (res) => {
8604
+ const chunks = [];
8605
+ res.on("data", (c) => chunks.push(c));
8606
+ res.on("error", () => done({ kind: "unreachable" }));
8607
+ res.on("end", () => {
8608
+ const status = res.statusCode ?? 0;
8609
+ const text2 = Buffer.concat(chunks).toString("utf8");
8610
+ let parsed = null;
8611
+ try {
8612
+ parsed = text2.length > 0 ? JSON.parse(text2) : null;
8613
+ } catch {
8614
+ parsed = null;
8615
+ }
8616
+ if (status >= 200 && status < 300 && parsed !== null) done({ kind: "ok", status, body: parsed });
8617
+ else if (status === 401 || status === 400 || status === 404 || status === 413) done({ kind: "refused", status, ...errorFields(parsed) });
8618
+ else done({ kind: "unreachable" });
8619
+ });
8620
+ });
8621
+ const connectTimer = setTimeout(() => {
8622
+ req.destroy();
8623
+ done({ kind: "unreachable" });
8624
+ }, connectTimeout);
8625
+ const responseTimer = setTimeout(() => {
8626
+ req.destroy();
8627
+ done({ kind: "unreachable" });
8628
+ }, connectTimeout + responseTimeout);
8629
+ req.on("socket", (socket) => {
8630
+ socket.once("connect", () => clearTimeout(connectTimer));
8631
+ });
8632
+ req.on("error", () => done({ kind: "unreachable" }));
8633
+ if (payload !== void 0) req.write(payload);
8634
+ req.end();
8635
+ });
8636
+ }
8637
+ async function callPresence(method, path5, body, options = {}) {
8638
+ const endpoint = resolveEndpoint(options);
8639
+ if (endpoint === null) return { kind: "not-configured" };
8640
+ return send(endpoint.info.port, endpoint.token, method, path5, body, options);
8641
+ }
8642
+ function openEventFeed(callbacks, options = {}) {
8643
+ const endpoint = resolveEndpoint(options);
8644
+ if (endpoint === null) return Promise.resolve(null);
8645
+ const deadline = (options.connectTimeoutMs ?? CONNECT_TIMEOUT_MS) + (options.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS);
8646
+ return new Promise((resolve10) => {
8647
+ let opened = false;
8648
+ let settled = false;
8649
+ let closedHere = false;
8650
+ let closeReported = false;
8651
+ const settle = (feed2) => {
8652
+ if (settled) return;
8653
+ settled = true;
8654
+ clearTimeout(timer);
8655
+ if (feed2 === null) req.destroy();
8656
+ resolve10(feed2);
8657
+ };
8658
+ const ended = () => {
8659
+ if (!opened) {
8660
+ settle(null);
8661
+ return;
8662
+ }
8663
+ if (closedHere || closeReported) return;
8664
+ closeReported = true;
8665
+ callbacks.onClose();
8666
+ };
8667
+ const feed = {
8668
+ close() {
8669
+ closedHere = true;
8670
+ req.destroy();
8671
+ }
8672
+ };
8673
+ const req = request(
8674
+ {
8675
+ host: LOOPBACK_HOST,
8676
+ port: endpoint.info.port,
8677
+ method: "GET",
8678
+ path: "/v0/events",
8679
+ headers: { Authorization: `Bearer ${endpoint.token}`, Accept: "text/event-stream" },
8680
+ agent: false
8681
+ },
8682
+ (res) => {
8683
+ if (res.statusCode !== 200) {
8684
+ res.resume();
8685
+ settle(null);
8686
+ return;
8687
+ }
8688
+ res.setEncoding("utf8");
8689
+ let buffer = "";
8690
+ res.on("data", (chunk) => {
8691
+ buffer += chunk.replace(/\r\n?/g, "\n");
8692
+ let at;
8693
+ while ((at = buffer.indexOf("\n\n")) >= 0) {
8694
+ const block = buffer.slice(0, at);
8695
+ buffer = buffer.slice(at + 2);
8696
+ const name = /^event: ?(.*)$/m.exec(block)?.[1];
8697
+ const data = /^data: ?(.*)$/m.exec(block)?.[1];
8698
+ if (name === void 0 || data === void 0) continue;
8699
+ let parsed;
8700
+ try {
8701
+ parsed = JSON.parse(data);
8702
+ } catch {
8703
+ continue;
8704
+ }
8705
+ if (!opened) {
8706
+ if (name !== "server.hello") {
8707
+ settle(null);
8708
+ return;
8709
+ }
8710
+ opened = true;
8711
+ settle(feed);
8712
+ continue;
8713
+ }
8714
+ if (!closedHere) callbacks.onEvent(name, parsed);
8715
+ }
8716
+ });
8717
+ res.on("end", ended);
8718
+ res.on("close", ended);
8719
+ res.on("error", ended);
8720
+ }
8721
+ );
8722
+ const timer = setTimeout(() => settle(null), deadline);
8723
+ req.on("error", ended);
8724
+ req.end();
8725
+ });
8726
+ }
8727
+
8728
+ // src/commands/doctor.ts
8729
+ import { statSync } from "fs";
8730
+ function isRecord7(value) {
8731
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8732
+ }
8733
+ async function checkNodeVersion() {
8734
+ const name = "Node.js version";
8735
+ const raw = process.version;
8736
+ const major = parseInt(raw.replace(/^v/, "").split(".")[0] ?? "0", 10);
8737
+ if (major >= 18) {
8738
+ return { name, passed: true, message: `${raw} (>=18 required)` };
8541
8739
  }
8542
- if (exampleHits.length > 0) {
8740
+ return {
8741
+ name,
8742
+ passed: false,
8743
+ message: `${raw} \u2014 Node.js 18 or later is required`
8744
+ };
8745
+ }
8746
+ async function checkArcaneManifest(targetDir) {
8747
+ const name = "Arcane manifest (.arcane.json)";
8748
+ const manifestPath2 = join24(targetDir, ".arcane.json");
8749
+ try {
8750
+ await access4(manifestPath2);
8751
+ } catch {
8752
+ const selfHostedManifestPath = join24(targetDir, "src", "assets", ".arcane.json");
8753
+ try {
8754
+ const raw = await readFile19(selfHostedManifestPath, "utf8");
8755
+ const manifest = JSON.parse(raw);
8756
+ if (isRecord7(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
8757
+ return {
8758
+ name,
8759
+ passed: true,
8760
+ message: "self-hosted source tree \u2014 committed source manifest is authoritative"
8761
+ };
8762
+ }
8763
+ } catch {
8764
+ }
8543
8765
  return {
8544
8766
  name,
8545
8767
  passed: false,
8546
- blocking: false,
8547
- message: `.mcp.json runs a package under the ${UNOWNED_EXAMPLE_SCOPE} scope nobody here owns: ${exampleHits.join(", ")}. The old \`spell init\` scaffold shipped it as a placeholder, and \`npx -y\` would run whoever publishes it. Replace the entry with your own MCP server package, or remove it; the file is yours, so doctor does not edit it`
8768
+ message: "No .arcane.json found. Run `spell init` to initialize Arcane in this project.",
8769
+ blocking: false
8548
8770
  };
8549
8771
  }
8550
- if (placeholderHits.length > 0) {
8772
+ try {
8773
+ const raw = await readFile19(manifestPath2, "utf8");
8774
+ const manifest = JSON.parse(raw);
8775
+ if (!isRecord7(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
8776
+ return {
8777
+ name,
8778
+ passed: false,
8779
+ message: ".arcane.json is present but missing required fields (version, components). Re-run `spell init`."
8780
+ };
8781
+ }
8551
8782
  return {
8552
8783
  name,
8553
- passed: false,
8554
- blocking: false,
8555
- message: `.mcp.json still holds the placeholder server from the retired mcp-config-template scaffold: ${placeholderHits.join(", ")} (${RETIRED_SCAFFOLD_PLACEHOLDER}). Clients load this file as live MCP config, so the placeholder fails on every session start. Delete the file if you run no MCP servers, or replace the entry with a real server package; the file is yours, so doctor does not edit it. An untouched scaffold is removed by \`spell update --prune\``
8784
+ passed: true,
8785
+ message: `v${manifest["version"]} \u2014 ${manifest["components"].length} component(s) installed`
8556
8786
  };
8557
- }
8558
- const missingTimeout = serverNames.filter((n) => typeof servers[n]?.timeout !== "number");
8559
- if (missingTimeout.length > 0) {
8787
+ } catch {
8560
8788
  return {
8561
8789
  name,
8562
8790
  passed: false,
8563
- blocking: false,
8564
- message: `server(s) missing a "timeout": ${missingTimeout.join(", ")} \u2014 add "timeout": 30000 (milliseconds; Claude Code ignores a value below 1000) to each entry under mcpServers, so a stuck tool call ends after 30 s instead of running to the client's own idle limit (as long as 30 minutes)`
8791
+ message: ".arcane.json is present but contains invalid JSON. Re-run `spell init`."
8565
8792
  };
8566
8793
  }
8567
- return {
8568
- name,
8569
- passed: true,
8570
- blocking: false,
8571
- message: `${serverNames.length} server(s) configured, all with a timeout set`
8572
- };
8573
- }
8574
- function isDelegation(value) {
8575
- return isRecord7(value) && typeof value["id"] === "string" && typeof value["status"] === "string" && typeof value["scope"] === "string" && Array.isArray(value["excludedActions"]);
8576
8794
  }
8577
- function isDelegationsFile(value) {
8578
- return isRecord7(value) && Array.isArray(value["delegations"]) && value["delegations"].every(isDelegation);
8579
- }
8580
- async function checkDelegations(targetDir) {
8581
- const name = "Standing delegations (T13)";
8582
- const filePath = join22(targetDir, ".arcane", "delegations.json");
8583
- let raw;
8584
- try {
8585
- raw = await readFile19(filePath, "utf8");
8586
- } catch {
8587
- return { name, passed: true, blocking: false, message: "no delegations recorded" };
8588
- }
8589
- let rawParsed;
8795
+ async function checkPullRebase(targetDir) {
8796
+ const name = "Git pull.rebase (rebase-and-fast-forward mandate)";
8590
8797
  try {
8591
- rawParsed = JSON.parse(raw);
8592
- } catch {
8798
+ const { stdout } = await runGit(targetDir, ["config", "--type=bool", "--get", "pull.rebase"]);
8799
+ const value = stdout.trim();
8800
+ if (value === "true") {
8801
+ return { name, passed: true, message: "pull.rebase=true" };
8802
+ }
8593
8803
  return {
8594
8804
  name,
8595
8805
  passed: false,
8596
- blocking: false,
8597
- message: `.arcane/delegations.json exists but contains invalid JSON`
8806
+ 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`,
8807
+ blocking: false
8598
8808
  };
8599
- }
8600
- if (!isDelegationsFile(rawParsed)) {
8809
+ } catch {
8601
8810
  return {
8602
8811
  name,
8603
8812
  passed: false,
8604
- blocking: false,
8605
- message: `.arcane/delegations.json exists but is not a valid delegations file (expected a "delegations" array of well-formed entries)`
8813
+ 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",
8814
+ blocking: false
8606
8815
  };
8607
8816
  }
8608
- const parsed = rawParsed;
8609
- const active = parsed.delegations.filter((d) => d.status === "active");
8610
- if (active.length === 0) {
8611
- return { name, passed: true, blocking: false, message: "no active delegations" };
8817
+ }
8818
+ function checkIncidentReleaseGate() {
8819
+ const result = evaluateIncidentGate(INCIDENT_QUEUE);
8820
+ if (result.blocked) {
8821
+ return {
8822
+ name: "Incident release gate (ARC-024)",
8823
+ passed: false,
8824
+ message: result.blockers.join("; ")
8825
+ };
8612
8826
  }
8613
- const summary = active.map((d) => `${d.id} (scope: ${d.scope}; excludes: ${d.excludedActions.length} action(s))`).join("; ");
8827
+ const accepted = result.acceptedRisks.length > 0 ? `; ${result.acceptedRisks.length} explicitly deferred` : "";
8614
8828
  return {
8615
- name,
8829
+ name: "Incident release gate (ARC-024)",
8616
8830
  passed: true,
8617
- blocking: false,
8618
- message: `${active.length} active: ${summary}`
8831
+ message: `${result.checked} incidents classified${accepted}`
8619
8832
  };
8620
8833
  }
8621
- async function checkPlatformBranchPolicy(targetDir) {
8622
- const name = "Platform branch/merge policy (T11)";
8623
- const targets = await pushTargets(targetDir);
8624
- const origin = targets.find((t) => t.remote === "origin") ?? targets[0];
8625
- const url = origin?.urls[0];
8626
- if (!url) {
8627
- return { name, passed: true, message: "no remote configured \u2014 nothing to check", blocking: false };
8834
+ async function checkSecrets(targetDir) {
8835
+ const name = "Secrets scan (ARC-037)";
8836
+ const excludePrefixes = await resolveSecretsScanExcludePrefixes(targetDir);
8837
+ const findings = await scanRepository(targetDir, SECRETS_RULES, void 0, excludePrefixes);
8838
+ if (findings.length === 0) {
8839
+ return { name, passed: true, message: "no credential-shaped patterns found" };
8628
8840
  }
8629
- const github = parseGitHubRemote(url);
8630
- if (github) {
8631
- const rulesets = await fetchGitHubRulesets(github.owner, github.repo);
8632
- if (rulesets === null) {
8633
- return {
8634
- name,
8635
- passed: false,
8841
+ const summary = findings.map((f) => `${f.file}:${f.line} [${f.rule}]`).join("; ");
8842
+ return {
8843
+ name,
8844
+ passed: false,
8845
+ message: `${findings.length} credential-shaped pattern(s) found: ${summary}`
8846
+ };
8847
+ }
8848
+ var SESSION_CONTINUITY_FILES = [
8849
+ "TODO.md",
8850
+ "DECISIONS.md",
8851
+ "ai-context/system-prompt-context.md",
8852
+ "journal/.gitkeep"
8853
+ ];
8854
+ async function checkSessionContinuity(targetDir) {
8855
+ const results = [];
8856
+ for (const file of SESSION_CONTINUITY_FILES) {
8857
+ const name = `Session file: ${file}`;
8858
+ const filePath = join24(targetDir, file);
8859
+ try {
8860
+ await access4(filePath);
8861
+ results.push({ name, passed: true, message: "present" });
8862
+ } catch {
8863
+ results.push({
8864
+ name,
8865
+ passed: false,
8866
+ message: `Missing. Required for spell-close-session. Run \`spell doctor --fix\` to create it.`,
8867
+ blocking: false
8868
+ });
8869
+ }
8870
+ }
8871
+ return results;
8872
+ }
8873
+ async function fixSessionContinuity(targetDir, assetsDir) {
8874
+ const created = [];
8875
+ for (const file of SESSION_CONTINUITY_FILES) {
8876
+ const destPath = join24(targetDir, file);
8877
+ try {
8878
+ await access4(destPath);
8879
+ } catch {
8880
+ const escape = findLinkedEscape(targetDir, dirname12(file));
8881
+ if (escape !== null) {
8882
+ console.log(
8883
+ ` ! Not created: ${file} \u2014 it is under "${escape.link}", a link to "${escape.resolvesTo}" outside "${resolve7(targetDir)}".`
8884
+ );
8885
+ continue;
8886
+ }
8887
+ const fileEscape = findLinkedFileEscape(targetDir, file);
8888
+ if (fileEscape !== null) {
8889
+ console.log(
8890
+ ` ! Not created: ${file} \u2014 it is a link to "${fileEscape.resolvesTo}" outside "${resolve7(targetDir)}".`
8891
+ );
8892
+ continue;
8893
+ }
8894
+ const srcPath = join24(assetsDir, file);
8895
+ await mkdir7(dirname12(destPath), { recursive: true });
8896
+ await fsCopyFile(srcPath, destPath);
8897
+ created.push(file);
8898
+ }
8899
+ }
8900
+ return created;
8901
+ }
8902
+ async function checkUserTier(homeDir = resolveHomeDir(), cliVersion) {
8903
+ const name = "User tier (ARC-045)";
8904
+ const storeRoot = userTierRoot(homeDir);
8905
+ let manifest;
8906
+ try {
8907
+ manifest = await readManifest(storeRoot);
8908
+ } catch (err) {
8909
+ if (err instanceof ManifestNotFoundError) {
8910
+ return {
8911
+ name,
8912
+ passed: true,
8636
8913
  blocking: false,
8637
- message: `could not query GitHub Rulesets for ${github.owner}/${github.repo} \u2014 is \`gh\` installed and authenticated? Verify manually: gh api repos/${github.owner}/${github.repo}/rulesets`
8914
+ message: "no user tier installed (optional \u2014 `spell init --user` shares every spell across the repositories on this machine)"
8638
8915
  };
8639
8916
  }
8640
- const result = evaluateGitHubMergePolicy(rulesets);
8641
- return { name, passed: result.passed, blocking: false, message: result.message };
8917
+ return {
8918
+ name,
8919
+ passed: false,
8920
+ blocking: false,
8921
+ message: `could not read ${storeRoot}/.arcane.json (${err instanceof Error ? err.message : String(err)}) \u2014 remove it and re-run \`spell init --user\``
8922
+ };
8642
8923
  }
8643
- const ado = parseAdoRemote2(url);
8644
- if (ado) {
8645
- const policies = await fetchAdoMergeTypePolicies(ado.org, ado.project, ado.repo, "main");
8646
- if (policies === null) {
8924
+ try {
8925
+ if (typeof manifest.version !== "string" || manifest.version.length === 0) {
8647
8926
  return {
8648
8927
  name,
8649
8928
  passed: false,
8650
8929
  blocking: false,
8651
- message: `could not query the Azure DevOps "Limit merge types" branch policy for ${ado.org}/${ado.project}/${ado.repo} \u2014 is \`az\` installed and authenticated? Verify manually in the ADO web UI under Branch Policies.`
8930
+ message: `${storeRoot}/.arcane.json has no version field \u2014 remove the store and re-run \`spell init --user\``
8652
8931
  };
8653
8932
  }
8654
- const result = evaluateAdoMergePolicy(policies);
8655
- return { name, passed: result.passed, blocking: false, message: result.message };
8933
+ const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
8934
+ const problems = [];
8935
+ if (cliVersion !== void 0 && majorMinor(manifest.version) !== majorMinor(cliVersion)) {
8936
+ problems.push(`installed at v${manifest.version}, this CLI is v${cliVersion}`);
8937
+ }
8938
+ const health = await inspectUserTierFanout(homeDir, manifest.fanout);
8939
+ if (health.missing.length > 0) {
8940
+ problems.push(`${health.missing.length} of ${health.total} client file(s) missing`);
8941
+ }
8942
+ if (problems.length > 0) {
8943
+ return {
8944
+ name,
8945
+ passed: false,
8946
+ blocking: false,
8947
+ message: `${problems.join("; ")} \u2014 run \`spell update --user\` (regenerates missing files; never overwrites edited ones)`
8948
+ };
8949
+ }
8950
+ const customized = health.customized.length > 0 ? `, ${health.customized.length} customized` : "";
8951
+ return {
8952
+ name,
8953
+ passed: true,
8954
+ blocking: false,
8955
+ message: `v${manifest.version} at ${storeRoot} \u2014 ${health.total} client file(s) in place${customized}`
8956
+ };
8957
+ } catch (err) {
8958
+ return {
8959
+ name,
8960
+ passed: false,
8961
+ blocking: false,
8962
+ message: `could not inspect the user tier at ${storeRoot} (${err instanceof Error ? err.message : String(err)}) \u2014 run \`spell update --user\`; if that fails too, remove the store and re-run \`spell init --user\``
8963
+ };
8656
8964
  }
8657
- return {
8658
- name,
8659
- passed: true,
8660
- blocking: false,
8661
- message: "remote is neither GitHub nor Azure DevOps \u2014 platform policy verification not available for this provider"
8662
- };
8663
8965
  }
8664
- async function checkManagedFileModes(targetDir) {
8665
- const name = "Managed files are regular files (#357)";
8666
- let manifest;
8667
- try {
8668
- manifest = await readManifest(targetDir);
8669
- } catch {
8670
- return { name, passed: true, skipped: true, message: "no .arcane.json \u2014 nothing to check" };
8671
- }
8672
- try {
8673
- await runGit(targetDir, ["rev-parse", "--git-dir"]);
8674
- } catch {
8675
- return { name, passed: true, skipped: true, message: "not a git repository \u2014 the index cannot be read" };
8966
+ async function checkPresenceService(options = {}) {
8967
+ const name = "Presence service (ARC-060)";
8968
+ if (!storeExists(options)) {
8969
+ return { name, passed: true, blocking: false, message: "no presence channel on this machine (optional \u2014 `spell presence serve` starts one)" };
8676
8970
  }
8677
- const managed = [...new Set(manifest.components.flatMap((c) => c.files))];
8678
- const linkTracked = await listSymlinkTrackedPaths(targetDir);
8679
- const asLinks = managed.filter((file) => linkTracked.has(file));
8680
- const pathBodies = [];
8681
- for (const file of managed) {
8682
- if (!isClientShimPath(file) || asLinks.includes(file)) continue;
8683
- let body;
8971
+ const alive = options.pidAlive ?? pidAlive;
8972
+ const problems = [];
8973
+ if ((options.platform ?? process.platform) !== "win32") {
8684
8974
  try {
8685
- body = await readFile19(join22(targetDir, file), "utf-8");
8975
+ const mode = statSync(tokenPath(options)).mode & 511;
8976
+ if ((mode & 63) !== 0) {
8977
+ problems.push(`the token file ${tokenPath(options)} has mode ${mode.toString(8).padStart(4, "0")}; run \`chmod 600\` on it, or restart \`spell presence serve\`, which re-sets it`);
8978
+ }
8686
8979
  } catch {
8687
- continue;
8688
8980
  }
8689
- if (looksLikeBarePath(body)) pathBodies.push(file);
8690
8981
  }
8691
- if (asLinks.length === 0 && pathBodies.length === 0) {
8692
- return { name, passed: true, message: `${managed.length} managed path(s), all regular files` };
8982
+ const warn = (state) => ({ name, passed: false, blocking: false, message: [state, ...problems].join("; ") });
8983
+ let recorded;
8984
+ try {
8985
+ statSync(serverInfoPath(options));
8986
+ recorded = true;
8987
+ } catch {
8988
+ recorded = false;
8693
8989
  }
8694
- const parts = [];
8695
- if (asLinks.length > 0) {
8696
- parts.push(`${asLinks.length} tracked as a symlink (mode 120000): ${asLinks.join(", ")}`);
8990
+ if (!recorded) {
8991
+ return problems.length > 0 ? warn("not running") : { name, passed: true, blocking: false, message: `not running (store at ${presenceDir(options)})` };
8697
8992
  }
8698
- if (pathBodies.length > 0) {
8699
- parts.push(`${pathBodies.length} client shim(s) whose body is only a path: ${pathBodies.join(", ")}`);
8993
+ const info = readServerInfo(options);
8994
+ if (info === null) return warn(`${serverInfoPath(options)} is not a record this CLI wrote; remove it if no service is running`);
8995
+ if (!alive(info.pid)) {
8996
+ return warn(`stale: ${serverInfoPath(options)} names pid ${info.pid}, which is not running \u2014 \`spell doctor --fix\` removes the file (the token stays)`);
8700
8997
  }
8701
- return {
8702
- name,
8703
- passed: false,
8704
- message: parts.join("; ") + " \u2014 clients silently skip or empty these spells. Run `spell update` (it rewrites them as regular files and stages the typechange), then commit."
8705
- };
8998
+ const health = await callPresence("GET", "/v0/health", void 0, { ...options, env: options.env ?? process.env });
8999
+ if (health.kind === "ok") {
9000
+ const body = health.body;
9001
+ const version = typeof body.generator?.version === "string" ? `v${body.generator.version}` : "unknown version";
9002
+ const sessions = typeof body.sessions === "number" ? `, ${body.sessions} session(s)` : "";
9003
+ if (problems.length > 0) return warn(`reachable at ${info.url}`);
9004
+ return { name, passed: true, blocking: false, message: `reachable at ${info.url} (pid ${info.pid}, ${version}${sessions})` };
9005
+ }
9006
+ if (health.kind === "refused" && health.status === 401) {
9007
+ return warn(`the service at ${info.url} refused this machine's token (rotated, or ARCANE_PRESENCE_TOKEN is set to another value)`);
9008
+ }
9009
+ return warn(`not reachable: ${info.url} (pid ${info.pid} is running but does not answer)`);
8706
9010
  }
8707
- async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
8708
- const name = "Spell scope (ARC-045)";
9011
+ function fixPresenceService(options = {}) {
9012
+ const info = readServerInfo(options);
9013
+ if (info === null || (options.pidAlive ?? pidAlive)(info.pid)) return null;
9014
+ removeServerInfo(options);
9015
+ return serverInfoPath(options);
9016
+ }
9017
+ async function checkPushPolicy(targetDir) {
9018
+ const name = "Push policy (EF-09)";
8709
9019
  let manifest;
8710
9020
  try {
8711
9021
  manifest = await readManifest(targetDir);
8712
9022
  } catch {
8713
- return { name, passed: true, blocking: false, message: "no .arcane.json \u2014 nothing to check" };
9023
+ return { name, passed: true, message: "not initialized \u2014 nothing to check" };
8714
9024
  }
8715
- if (effectiveSpellScope(manifest) === "repo") {
8716
- return {
8717
- name,
8718
- passed: true,
8719
- blocking: false,
8720
- message: "repo \u2014 this repository carries its own spells (the default)"
8721
- };
9025
+ const policy = manifest.push_policy ?? "open";
9026
+ if (policy === "open") {
9027
+ return { name, passed: true, message: "open \u2014 pushes allowed" };
8722
9028
  }
8723
- const storeRoot = userTierRoot(homeDir);
8724
- let store;
8725
- try {
8726
- store = await readManifest(storeRoot);
8727
- } catch (err) {
9029
+ if (policy === "guarded") {
9030
+ const targets = await pushTargets(targetDir);
9031
+ const where = targets.length === 0 ? "none configured" : targets.map(({ remote, urls }) => `${remote} \u2192 ${urls.length > 0 ? urls.join(", ") : "unknown"}`).join("; ");
8728
9032
  return {
8729
9033
  name,
8730
9034
  passed: false,
8731
- message: `this repository takes its spells from the user tier (spell_scope: "user"), but ${storeRoot} ${err instanceof ManifestNotFoundError ? "is not installed" : `could not be read (${err instanceof Error ? err.message : String(err)})`} \u2014 no client can reach a spell here. Run \`spell init --user\`, or set spell_scope back to "repo" and run \`spell update\`.`
9035
+ blocking: false,
9036
+ message: `guarded \u2014 no technical control installed. Push targets: ${where}. Confirm those are the remotes you intend before pushing.`
8732
9037
  };
8733
9038
  }
8734
- let spellCount = 0;
8735
- let probed = false;
8736
- const probe2 = "spell-status.md";
8737
- try {
8738
- const entries = await readdir11(join22(storeRoot, USER_TIER_SPELLS_DIR));
8739
- const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
8740
- spellCount = spells.length;
8741
- probed = spells.includes(probe2);
8742
- } catch {
8743
- }
8744
- if (spellCount === 0 || !probed) {
9039
+ const hookActive = await isHookEnforced(targetDir);
9040
+ const remotes = await listRemotes(targetDir);
9041
+ const open = await undisabledRemotes(targetDir);
9042
+ if (hookActive && open.length === 0) {
9043
+ if (remotes.length === 0) {
9044
+ return {
9045
+ name,
9046
+ passed: false,
9047
+ blocking: false,
9048
+ message: "blocked \u2014 pre-push hook in place, but no remote is configured, so no push URL is disabled. If a remote is added later, run `spell doctor` again: a --no-verify push to it would succeed until the URL is disabled too."
9049
+ };
9050
+ }
8745
9051
  return {
8746
9052
  name,
8747
- passed: false,
8748
- message: `the user tier at ${storeRoot} holds ${spellCount} spell(s)${spellCount > 0 && !probed ? ` and not ${probe2}` : ""} \u2014 this repository expects it to supply every spell. Run \`spell update --user\`.`
9053
+ passed: true,
9054
+ message: `blocked \u2014 pre-push hook in place and push URL disabled for ${remotes.join(", ")}`
8749
9055
  };
8750
9056
  }
8751
- const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
8752
- if (typeof manifest.version === "string" && majorMinor(store.version) !== majorMinor(manifest.version)) {
9057
+ const missing = [
9058
+ hookActive ? null : "pre-push hook (config or hook file absent)",
9059
+ open.length > 0 ? `push URL still live for: ${open.join(", ")}` : null
9060
+ ].filter(Boolean);
9061
+ return {
9062
+ name,
9063
+ passed: false,
9064
+ blocking: false,
9065
+ // ARC-049 decision 3: name the command that closes the gap.
9066
+ message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have. Run \`spell block-push\` to install the controls.`
9067
+ };
9068
+ }
9069
+ async function checkGovernancePlaceholders(targetDir) {
9070
+ const name = "Governance placeholders (ARC-051)";
9071
+ let scan;
9072
+ try {
9073
+ scan = await scanGovernancePlaceholders(join24(targetDir, ".arcane", "governance"));
9074
+ } catch (err) {
8753
9075
  return {
8754
9076
  name,
8755
9077
  passed: false,
8756
9078
  blocking: false,
8757
- message: `this repository is at v${manifest.version} but the user tier it uses is at v${store.version} \u2014 run \`spell update --user\` (and \`spell update\` here) to bring them together.`
9079
+ message: `could not scan .arcane/governance (${err instanceof Error ? err.message : String(err)})`
8758
9080
  };
8759
9081
  }
8760
- const repoExpectsAgents = await rosterExists(targetDir);
8761
- if (repoExpectsAgents && !await rosterExists(storeRoot, "user")) {
9082
+ if (scan.status === "skipped") {
9083
+ return { name, passed: true, blocking: false, skipped: true, message: `skipped \u2014 ${scan.reason}` };
9084
+ }
9085
+ if (scan.findings.length === 0) {
8762
9086
  return {
8763
9087
  name,
8764
- passed: false,
8765
- message: `this repository has an agent roster and takes its client files from the user tier, but ${storeRoot} has no roster of its own \u2014 no agent mode can reach a client here. Run \`spell agents init --user\`, or set spell_scope back to "repo" and run \`spell agents sync\`.`
9088
+ passed: true,
9089
+ blocking: false,
9090
+ message: `${scan.activeDocs.length} active doc(s) scanned, no unknown placeholders`
8766
9091
  };
8767
9092
  }
9093
+ const listed = scan.findings.map(({ file, tokens }) => `.arcane/governance/${file}: ${tokens.join(", ")}`);
8768
9094
  return {
8769
9095
  name,
8770
- passed: true,
9096
+ passed: false,
8771
9097
  blocking: false,
8772
- message: `user \u2014 ${spellCount} spell(s) from ${storeRoot} (v${store.version}), ${probe2} present${repoExpectsAgents ? ", agent roster present in the tier" : ""}`
9098
+ message: `unknown placeholder(s) in status: active doc(s) \u2014 ${listed.join("; ")}. Fill each one in; or, if an agent resolves it at use time, add it to the runtime-placeholders list in .arcane/governance/${PLACEHOLDER_STANDARD_FILE}; or, if the document is meant to be filled in by hand, mark it \`status: template\`.`
8773
9099
  };
8774
9100
  }
8775
- async function runDoctor(targetDir, options = {}, assetsDir) {
8776
- console.log("\nspell doctor \u2014 checking your Arcane environment\n");
8777
- if (options.leaks) {
8778
- const result = await checkSecrets(targetDir);
8779
- const icon = result.passed ? "\u2713" : "\u2717";
8780
- console.log(` ${icon} [${result.passed ? "pass" : "FAIL"}] ${result.name}`);
8781
- if (!result.passed) {
8782
- console.log(` ${result.message}`);
8783
- console.log("\n One or more checks failed. Fix the issues above before proceeding.\n");
8784
- process.exitCode = 1;
8785
- } else {
8786
- console.log("\n All checks passed. Your environment is ready.\n");
8787
- }
8788
- return;
9101
+ var UNOWNED_EXAMPLE_SCOPE = "@example/";
9102
+ var RETIRED_SCAFFOLD_PLACEHOLDER = "<your-mcp-server-package>";
9103
+ async function checkComponentRequires(targetDir) {
9104
+ const name = "Component prerequisites";
9105
+ let manifest;
9106
+ try {
9107
+ manifest = await readManifest(targetDir);
9108
+ } catch {
9109
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no readable .arcane.json" };
8789
9110
  }
8790
- const results = await Promise.all([
8791
- checkNodeVersion(),
8792
- checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)", resolveHomeDir()),
8793
- checkArcaneManifest(targetDir),
8794
- Promise.resolve(checkIncidentReleaseGate()),
8795
- checkPullRebase(targetDir),
8796
- checkPushPolicy(targetDir),
8797
- checkPlatformBranchPolicy(targetDir),
8798
- checkDelegations(targetDir),
8799
- checkMcpConfig(targetDir),
8800
- checkUserTier(resolveHomeDir(), options.packageVersion),
8801
- checkSpellScope(targetDir),
8802
- checkManagedFileModes(targetDir),
8803
- checkGovernancePlaceholders(targetDir),
8804
- checkComponentRequires(targetDir),
8805
- checkNewlyAvailableComponents(targetDir)
8806
- ]);
8807
- const sessionResults = await checkSessionContinuity(targetDir);
8808
- results.push(...sessionResults);
8809
- let allPassed = true;
8810
- for (const result of results) {
8811
- const skipped = result.passed && result.skipped === true;
8812
- const icon = skipped ? "\u2013" : result.passed ? "\u2713" : result.blocking === false ? "\u26A0" : "\u2717";
8813
- const label2 = skipped ? "skip" : result.passed ? "pass" : result.blocking === false ? "warn" : "FAIL";
8814
- console.log(` ${icon} [${label2}] ${result.name}`);
8815
- if (!result.passed || skipped) {
8816
- console.log(` ${result.message}`);
8817
- }
8818
- if (!result.passed && result.blocking !== false) {
8819
- allPassed = false;
8820
- }
9111
+ if (manifest.scope === "user") {
9112
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 the user tier holds spells only" };
8821
9113
  }
8822
- console.log();
8823
- if (allPassed) {
8824
- console.log(" All checks passed. Your environment is ready.\n");
8825
- } else {
8826
- console.log(" One or more checks failed. Fix the issues above before proceeding.\n");
8827
- process.exitCode = 1;
9114
+ if (!Array.isArray(manifest.components)) {
9115
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 .arcane.json lists no components" };
8828
9116
  }
8829
- const hasMissingSession = sessionResults.some((r) => !r.passed);
8830
- if (options.fix && hasMissingSession && assetsDir) {
8831
- console.log(" \u{1F527} Fixing missing session continuity files...\n");
8832
- const created = await fixSessionContinuity(targetDir, assetsDir);
8833
- for (const file of created) {
8834
- console.log(` \u2713 Created: ${file}`);
8835
- }
8836
- const stillMissing = (await checkSessionContinuity(targetDir)).filter((r) => !r.passed).length;
8837
- if (stillMissing > 0) {
8838
- console.log(
8839
- `
8840
- ${created.length} file(s) created; ${stillMissing} still missing (named above). Repo is not close-session-ready yet.
8841
- `
8842
- );
8843
- } else if (created.length > 0) {
8844
- console.log(`
8845
- ${created.length} file(s) created. Repo is now close-session-ready.
8846
- `);
8847
- } else {
8848
- console.log(" No files needed creation (all present).\n");
8849
- }
8850
- } else if (hasMissingSession && !options.fix) {
8851
- console.log(" \u{1F4A1} Run `spell doctor --fix` to create missing session files.\n");
9117
+ const missing = findMissingRequires(manifest.components, manifest.profile);
9118
+ if (missing.length === 0) {
9119
+ return { name, passed: true, blocking: false, message: "every installed component's prerequisites are installed" };
8852
9120
  }
9121
+ return {
9122
+ name,
9123
+ passed: false,
9124
+ blocking: false,
9125
+ message: missing.map(({ name: required, requiredBy }) => `${requiredBy.join(", ")} cites ${required}, which is not installed \u2014 ${getComponent(required).initOnly ? "" : "`spell update` installs it, or "}\`spell add ${required}\``).join("; ")
9126
+ };
8853
9127
  }
8854
-
8855
- // src/commands/ward.ts
8856
- import { readFile as readFile21 } from "fs/promises";
8857
-
8858
- // src/modules/ward.ts
8859
- import { readdir as readdir12, readFile as readFile20 } from "fs/promises";
8860
- import { join as join23, relative as relative2 } from "path";
8861
- async function scanFileAllRules(absolutePath, displayPath, rules) {
8862
- const findings = [];
8863
- let content;
9128
+ async function checkNewlyAvailableComponents(targetDir) {
9129
+ const name = "Newly available components";
9130
+ let manifest;
8864
9131
  try {
8865
- content = await readFile20(absolutePath, "utf8");
9132
+ manifest = await readManifest(targetDir);
8866
9133
  } catch {
8867
- return findings;
9134
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no readable .arcane.json" };
8868
9135
  }
8869
- const lines = content.split("\n");
8870
- for (let index = 0; index < lines.length; index++) {
8871
- const searchable = lines[index].replace(/\{[A-Z][A-Z0-9_]*\}/g, "");
8872
- for (const rule of rules) {
8873
- if (rule.pattern.test(searchable)) {
8874
- findings.push({ file: displayPath, line: index + 1, rule: rule.label });
8875
- }
8876
- }
9136
+ if (manifest.scope === "user" || !Array.isArray(manifest.components)) {
9137
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no repository install to compare" };
8877
9138
  }
8878
- return findings;
8879
- }
8880
- var VENDOR_IDENTIFIER_PROTECTION_LIST = [
8881
- "claude",
8882
- "gpt-4",
8883
- "gpt-3.5",
8884
- "gemini",
8885
- "llama",
8886
- "mistral",
8887
- "copilot",
8888
- "anthropic",
8889
- "openai"
8890
- ];
8891
- var GREP_PROOF_EXTENSIONS = [".gif", ".png", ".jpg", ".jpeg", ".mp4", ".mov", ".mp3", ".wav"];
8892
- var WARD_TEXT_EXTENSIONS = [
8893
- ".md",
8894
- ".ts",
8895
- ".tsx",
8896
- ".js",
8897
- ".jsx",
8898
- ".json",
8899
- ".yml",
8900
- ".yaml",
8901
- ".txt",
8902
- ".mdx"
8903
- ];
8904
- function applyExclusions(findings, fileLines, exclusions) {
8905
- if (exclusions.length === 0) return findings;
8906
- return findings.filter((finding) => {
8907
- const applicable = exclusions.filter((e) => e.rule === finding.rule);
8908
- if (applicable.length === 0) return true;
8909
- const lines = fileLines.get(finding.file);
8910
- const lineText = lines?.[finding.line - 1] ?? "";
8911
- return !applicable.some((e) => lineText.includes(e.context));
8912
- });
9139
+ const installed = manifest.components.flatMap(
9140
+ (c) => (LEGACY_COMPONENT_MIGRATIONS[c.name] ?? [c.name]).map((n) => ({ ...c, name: n }))
9141
+ );
9142
+ const prerequisites = findMissingRequires(manifest.components, manifest.profile).map((m) => m.name);
9143
+ const fresh = (await findNewlyAvailable(targetDir, manifest.profile, installed, effectiveSpellScope(manifest))).filter((c) => !prerequisites.includes(c.name));
9144
+ if (fresh.length === 0) {
9145
+ return { name, passed: true, blocking: false, message: "the profile has nothing this install lacks" };
9146
+ }
9147
+ const offerable = fresh.filter((c) => !getComponent(c.name).initOnly).map((c) => c.name);
9148
+ const byHand = fresh.filter((c) => getComponent(c.name).initOnly).map((c) => c.name);
9149
+ const parts = [];
9150
+ if (offerable.length > 0) {
9151
+ parts.push(`${offerable.join(", ")} \u2014 \`spell update --add-new\` installs ${offerable.length === 1 ? "it" : "them"}`);
9152
+ }
9153
+ if (byHand.length > 0) {
9154
+ parts.push(`${byHand.join(", ")} \u2014 initOnly, so only ${byHand.map((n) => `\`spell add ${n}\``).join(", ")}`);
9155
+ }
9156
+ return { name, passed: false, blocking: false, message: `newly available for your profile, not installed: ${parts.join("; ")}` };
8913
9157
  }
8914
- async function scanFilenames(root, rules, repoRoot = root) {
8915
- const findings = [];
8916
- let entries;
9158
+ async function checkMcpConfig(targetDir) {
9159
+ const name = "MCP config (per-server timeout, I12)";
9160
+ const filePath = join24(targetDir, ".mcp.json");
9161
+ let raw;
8917
9162
  try {
8918
- entries = await readdir12(root, { withFileTypes: true });
9163
+ raw = await readFile19(filePath, "utf8");
8919
9164
  } catch {
8920
- return findings;
9165
+ return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
8921
9166
  }
8922
- for (const entry of entries) {
8923
- const absolutePath = join23(root, entry.name);
8924
- if (entry.isDirectory()) {
8925
- if (SKIP_DIRECTORIES.has(entry.name)) continue;
8926
- findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
8927
- continue;
8928
- }
8929
- const displayPath = relative2(repoRoot, absolutePath).replace(/\\/g, "/");
8930
- for (const rule of rules) {
8931
- if (rule.pattern.test(entry.name)) {
8932
- findings.push({ file: displayPath, line: 0, rule: rule.label });
8933
- break;
8934
- }
8935
- }
9167
+ let rawParsed;
9168
+ try {
9169
+ rawParsed = JSON.parse(raw);
9170
+ } catch {
9171
+ return {
9172
+ name,
9173
+ passed: false,
9174
+ blocking: false,
9175
+ message: ".mcp.json exists but contains invalid JSON"
9176
+ };
8936
9177
  }
8937
- return findings;
8938
- }
8939
- async function flagGrepProofMedia(root, repoRoot = root) {
8940
- const files = await collectScannableFiles(root, repoRoot, GREP_PROOF_EXTENSIONS);
8941
- return files.map(([, displayPath]) => ({
8942
- file: displayPath,
8943
- reason: "binary media format -- text extraction is unreliable; review manually for embedded identifiers"
8944
- }));
8945
- }
8946
- async function runWard(repoRoot, options = {}) {
8947
- const userTerms = options.terms ?? [];
8948
- const exclusions = options.exclusions ?? [];
8949
- const extensions = [...WARD_TEXT_EXTENSIONS, ...options.binaryExtensions ?? []];
8950
- const userRules = createDenylistRules(userTerms, "ward-term");
8951
- const vendorRules = createDenylistRules([...VENDOR_IDENTIFIER_PROTECTION_LIST], "ward-vendor");
8952
- const allRules = [...userRules, ...vendorRules];
8953
- const contentFiles = await collectScannableFiles(repoRoot, repoRoot, extensions);
8954
- const fileLines = /* @__PURE__ */ new Map();
8955
- const contentFindings = [];
8956
- for (const [absolutePath, displayPath] of contentFiles) {
8957
- const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
8958
- if (findings2.length > 0) {
8959
- try {
8960
- fileLines.set(displayPath, (await readFile20(absolutePath, "utf8")).split("\n"));
8961
- } catch {
8962
- }
9178
+ if (!isRecord7(rawParsed)) {
9179
+ return {
9180
+ name,
9181
+ passed: false,
9182
+ blocking: false,
9183
+ message: ".mcp.json exists but is not a JSON object"
9184
+ };
9185
+ }
9186
+ const parsed = rawParsed;
9187
+ const servers = parsed.mcpServers ?? {};
9188
+ const serverNames = Object.keys(servers);
9189
+ if (serverNames.length === 0) {
9190
+ return { name, passed: true, blocking: false, message: "no MCP servers configured" };
9191
+ }
9192
+ const exampleHits = [];
9193
+ const placeholderHits = [];
9194
+ for (const n of serverNames) {
9195
+ const args = servers[n]?.args;
9196
+ if (!Array.isArray(args)) continue;
9197
+ for (const a of args) {
9198
+ if (typeof a !== "string") continue;
9199
+ if (a.startsWith(UNOWNED_EXAMPLE_SCOPE)) exampleHits.push(`${n} (${a})`);
9200
+ if (a === RETIRED_SCAFFOLD_PLACEHOLDER) placeholderHits.push(n);
8963
9201
  }
8964
- contentFindings.push(...findings2);
8965
9202
  }
8966
- const filenameFindings = await scanFilenames(repoRoot, allRules);
8967
- const combined = [...contentFindings, ...filenameFindings];
8968
- const excluded = applyExclusions(combined, fileLines, exclusions);
8969
- const vendorLabels = new Set(vendorRules.map((r) => r.label));
8970
- const findings = excluded.map((f) => ({
8971
- ...f,
8972
- category: vendorLabels.has(f.rule) ? "protected-vendor-identifier" : "leak"
8973
- }));
8974
- const mediaFlags = await flagGrepProofMedia(repoRoot);
8975
- return { findings, mediaFlags };
9203
+ if (exampleHits.length > 0) {
9204
+ return {
9205
+ name,
9206
+ passed: false,
9207
+ blocking: false,
9208
+ message: `.mcp.json runs a package under the ${UNOWNED_EXAMPLE_SCOPE} scope nobody here owns: ${exampleHits.join(", ")}. The old \`spell init\` scaffold shipped it as a placeholder, and \`npx -y\` would run whoever publishes it. Replace the entry with your own MCP server package, or remove it; the file is yours, so doctor does not edit it`
9209
+ };
9210
+ }
9211
+ if (placeholderHits.length > 0) {
9212
+ return {
9213
+ name,
9214
+ passed: false,
9215
+ blocking: false,
9216
+ message: `.mcp.json still holds the placeholder server from the retired mcp-config-template scaffold: ${placeholderHits.join(", ")} (${RETIRED_SCAFFOLD_PLACEHOLDER}). Clients load this file as live MCP config, so the placeholder fails on every session start. Delete the file if you run no MCP servers, or replace the entry with a real server package; the file is yours, so doctor does not edit it. An untouched scaffold is removed by \`spell update --prune\``
9217
+ };
9218
+ }
9219
+ const missingTimeout = serverNames.filter((n) => typeof servers[n]?.timeout !== "number");
9220
+ if (missingTimeout.length > 0) {
9221
+ return {
9222
+ name,
9223
+ passed: false,
9224
+ blocking: false,
9225
+ message: `server(s) missing a "timeout": ${missingTimeout.join(", ")} \u2014 add "timeout": 30000 (milliseconds; Claude Code ignores a value below 1000) to each entry under mcpServers, so a stuck tool call ends after 30 s instead of running to the client's own idle limit (as long as 30 minutes)`
9226
+ };
9227
+ }
9228
+ return {
9229
+ name,
9230
+ passed: true,
9231
+ blocking: false,
9232
+ message: `${serverNames.length} server(s) configured, all with a timeout set`
9233
+ };
8976
9234
  }
8977
-
8978
- // src/commands/ward.ts
8979
- function parseTerms(raw) {
8980
- if (!raw) return [];
8981
- return raw.split(",").map((t) => t.trim()).filter(Boolean);
9235
+ function isDelegation(value) {
9236
+ return isRecord7(value) && typeof value["id"] === "string" && typeof value["status"] === "string" && typeof value["scope"] === "string" && Array.isArray(value["excludedActions"]);
8982
9237
  }
8983
- function parseTermsFileContent(raw) {
8984
- return raw.split(/[,\r\n]+/).map((t) => t.trim()).filter(Boolean);
9238
+ function isDelegationsFile(value) {
9239
+ return isRecord7(value) && Array.isArray(value["delegations"]) && value["delegations"].every(isDelegation);
8985
9240
  }
8986
- async function readTermsFile(filePath) {
9241
+ async function checkDelegations(targetDir) {
9242
+ const name = "Standing delegations (T13)";
9243
+ const filePath = join24(targetDir, ".arcane", "delegations.json");
9244
+ let raw;
8987
9245
  try {
8988
- return parseTermsFileContent(await readFile21(filePath, "utf8"));
8989
- } catch (error2) {
8990
- console.warn(
8991
- `\u26A0 arcane: could not read --terms-file "${filePath}": ${error2.message}`
8992
- );
8993
- return [];
9246
+ raw = await readFile19(filePath, "utf8");
9247
+ } catch {
9248
+ return { name, passed: true, blocking: false, message: "no delegations recorded" };
8994
9249
  }
8995
- }
8996
- async function runWardCli(targetDir, options = {}, exclusions = []) {
8997
- const terms = [
8998
- ...parseTerms(options.terms),
8999
- ...options.termsFile ? await readTermsFile(options.termsFile) : []
9000
- ];
9001
- const report = await runWard(targetDir, { terms, exclusions });
9002
- console.log("\nspell ward \u2014 scanning for leaked identifiers\n");
9003
- const leaks = report.findings.filter((f) => f.category === "leak");
9004
- const protectedHits = report.findings.filter((f) => f.category === "protected-vendor-identifier");
9005
- if (leaks.length === 0 && protectedHits.length === 0) {
9006
- console.log(" \u2713 No denylisted terms found.");
9250
+ let rawParsed;
9251
+ try {
9252
+ rawParsed = JSON.parse(raw);
9253
+ } catch {
9254
+ return {
9255
+ name,
9256
+ passed: false,
9257
+ blocking: false,
9258
+ message: `.arcane/delegations.json exists but contains invalid JSON`
9259
+ };
9007
9260
  }
9008
- for (const finding of leaks) {
9009
- const where = finding.line === 0 ? `${finding.file} (filename)` : `${finding.file}:${finding.line}`;
9010
- console.log(` \u2717 [leak] ${where} \u2014 rule: ${finding.rule}`);
9261
+ if (!isDelegationsFile(rawParsed)) {
9262
+ return {
9263
+ name,
9264
+ passed: false,
9265
+ blocking: false,
9266
+ message: `.arcane/delegations.json exists but is not a valid delegations file (expected a "delegations" array of well-formed entries)`
9267
+ };
9011
9268
  }
9012
- for (const finding of protectedHits) {
9013
- const where = finding.line === 0 ? `${finding.file} (filename)` : `${finding.file}:${finding.line}`;
9014
- console.log(
9015
- ` \u26A0 [protected-vendor-identifier] ${where} \u2014 rule: ${finding.rule} \u2014 verify before any automated rename touches this line`
9016
- );
9269
+ const parsed = rawParsed;
9270
+ const active = parsed.delegations.filter((d) => d.status === "active");
9271
+ if (active.length === 0) {
9272
+ return { name, passed: true, blocking: false, message: "no active delegations" };
9017
9273
  }
9018
- if (report.mediaFlags.length > 0) {
9019
- console.log("\n Grep-proof media flagged for manual review:");
9020
- for (const flag of report.mediaFlags) {
9021
- console.log(` ? ${flag.file} \u2014 ${flag.reason}`);
9022
- }
9274
+ const summary = active.map((d) => `${d.id} (scope: ${d.scope}; excludes: ${d.excludedActions.length} action(s))`).join("; ");
9275
+ return {
9276
+ name,
9277
+ passed: true,
9278
+ blocking: false,
9279
+ message: `${active.length} active: ${summary}`
9280
+ };
9281
+ }
9282
+ async function checkPlatformBranchPolicy(targetDir) {
9283
+ const name = "Platform branch/merge policy (T11)";
9284
+ const targets = await pushTargets(targetDir);
9285
+ const origin = targets.find((t) => t.remote === "origin") ?? targets[0];
9286
+ const url = origin?.urls[0];
9287
+ if (!url) {
9288
+ return { name, passed: true, message: "no remote configured \u2014 nothing to check", blocking: false };
9023
9289
  }
9024
- console.log();
9025
- if (options.gate && leaks.length > 0) {
9026
- console.log(` ${leaks.length} leak(s) found. Failing (--gate mode).
9027
- `);
9028
- process.exitCode = 1;
9029
- return;
9290
+ const github = parseGitHubRemote(url);
9291
+ if (github) {
9292
+ const rulesets = await fetchGitHubRulesets(github.owner, github.repo);
9293
+ if (rulesets === null) {
9294
+ return {
9295
+ name,
9296
+ passed: false,
9297
+ blocking: false,
9298
+ message: `could not query GitHub Rulesets for ${github.owner}/${github.repo} \u2014 is \`gh\` installed and authenticated? Verify manually: gh api repos/${github.owner}/${github.repo}/rulesets`
9299
+ };
9300
+ }
9301
+ const result = evaluateGitHubMergePolicy(rulesets);
9302
+ return { name, passed: result.passed, blocking: false, message: result.message };
9030
9303
  }
9031
- if (leaks.length > 0) {
9032
- console.log(` ${leaks.length} leak(s) found. Run with --gate to fail CI on this.
9033
- `);
9304
+ const ado = parseAdoRemote2(url);
9305
+ if (ado) {
9306
+ const policies = await fetchAdoMergeTypePolicies(ado.org, ado.project, ado.repo, "main");
9307
+ if (policies === null) {
9308
+ return {
9309
+ name,
9310
+ passed: false,
9311
+ blocking: false,
9312
+ message: `could not query the Azure DevOps "Limit merge types" branch policy for ${ado.org}/${ado.project}/${ado.repo} \u2014 is \`az\` installed and authenticated? Verify manually in the ADO web UI under Branch Policies.`
9313
+ };
9314
+ }
9315
+ const result = evaluateAdoMergePolicy(policies);
9316
+ return { name, passed: result.passed, blocking: false, message: result.message };
9034
9317
  }
9318
+ return {
9319
+ name,
9320
+ passed: true,
9321
+ blocking: false,
9322
+ message: "remote is neither GitHub nor Azure DevOps \u2014 platform policy verification not available for this provider"
9323
+ };
9035
9324
  }
9036
-
9037
- // src/commands/unblock-push.ts
9038
- import { basename as basename3, resolve as resolve8 } from "path";
9039
- import { input as input4 } from "@inquirer/prompts";
9040
- async function runUnblockPush(targetDir) {
9325
+ async function checkManagedFileModes(targetDir) {
9326
+ const name = "Managed files are regular files (#357)";
9041
9327
  let manifest;
9042
9328
  try {
9043
9329
  manifest = await readManifest(targetDir);
9044
- } catch (err) {
9045
- if (err instanceof ManifestNotFoundError) {
9046
- console.error('Not initialized. Run "spell init" first.');
9047
- process.exit(1);
9048
- return;
9049
- }
9050
- throw err;
9330
+ } catch {
9331
+ return { name, passed: true, skipped: true, message: "no .arcane.json \u2014 nothing to check" };
9051
9332
  }
9052
- const policy = manifest.push_policy ?? "open";
9053
- const stillInForce = await isHookEnforced(targetDir) || (await blockedRemotes(targetDir)).length > 0;
9054
- if (policy === "open" && !stillInForce) {
9055
- printInfo("This repository is not push-blocked \u2014 nothing to undo.");
9056
- return;
9333
+ try {
9334
+ await runGit(targetDir, ["rev-parse", "--git-dir"]);
9335
+ } catch {
9336
+ return { name, passed: true, skipped: true, message: "not a git repository \u2014 the index cannot be read" };
9057
9337
  }
9058
- if (policy === "open") {
9059
- printWarning(
9060
- "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."
9061
- );
9338
+ const managed = [...new Set(manifest.components.flatMap((c) => c.files))];
9339
+ const linkTracked = await listSymlinkTrackedPaths(targetDir);
9340
+ const asLinks = managed.filter((file) => linkTracked.has(file));
9341
+ const pathBodies = [];
9342
+ for (const file of managed) {
9343
+ if (!isClientShimPath(file) || asLinks.includes(file)) continue;
9344
+ let body;
9345
+ try {
9346
+ body = await readFile19(join24(targetDir, file), "utf-8");
9347
+ } catch {
9348
+ continue;
9349
+ }
9350
+ if (looksLikeBarePath(body)) pathBodies.push(file);
9062
9351
  }
9063
- if (!process.stdin.isTTY) {
9064
- console.error(
9065
- "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."
9066
- );
9067
- process.exit(1);
9068
- return;
9352
+ if (asLinks.length === 0 && pathBodies.length === 0) {
9353
+ return { name, passed: true, message: `${managed.length} managed path(s), all regular files` };
9069
9354
  }
9070
- const repoName = basename3(resolve8(targetDir));
9071
- if (repoName === "") {
9072
- console.error(
9073
- "Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
9074
- );
9075
- process.exit(1);
9076
- return;
9355
+ const parts = [];
9356
+ if (asLinks.length > 0) {
9357
+ parts.push(`${asLinks.length} tracked as a symlink (mode 120000): ${asLinks.join(", ")}`);
9077
9358
  }
9078
- printWarning(`This repository's push_policy is "${policy}".`);
9079
- printInfo(
9080
- "Unblocking restores the ability to push this repository's full history to its remote. Deleting a file later does not undo a push: the content stays in history, in any clone already made, and in the remote's own backups."
9081
- );
9082
- console.log();
9083
- const typed = await input4({
9084
- message: `Type the repository name to confirm (${repoName}):`
9085
- });
9086
- if (typed.trim() !== repoName) {
9087
- console.error(`
9088
- Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
9089
- process.exit(1);
9090
- return;
9359
+ if (pathBodies.length > 0) {
9360
+ parts.push(`${pathBodies.length} client shim(s) whose body is only a path: ${pathBodies.join(", ")}`);
9091
9361
  }
9092
- await removePrePushHook(targetDir);
9093
- const results = await restorePushUrls(targetDir);
9094
- const restored = results.filter((r) => r.status === "restored").map((r) => r.remote);
9095
- const failed = results.filter((r) => r.status === "failed");
9096
- const stillBlocked = await blockedRemotes(targetDir);
9097
- const updated = {
9098
- ...manifest,
9099
- push_policy: "open",
9100
- push_policy_unblocked_at: (/* @__PURE__ */ new Date()).toISOString()
9362
+ return {
9363
+ name,
9364
+ passed: false,
9365
+ message: parts.join("; ") + " \u2014 clients silently skip or empty these spells. Run `spell update` (it rewrites them as regular files and stages the typechange), then commit."
9101
9366
  };
9102
- await writeManifest(targetDir, updated);
9103
- const hookStillOn = await isHookEnforced(targetDir);
9104
- const fullyLifted = failed.length === 0 && stillBlocked.length === 0 && !hookStillOn;
9105
- if (fullyLifted) {
9106
- printSuccess(`Push unblocked for "${repoName}".`);
9107
- } else {
9108
- printWarning(
9109
- `Push only PARTIALLY unblocked for "${repoName}". The manifest now says open; the repository is not fully open yet. Details below.`
9110
- );
9111
- }
9112
- if (restored.length > 0) {
9113
- printInfo(`Restored push URL for: ${restored.join(", ")}.`);
9114
- }
9115
- if (hookStillOn) {
9116
- printWarning(
9117
- "The pre-push hook is still in force. Arcane did not remove it because `core.hooksPath` is not one it owns, could not be read, or names a directory that a link takes outside this repository. Check `git config --show-scope --get core.hooksPath`."
9118
- );
9119
- }
9120
- if (failed.length > 0) {
9121
- printWarning(
9122
- `Could not restore the push URL for: ${failed.map((r) => `${r.remote} (${r.reason ?? "unknown error"})`).join("; ")}.`
9123
- );
9124
- }
9125
- if (stillBlocked.length > 0) {
9126
- printWarning(
9127
- `These remotes still have a blocked push URL and no recorded original: ${stillBlocked.join(", ")}. Set them yourself with \`git remote set-url --push <name> <url>\` \u2014 until then, pushing to them will fail even though the manifest now says open.`
9128
- );
9129
- }
9130
- printInfo(
9131
- "Recorded in .arcane.json as push_policy: open, with the time it was lifted. Commit that change so the repository's actual posture stays visible to everyone."
9132
- );
9133
9367
  }
9134
-
9135
- // src/commands/block-push.ts
9136
- async function runBlockPush(targetDir) {
9368
+ async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
9369
+ const name = "Spell scope (ARC-045)";
9137
9370
  let manifest;
9138
9371
  try {
9139
9372
  manifest = await readManifest(targetDir);
9373
+ } catch {
9374
+ return { name, passed: true, blocking: false, message: "no .arcane.json \u2014 nothing to check" };
9375
+ }
9376
+ if (effectiveSpellScope(manifest) === "repo") {
9377
+ return {
9378
+ name,
9379
+ passed: true,
9380
+ blocking: false,
9381
+ message: "repo \u2014 this repository carries its own spells (the default)"
9382
+ };
9383
+ }
9384
+ const storeRoot = userTierRoot(homeDir);
9385
+ let store;
9386
+ try {
9387
+ store = await readManifest(storeRoot);
9140
9388
  } catch (err) {
9141
- if (err instanceof ManifestNotFoundError) {
9142
- console.error('Not initialized. Run "spell init" first.');
9143
- process.exit(1);
9144
- return;
9145
- }
9146
- throw err;
9389
+ return {
9390
+ name,
9391
+ passed: false,
9392
+ message: `this repository takes its spells from the user tier (spell_scope: "user"), but ${storeRoot} ${err instanceof ManifestNotFoundError ? "is not installed" : `could not be read (${err instanceof Error ? err.message : String(err)})`} \u2014 no client can reach a spell here. Run \`spell init --user\`, or set spell_scope back to "repo" and run \`spell update\`.`
9393
+ };
9147
9394
  }
9148
- if (manifest.scope === "user") {
9149
- console.error(
9150
- "spell block-push applies to a repository, not the user tier: push_policy describes a repository."
9151
- );
9152
- process.exit(1);
9153
- return;
9395
+ let spellCount = 0;
9396
+ let probed = false;
9397
+ const probe2 = "spell-status.md";
9398
+ try {
9399
+ const entries = await readdir11(join24(storeRoot, USER_TIER_SPELLS_DIR));
9400
+ const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
9401
+ spellCount = spells.length;
9402
+ probed = spells.includes(probe2);
9403
+ } catch {
9154
9404
  }
9155
- if ((await inspectGitRepository(targetDir)).status === "not-repository") {
9156
- console.error("spell block-push needs a Git repository: there is nothing here to block.");
9157
- process.exit(1);
9158
- return;
9405
+ if (spellCount === 0 || !probed) {
9406
+ return {
9407
+ name,
9408
+ passed: false,
9409
+ message: `the user tier at ${storeRoot} holds ${spellCount} spell(s)${spellCount > 0 && !probed ? ` and not ${probe2}` : ""} \u2014 this repository expects it to supply every spell. Run \`spell update --user\`.`
9410
+ };
9159
9411
  }
9160
- const recorded = manifest.push_policy ?? "open";
9161
- const alreadyEnforced = recorded === "blocked" && await isHookEnforced(targetDir) && (await undisabledRemotes(targetDir)).length === 0;
9162
- if (alreadyEnforced) {
9163
- printSuccess("Push is already blocked and enforced here. Nothing changed.");
9164
- if ((await listRemotes(targetDir)).length === 0) {
9165
- printWarning(
9166
- "No remote is configured, so only the pre-push hook is active. Re-run `spell block-push` after adding a remote to disable its push URL too."
9167
- );
9412
+ const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
9413
+ if (typeof manifest.version === "string" && majorMinor(store.version) !== majorMinor(manifest.version)) {
9414
+ return {
9415
+ name,
9416
+ passed: false,
9417
+ blocking: false,
9418
+ message: `this repository is at v${manifest.version} but the user tier it uses is at v${store.version} \u2014 run \`spell update --user\` (and \`spell update\` here) to bring them together.`
9419
+ };
9420
+ }
9421
+ const repoExpectsAgents = await rosterExists(targetDir);
9422
+ if (repoExpectsAgents && !await rosterExists(storeRoot, "user")) {
9423
+ return {
9424
+ name,
9425
+ passed: false,
9426
+ message: `this repository has an agent roster and takes its client files from the user tier, but ${storeRoot} has no roster of its own \u2014 no agent mode can reach a client here. Run \`spell agents init --user\`, or set spell_scope back to "repo" and run \`spell agents sync\`.`
9427
+ };
9428
+ }
9429
+ return {
9430
+ name,
9431
+ passed: true,
9432
+ blocking: false,
9433
+ message: `user \u2014 ${spellCount} spell(s) from ${storeRoot} (v${store.version}), ${probe2} present${repoExpectsAgents ? ", agent roster present in the tier" : ""}`
9434
+ };
9435
+ }
9436
+ async function runDoctor(targetDir, options = {}, assetsDir) {
9437
+ console.log("\nspell doctor \u2014 checking your Arcane environment\n");
9438
+ if (options.leaks) {
9439
+ const result = await checkSecrets(targetDir);
9440
+ const icon = result.passed ? "\u2713" : "\u2717";
9441
+ console.log(` ${icon} [${result.passed ? "pass" : "FAIL"}] ${result.name}`);
9442
+ if (!result.passed) {
9443
+ console.log(` ${result.message}`);
9444
+ console.log("\n One or more checks failed. Fix the issues above before proceeding.\n");
9445
+ process.exitCode = 1;
9446
+ } else {
9447
+ console.log("\n All checks passed. Your environment is ready.\n");
9168
9448
  }
9169
- printInfo("Run `spell unblock-push` from a terminal to undo this.");
9170
9449
  return;
9171
9450
  }
9172
- const outcome = await applyBlockedPushControls(targetDir, {
9173
- stopOnHookRefusal: true,
9174
- emit: (m) => m.level === "warning" ? printWarning(m.text) : printInfo(m.text)
9175
- });
9176
- const failedRemotes = outcome.urls.filter((u) => u.status === "failed");
9177
- if (!hookInForce(outcome.hook) || failedRemotes.length > 0) {
9178
- printWarning(
9179
- outcome.stoppedAtHook ? "Nothing was changed. Resolve the refusal above and re-run `spell block-push`." : "Push is NOT fully blocked: the remotes named above can still be pushed to. Fix them and re-run `spell block-push`."
9180
- );
9181
- if (recorded !== "blocked") {
9182
- printInfo(
9183
- `The manifest still records push_policy: "${recorded}" \u2014 it is not changed to "blocked" until both controls are in force.`
9184
- );
9451
+ const results = await Promise.all([
9452
+ checkNodeVersion(),
9453
+ checkArcaneManifest(targetDir),
9454
+ Promise.resolve(checkIncidentReleaseGate()),
9455
+ checkPullRebase(targetDir),
9456
+ checkPushPolicy(targetDir),
9457
+ checkPlatformBranchPolicy(targetDir),
9458
+ checkDelegations(targetDir),
9459
+ checkMcpConfig(targetDir),
9460
+ checkUserTier(resolveHomeDir(), options.packageVersion),
9461
+ checkSpellScope(targetDir),
9462
+ checkManagedFileModes(targetDir),
9463
+ checkGovernancePlaceholders(targetDir),
9464
+ checkComponentRequires(targetDir),
9465
+ checkNewlyAvailableComponents(targetDir),
9466
+ checkPresenceService()
9467
+ ]);
9468
+ const sessionResults = await checkSessionContinuity(targetDir);
9469
+ results.push(...sessionResults);
9470
+ let allPassed = true;
9471
+ for (const result of results) {
9472
+ const skipped = result.passed && result.skipped === true;
9473
+ const icon = skipped ? "\u2013" : result.passed ? "\u2713" : result.blocking === false ? "\u26A0" : "\u2717";
9474
+ const label2 = skipped ? "skip" : result.passed ? "pass" : result.blocking === false ? "warn" : "FAIL";
9475
+ console.log(` ${icon} [${label2}] ${result.name}`);
9476
+ if (!result.passed || skipped) {
9477
+ console.log(` ${result.message}`);
9478
+ }
9479
+ if (!result.passed && result.blocking !== false) {
9480
+ allPassed = false;
9185
9481
  }
9186
- process.exit(1);
9187
- return;
9188
9482
  }
9189
- if (recorded !== "blocked") {
9190
- await writeManifest(targetDir, { ...manifest, push_policy: "blocked" });
9191
- printSuccess(`Push blocked. Recorded push_policy: "blocked" (was "${recorded}") in .arcane.json.`);
9192
- printInfo("Commit that change so the repository's posture stays visible to everyone.");
9483
+ console.log();
9484
+ if (allPassed) {
9485
+ console.log(" All checks passed. Your environment is ready.\n");
9193
9486
  } else {
9194
- printSuccess('Push blocked. The manifest already recorded push_policy: "blocked"; it is now enforced.');
9195
- }
9196
- }
9197
-
9198
- // src/modules/credential-store.ts
9199
- import { createHash as createHash4, randomBytes } from "crypto";
9200
- var CHUNK_SIZE = 1e3;
9201
- var MAX_CHUNKS = 64;
9202
- var INDEX_NAME = "session/index";
9203
- var CHUNK_PREFIX = "session/";
9204
- var INDEX_VERSION = 1;
9205
- var KeychainUnavailableError = class extends Error {
9206
- constructor(operation, cause) {
9207
- super(`The OS keychain is not available (${operation} failed).`);
9208
- this.name = "KeychainUnavailableError";
9209
- this.cause = cause;
9487
+ console.log(" One or more checks failed. Fix the issues above before proceeding.\n");
9488
+ process.exitCode = 1;
9210
9489
  }
9211
- };
9212
- function sha256Hex(value) {
9213
- return createHash4("sha256").update(value, "utf8").digest("hex");
9214
- }
9215
- function chunkName(gen, n) {
9216
- return `${CHUNK_PREFIX}${gen}/${n}`;
9217
- }
9218
- function splitSecret(secret, size = CHUNK_SIZE) {
9219
- const chunks = [];
9220
- let start = 0;
9221
- while (start < secret.length) {
9222
- let end = Math.min(start + size, secret.length);
9223
- if (end < secret.length) {
9224
- const last = secret.charCodeAt(end - 1);
9225
- if (last >= 55296 && last <= 56319) end -= 1;
9490
+ const hasMissingSession = sessionResults.some((r) => !r.passed);
9491
+ if (options.fix && hasMissingSession && assetsDir) {
9492
+ console.log(" \u{1F527} Fixing missing session continuity files...\n");
9493
+ const created = await fixSessionContinuity(targetDir, assetsDir);
9494
+ for (const file of created) {
9495
+ console.log(` \u2713 Created: ${file}`);
9226
9496
  }
9227
- chunks.push(secret.slice(start, end));
9228
- start = end;
9497
+ const stillMissing = (await checkSessionContinuity(targetDir)).filter((r) => !r.passed).length;
9498
+ if (stillMissing > 0) {
9499
+ console.log(
9500
+ `
9501
+ ${created.length} file(s) created; ${stillMissing} still missing (named above). Repo is not close-session-ready yet.
9502
+ `
9503
+ );
9504
+ } else if (created.length > 0) {
9505
+ console.log(`
9506
+ ${created.length} file(s) created. Repo is now close-session-ready.
9507
+ `);
9508
+ } else {
9509
+ console.log(" No files needed creation (all present).\n");
9510
+ }
9511
+ } else if (hasMissingSession && !options.fix) {
9512
+ console.log(" \u{1F4A1} Run `spell doctor --fix` to create missing session files.\n");
9513
+ }
9514
+ if (options.fix) {
9515
+ const removed = fixPresenceService();
9516
+ if (removed !== null) console.log(` \u{1F527} Removed the stale presence record ${removed} (the token stays).
9517
+ `);
9229
9518
  }
9230
- return chunks;
9231
9519
  }
9232
- function parseIndex(raw) {
9233
- let parsed;
9520
+
9521
+ // src/commands/ward.ts
9522
+ import { readFile as readFile21 } from "fs/promises";
9523
+
9524
+ // src/modules/ward.ts
9525
+ import { readdir as readdir12, readFile as readFile20 } from "fs/promises";
9526
+ import { join as join25, relative as relative3 } from "path";
9527
+ async function scanFileAllRules(absolutePath, displayPath, rules) {
9528
+ const findings = [];
9529
+ let content;
9234
9530
  try {
9235
- parsed = JSON.parse(raw);
9531
+ content = await readFile20(absolutePath, "utf8");
9236
9532
  } catch {
9237
- return null;
9533
+ return findings;
9238
9534
  }
9239
- if (typeof parsed !== "object" || parsed === null) return null;
9240
- const o = parsed;
9241
- if (o["v"] !== INDEX_VERSION || typeof o["gen"] !== "string" || !/^[0-9a-f]{16}$/.test(o["gen"]) || typeof o["count"] !== "number" || !Number.isInteger(o["count"]) || o["count"] < 1 || o["count"] > MAX_CHUNKS || typeof o["sha256"] !== "string" || !/^[0-9a-f]{64}$/.test(o["sha256"])) {
9242
- return null;
9243
- }
9244
- return { v: o["v"], gen: o["gen"], count: o["count"], sha256: o["sha256"] };
9245
- }
9246
- var ChunkedSecretStore = class {
9247
- constructor(backend) {
9248
- this.backend = backend;
9249
- }
9250
- backend;
9251
- guard(operation, fn) {
9252
- try {
9253
- return fn();
9254
- } catch (error2) {
9255
- throw new KeychainUnavailableError(operation, error2);
9535
+ const lines = content.split("\n");
9536
+ for (let index = 0; index < lines.length; index++) {
9537
+ const searchable = lines[index].replace(/\{[A-Z][A-Z0-9_]*\}/g, "");
9538
+ for (const rule of rules) {
9539
+ if (rule.pattern.test(searchable)) {
9540
+ findings.push({ file: displayPath, line: index + 1, rule: rule.label });
9541
+ }
9256
9542
  }
9257
9543
  }
9258
- /** Reads the stored secret. Never throws for bad data; throws only when the keychain itself fails. */
9259
- load() {
9260
- const raw = this.guard("read", () => this.backend.get(INDEX_NAME));
9261
- if (raw === null) return { state: "signed-out" };
9262
- const index = parseIndex(raw);
9263
- if (index === null) return { state: "corrupt", reason: "the index entry is not valid" };
9264
- const parts = [];
9265
- for (let n = 0; n < index.count; n += 1) {
9266
- const part = this.guard("read", () => this.backend.get(chunkName(index.gen, n)));
9267
- if (part === null) return { state: "corrupt", reason: `chunk ${n} of ${index.count} is missing` };
9268
- parts.push(part);
9269
- }
9270
- const secret = parts.join("");
9271
- if (sha256Hex(secret) !== index.sha256) {
9272
- return { state: "corrupt", reason: "the stored pieces do not match their checksum" };
9273
- }
9274
- return { state: "ok", secret };
9544
+ return findings;
9545
+ }
9546
+ var VENDOR_IDENTIFIER_PROTECTION_LIST = [
9547
+ "claude",
9548
+ "gpt-4",
9549
+ "gpt-3.5",
9550
+ "gemini",
9551
+ "llama",
9552
+ "mistral",
9553
+ "copilot",
9554
+ "anthropic",
9555
+ "openai"
9556
+ ];
9557
+ var GREP_PROOF_EXTENSIONS = [".gif", ".png", ".jpg", ".jpeg", ".mp4", ".mov", ".mp3", ".wav"];
9558
+ var WARD_TEXT_EXTENSIONS = [
9559
+ ".md",
9560
+ ".ts",
9561
+ ".tsx",
9562
+ ".js",
9563
+ ".jsx",
9564
+ ".json",
9565
+ ".yml",
9566
+ ".yaml",
9567
+ ".txt",
9568
+ ".mdx"
9569
+ ];
9570
+ function applyExclusions(findings, fileLines, exclusions) {
9571
+ if (exclusions.length === 0) return findings;
9572
+ return findings.filter((finding) => {
9573
+ const applicable = exclusions.filter((e) => e.rule === finding.rule);
9574
+ if (applicable.length === 0) return true;
9575
+ const lines = fileLines.get(finding.file);
9576
+ const lineText = lines?.[finding.line - 1] ?? "";
9577
+ return !applicable.some((e) => lineText.includes(e.context));
9578
+ });
9579
+ }
9580
+ async function scanFilenames(root, rules, repoRoot = root) {
9581
+ const findings = [];
9582
+ let entries;
9583
+ try {
9584
+ entries = await readdir12(root, { withFileTypes: true });
9585
+ } catch {
9586
+ return findings;
9275
9587
  }
9276
- /**
9277
- * Replaces the stored secret. The previous session stays readable until the
9278
- * new index is written; a failure before that leaves it untouched.
9279
- */
9280
- save(secret) {
9281
- if (secret.length === 0) throw new Error("Refusing to store an empty secret.");
9282
- const gen = randomBytes(8).toString("hex");
9283
- const chunks = splitSecret(secret);
9284
- if (chunks.length > MAX_CHUNKS) {
9285
- throw new Error(`Refusing to store a secret longer than ${MAX_CHUNKS * CHUNK_SIZE} characters.`);
9588
+ for (const entry of entries) {
9589
+ const absolutePath = join25(root, entry.name);
9590
+ if (entry.isDirectory()) {
9591
+ if (SKIP_DIRECTORIES.has(entry.name)) continue;
9592
+ findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
9593
+ continue;
9286
9594
  }
9287
- chunks.forEach((chunk, n) => {
9288
- this.guard("write", () => this.backend.set(chunkName(gen, n), chunk));
9289
- });
9290
- const index = { v: INDEX_VERSION, gen, count: chunks.length, sha256: sha256Hex(secret) };
9291
- this.guard("write", () => this.backend.set(INDEX_NAME, JSON.stringify(index)));
9292
- try {
9293
- this.sweep(gen);
9294
- } catch {
9595
+ const displayPath = relative3(repoRoot, absolutePath).replace(/\\/g, "/");
9596
+ for (const rule of rules) {
9597
+ if (rule.pattern.test(entry.name)) {
9598
+ findings.push({ file: displayPath, line: 0, rule: rule.label });
9599
+ break;
9600
+ }
9295
9601
  }
9296
9602
  }
9297
- /** Deletes everything this store wrote, strays included. Succeeds when nothing is stored. */
9298
- clear() {
9299
- this.guard("delete", () => this.backend.delete(INDEX_NAME));
9300
- this.sweep(null);
9301
- }
9302
- /** Removes every chunk that does not belong to `keepGen` (all chunks when it is null). */
9303
- sweep(keepGen) {
9304
- const names = this.guard("list", () => this.backend.list());
9305
- for (const name of names) {
9306
- if (name === INDEX_NAME || !name.startsWith(CHUNK_PREFIX)) continue;
9307
- if (keepGen !== null && name.startsWith(`${CHUNK_PREFIX}${keepGen}/`)) continue;
9308
- this.guard("delete", () => this.backend.delete(name));
9603
+ return findings;
9604
+ }
9605
+ async function flagGrepProofMedia(root, repoRoot = root) {
9606
+ const files = await collectScannableFiles(root, repoRoot, GREP_PROOF_EXTENSIONS);
9607
+ return files.map(([, displayPath]) => ({
9608
+ file: displayPath,
9609
+ reason: "binary media format -- text extraction is unreliable; review manually for embedded identifiers"
9610
+ }));
9611
+ }
9612
+ async function runWard(repoRoot, options = {}) {
9613
+ const userTerms = options.terms ?? [];
9614
+ const exclusions = options.exclusions ?? [];
9615
+ const extensions = [...WARD_TEXT_EXTENSIONS, ...options.binaryExtensions ?? []];
9616
+ const userRules = createDenylistRules(userTerms, "ward-term");
9617
+ const vendorRules = createDenylistRules([...VENDOR_IDENTIFIER_PROTECTION_LIST], "ward-vendor");
9618
+ const allRules = [...userRules, ...vendorRules];
9619
+ const contentFiles = await collectScannableFiles(repoRoot, repoRoot, extensions);
9620
+ const fileLines = /* @__PURE__ */ new Map();
9621
+ const contentFindings = [];
9622
+ for (const [absolutePath, displayPath] of contentFiles) {
9623
+ const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
9624
+ if (findings2.length > 0) {
9625
+ try {
9626
+ fileLines.set(displayPath, (await readFile20(absolutePath, "utf8")).split("\n"));
9627
+ } catch {
9628
+ }
9309
9629
  }
9630
+ contentFindings.push(...findings2);
9310
9631
  }
9311
- };
9632
+ const filenameFindings = await scanFilenames(repoRoot, allRules);
9633
+ const combined = [...contentFindings, ...filenameFindings];
9634
+ const excluded = applyExclusions(combined, fileLines, exclusions);
9635
+ const vendorLabels = new Set(vendorRules.map((r) => r.label));
9636
+ const findings = excluded.map((f) => ({
9637
+ ...f,
9638
+ category: vendorLabels.has(f.rule) ? "protected-vendor-identifier" : "leak"
9639
+ }));
9640
+ const mediaFlags = await flagGrepProofMedia(repoRoot);
9641
+ return { findings, mediaFlags };
9642
+ }
9312
9643
 
9313
- // src/modules/identity-config.ts
9314
- function defineIdentityEnvironment(config) {
9315
- return {
9316
- ...config,
9317
- discoveryUrl: `https://${config.signInHost}/${config.tenantId}/v2.0/.well-known/openid-configuration`
9318
- };
9644
+ // src/commands/ward.ts
9645
+ function parseTerms(raw) {
9646
+ if (!raw) return [];
9647
+ return raw.split(",").map((t) => t.trim()).filter(Boolean);
9319
9648
  }
9320
- var IDENTITY_ENVIRONMENTS = {
9321
- production: defineIdentityEnvironment({
9322
- environment: "production",
9323
- tenantId: "9f8edbfb-291c-4c4a-b9cc-8820a04f8973",
9324
- signInHost: "arcaneai.ciamlogin.com",
9325
- clientId: "7cd8f05d-9979-4458-8ca2-47c67748c41f"
9326
- }),
9327
- development: defineIdentityEnvironment({
9328
- environment: "development",
9329
- tenantId: "b365de0e-3026-4afe-bf5b-556fccb4ffcf",
9330
- signInHost: "arcaneaidev.ciamlogin.com",
9331
- clientId: "651be720-83cc-452b-8346-abf20713ad12"
9332
- })
9333
- };
9334
- var SIGN_IN_SCOPES = ["openid", "profile", "email", "offline_access"];
9335
- var ENVIRONMENT_VARIABLE = "ARCANE_ENVIRONMENT";
9336
- var UnknownEnvironmentError = class extends Error {
9337
- constructor(value) {
9338
- super(
9339
- `${ENVIRONMENT_VARIABLE}=${JSON.stringify(value)} is not an environment. Use "production" (the default) or "development".`
9649
+ function parseTermsFileContent(raw) {
9650
+ return raw.split(/[,\r\n]+/).map((t) => t.trim()).filter(Boolean);
9651
+ }
9652
+ async function readTermsFile(filePath) {
9653
+ try {
9654
+ return parseTermsFileContent(await readFile21(filePath, "utf8"));
9655
+ } catch (error2) {
9656
+ console.warn(
9657
+ `\u26A0 arcane: could not read --terms-file "${filePath}": ${error2.message}`
9340
9658
  );
9341
- this.name = "UnknownEnvironmentError";
9659
+ return [];
9660
+ }
9661
+ }
9662
+ async function runWardCli(targetDir, options = {}, exclusions = []) {
9663
+ const terms = [
9664
+ ...parseTerms(options.terms),
9665
+ ...options.termsFile ? await readTermsFile(options.termsFile) : []
9666
+ ];
9667
+ const report = await runWard(targetDir, { terms, exclusions });
9668
+ console.log("\nspell ward \u2014 scanning for leaked identifiers\n");
9669
+ const leaks = report.findings.filter((f) => f.category === "leak");
9670
+ const protectedHits = report.findings.filter((f) => f.category === "protected-vendor-identifier");
9671
+ if (leaks.length === 0 && protectedHits.length === 0) {
9672
+ console.log(" \u2713 No denylisted terms found.");
9673
+ }
9674
+ for (const finding of leaks) {
9675
+ const where = finding.line === 0 ? `${finding.file} (filename)` : `${finding.file}:${finding.line}`;
9676
+ console.log(` \u2717 [leak] ${where} \u2014 rule: ${finding.rule}`);
9677
+ }
9678
+ for (const finding of protectedHits) {
9679
+ const where = finding.line === 0 ? `${finding.file} (filename)` : `${finding.file}:${finding.line}`;
9680
+ console.log(
9681
+ ` \u26A0 [protected-vendor-identifier] ${where} \u2014 rule: ${finding.rule} \u2014 verify before any automated rename touches this line`
9682
+ );
9683
+ }
9684
+ if (report.mediaFlags.length > 0) {
9685
+ console.log("\n Grep-proof media flagged for manual review:");
9686
+ for (const flag of report.mediaFlags) {
9687
+ console.log(` ? ${flag.file} \u2014 ${flag.reason}`);
9688
+ }
9689
+ }
9690
+ console.log();
9691
+ if (options.gate && leaks.length > 0) {
9692
+ console.log(` ${leaks.length} leak(s) found. Failing (--gate mode).
9693
+ `);
9694
+ process.exitCode = 1;
9695
+ return;
9696
+ }
9697
+ if (leaks.length > 0) {
9698
+ console.log(` ${leaks.length} leak(s) found. Run with --gate to fail CI on this.
9699
+ `);
9342
9700
  }
9343
- };
9344
- function resolveIdentityEnvironment(env = process.env) {
9345
- const raw = (env[ENVIRONMENT_VARIABLE] ?? "").trim().toLowerCase();
9346
- if (raw === "" || raw === "prod" || raw === "production") return IDENTITY_ENVIRONMENTS.production;
9347
- if (raw === "dev" || raw === "development") return IDENTITY_ENVIRONMENTS.development;
9348
- throw new UnknownEnvironmentError(env[ENVIRONMENT_VARIABLE] ?? "");
9349
9701
  }
9350
9702
 
9351
- // src/modules/oidc.ts
9352
- import { createServer } from "http";
9353
- import { execFile as execFile3 } from "child_process";
9354
- import * as client from "openid-client";
9355
-
9356
- // src/modules/result-pages.ts
9357
- import { createHash as createHash5 } from "crypto";
9358
- import { readFile as readFile22 } from "fs/promises";
9359
- import { join as join24 } from "path";
9360
- var RESULT_KINDS = ["success", "denied", "failure"];
9703
+ // src/commands/unblock-push.ts
9704
+ import { basename as basename3, resolve as resolve8 } from "path";
9705
+ import { input as input4 } from "@inquirer/prompts";
9706
+ async function runUnblockPush(targetDir) {
9707
+ let manifest;
9708
+ try {
9709
+ manifest = await readManifest(targetDir);
9710
+ } catch (err) {
9711
+ if (err instanceof ManifestNotFoundError) {
9712
+ console.error('Not initialized. Run "spell init" first.');
9713
+ process.exit(1);
9714
+ return;
9715
+ }
9716
+ throw err;
9717
+ }
9718
+ const policy = manifest.push_policy ?? "open";
9719
+ const stillInForce = await isHookEnforced(targetDir) || (await blockedRemotes(targetDir)).length > 0;
9720
+ if (policy === "open" && !stillInForce) {
9721
+ printInfo("This repository is not push-blocked \u2014 nothing to undo.");
9722
+ return;
9723
+ }
9724
+ if (policy === "open") {
9725
+ printWarning(
9726
+ "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."
9727
+ );
9728
+ }
9729
+ if (!process.stdin.isTTY) {
9730
+ console.error(
9731
+ "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."
9732
+ );
9733
+ process.exit(1);
9734
+ return;
9735
+ }
9736
+ const repoName = basename3(resolve8(targetDir));
9737
+ if (repoName === "") {
9738
+ console.error(
9739
+ "Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
9740
+ );
9741
+ process.exit(1);
9742
+ return;
9743
+ }
9744
+ printWarning(`This repository's push_policy is "${policy}".`);
9745
+ printInfo(
9746
+ "Unblocking restores the ability to push this repository's full history to its remote. Deleting a file later does not undo a push: the content stays in history, in any clone already made, and in the remote's own backups."
9747
+ );
9748
+ console.log();
9749
+ const typed = await input4({
9750
+ message: `Type the repository name to confirm (${repoName}):`
9751
+ });
9752
+ if (typed.trim() !== repoName) {
9753
+ console.error(`
9754
+ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
9755
+ process.exit(1);
9756
+ return;
9757
+ }
9758
+ await removePrePushHook(targetDir);
9759
+ const results = await restorePushUrls(targetDir);
9760
+ const restored = results.filter((r) => r.status === "restored").map((r) => r.remote);
9761
+ const failed = results.filter((r) => r.status === "failed");
9762
+ const stillBlocked = await blockedRemotes(targetDir);
9763
+ const updated = {
9764
+ ...manifest,
9765
+ push_policy: "open",
9766
+ push_policy_unblocked_at: (/* @__PURE__ */ new Date()).toISOString()
9767
+ };
9768
+ await writeManifest(targetDir, updated);
9769
+ const hookStillOn = await isHookEnforced(targetDir);
9770
+ const fullyLifted = failed.length === 0 && stillBlocked.length === 0 && !hookStillOn;
9771
+ if (fullyLifted) {
9772
+ printSuccess(`Push unblocked for "${repoName}".`);
9773
+ } else {
9774
+ printWarning(
9775
+ `Push only PARTIALLY unblocked for "${repoName}". The manifest now says open; the repository is not fully open yet. Details below.`
9776
+ );
9777
+ }
9778
+ if (restored.length > 0) {
9779
+ printInfo(`Restored push URL for: ${restored.join(", ")}.`);
9780
+ }
9781
+ if (hookStillOn) {
9782
+ printWarning(
9783
+ "The pre-push hook is still in force. Arcane did not remove it because `core.hooksPath` is not one it owns, could not be read, or names a directory that a link takes outside this repository. Check `git config --show-scope --get core.hooksPath`."
9784
+ );
9785
+ }
9786
+ if (failed.length > 0) {
9787
+ printWarning(
9788
+ `Could not restore the push URL for: ${failed.map((r) => `${r.remote} (${r.reason ?? "unknown error"})`).join("; ")}.`
9789
+ );
9790
+ }
9791
+ if (stillBlocked.length > 0) {
9792
+ printWarning(
9793
+ `These remotes still have a blocked push URL and no recorded original: ${stillBlocked.join(", ")}. Set them yourself with \`git remote set-url --push <name> <url>\` \u2014 until then, pushing to them will fail even though the manifest now says open.`
9794
+ );
9795
+ }
9796
+ printInfo(
9797
+ "Recorded in .arcane.json as push_policy: open, with the time it was lifted. Commit that change so the repository's actual posture stays visible to everyone."
9798
+ );
9799
+ }
9800
+
9801
+ // src/commands/block-push.ts
9802
+ async function runBlockPush(targetDir) {
9803
+ let manifest;
9804
+ try {
9805
+ manifest = await readManifest(targetDir);
9806
+ } catch (err) {
9807
+ if (err instanceof ManifestNotFoundError) {
9808
+ console.error('Not initialized. Run "spell init" first.');
9809
+ process.exit(1);
9810
+ return;
9811
+ }
9812
+ throw err;
9813
+ }
9814
+ if (manifest.scope === "user") {
9815
+ console.error(
9816
+ "spell block-push applies to a repository, not the user tier: push_policy describes a repository."
9817
+ );
9818
+ process.exit(1);
9819
+ return;
9820
+ }
9821
+ if ((await inspectGitRepository(targetDir)).status === "not-repository") {
9822
+ console.error("spell block-push needs a Git repository: there is nothing here to block.");
9823
+ process.exit(1);
9824
+ return;
9825
+ }
9826
+ const recorded = manifest.push_policy ?? "open";
9827
+ const alreadyEnforced = recorded === "blocked" && await isHookEnforced(targetDir) && (await undisabledRemotes(targetDir)).length === 0;
9828
+ if (alreadyEnforced) {
9829
+ printSuccess("Push is already blocked and enforced here. Nothing changed.");
9830
+ if ((await listRemotes(targetDir)).length === 0) {
9831
+ printWarning(
9832
+ "No remote is configured, so only the pre-push hook is active. Re-run `spell block-push` after adding a remote to disable its push URL too."
9833
+ );
9834
+ }
9835
+ printInfo("Run `spell unblock-push` from a terminal to undo this.");
9836
+ return;
9837
+ }
9838
+ const outcome = await applyBlockedPushControls(targetDir, {
9839
+ stopOnHookRefusal: true,
9840
+ emit: (m) => m.level === "warning" ? printWarning(m.text) : printInfo(m.text)
9841
+ });
9842
+ const failedRemotes = outcome.urls.filter((u) => u.status === "failed");
9843
+ if (!hookInForce(outcome.hook) || failedRemotes.length > 0) {
9844
+ printWarning(
9845
+ outcome.stoppedAtHook ? "Nothing was changed. Resolve the refusal above and re-run `spell block-push`." : "Push is NOT fully blocked: the remotes named above can still be pushed to. Fix them and re-run `spell block-push`."
9846
+ );
9847
+ if (recorded !== "blocked") {
9848
+ printInfo(
9849
+ `The manifest still records push_policy: "${recorded}" \u2014 it is not changed to "blocked" until both controls are in force.`
9850
+ );
9851
+ }
9852
+ process.exit(1);
9853
+ return;
9854
+ }
9855
+ if (recorded !== "blocked") {
9856
+ await writeManifest(targetDir, { ...manifest, push_policy: "blocked" });
9857
+ printSuccess(`Push blocked. Recorded push_policy: "blocked" (was "${recorded}") in .arcane.json.`);
9858
+ printInfo("Commit that change so the repository's posture stays visible to everyone.");
9859
+ } else {
9860
+ printSuccess('Push blocked. The manifest already recorded push_policy: "blocked"; it is now enforced.');
9861
+ }
9862
+ }
9863
+
9864
+ // src/modules/identity-config.ts
9865
+ function defineIdentityEnvironment(config) {
9866
+ return {
9867
+ ...config,
9868
+ discoveryUrl: `https://${config.signInHost}/${config.tenantId}/v2.0/.well-known/openid-configuration`
9869
+ };
9870
+ }
9871
+ var IDENTITY_ENVIRONMENTS = {
9872
+ production: defineIdentityEnvironment({
9873
+ environment: "production",
9874
+ tenantId: "9f8edbfb-291c-4c4a-b9cc-8820a04f8973",
9875
+ signInHost: "arcaneai.ciamlogin.com",
9876
+ clientId: "7cd8f05d-9979-4458-8ca2-47c67748c41f"
9877
+ }),
9878
+ development: defineIdentityEnvironment({
9879
+ environment: "development",
9880
+ tenantId: "b365de0e-3026-4afe-bf5b-556fccb4ffcf",
9881
+ signInHost: "arcaneaidev.ciamlogin.com",
9882
+ clientId: "651be720-83cc-452b-8346-abf20713ad12"
9883
+ })
9884
+ };
9885
+ var SIGN_IN_SCOPES = ["openid", "profile", "email", "offline_access"];
9886
+ var ENVIRONMENT_VARIABLE = "ARCANE_ENVIRONMENT";
9887
+ var UnknownEnvironmentError = class extends Error {
9888
+ constructor(value) {
9889
+ super(
9890
+ `${ENVIRONMENT_VARIABLE}=${JSON.stringify(value)} is not an environment. Use "production" (the default) or "development".`
9891
+ );
9892
+ this.name = "UnknownEnvironmentError";
9893
+ }
9894
+ };
9895
+ function resolveIdentityEnvironment(env = process.env) {
9896
+ const raw = (env[ENVIRONMENT_VARIABLE] ?? "").trim().toLowerCase();
9897
+ if (raw === "" || raw === "prod" || raw === "production") return IDENTITY_ENVIRONMENTS.production;
9898
+ if (raw === "dev" || raw === "development") return IDENTITY_ENVIRONMENTS.development;
9899
+ throw new UnknownEnvironmentError(env[ENVIRONMENT_VARIABLE] ?? "");
9900
+ }
9901
+
9902
+ // src/modules/oidc.ts
9903
+ import { createServer } from "http";
9904
+ import { execFile as execFile3 } from "child_process";
9905
+ import * as client from "openid-client";
9906
+
9907
+ // src/modules/result-pages.ts
9908
+ import { createHash as createHash5 } from "crypto";
9909
+ import { readFile as readFile22 } from "fs/promises";
9910
+ import { join as join26 } from "path";
9911
+ var RESULT_KINDS = ["success", "denied", "failure"];
9361
9912
  function resultPagePath(kind) {
9362
- return join24("sign-in", `sign-in-callback-${kind}.html`);
9913
+ return join26("sign-in", `sign-in-callback-${kind}.html`);
9363
9914
  }
9364
9915
  function styleHashes(html) {
9365
9916
  return [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)].map((match2) => {
@@ -9391,431 +9942,228 @@ function resultPage(html) {
9391
9942
  function plainPage(heading, next) {
9392
9943
  return resultPage(
9393
9944
  [
9394
- '<!doctype html><html lang="en"><meta charset="utf-8">',
9395
- '<meta name="viewport" content="width=device-width, initial-scale=1">',
9396
- `<title>${heading} \u2014 Arcane</title>`,
9397
- "<style>:root{color-scheme:light dark}body{font-family:system-ui,sans-serif;margin:3rem;line-height:1.5}</style>",
9398
- `<body><h1>${heading}</h1><p>${next}</p></body></html>`
9399
- ].join("")
9400
- );
9401
- }
9402
- var PLAIN_RESULT_PAGES = {
9403
- success: plainPage("Signed in to Arcane", "Return to your terminal. Your browser can be closed now."),
9404
- denied: plainPage("Sign-in was not completed", "Return to your terminal to try again. No new session was stored."),
9405
- failure: plainPage("Could not finish sign-in", "Return to your terminal for details. No new session was stored.")
9406
- };
9407
- async function loadResultPages(assetsDir) {
9408
- try {
9409
- const [success, denied, failure] = await Promise.all(
9410
- RESULT_KINDS.map(async (kind) => resultPage(await readFile22(join24(assetsDir, resultPagePath(kind)), "utf8")))
9411
- );
9412
- return { success, denied, failure };
9413
- } catch {
9414
- return null;
9415
- }
9416
- }
9417
-
9418
- // src/modules/oidc.ts
9419
- var IdentityUnreachableError = class extends Error {
9420
- constructor(host, cause) {
9421
- super(`Could not reach the identity service at ${host}.`);
9422
- this.name = "IdentityUnreachableError";
9423
- this.cause = cause;
9424
- }
9425
- };
9426
- var LoginTimeoutError = class extends Error {
9427
- constructor() {
9428
- super("Timed out waiting for the sign-in to finish in the browser.");
9429
- this.name = "LoginTimeoutError";
9430
- }
9431
- };
9432
- var LoginBrowserUnavailableError = class extends Error {
9433
- constructor() {
9434
- super("No browser could be opened. Run `spell login --device-code` and finish signing in on another device.");
9435
- this.name = "LoginBrowserUnavailableError";
9436
- }
9437
- };
9438
- var LoginDeniedError = class extends Error {
9439
- constructor(code) {
9440
- super(`The identity service refused the sign-in (${code}).`);
9441
- this.name = "LoginDeniedError";
9442
- }
9443
- };
9444
- async function loadIdentityConfiguration(environment, options = {}) {
9445
- const fetchImpl = options.fetch ?? fetch;
9446
- let metadata;
9447
- try {
9448
- const response = await fetchImpl(environment.discoveryUrl, { redirect: "manual" });
9449
- if (!response.ok) throw new Error(`discovery returned HTTP ${response.status}`);
9450
- metadata = await response.json();
9451
- } catch (error2) {
9452
- throw new IdentityUnreachableError(environment.signInHost, error2);
9453
- }
9454
- if (typeof metadata.issuer !== "string" || !metadata.token_endpoint || !metadata.authorization_endpoint) {
9455
- throw new IdentityUnreachableError(environment.signInHost, new Error("discovery document incomplete"));
9456
- }
9457
- const config = new client.Configuration(metadata, environment.clientId, void 0, client.None());
9458
- if (options.fetch) config[client.customFetch] = options.fetch;
9459
- if (options.allowInsecureHttp) client.allowInsecureRequests(config);
9460
- return config;
9461
- }
9462
- function toTokenResult(tokens, previousRefreshToken) {
9463
- const claims = tokens.claims();
9464
- if (!claims?.sub) throw new Error("The identity service returned no account identifier.");
9465
- const refreshToken = tokens.refresh_token ?? previousRefreshToken;
9466
- if (!refreshToken) {
9467
- throw new Error("The identity service returned no refresh token; nothing can be stored.");
9468
- }
9469
- const now = /* @__PURE__ */ new Date();
9470
- const seconds = tokens.expiresIn() ?? 0;
9471
- const email = typeof claims["email"] === "string" ? claims["email"] : typeof claims["preferred_username"] === "string" ? claims["preferred_username"] : null;
9472
- return {
9473
- refreshToken,
9474
- sub: claims.sub,
9475
- email,
9476
- obtainedAt: now,
9477
- expiresAt: new Date(now.getTime() + seconds * 1e3)
9478
- };
9479
- }
9480
- var resultPath = (kind) => `/result/${kind}`;
9481
- var RESULT_PATHS = new Set(RESULT_KINDS.map(resultPath));
9482
- async function browserLogin(config, options) {
9483
- const codeVerifier = client.randomPKCECodeVerifier();
9484
- const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
9485
- const state = client.randomState();
9486
- const nonce = client.randomNonce();
9487
- const server = createServer();
9488
- await new Promise((resolve10, reject) => {
9489
- server.once("error", reject);
9490
- server.listen(0, "127.0.0.1", () => resolve10());
9491
- });
9492
- const address = server.address();
9493
- if (address === null || typeof address === "string") {
9494
- server.close();
9495
- throw new Error("Could not open a loopback port for the sign-in.");
9496
- }
9497
- const redirectUri = new URL(`http://127.0.0.1:${address.port}`).href;
9498
- const authorizationUrl = client.buildAuthorizationUrl(config, {
9499
- redirect_uri: redirectUri,
9500
- scope: options.scopes.join(" "),
9501
- code_challenge: codeChallenge,
9502
- code_challenge_method: "S256",
9503
- state,
9504
- nonce
9505
- });
9506
- const pages = options.pages ?? PLAIN_RESULT_PAGES;
9507
- const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
9508
- let timer;
9509
- let callbackConsumed = false;
9510
- let browserResult;
9511
- const close = () => {
9512
- if (timer) clearTimeout(timer);
9513
- server.closeAllConnections?.();
9514
- server.close();
9515
- };
9516
- const tokens = new Promise((resolve10, reject) => {
9517
- server.on("request", (req, res) => {
9518
- const url = new URL(req.url ?? "/", redirectUri);
9519
- if (RESULT_PATHS.has(url.pathname)) {
9520
- if (!browserResult || url.pathname !== resultPath(browserResult.kind) || url.search !== "") {
9521
- res.writeHead(404).end();
9522
- return;
9523
- }
9524
- const served = browserResult;
9525
- const page = pages[served.kind];
9526
- const settle = () => {
9527
- if (served.kind === "success") resolve10(served.result);
9528
- else reject(served.error);
9529
- };
9530
- res.once("finish", settle).once("close", settle);
9531
- res.writeHead(200, page.headers).end(page.html);
9532
- return;
9533
- }
9534
- if (url.pathname !== "/" || !url.searchParams.has("code") && !url.searchParams.has("error")) {
9535
- res.writeHead(404).end();
9536
- return;
9537
- }
9538
- if (callbackConsumed) {
9539
- res.writeHead(409, { "cache-control": "no-store" }).end();
9540
- return;
9541
- }
9542
- if (url.searchParams.get("state") !== state) {
9543
- res.writeHead(400, pages.failure.headers).end(pages.failure.html);
9544
- return;
9545
- }
9546
- callbackConsumed = true;
9547
- const redirectToResult = (result) => {
9548
- browserResult = result;
9549
- res.writeHead(303, {
9550
- "cache-control": "no-store",
9551
- location: resultPath(result.kind),
9552
- "referrer-policy": "no-referrer"
9553
- }).end();
9554
- if (result.kind === "success") {
9555
- if (timer) clearTimeout(timer);
9556
- timer = setTimeout(() => resolve10(result.result), Math.min(timeoutMs, 5e3));
9557
- }
9558
- };
9559
- const errorCode = url.searchParams.get("error");
9560
- if (errorCode) {
9561
- const code = errorCode.replace(/[^a-z_]/gi, "");
9562
- redirectToResult({
9563
- kind: code === "access_denied" ? "denied" : "failure",
9564
- error: new LoginDeniedError(code)
9565
- });
9566
- return;
9567
- }
9568
- client.authorizationCodeGrant(config, url, {
9569
- pkceCodeVerifier: codeVerifier,
9570
- expectedState: state,
9571
- expectedNonce: nonce,
9572
- idTokenExpected: true
9573
- }).then(async (response) => {
9574
- const result = toTokenResult(response);
9575
- await options.onValidated?.(result);
9576
- redirectToResult({ kind: "success", result });
9577
- }).catch((error2) => {
9578
- redirectToResult({ kind: "failure", error: error2 });
9579
- });
9580
- });
9581
- timer = setTimeout(() => reject(new LoginTimeoutError()), timeoutMs);
9582
- });
9583
- tokens.catch(() => {
9584
- });
9585
- try {
9586
- const opened = await options.openUrl(authorizationUrl.href);
9587
- if (!opened) {
9588
- options.onCannotOpen?.();
9589
- throw new LoginBrowserUnavailableError();
9590
- }
9591
- return await tokens;
9592
- } finally {
9593
- close();
9594
- }
9595
- }
9596
- async function deviceLogin(config, options) {
9597
- const handle = await client.initiateDeviceAuthorization(config, { scope: options.scopes.join(" ") });
9598
- options.prompt(handle.verification_uri, handle.user_code, handle.expires_in);
9599
- const response = await client.pollDeviceAuthorizationGrant(config, handle);
9600
- return toTokenResult(response);
9601
- }
9602
- async function refreshSession(config, refreshToken, scopes) {
9603
- const response = await client.refreshTokenGrant(config, refreshToken, { scope: scopes.join(" ") });
9604
- return toTokenResult(response, refreshToken);
9605
- }
9606
- function openInDefaultBrowser(url) {
9607
- const [command, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
9608
- return new Promise((resolve10) => {
9609
- try {
9610
- const child = execFile3(command, args, { windowsHide: true }, (error2) => resolve10(!error2));
9611
- child.on("error", () => resolve10(false));
9612
- } catch {
9613
- resolve10(false);
9614
- }
9615
- });
9616
- }
9617
-
9618
- // src/modules/session-store.ts
9619
- import { execFileSync } from "child_process";
9620
- import { mkdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync, existsSync as existsSync2 } from "fs";
9621
- import { userInfo } from "os";
9622
- import { dirname as dirname12, join as join25 } from "path";
9623
-
9624
- // src/modules/keychain-backend.ts
9625
- import { createRequire as createRequire2 } from "module";
9626
- var KEYCHAIN_SERVICE = "arcane-cli";
9627
- function createKeychainBackend(service = KEYCHAIN_SERVICE) {
9628
- let loaded = null;
9629
- const keyring = () => {
9630
- loaded ??= createRequire2(import.meta.url)("@napi-rs/keyring");
9631
- return loaded;
9632
- };
9633
- const entry = (name) => new (keyring()).Entry(service, name);
9634
- return {
9635
- get: (name) => entry(name).getPassword() ?? null,
9636
- set: (name, value) => {
9637
- entry(name).setPassword(value);
9638
- },
9639
- delete: (name) => {
9640
- entry(name).deletePassword();
9641
- },
9642
- list: () => keyring().findCredentials(service).map((credential) => credential.account)
9643
- };
9644
- }
9645
-
9646
- // src/modules/session-store.ts
9647
- var INSECURE_SESSION_FILE_NAME = "session-insecure.json";
9648
- var SessionClearError = class extends Error {
9649
- constructor(keychainError, removed) {
9650
- super(keychainError.message);
9651
- this.keychainError = keychainError;
9652
- this.removed = removed;
9653
- this.name = "SessionClearError";
9654
- this.cause = keychainError;
9655
- }
9656
- keychainError;
9657
- removed;
9658
- };
9659
- function isIsoDate(value) {
9660
- return typeof value === "string" && !Number.isNaN(Date.parse(value));
9945
+ '<!doctype html><html lang="en"><meta charset="utf-8">',
9946
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
9947
+ `<title>${heading} \u2014 Arcane</title>`,
9948
+ "<style>:root{color-scheme:light dark}body{font-family:system-ui,sans-serif;margin:3rem;line-height:1.5}</style>",
9949
+ `<body><h1>${heading}</h1><p>${next}</p></body></html>`
9950
+ ].join("")
9951
+ );
9661
9952
  }
9662
- function parseSessionRecord(raw) {
9663
- let parsed;
9953
+ var PLAIN_RESULT_PAGES = {
9954
+ success: plainPage("Signed in to Arcane", "Return to your terminal. Your browser can be closed now."),
9955
+ denied: plainPage("Sign-in was not completed", "Return to your terminal to try again. No new session was stored."),
9956
+ failure: plainPage("Could not finish sign-in", "Return to your terminal for details. No new session was stored.")
9957
+ };
9958
+ async function loadResultPages(assetsDir) {
9664
9959
  try {
9665
- parsed = JSON.parse(raw);
9960
+ const [success, denied, failure] = await Promise.all(
9961
+ RESULT_KINDS.map(async (kind) => resultPage(await readFile22(join26(assetsDir, resultPagePath(kind)), "utf8")))
9962
+ );
9963
+ return { success, denied, failure };
9666
9964
  } catch {
9667
9965
  return null;
9668
9966
  }
9669
- if (typeof parsed !== "object" || parsed === null) return null;
9670
- const o = parsed;
9671
- if (o["v"] !== 1 || o["environment"] !== "production" && o["environment"] !== "development" || typeof o["sub"] !== "string" || o["sub"] === "" || !(typeof o["email"] === "string" || o["email"] === null) || typeof o["refreshToken"] !== "string" || o["refreshToken"] === "" || !isIsoDate(o["obtainedAt"]) || !isIsoDate(o["expiresAt"])) {
9672
- return null;
9673
- }
9674
- return {
9675
- v: 1,
9676
- environment: o["environment"],
9677
- sub: o["sub"],
9678
- email: o["email"],
9679
- refreshToken: o["refreshToken"],
9680
- obtainedAt: o["obtainedAt"],
9681
- expiresAt: o["expiresAt"]
9682
- };
9683
9967
  }
9684
- var FileSecretBackend = class {
9685
- constructor(filePath, restrict = restrictToCurrentUser) {
9686
- this.filePath = filePath;
9687
- this.restrict = restrict;
9688
- }
9689
- filePath;
9690
- restrict;
9691
- exists() {
9692
- return existsSync2(this.filePath);
9968
+
9969
+ // src/modules/oidc.ts
9970
+ var IdentityUnreachableError = class extends Error {
9971
+ constructor(host, cause) {
9972
+ super(`Could not reach the identity service at ${host}.`);
9973
+ this.name = "IdentityUnreachableError";
9974
+ this.cause = cause;
9693
9975
  }
9694
- read() {
9695
- if (!existsSync2(this.filePath)) return {};
9696
- const parsed = JSON.parse(readFileSync2(this.filePath, "utf8"));
9697
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
9698
- throw new Error("the session file is not an object");
9699
- }
9700
- const out = {};
9701
- for (const [k, v] of Object.entries(parsed)) {
9702
- if (typeof v === "string") out[k] = v;
9703
- }
9704
- return out;
9976
+ };
9977
+ var LoginTimeoutError = class extends Error {
9978
+ constructor() {
9979
+ super("Timed out waiting for the sign-in to finish in the browser.");
9980
+ this.name = "LoginTimeoutError";
9705
9981
  }
9706
- write(entries) {
9707
- if (Object.keys(entries).length === 0) {
9708
- rmSync(this.filePath, { force: true });
9709
- return;
9710
- }
9711
- mkdirSync(dirname12(this.filePath), { recursive: true });
9712
- const tmp = `${this.filePath}.${process.pid}.tmp`;
9713
- writeFileSync(tmp, JSON.stringify(entries), { encoding: "utf8", mode: 384 });
9714
- try {
9715
- this.restrict(tmp);
9716
- } catch (error2) {
9717
- rmSync(tmp, { force: true });
9718
- throw new Error(
9719
- "Could not restrict the session file to your account, so it was not written.",
9720
- { cause: error2 }
9721
- );
9722
- }
9723
- renameSync(tmp, this.filePath);
9982
+ };
9983
+ var LoginBrowserUnavailableError = class extends Error {
9984
+ constructor() {
9985
+ super("No browser could be opened. Run `spell login --device-code` and finish signing in on another device.");
9986
+ this.name = "LoginBrowserUnavailableError";
9724
9987
  }
9725
- get(name) {
9726
- return this.read()[name] ?? null;
9988
+ };
9989
+ var LoginDeniedError = class extends Error {
9990
+ constructor(code) {
9991
+ super(`The identity service refused the sign-in (${code}).`);
9992
+ this.name = "LoginDeniedError";
9727
9993
  }
9728
- set(name, value) {
9729
- const entries = this.read();
9730
- entries[name] = value;
9731
- this.write(entries);
9994
+ };
9995
+ async function loadIdentityConfiguration(environment, options = {}) {
9996
+ const fetchImpl = options.fetch ?? fetch;
9997
+ let metadata;
9998
+ try {
9999
+ const response = await fetchImpl(environment.discoveryUrl, { redirect: "manual" });
10000
+ if (!response.ok) throw new Error(`discovery returned HTTP ${response.status}`);
10001
+ metadata = await response.json();
10002
+ } catch (error2) {
10003
+ throw new IdentityUnreachableError(environment.signInHost, error2);
9732
10004
  }
9733
- delete(name) {
9734
- const entries = this.read();
9735
- if (!(name in entries)) return;
9736
- delete entries[name];
9737
- this.write(entries);
10005
+ if (typeof metadata.issuer !== "string" || !metadata.token_endpoint || !metadata.authorization_endpoint) {
10006
+ throw new IdentityUnreachableError(environment.signInHost, new Error("discovery document incomplete"));
9738
10007
  }
9739
- list() {
9740
- return Object.keys(this.read());
10008
+ const config = new client.Configuration(metadata, environment.clientId, void 0, client.None());
10009
+ if (options.fetch) config[client.customFetch] = options.fetch;
10010
+ if (options.allowInsecureHttp) client.allowInsecureRequests(config);
10011
+ return config;
10012
+ }
10013
+ function toTokenResult(tokens, previousRefreshToken) {
10014
+ const claims = tokens.claims();
10015
+ if (!claims?.sub) throw new Error("The identity service returned no account identifier.");
10016
+ const refreshToken = tokens.refresh_token ?? previousRefreshToken;
10017
+ if (!refreshToken) {
10018
+ throw new Error("The identity service returned no refresh token; nothing can be stored.");
9741
10019
  }
9742
- /** Removes the file itself, strays and all. */
9743
- remove() {
9744
- rmSync(this.filePath, { force: true });
10020
+ const now = /* @__PURE__ */ new Date();
10021
+ const seconds = tokens.expiresIn() ?? 0;
10022
+ const email = typeof claims["email"] === "string" ? claims["email"] : typeof claims["preferred_username"] === "string" ? claims["preferred_username"] : null;
10023
+ return {
10024
+ refreshToken,
10025
+ sub: claims.sub,
10026
+ email,
10027
+ obtainedAt: now,
10028
+ expiresAt: new Date(now.getTime() + seconds * 1e3)
10029
+ };
10030
+ }
10031
+ var resultPath = (kind) => `/result/${kind}`;
10032
+ var RESULT_PATHS = new Set(RESULT_KINDS.map(resultPath));
10033
+ async function browserLogin(config, options) {
10034
+ const codeVerifier = client.randomPKCECodeVerifier();
10035
+ const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
10036
+ const state = client.randomState();
10037
+ const nonce = client.randomNonce();
10038
+ const server = createServer();
10039
+ await new Promise((resolve10, reject) => {
10040
+ server.once("error", reject);
10041
+ server.listen(0, "127.0.0.1", () => resolve10());
10042
+ });
10043
+ const address = server.address();
10044
+ if (address === null || typeof address === "string") {
10045
+ server.close();
10046
+ throw new Error("Could not open a loopback port for the sign-in.");
9745
10047
  }
9746
- };
9747
- function restrictToCurrentUser(filePath) {
9748
- if (process.platform !== "win32") return;
9749
- const account = userInfo().username;
9750
- execFileSync("icacls", [filePath, "/inheritance:r", "/grant:r", `${account}:F`], {
9751
- stdio: "ignore",
9752
- windowsHide: true
10048
+ const redirectUri = new URL(`http://127.0.0.1:${address.port}`).href;
10049
+ const authorizationUrl = client.buildAuthorizationUrl(config, {
10050
+ redirect_uri: redirectUri,
10051
+ scope: options.scopes.join(" "),
10052
+ code_challenge: codeChallenge,
10053
+ code_challenge_method: "S256",
10054
+ state,
10055
+ nonce
9753
10056
  });
9754
- }
9755
- function insecureSessionFilePath(homeDir) {
9756
- return join25(userTierRoot(homeDir), INSECURE_SESSION_FILE_NAME);
9757
- }
9758
- function createSessionStorage(options = {}) {
9759
- const keychain = new ChunkedSecretStore(options.keychainBackend ?? createKeychainBackend());
9760
- const fileBackend = options.fileBackend ?? new FileSecretBackend(insecureSessionFilePath());
9761
- const file = new ChunkedSecretStore(fileBackend);
9762
- const interpret = (result, source) => {
9763
- if (result.state === "signed-out") return null;
9764
- if (result.state === "corrupt") return { state: "corrupt", source, reason: result.reason };
9765
- const record = parseSessionRecord(result.secret);
9766
- if (record === null) return { state: "corrupt", source, reason: "the stored session is not valid" };
9767
- return { state: "ok", source, record };
10057
+ const pages = options.pages ?? PLAIN_RESULT_PAGES;
10058
+ const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
10059
+ let timer;
10060
+ let callbackConsumed = false;
10061
+ let browserResult;
10062
+ const close = () => {
10063
+ if (timer) clearTimeout(timer);
10064
+ server.closeAllConnections?.();
10065
+ server.close();
9768
10066
  };
9769
- return {
9770
- load() {
9771
- let keychainError;
9772
- try {
9773
- const fromKeychain = interpret(keychain.load(), "keychain");
9774
- if (fromKeychain) return fromKeychain;
9775
- } catch (error2) {
9776
- if (!(error2 instanceof KeychainUnavailableError)) throw error2;
9777
- keychainError = error2;
9778
- }
9779
- if (fileBackend.exists()) {
9780
- const fromFile = interpret(file.load(), "file");
9781
- if (fromFile) return fromFile;
9782
- }
9783
- return keychainError ? { state: "signed-out", keychainError } : { state: "signed-out" };
9784
- },
9785
- save(record, { insecure }) {
9786
- const serialized = JSON.stringify(record);
9787
- if (insecure) {
9788
- file.save(serialized);
9789
- try {
9790
- keychain.clear();
9791
- } catch {
10067
+ const tokens = new Promise((resolve10, reject) => {
10068
+ server.on("request", (req, res) => {
10069
+ const url = new URL(req.url ?? "/", redirectUri);
10070
+ if (RESULT_PATHS.has(url.pathname)) {
10071
+ if (!browserResult || url.pathname !== resultPath(browserResult.kind) || url.search !== "") {
10072
+ res.writeHead(404).end();
10073
+ return;
9792
10074
  }
10075
+ const served = browserResult;
10076
+ const page = pages[served.kind];
10077
+ const settle = () => {
10078
+ if (served.kind === "success") resolve10(served.result);
10079
+ else reject(served.error);
10080
+ };
10081
+ res.once("finish", settle).once("close", settle);
10082
+ res.writeHead(200, page.headers).end(page.html);
9793
10083
  return;
9794
10084
  }
9795
- keychain.save(serialized);
9796
- fileBackend.remove();
9797
- },
9798
- clear() {
9799
- const removed = [];
9800
- let keychainError;
9801
- try {
9802
- const hadKeychainSession = keychain.load().state !== "signed-out";
9803
- keychain.clear();
9804
- if (hadKeychainSession) removed.push("keychain");
9805
- } catch (error2) {
9806
- keychainError = error2;
10085
+ if (url.pathname !== "/" || !url.searchParams.has("code") && !url.searchParams.has("error")) {
10086
+ res.writeHead(404).end();
10087
+ return;
9807
10088
  }
9808
- if (fileBackend.exists()) {
9809
- fileBackend.remove();
9810
- removed.push("file");
10089
+ if (callbackConsumed) {
10090
+ res.writeHead(409, { "cache-control": "no-store" }).end();
10091
+ return;
10092
+ }
10093
+ if (url.searchParams.get("state") !== state) {
10094
+ res.writeHead(400, pages.failure.headers).end(pages.failure.html);
10095
+ return;
9811
10096
  }
9812
- if (keychainError instanceof KeychainUnavailableError) {
9813
- throw new SessionClearError(keychainError, removed);
10097
+ callbackConsumed = true;
10098
+ const redirectToResult = (result) => {
10099
+ browserResult = result;
10100
+ res.writeHead(303, {
10101
+ "cache-control": "no-store",
10102
+ location: resultPath(result.kind),
10103
+ "referrer-policy": "no-referrer"
10104
+ }).end();
10105
+ if (result.kind === "success") {
10106
+ if (timer) clearTimeout(timer);
10107
+ timer = setTimeout(() => resolve10(result.result), Math.min(timeoutMs, 5e3));
10108
+ }
10109
+ };
10110
+ const errorCode = url.searchParams.get("error");
10111
+ if (errorCode) {
10112
+ const code = errorCode.replace(/[^a-z_]/gi, "");
10113
+ redirectToResult({
10114
+ kind: code === "access_denied" ? "denied" : "failure",
10115
+ error: new LoginDeniedError(code)
10116
+ });
10117
+ return;
9814
10118
  }
9815
- if (keychainError) throw keychainError;
9816
- return { removed };
10119
+ client.authorizationCodeGrant(config, url, {
10120
+ pkceCodeVerifier: codeVerifier,
10121
+ expectedState: state,
10122
+ expectedNonce: nonce,
10123
+ idTokenExpected: true
10124
+ }).then(async (response) => {
10125
+ const result = toTokenResult(response);
10126
+ await options.onValidated?.(result);
10127
+ redirectToResult({ kind: "success", result });
10128
+ }).catch((error2) => {
10129
+ redirectToResult({ kind: "failure", error: error2 });
10130
+ });
10131
+ });
10132
+ timer = setTimeout(() => reject(new LoginTimeoutError()), timeoutMs);
10133
+ });
10134
+ tokens.catch(() => {
10135
+ });
10136
+ try {
10137
+ const opened = await options.openUrl(authorizationUrl.href);
10138
+ if (!opened) {
10139
+ options.onCannotOpen?.();
10140
+ throw new LoginBrowserUnavailableError();
9817
10141
  }
9818
- };
10142
+ return await tokens;
10143
+ } finally {
10144
+ close();
10145
+ }
10146
+ }
10147
+ async function deviceLogin(config, options) {
10148
+ const handle = await client.initiateDeviceAuthorization(config, { scope: options.scopes.join(" ") });
10149
+ options.prompt(handle.verification_uri, handle.user_code, handle.expires_in);
10150
+ const response = await client.pollDeviceAuthorizationGrant(config, handle);
10151
+ return toTokenResult(response);
10152
+ }
10153
+ async function refreshSession(config, refreshToken, scopes) {
10154
+ const response = await client.refreshTokenGrant(config, refreshToken, { scope: scopes.join(" ") });
10155
+ return toTokenResult(response, refreshToken);
10156
+ }
10157
+ function openInDefaultBrowser(url) {
10158
+ const [command, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
10159
+ return new Promise((resolve10) => {
10160
+ try {
10161
+ const child = execFile3(command, args, { windowsHide: true }, (error2) => resolve10(!error2));
10162
+ child.on("error", () => resolve10(false));
10163
+ } catch {
10164
+ resolve10(false);
10165
+ }
10166
+ });
9819
10167
  }
9820
10168
 
9821
10169
  // src/commands/login.ts
@@ -10021,15 +10369,15 @@ async function runWhoami(options, deps = defaultWhoamiDeps()) {
10021
10369
 
10022
10370
  // src/commands/report.ts
10023
10371
  import { access as access5 } from "fs/promises";
10024
- import { join as join28 } from "path";
10372
+ import { join as join29 } from "path";
10025
10373
 
10026
10374
  // src/modules/show-report/generate.ts
10027
10375
  import { mkdir as mkdir8, readdir as readdir13, readFile as readFile24, writeFile as writeFile11 } from "fs/promises";
10028
- import { basename as basename4, dirname as dirname13, isAbsolute, join as join27, relative as relative3, resolve as resolve9 } from "path";
10376
+ import { basename as basename4, dirname as dirname13, isAbsolute, join as join28, relative as relative4, resolve as resolve9 } from "path";
10029
10377
 
10030
10378
  // src/modules/show-report/model.ts
10031
10379
  import { readFile as readFile23 } from "fs/promises";
10032
- import { join as join26 } from "path";
10380
+ import { join as join27 } from "path";
10033
10381
 
10034
10382
  // src/modules/show-report/plan-parser.ts
10035
10383
  function parseFrontmatter(content) {
@@ -10376,11 +10724,11 @@ function buildCorrections(ledgerRows, programName) {
10376
10724
  };
10377
10725
  }
10378
10726
  async function buildShowReportModel(options) {
10379
- const planAbsPath = join26(options.rootDir, options.planRelPath);
10380
- const queueAbsPath = join26(options.rootDir, options.queueRelPath);
10727
+ const planAbsPath = join27(options.rootDir, options.planRelPath);
10728
+ const queueAbsPath = join27(options.rootDir, options.queueRelPath);
10381
10729
  const ledgerRelPath = "docs/verification-ledger.md";
10382
- const ledgerAbsPath = join26(options.rootDir, ledgerRelPath);
10383
- const decisionsAbsPath = join26(options.rootDir, "DECISIONS.md");
10730
+ const ledgerAbsPath = join27(options.rootDir, ledgerRelPath);
10731
+ const decisionsAbsPath = join27(options.rootDir, "DECISIONS.md");
10384
10732
  const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
10385
10733
  readFile23(planAbsPath, "utf8"),
10386
10734
  readOptional(queueAbsPath),
@@ -10578,7 +10926,7 @@ function toPosix(path5) {
10578
10926
  async function programFromPlan(rootDir, slug, planRelPath) {
10579
10927
  let planContent;
10580
10928
  try {
10581
- planContent = await readFile24(join27(rootDir, planRelPath), "utf8");
10929
+ planContent = await readFile24(join28(rootDir, planRelPath), "utf8");
10582
10930
  } catch {
10583
10931
  return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
10584
10932
  }
@@ -10590,13 +10938,13 @@ async function programFromPlan(rootDir, slug, planRelPath) {
10590
10938
  program: {
10591
10939
  slug,
10592
10940
  planRelPath,
10593
- queueRelPath: toPosix(join27(dirname13(planRelPath), "OPERATOR-QUEUE.md")),
10941
+ queueRelPath: toPosix(join28(dirname13(planRelPath), "OPERATOR-QUEUE.md")),
10594
10942
  ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
10595
10943
  }
10596
10944
  };
10597
10945
  }
10598
10946
  async function discoverPrograms(rootDir) {
10599
- const plansDir = join27(rootDir, "docs", "plans");
10947
+ const plansDir = join28(rootDir, "docs", "plans");
10600
10948
  let entries;
10601
10949
  try {
10602
10950
  entries = (await readdir13(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
@@ -10615,7 +10963,7 @@ async function discoverPrograms(rootDir) {
10615
10963
  }
10616
10964
  async function programFromPlanPath(rootDir, planPath) {
10617
10965
  const planAbs = isAbsolute(planPath) ? planPath : resolve9(rootDir, planPath);
10618
- const planRelPath = toPosix(relative3(rootDir, planAbs));
10966
+ const planRelPath = toPosix(relative4(rootDir, planAbs));
10619
10967
  return programFromPlan(rootDir, basename4(dirname13(planAbs)), planRelPath);
10620
10968
  }
10621
10969
  function normalizeLineEndings2(content) {
@@ -10630,7 +10978,7 @@ async function readOptionalFile(path5) {
10630
10978
  }
10631
10979
  }
10632
10980
  async function generateReport(rootDir, program2, templateHtml, theme = "auto") {
10633
- const planContent = await readFile24(join27(rootDir, program2.planRelPath), "utf8");
10981
+ const planContent = await readFile24(join28(rootDir, program2.planRelPath), "utf8");
10634
10982
  const frontmatter = parseFrontmatter(planContent);
10635
10983
  const model = await buildShowReportModel({
10636
10984
  rootDir,
@@ -10662,18 +11010,18 @@ async function runReportGeneration(options) {
10662
11010
  const missing = JSON.parse(generated.json).sections.flatMap((section) => section.rows).filter((row) => row.descriptionState === "unwritten").map((row) => row.id);
10663
11011
  if (missing.length > 0) unwritten.push({ slug: program2.slug, ids: missing });
10664
11012
  const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve9(options.rootDir, options.outDir) : resolve9(options.rootDir, dirname13(program2.planRelPath));
10665
- const outRel = toPosix(relative3(options.rootDir, outAbs));
11013
+ const outRel = toPosix(relative4(options.rootDir, outAbs));
10666
11014
  const outputs = [
10667
11015
  [`${outRel}/show-report.json`, generated.json],
10668
11016
  [`${outRel}/show-report.html`, generated.html]
10669
11017
  ];
10670
11018
  for (const [relPath, expected] of outputs) {
10671
- const actual = await readOptionalFile(join27(options.rootDir, relPath));
11019
+ const actual = await readOptionalFile(join28(options.rootDir, relPath));
10672
11020
  if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
10673
11021
  drifted.push(`${relPath} does not match a regeneration from its sources.`);
10674
11022
  if (options.mode === "write") {
10675
11023
  await mkdir8(outAbs, { recursive: true });
10676
- await writeFile11(join27(options.rootDir, relPath), expected, "utf8");
11024
+ await writeFile11(join28(options.rootDir, relPath), expected, "utf8");
10677
11025
  repaired.push(relPath);
10678
11026
  }
10679
11027
  }
@@ -10684,7 +11032,7 @@ async function runReportGeneration(options) {
10684
11032
 
10685
11033
  // src/commands/report.ts
10686
11034
  async function runReport(targetDir, options = {}, assetsDir) {
10687
- const templatePath = join28(assetsDir, "report", "show-report.template.html");
11035
+ const templatePath = join29(assetsDir, "report", "show-report.template.html");
10688
11036
  try {
10689
11037
  await access5(templatePath);
10690
11038
  } catch {
@@ -10737,324 +11085,100 @@ async function runReport(targetDir, options = {}, assetsDir) {
10737
11085
  mode: "write",
10738
11086
  programs,
10739
11087
  outDir: options.out,
10740
- theme
10741
- });
10742
- for (const u of result.unwritten) {
10743
- console.warn(
10744
- `\u26A0 arcane: ${u.slug} has ${u.ids.length} epic(s) with no \`**Report:**\` line in PLAN.md -- they render as "unwritten": ${u.ids.join(", ")}.`
10745
- );
10746
- }
10747
- for (const slug of result.themeSkipped) {
10748
- console.warn(
10749
- `\u26A0 arcane: ${slug} -- --theme ${theme} requested, but this template has no theme toggle to set (older vendored template?); left unchanged.`
10750
- );
10751
- }
10752
- if (result.repaired.length === 0) {
10753
- console.log(`Show report: ${programs.length} program(s) already up to date.`);
10754
- return;
10755
- }
10756
- console.log(`Show report: wrote ${result.repaired.length} file(s).`);
10757
- for (const path5 of result.repaired) console.log(` ${path5}`);
10758
- }
10759
-
10760
- // src/modules/agent-ref.ts
10761
- import { randomBytes as randomBytes2 } from "crypto";
10762
- var REF_TYPES = ["agent", "template", "session"];
10763
- var UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
10764
- var ANY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
10765
- function createUuidV7Generator(options = {}) {
10766
- const now = options.now ?? Date.now;
10767
- const random = options.random ?? ((n) => randomBytes2(n));
10768
- let lastMs = -1;
10769
- let counter = 0;
10770
- return () => {
10771
- let ms = Math.floor(now());
10772
- const bytes = random(10);
10773
- if (ms > lastMs) {
10774
- counter = (bytes[0] << 8 | bytes[1]) & 2047;
10775
- } else {
10776
- ms = lastMs;
10777
- counter += 1;
10778
- if (counter > 4095) {
10779
- ms += 1;
10780
- counter = (bytes[0] << 8 | bytes[1]) & 2047;
10781
- }
10782
- }
10783
- lastMs = ms;
10784
- const out = new Uint8Array(16);
10785
- let t = ms;
10786
- for (let i = 5; i >= 0; i -= 1) {
10787
- out[i] = t % 256;
10788
- t = Math.floor(t / 256);
10789
- }
10790
- out[6] = 112 | counter >> 8 & 15;
10791
- out[7] = counter & 255;
10792
- out[8] = 128 | bytes[2] & 63;
10793
- for (let i = 9; i < 16; i += 1) out[i] = bytes[i - 6];
10794
- const hex = Array.from(out, (b) => b.toString(16).padStart(2, "0")).join("");
10795
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
10796
- };
10797
- }
10798
- var defaultUuidV7 = createUuidV7Generator();
10799
- function uuidv7() {
10800
- return defaultUuidV7();
10801
- }
10802
- function mintRef(type, generate = uuidv7) {
10803
- if (!REF_TYPES.includes(type)) {
10804
- throw new Error(`"${String(type)}" is not a registered reference type`);
10805
- }
10806
- return `arcane:${type}:${generate()}`;
10807
- }
10808
- function mintSessionRef() {
10809
- return mintRef("session");
10810
- }
10811
- function parseRef(text2, expected) {
10812
- if (typeof text2 !== "string") return { ok: false, reason: "a reference must be a string" };
10813
- if (text2.length === 0) return { ok: false, reason: "a reference must not be empty" };
10814
- if (text2 !== text2.trim()) return { ok: false, reason: "a reference must not carry surrounding whitespace" };
10815
- if (text2 !== text2.toLowerCase()) return { ok: false, reason: "a reference must be lowercase" };
10816
- const parts = text2.split(":");
10817
- if (parts.length !== 3 || parts[0] !== "arcane") {
10818
- return { ok: false, reason: 'a reference must have the form "arcane:<type>:<uuid>"' };
10819
- }
10820
- const type = parts[1];
10821
- const uuid = parts[2];
10822
- if (!REF_TYPES.includes(type)) {
10823
- return { ok: false, reason: `"${type}" is not a registered reference type` };
10824
- }
10825
- if (uuid.startsWith("{") || uuid.endsWith("}")) {
10826
- return { ok: false, reason: "a reference's uuid must not be braced" };
10827
- }
10828
- if (!ANY_UUID.test(uuid)) return { ok: false, reason: "a reference's uuid must be in canonical hex form" };
10829
- if (!UUID_V7.test(uuid)) return { ok: false, reason: "a reference's uuid must be a version 7 UUID" };
10830
- if (expected !== void 0 && type !== expected) {
10831
- return { ok: false, reason: `expected a ${expected} reference, got one of type ${type}` };
10832
- }
10833
- return { ok: true, type, uuid };
10834
- }
10835
-
10836
- // src/modules/presence/client.ts
10837
- import { request } from "http";
10838
-
10839
- // src/modules/presence/store.ts
10840
- import { randomBytes as randomBytes3 } from "crypto";
10841
- import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
10842
- import { join as join29, relative as relative4 } from "path";
10843
-
10844
- // src/modules/presence/types.ts
10845
- var PRESENCE_CONTRACT = "v0";
10846
- var DEFAULT_PORT = 27226;
10847
- var PRESENCE_ENV = { port: "ARCANE_PRESENCE_PORT", token: "ARCANE_PRESENCE_TOKEN" };
10848
- var LOOPBACK_HOST = "127.0.0.1";
10849
- var MAX_BODY_BYTES = 65536;
10850
- var MAX_LINE_CHARS = 256;
10851
- var MAX_NEEDS_YOU = 20;
10852
- var MAX_SUBSCRIBERS = 32;
10853
- var MAX_SUBSCRIBER_BUFFER_BYTES = 1048576;
10854
- var MAX_CONNECTIONS = 128;
10855
- var MAX_SESSIONS = 256;
10856
- var MAX_ALIASES = 32;
10857
- var CONNECTIONS_CHECK_MS = 1e3;
10858
- var HEADERS_TIMEOUT_MS = 5e3;
10859
- var REQUEST_TIMEOUT_MS = 1e4;
10860
- var STALE_AFTER_MS = 9e4;
10861
- var EXPIRE_AFTER_MS = 864e5;
10862
- var KEEPALIVE_MS = 15e3;
10863
- var SWEEP_MS = 5e3;
10864
- var HARNESSES = ["claude-code", "copilot", "codex", "openclaw", "cron", "other"];
10865
- var REPORT_STATUSES = ["working", "waiting", "blocked", "idle"];
10866
- var ALIAS_CONTEXTS = ["openclaw", "slug"];
10867
- var HELLO_EVENT = "server.hello";
10868
- function presenceCapabilities() {
10869
- return { observe: true, actions: [] };
10870
- }
10871
- function nullAgentBlock() {
10872
- return { ref: null, slug: null, name: null, origin: null, definition_revision: null, aliases: [] };
10873
- }
10874
-
10875
- // src/modules/presence/store.ts
10876
- var PRESENCE_DIR_NAME = "presence";
10877
- var STORE_FILES = { token: "token", serverInfo: "server.json" };
10878
- var PresenceStoreError = class extends Error {
10879
- };
10880
- function home(options) {
10881
- return options.home ?? resolveHomeDir();
10882
- }
10883
- function presenceDir(options = {}) {
10884
- return join29(userTierRoot(home(options)), PRESENCE_DIR_NAME);
10885
- }
10886
- function tokenPath(options = {}) {
10887
- return join29(presenceDir(options), STORE_FILES.token);
10888
- }
10889
- function serverInfoPath(options = {}) {
10890
- return join29(presenceDir(options), STORE_FILES.serverInfo);
10891
- }
10892
- function ensureStore(options = {}) {
10893
- const root = userTierRoot(home(options));
10894
- const dir = presenceDir(options);
10895
- mkdirSync2(root, { recursive: true });
10896
- const realRoot = resolveRealPath(root);
10897
- if (relative4(realRoot, resolveRealPath(dir)) !== PRESENCE_DIR_NAME) {
10898
- throw new PresenceStoreError(`Refusing to use ${dir}: it resolves outside ${root}.`);
10899
- }
10900
- if (relative4(realRoot, resolveRealPath(tokenPath(options))) !== join29(PRESENCE_DIR_NAME, STORE_FILES.token)) {
10901
- throw new PresenceStoreError(`Refusing to use ${tokenPath(options)}: it resolves outside ${dir}.`);
10902
- }
10903
- mkdirSync2(dir, { recursive: true, mode: 448 });
10904
- chmodSync(dir, 448);
10905
- return dir;
10906
- }
10907
- function writeRestricted(target, content, options) {
10908
- const restrict = options.restrict ?? restrictToCurrentUser;
10909
- const tmp = `${target}.${process.pid}.tmp`;
10910
- writeFileSync2(tmp, content, { encoding: "utf8", mode: 384 });
10911
- try {
10912
- restrict(tmp);
10913
- } catch (error2) {
10914
- rmSync2(tmp, { force: true });
10915
- throw new PresenceStoreError(`Could not restrict ${target} to your account, so it was not written.`, { cause: error2 });
10916
- }
10917
- renameSync2(tmp, target);
10918
- chmodSync(target, 384);
10919
- }
10920
- function generateToken() {
10921
- return randomBytes3(32).toString("base64url");
10922
- }
10923
- function readTokenFile(options) {
10924
- let raw;
10925
- try {
10926
- raw = readFileSync3(tokenPath(options), "utf8");
10927
- } catch {
10928
- return null;
11088
+ theme
11089
+ });
11090
+ for (const u of result.unwritten) {
11091
+ console.warn(
11092
+ `\u26A0 arcane: ${u.slug} has ${u.ids.length} epic(s) with no \`**Report:**\` line in PLAN.md -- they render as "unwritten": ${u.ids.join(", ")}.`
11093
+ );
10929
11094
  }
10930
- const token = raw.replace(/[\r\n]+$/, "").trim();
10931
- return token.length > 0 ? token : null;
10932
- }
10933
- function ensureToken(options = {}) {
10934
- ensureStore(options);
10935
- const existing = options.rotate === true ? null : readTokenFile(options);
10936
- if (existing !== null) {
10937
- chmodSync(tokenPath(options), 384);
10938
- (options.restrict ?? restrictToCurrentUser)(tokenPath(options));
10939
- return existing;
11095
+ for (const slug of result.themeSkipped) {
11096
+ console.warn(
11097
+ `\u26A0 arcane: ${slug} -- --theme ${theme} requested, but this template has no theme toggle to set (older vendored template?); left unchanged.`
11098
+ );
10940
11099
  }
10941
- const token = generateToken();
10942
- writeRestricted(tokenPath(options), `${token}
10943
- `, options);
10944
- return token;
10945
- }
10946
- function readToken(options = {}) {
10947
- const fromEnv = (options.env ?? process.env)[PRESENCE_ENV.token];
10948
- if (fromEnv !== void 0 && fromEnv.replace(/[\r\n]+$/, "") !== "") return fromEnv.replace(/[\r\n]+$/, "");
10949
- return readTokenFile(options);
10950
- }
10951
- function writeServerInfo(info, options = {}) {
10952
- ensureStore(options);
10953
- writeRestricted(serverInfoPath(options), `${JSON.stringify(info, null, 2)}
10954
- `, options);
10955
- }
10956
- function readServerInfo(options = {}) {
10957
- let parsed;
10958
- try {
10959
- parsed = JSON.parse(readFileSync3(serverInfoPath(options), "utf8"));
10960
- } catch {
10961
- return null;
11100
+ if (result.repaired.length === 0) {
11101
+ console.log(`Show report: ${programs.length} program(s) already up to date.`);
11102
+ return;
10962
11103
  }
10963
- if (typeof parsed !== "object" || parsed === null) return null;
10964
- const o = parsed;
10965
- const generator = o["generator"];
10966
- const valid = typeof o["url"] === "string" && Number.isInteger(o["pid"]) && o["pid"] > 0 && Number.isInteger(o["port"]) && o["port"] >= 1 && o["port"] <= 65535 && typeof o["started_at"] === "string" && typeof generator === "object" && generator !== null && generator["name"] === "arcane-cli" && typeof generator["version"] === "string";
10967
- if (!valid) return null;
10968
- return {
10969
- url: o["url"],
10970
- pid: o["pid"],
10971
- port: o["port"],
10972
- started_at: o["started_at"],
10973
- generator: { name: "arcane-cli", version: generator["version"] }
10974
- };
10975
- }
10976
- function removeServerInfo(options = {}) {
10977
- rmSync2(serverInfoPath(options), { force: true });
11104
+ console.log(`Show report: wrote ${result.repaired.length} file(s).`);
11105
+ for (const path5 of result.repaired) console.log(` ${path5}`);
10978
11106
  }
10979
11107
 
10980
- // src/modules/presence/client.ts
10981
- var CONNECT_TIMEOUT_MS = 1e3;
10982
- var RESPONSE_TIMEOUT_MS = 1500;
10983
- function pidAlive(pid) {
10984
- try {
10985
- process.kill(pid, 0);
10986
- return true;
10987
- } catch {
10988
- return false;
10989
- }
11108
+ // src/modules/agent-ref.ts
11109
+ import { randomBytes as randomBytes3 } from "crypto";
11110
+ var REF_TYPES = ["agent", "template", "session"];
11111
+ var UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
11112
+ var ANY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
11113
+ function createUuidV7Generator(options = {}) {
11114
+ const now = options.now ?? Date.now;
11115
+ const random = options.random ?? ((n) => randomBytes3(n));
11116
+ let lastMs = -1;
11117
+ let counter = 0;
11118
+ return () => {
11119
+ let ms = Math.floor(now());
11120
+ const bytes = random(10);
11121
+ if (ms > lastMs) {
11122
+ counter = (bytes[0] << 8 | bytes[1]) & 2047;
11123
+ } else {
11124
+ ms = lastMs;
11125
+ counter += 1;
11126
+ if (counter > 4095) {
11127
+ ms += 1;
11128
+ counter = (bytes[0] << 8 | bytes[1]) & 2047;
11129
+ }
11130
+ }
11131
+ lastMs = ms;
11132
+ const out = new Uint8Array(16);
11133
+ let t = ms;
11134
+ for (let i = 5; i >= 0; i -= 1) {
11135
+ out[i] = t % 256;
11136
+ t = Math.floor(t / 256);
11137
+ }
11138
+ out[6] = 112 | counter >> 8 & 15;
11139
+ out[7] = counter & 255;
11140
+ out[8] = 128 | bytes[2] & 63;
11141
+ for (let i = 9; i < 16; i += 1) out[i] = bytes[i - 6];
11142
+ const hex = Array.from(out, (b) => b.toString(16).padStart(2, "0")).join("");
11143
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
11144
+ };
10990
11145
  }
10991
- function resolveEndpoint(options = {}) {
10992
- const info = readServerInfo(options);
10993
- if (info === null || !pidAlive(info.pid)) return null;
10994
- const token = readToken({ ...options, env: options.env ?? process.env });
10995
- if (token === null) return null;
10996
- return { info, token };
11146
+ var defaultUuidV7 = createUuidV7Generator();
11147
+ function uuidv7() {
11148
+ return defaultUuidV7();
10997
11149
  }
10998
- function errorFields(body) {
10999
- const e = body?.error;
11000
- return { code: typeof e?.code === "string" ? e.code : null, field: typeof e?.field === "string" ? e.field : null };
11150
+ function mintRef(type, generate = uuidv7) {
11151
+ if (!REF_TYPES.includes(type)) {
11152
+ throw new Error(`"${String(type)}" is not a registered reference type`);
11153
+ }
11154
+ return `arcane:${type}:${generate()}`;
11001
11155
  }
11002
- function send(port, token, method, path5, body, options = {}) {
11003
- const connectTimeout = options.connectTimeoutMs ?? CONNECT_TIMEOUT_MS;
11004
- const responseTimeout = options.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS;
11005
- const payload = body === void 0 ? void 0 : JSON.stringify(body);
11006
- return new Promise((resolve10) => {
11007
- let settled = false;
11008
- const done = (result) => {
11009
- if (settled) return;
11010
- settled = true;
11011
- clearTimeout(connectTimer);
11012
- clearTimeout(responseTimer);
11013
- resolve10(result);
11014
- };
11015
- const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };
11016
- if (payload !== void 0) {
11017
- headers["Content-Type"] = "application/json";
11018
- headers["Content-Length"] = Buffer.byteLength(payload);
11019
- }
11020
- const req = request({ host: LOOPBACK_HOST, port, method, path: path5, headers, agent: false }, (res) => {
11021
- const chunks = [];
11022
- res.on("data", (c) => chunks.push(c));
11023
- res.on("error", () => done({ kind: "unreachable" }));
11024
- res.on("end", () => {
11025
- const status = res.statusCode ?? 0;
11026
- const text2 = Buffer.concat(chunks).toString("utf8");
11027
- let parsed = null;
11028
- try {
11029
- parsed = text2.length > 0 ? JSON.parse(text2) : null;
11030
- } catch {
11031
- parsed = null;
11032
- }
11033
- if (status >= 200 && status < 300 && parsed !== null) done({ kind: "ok", status, body: parsed });
11034
- else if (status === 401 || status === 400 || status === 404 || status === 413) done({ kind: "refused", status, ...errorFields(parsed) });
11035
- else done({ kind: "unreachable" });
11036
- });
11037
- });
11038
- const connectTimer = setTimeout(() => {
11039
- req.destroy();
11040
- done({ kind: "unreachable" });
11041
- }, connectTimeout);
11042
- const responseTimer = setTimeout(() => {
11043
- req.destroy();
11044
- done({ kind: "unreachable" });
11045
- }, connectTimeout + responseTimeout);
11046
- req.on("socket", (socket) => {
11047
- socket.once("connect", () => clearTimeout(connectTimer));
11048
- });
11049
- req.on("error", () => done({ kind: "unreachable" }));
11050
- if (payload !== void 0) req.write(payload);
11051
- req.end();
11052
- });
11156
+ function mintSessionRef() {
11157
+ return mintRef("session");
11053
11158
  }
11054
- async function callPresence(method, path5, body, options = {}) {
11055
- const endpoint = resolveEndpoint(options);
11056
- if (endpoint === null) return { kind: "not-configured" };
11057
- return send(endpoint.info.port, endpoint.token, method, path5, body, options);
11159
+ function parseRef(text2, expected) {
11160
+ if (typeof text2 !== "string") return { ok: false, reason: "a reference must be a string" };
11161
+ if (text2.length === 0) return { ok: false, reason: "a reference must not be empty" };
11162
+ if (text2 !== text2.trim()) return { ok: false, reason: "a reference must not carry surrounding whitespace" };
11163
+ if (text2 !== text2.toLowerCase()) return { ok: false, reason: "a reference must be lowercase" };
11164
+ const parts = text2.split(":");
11165
+ if (parts.length !== 3 || parts[0] !== "arcane") {
11166
+ return { ok: false, reason: 'a reference must have the form "arcane:<type>:<uuid>"' };
11167
+ }
11168
+ const type = parts[1];
11169
+ const uuid = parts[2];
11170
+ if (!REF_TYPES.includes(type)) {
11171
+ return { ok: false, reason: `"${type}" is not a registered reference type` };
11172
+ }
11173
+ if (uuid.startsWith("{") || uuid.endsWith("}")) {
11174
+ return { ok: false, reason: "a reference's uuid must not be braced" };
11175
+ }
11176
+ if (!ANY_UUID.test(uuid)) return { ok: false, reason: "a reference's uuid must be in canonical hex form" };
11177
+ if (!UUID_V7.test(uuid)) return { ok: false, reason: "a reference's uuid must be a version 7 UUID" };
11178
+ if (expected !== void 0 && type !== expected) {
11179
+ return { ok: false, reason: `expected a ${expected} reference, got one of type ${type}` };
11180
+ }
11181
+ return { ok: true, type, uuid };
11058
11182
  }
11059
11183
 
11060
11184
  // src/modules/presence/mcp.ts
@@ -11131,11 +11255,11 @@ function detectHarness(env) {
11131
11255
  }
11132
11256
 
11133
11257
  // src/modules/presence/reporter.ts
11134
- var NEEDS_YOU_SEPARATOR = " \u2014 ";
11135
- function parseNeedsYou(line2) {
11136
- const at = line2.indexOf(NEEDS_YOU_SEPARATOR);
11258
+ var REQUIRED_ACTION_SEPARATOR = " \u2014 ";
11259
+ function parseRequiredAction(line2) {
11260
+ const at = line2.indexOf(REQUIRED_ACTION_SEPARATOR);
11137
11261
  if (at < 0) return { action: line2, reason: null };
11138
- return { action: line2.slice(0, at), reason: line2.slice(at + NEEDS_YOU_SEPARATOR.length) };
11262
+ return { action: line2.slice(0, at), reason: line2.slice(at + REQUIRED_ACTION_SEPARATOR.length) };
11139
11263
  }
11140
11264
  async function quiet(fn) {
11141
11265
  try {
@@ -11191,7 +11315,7 @@ async function sendReport(cwd, flags, deps = {}) {
11191
11315
  agent: agent2,
11192
11316
  repository: located.repository,
11193
11317
  checkout: located.checkout,
11194
- needs_you: (flags.needsYou ?? []).map(parseNeedsYou)
11318
+ required_actions: (flags.requiredActions ?? []).map(parseRequiredAction)
11195
11319
  };
11196
11320
  if (flags.spell !== void 0) body["spell"] = flags.spell;
11197
11321
  if (flags.task !== void 0) body["task"] = flags.task;
@@ -11220,9 +11344,11 @@ var SERVER_NAME = "arcane-presence";
11220
11344
  var NOT_CONFIGURED_TEXT = "no presence channel is configured on this machine";
11221
11345
  var VERSION_KEY = "io.modelcontextprotocol/protocolVersion";
11222
11346
  var SERVER_INFO_KEY = "io.modelcontextprotocol/serverInfo";
11347
+ var SUBSCRIPTION_KEY = "io.modelcontextprotocol/subscriptionId";
11348
+ var FEED_RETRY_MS = 5e3;
11223
11349
  var SESSIONS_URI = "arcane://presence/sessions";
11224
11350
  var CAPABILITIES_URI = "arcane://presence/capabilities";
11225
- var SERVER_CAPABILITIES = { tools: {}, resources: {} };
11351
+ var SERVER_CAPABILITIES = { tools: {}, resources: { subscribe: true } };
11226
11352
  var LINE = { type: "string", maxLength: 256 };
11227
11353
  var PRESENCE_TOOLS = [
11228
11354
  {
@@ -11238,7 +11364,7 @@ var PRESENCE_TOOLS = [
11238
11364
  task: { ...LINE, description: "What the session is doing, in one line." },
11239
11365
  spell: { ...LINE, description: "The spell running, e.g. spell-implement." },
11240
11366
  message: { ...LINE, description: "A free one-line note; an empty string clears it." },
11241
- needs_you: { type: "array", maxItems: 20, items: { type: "string" }, description: "Lines of a Needs you block, '<action> \u2014 <reason>'." },
11367
+ required_actions: { type: "array", maxItems: 20, items: { type: "string" }, description: "Lines of the Now tier of an Action required block, '<action> \u2014 <reason>'." },
11242
11368
  agent: { type: "string", description: "The roster agent (a slug or an alias); default: the orchestrator." },
11243
11369
  harness: { type: "string", enum: [...HARNESSES] },
11244
11370
  cwd: { type: "string", description: "The checkout to report for; default: the server's working directory." }
@@ -11295,16 +11421,16 @@ function serveMcp(options) {
11295
11421
  const report = async (args) => {
11296
11422
  const status = stringArg(args, "status");
11297
11423
  if (status === void 0) return text("status is required: one of working, waiting, blocked, idle.", true);
11298
- const needs = args["needs_you"];
11424
+ const needs = args["required_actions"];
11299
11425
  if (needs !== void 0 && (!Array.isArray(needs) || needs.some((n) => typeof n !== "string"))) {
11300
- return text("needs_you must be a list of strings.", true);
11426
+ return text("required_actions must be a list of strings.", true);
11301
11427
  }
11302
11428
  const flags = { status };
11303
11429
  for (const key of ["task", "spell", "message", "agent", "harness"]) {
11304
11430
  const v = stringArg(args, key);
11305
11431
  if (v !== void 0) flags[key] = v;
11306
11432
  }
11307
- if (Array.isArray(needs)) flags.needsYou = needs;
11433
+ if (Array.isArray(needs)) flags.requiredActions = needs;
11308
11434
  if (resolveEndpoint(deps) === null) return text(NOT_CONFIGURED_TEXT);
11309
11435
  const where = stringArg(args, "cwd") ?? cwd;
11310
11436
  const outcome = await sendReport(where, flags, deps);
@@ -11321,9 +11447,132 @@ function serveMcp(options) {
11321
11447
  if (outcome.kind === "silent") return text(`No session was open for ${where}.`);
11322
11448
  return { ...text(`Ended ${outcome.record.session}.`), structuredContent: outcome.record };
11323
11449
  };
11324
- const read = async (uri) => {
11450
+ const findResource = (uri) => {
11325
11451
  const resource = PRESENCE_RESOURCES.find((r) => r.uri === uri);
11326
11452
  if (resource === void 0) throw new RpcError(-32602, "Resource not found", { uri });
11453
+ return resource;
11454
+ };
11455
+ const legacy = /* @__PURE__ */ new Set();
11456
+ const listens = /* @__PURE__ */ new Map();
11457
+ let pending = 0;
11458
+ let feed = null;
11459
+ let connecting = null;
11460
+ let retry;
11461
+ let down = false;
11462
+ let closed2 = false;
11463
+ const retryMs = options.feedRetryMs ?? FEED_RETRY_MS;
11464
+ const watchesSessions = () => pending > 0 || legacy.has(SESSIONS_URI) || [...listens.values()].some((uris) => uris.includes(SESSIONS_URI));
11465
+ const notifyUpdated = () => {
11466
+ if (legacy.has(SESSIONS_URI)) write({ jsonrpc: "2.0", method: "notifications/resources/updated", params: { uri: SESSIONS_URI } });
11467
+ for (const [id, uris] of listens) {
11468
+ if (uris.includes(SESSIONS_URI)) write({ jsonrpc: "2.0", method: "notifications/resources/updated", params: { _meta: { [SUBSCRIPTION_KEY]: id }, uri: SESSIONS_URI } });
11469
+ }
11470
+ };
11471
+ const releaseFeedIfIdle = () => {
11472
+ if (watchesSessions()) return;
11473
+ feed?.close();
11474
+ feed = null;
11475
+ clearTimeout(retry);
11476
+ retry = void 0;
11477
+ down = false;
11478
+ };
11479
+ const scheduleRetry = () => {
11480
+ if (retry !== void 0 || closed2 || !watchesSessions()) return;
11481
+ retry = setTimeout(() => {
11482
+ retry = void 0;
11483
+ void ensureFeed();
11484
+ }, retryMs);
11485
+ retry.unref();
11486
+ };
11487
+ const ensureFeed = () => {
11488
+ if (closed2 || feed !== null || !watchesSessions()) return Promise.resolve();
11489
+ if (connecting !== null) return connecting;
11490
+ connecting = openEventFeed(
11491
+ {
11492
+ onEvent: (name) => {
11493
+ if (SESSION_EVENTS.includes(name)) notifyUpdated();
11494
+ },
11495
+ onClose: () => {
11496
+ feed = null;
11497
+ if (closed2 || !watchesSessions()) return;
11498
+ down = true;
11499
+ notifyUpdated();
11500
+ scheduleRetry();
11501
+ }
11502
+ },
11503
+ deps
11504
+ ).then((opened) => {
11505
+ connecting = null;
11506
+ if (opened === null) {
11507
+ down = true;
11508
+ scheduleRetry();
11509
+ return;
11510
+ }
11511
+ if (closed2 || !watchesSessions()) {
11512
+ opened.close();
11513
+ return;
11514
+ }
11515
+ feed = opened;
11516
+ if (down) {
11517
+ down = false;
11518
+ notifyUpdated();
11519
+ }
11520
+ });
11521
+ return connecting;
11522
+ };
11523
+ const subscribe = async (uri) => {
11524
+ const resource = findResource(uri);
11525
+ if (resource.uri === SESSIONS_URI) {
11526
+ pending++;
11527
+ await ensureFeed();
11528
+ pending--;
11529
+ }
11530
+ legacy.add(resource.uri);
11531
+ return {};
11532
+ };
11533
+ const unsubscribe = (uri) => {
11534
+ legacy.delete(findResource(uri).uri);
11535
+ releaseFeedIfIdle();
11536
+ return {};
11537
+ };
11538
+ const listen = async (id, params) => {
11539
+ if (listens.has(id)) throw new RpcError(-32602, "A subscription with this id is already open", { id });
11540
+ const filter = isObject(params["notifications"]) ? params["notifications"] : {};
11541
+ const asked = filter["resourceSubscriptions"];
11542
+ if (asked !== void 0 && (!Array.isArray(asked) || asked.some((u) => typeof u !== "string"))) {
11543
+ throw new RpcError(-32602, "notifications.resourceSubscriptions must be a list of URIs");
11544
+ }
11545
+ const honored = Array.isArray(asked) && asked.includes(SESSIONS_URI) ? [SESSIONS_URI] : [];
11546
+ if (honored.length > 0) {
11547
+ pending++;
11548
+ await ensureFeed();
11549
+ pending--;
11550
+ }
11551
+ write({
11552
+ jsonrpc: "2.0",
11553
+ method: "notifications/subscriptions/acknowledged",
11554
+ params: { _meta: { [SUBSCRIPTION_KEY]: id }, notifications: honored.length > 0 ? { resourceSubscriptions: honored } : {} }
11555
+ });
11556
+ listens.set(id, honored);
11557
+ };
11558
+ const cancel = (params) => {
11559
+ const requestId = params["requestId"];
11560
+ if ((typeof requestId === "string" || typeof requestId === "number") && listens.delete(requestId)) releaseFeedIfIdle();
11561
+ };
11562
+ const teardown = () => {
11563
+ closed2 = true;
11564
+ for (const id of listens.keys()) {
11565
+ write({ jsonrpc: "2.0", id, result: { resultType: "complete", _meta: { [SUBSCRIPTION_KEY]: id } } });
11566
+ write({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: id, reason: "The server's input closed." } });
11567
+ }
11568
+ listens.clear();
11569
+ legacy.clear();
11570
+ feed?.close();
11571
+ feed = null;
11572
+ clearTimeout(retry);
11573
+ };
11574
+ const read = async (uri) => {
11575
+ const resource = findResource(uri);
11327
11576
  const result = await callPresence("GET", resource.uri === SESSIONS_URI ? "/v0/sessions" : "/v0/health", void 0, deps);
11328
11577
  if (result.kind !== "ok") return { contents: [{ uri: resource.uri, mimeType: "text/plain", text: NOT_CONFIGURED_TEXT }] };
11329
11578
  const body = resource.uri === SESSIONS_URI ? result.body : result.body.capabilities;
@@ -11351,6 +11600,10 @@ function serveMcp(options) {
11351
11600
  return { resources: PRESENCE_RESOURCES };
11352
11601
  case "resources/read":
11353
11602
  return read(params["uri"]);
11603
+ case "resources/subscribe":
11604
+ return subscribe(params["uri"]);
11605
+ case "resources/unsubscribe":
11606
+ return unsubscribe(params["uri"]);
11354
11607
  default:
11355
11608
  throw new RpcError(-32601, `Method not found: ${method}`);
11356
11609
  }
@@ -11377,7 +11630,10 @@ function serveMcp(options) {
11377
11630
  }
11378
11631
  const id = rawId ?? null;
11379
11632
  const params = isObject(message["params"]) ? message["params"] : {};
11380
- if (!hasId) return;
11633
+ if (!hasId) {
11634
+ if (method === "notifications/cancelled") cancel(params);
11635
+ return;
11636
+ }
11381
11637
  try {
11382
11638
  if (method === "initialize") {
11383
11639
  const requested2 = params["protocolVersion"];
@@ -11396,6 +11652,13 @@ function serveMcp(options) {
11396
11652
  reply(id, { resultType: "complete", supportedVersions: SUPPORTED_VERSIONS, capabilities: SERVER_CAPABILITIES, _meta: { [SERVER_INFO_KEY]: { name: SERVER_NAME, version: options.version } } });
11397
11653
  return;
11398
11654
  }
11655
+ if (method === "subscriptions/listen") {
11656
+ if (!modern) throw new RpcError(-32601, `Method not found: ${method}`);
11657
+ if (id === null) throw new RpcError(-32600, "subscriptions/listen needs a request id");
11658
+ await listen(id, params);
11659
+ return;
11660
+ }
11661
+ if (modern && (method === "resources/subscribe" || method === "resources/unsubscribe")) throw new RpcError(-32601, `Method not found: ${method}`);
11399
11662
  const result = await handle(method, params);
11400
11663
  reply(id, modern ? { resultType: "complete", ...result } : result);
11401
11664
  } catch (error2) {
@@ -11415,7 +11678,10 @@ function serveMcp(options) {
11415
11678
  chain = chain.then(() => dispatch(line2));
11416
11679
  });
11417
11680
  lines.on("close", () => {
11418
- void chain.then(() => resolve10());
11681
+ void chain.then(() => {
11682
+ teardown();
11683
+ resolve10();
11684
+ });
11419
11685
  });
11420
11686
  });
11421
11687
  }
@@ -11550,13 +11816,14 @@ function checkout(value) {
11550
11816
  }
11551
11817
  return { path: nonEmpty(c["path"], "checkout.path"), branch: line(c["branch"], "checkout.branch", true), linked_worktree: c["linked_worktree"] };
11552
11818
  }
11553
- function needsYou(value) {
11554
- if (!Array.isArray(value)) throw new Refusal("invalid_field", "needs_you", "needs_you must be a list");
11555
- if (value.length > MAX_NEEDS_YOU) {
11556
- throw new Refusal("too_many_lines", "needs_you", `needs_you carries at most ${MAX_NEEDS_YOU} lines; ${value.length} were sent`);
11819
+ var LEGACY_REQUIRED_ACTIONS_KEY = "needs_you";
11820
+ function requiredActions(value, key = "required_actions") {
11821
+ if (!Array.isArray(value)) throw new Refusal("invalid_field", key, `${key} must be a list`);
11822
+ if (value.length > MAX_REQUIRED_ACTIONS) {
11823
+ throw new Refusal("too_many_lines", key, `${key} carries at most ${MAX_REQUIRED_ACTIONS} lines; ${value.length} were sent`);
11557
11824
  }
11558
11825
  return value.map((item, i) => {
11559
- const field = `needs_you.${i}`;
11826
+ const field = `${key}.${i}`;
11560
11827
  const n = closed(item, field, ["action", "reason"]);
11561
11828
  return { action: nonEmptyLine(n["action"], `${field}.action`), reason: line(n["reason"], `${field}.reason`, true) };
11562
11829
  });
@@ -11573,7 +11840,7 @@ function refuse(fn) {
11573
11840
  }
11574
11841
  function validateReport(value) {
11575
11842
  return refuse(() => {
11576
- const b = closed(value, void 0, ["status", "harness", "agent", "repository", "checkout"], ["spell", "task", "message", "needs_you"]);
11843
+ const b = closed(value, void 0, ["status", "harness", "agent", "repository", "checkout"], ["spell", "task", "message", "required_actions", LEGACY_REQUIRED_ACTIONS_KEY]);
11577
11844
  if (b["status"] === "done") {
11578
11845
  throw new Refusal("invalid_field", "status", "status done is set by ending a session: use POST /v0/sessions/end (spell presence end)");
11579
11846
  }
@@ -11587,7 +11854,11 @@ function validateReport(value) {
11587
11854
  for (const key of ["spell", "task", "message"]) {
11588
11855
  if (key in b) out[key] = line(b[key], key, true);
11589
11856
  }
11590
- if ("needs_you" in b) out.needs_you = needsYou(b["needs_you"]);
11857
+ if ("required_actions" in b && LEGACY_REQUIRED_ACTIONS_KEY in b) {
11858
+ throw new Refusal("invalid_field", LEGACY_REQUIRED_ACTIONS_KEY, "send required_actions only: needs_you is its name before 1.20");
11859
+ }
11860
+ if ("required_actions" in b) out.required_actions = requiredActions(b["required_actions"]);
11861
+ else if (LEGACY_REQUIRED_ACTIONS_KEY in b) out.required_actions = requiredActions(b[LEGACY_REQUIRED_ACTIONS_KEY], LEGACY_REQUIRED_ACTIONS_KEY);
11591
11862
  return out;
11592
11863
  });
11593
11864
  }
@@ -11829,7 +12100,7 @@ data: ${JSON.stringify(hello)}
11829
12100
  function copy(record) {
11830
12101
  return structuredClone(record);
11831
12102
  }
11832
- function sameNeedsYou(a, b) {
12103
+ function sameRequiredActions(a, b) {
11833
12104
  if (a.length !== b.length) return false;
11834
12105
  return a.every((item, i) => item.action === b[i]?.action && item.reason === b[i]?.reason);
11835
12106
  }
@@ -11854,7 +12125,7 @@ function createSessionTable(options) {
11854
12125
  const ms = now();
11855
12126
  const at = new Date(ms).toISOString();
11856
12127
  const key = body.checkout.path;
11857
- const needsYou2 = (body.needs_you ?? []).map((n) => ({ action: n.action, reason: n.reason }));
12128
+ const requiredActions2 = (body.required_actions ?? []).map((n) => ({ action: n.action, reason: n.reason }));
11858
12129
  const existing = entries.get(key);
11859
12130
  if (existing === void 0) {
11860
12131
  const record2 = {
@@ -11867,7 +12138,7 @@ function createSessionTable(options) {
11867
12138
  task: body.task ?? null,
11868
12139
  message: body.message ?? null,
11869
12140
  status: body.status,
11870
- needs_you: needsYou2,
12141
+ required_actions: requiredActions2,
11871
12142
  started_at: at,
11872
12143
  reported_at: at,
11873
12144
  ended_at: null,
@@ -11879,7 +12150,7 @@ function createSessionTable(options) {
11879
12150
  }
11880
12151
  const record = existing.record;
11881
12152
  const wasStale = record.stale;
11882
- const needsChanged = !sameNeedsYou(record.needs_you, needsYou2);
12153
+ const needsChanged = !sameRequiredActions(record.required_actions, requiredActions2);
11883
12154
  record.agent = structuredClone(body.agent);
11884
12155
  record.harness = body.harness;
11885
12156
  record.repository = structuredClone(body.repository);
@@ -11888,11 +12159,11 @@ function createSessionTable(options) {
11888
12159
  if (body.task !== void 0) record.task = body.task;
11889
12160
  if (body.message !== void 0) record.message = body.message;
11890
12161
  record.status = body.status;
11891
- record.needs_you = needsYou2;
12162
+ record.required_actions = requiredActions2;
11892
12163
  record.reported_at = at;
11893
12164
  record.stale = false;
11894
12165
  existing.reportedMs = ms;
11895
- const event = wasStale ? "session.resumed" : needsChanged ? "session.needs_you" : "session.updated";
12166
+ const event = wasStale ? "session.resumed" : needsChanged ? "session.action_required" : "session.updated";
11896
12167
  emit(event, record);
11897
12168
  return { record: copy(record), created: false, event };
11898
12169
  },
@@ -12062,6 +12333,12 @@ function isHealth(body) {
12062
12333
  const b = body;
12063
12334
  return b !== null && typeof b === "object" && b.ok === true && b.contract === "v0" && typeof b.started_at === "string";
12064
12335
  }
12336
+ function unavailableLine(info, result) {
12337
+ if (result.kind === "refused" && result.status === 401) {
12338
+ return `The presence service at ${info.url} refused this machine's token (rotated, or ${PRESENCE_ENV.token} is set to another value).`;
12339
+ }
12340
+ return `The presence service recorded at ${info.url} is not reachable.`;
12341
+ }
12065
12342
  async function runPresenceStatus(io = consoleIo, store = {}) {
12066
12343
  const info = readServerInfo(store);
12067
12344
  if (info === null) {
@@ -12076,12 +12353,59 @@ async function runPresenceStatus(io = consoleIo, store = {}) {
12076
12353
  io.out(` sessions: ${result.body.sessions}`);
12077
12354
  return 0;
12078
12355
  }
12079
- if (result.kind === "refused" && result.status === 401) {
12080
- io.out(`The presence service at ${info.url} refused this machine's token (rotated, or ${PRESENCE_ENV.token} is set to another value).`);
12356
+ io.out(unavailableLine(info, result));
12357
+ return 1;
12358
+ }
12359
+ function isSnapshot(body) {
12360
+ const b = body;
12361
+ return b !== null && typeof b === "object" && Array.isArray(b.sessions) && typeof b.generated_at === "string";
12362
+ }
12363
+ function formatAge(fromIso, now) {
12364
+ const seconds = Math.max(0, Math.floor((now - Date.parse(fromIso)) / 1e3));
12365
+ if (!Number.isFinite(seconds)) return "-";
12366
+ if (seconds < 60) return `${seconds}s`;
12367
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
12368
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
12369
+ return `${Math.floor(seconds / 86400)}d`;
12370
+ }
12371
+ function renderSessionsTable(sessions, now) {
12372
+ if (sessions.length === 0) return ["No session is reporting."];
12373
+ const header = ["STATUS", "AGENT", "HARNESS", "REPOSITORY", "BRANCH", "AGE", "TASK"];
12374
+ const rows = sessions.map((s) => [
12375
+ s.stale ? "unknown" : s.status,
12376
+ s.agent.slug ?? "-",
12377
+ s.harness,
12378
+ s.repository.name,
12379
+ s.checkout.branch ?? "-",
12380
+ formatAge(s.reported_at, now),
12381
+ s.task ?? "-"
12382
+ ]);
12383
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
12384
+ const line2 = (cells) => cells.map((c, i) => i === cells.length - 1 ? c : c.padEnd(widths[i])).join(" ").trimEnd();
12385
+ const out = [line2(header)];
12386
+ sessions.forEach((s, i) => {
12387
+ out.push(line2(rows[i]));
12388
+ for (const n of s.required_actions) out.push(` action required: ${n.reason === null ? n.action : `${n.action} \u2014 ${n.reason}`}`);
12389
+ });
12390
+ return out;
12391
+ }
12392
+ async function runPresenceSessions(options, io = consoleIo, store = {}, now = Date.now) {
12393
+ const info = readServerInfo(store);
12394
+ if (info === null) {
12395
+ io.out(NOT_CONFIGURED);
12081
12396
  return 1;
12082
12397
  }
12083
- io.out(`The presence service recorded at ${info.url} is not reachable.`);
12084
- return 1;
12398
+ const result = await callPresence("GET", "/v0/sessions", void 0, store);
12399
+ if (result.kind !== "ok" || !isSnapshot(result.body)) {
12400
+ io.out(unavailableLine(info, result));
12401
+ return 1;
12402
+ }
12403
+ if (options.json === true) {
12404
+ io.out(JSON.stringify(result.body, null, 2));
12405
+ return 0;
12406
+ }
12407
+ for (const line2 of renderSessionsTable(result.body.sessions, now())) io.out(line2);
12408
+ return 0;
12085
12409
  }
12086
12410
  async function waitForExit(pid, ms) {
12087
12411
  const until = Date.now() + ms;
@@ -12294,7 +12618,7 @@ program.command("logout").description("Sign out on this machine: remove the stor
12294
12618
  program.command("whoami").description("Show who is signed in; exits 1 when signed out").option("--verify", "Also check the session with the identity service").action(async (opts) => {
12295
12619
  await runWhoami({ verify: opts.verify });
12296
12620
  });
12297
- program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").option("--leaks", "Run only the on-demand secrets/credential scan (ARC-037), skipping the default checks").action(async (opts) => {
12621
+ program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Create missing session continuity files, and remove a presence record whose service is gone").option("--leaks", "Run only the on-demand secrets/credential scan (ARC-037), skipping the default checks").action(async (opts) => {
12298
12622
  await runDoctor(
12299
12623
  process.cwd(),
12300
12624
  { fix: opts.fix, leaks: opts.leaks, packageVersion: packageJson.version },
@@ -12376,6 +12700,9 @@ presenceCommand.command("serve").description("Run the presence service in the fo
12376
12700
  presenceCommand.command("status").description("Say whether a presence service is running and answering this machine's token (exit 0) or not (exit 1)").action(async () => {
12377
12701
  process.exitCode = await runPresenceStatus();
12378
12702
  });
12703
+ presenceCommand.command("sessions").description("List the sessions the presence service holds: a table, or the GET /v0/sessions document with --json. Exit 1 when no channel answers").option("--json", "Print the snapshot document as JSON instead of the table").action(async (opts) => {
12704
+ process.exitCode = await runPresenceSessions(opts);
12705
+ });
12379
12706
  presenceCommand.command("stop").description("Stop the presence service this machine recorded; exits 0 whether or not one was running").action(async () => {
12380
12707
  process.exitCode = await runPresenceStop();
12381
12708
  });
@@ -12383,8 +12710,9 @@ function collectLine(value, previous) {
12383
12710
  return [...previous, value];
12384
12711
  }
12385
12712
  var exitZero = () => process.exit(0);
12386
- presenceCommand.command("report").description("Report this checkout's session to the presence service. Silent, exit 0, when no channel is configured; never blocks").requiredOption("--status <status>", "working | waiting | blocked | idle").option("--task <line>", "What the session is doing, in one line").option("--spell <id>", "The spell running, e.g. spell-implement").option("--message <line>", "A free one-line note; an empty value clears it").option("--needs-you <line>", "One line of a Needs you block, '<action> \u2014 <reason>'; repeat per line", collectLine, []).option("--agent <slug>", "The roster agent this session reports as (a slug or an alias); default: the orchestrator").option("--harness <id>", "claude-code | copilot | codex | openclaw | cron | other; default: detected, else other").exitOverride(exitZero).action(async (opts) => {
12387
- const run = await runPresenceReport(process.cwd(), opts);
12713
+ presenceCommand.command("report").description("Report this checkout's session to the presence service. Silent, exit 0, when no channel is configured; never blocks").requiredOption("--status <status>", "working | waiting | blocked | idle").option("--task <line>", "What the session is doing, in one line").option("--spell <id>", "The spell running, e.g. spell-implement").option("--message <line>", "A free one-line note; an empty value clears it").option("--required-action <line>", "One open line of the Now tier of an Action required block, '<action> \u2014 <reason>'; repeat per line", collectLine, []).addOption(new Option("--needs-you <line>").argParser(collectLine).default([]).hideHelp()).option("--agent <slug>", "The roster agent this session reports as (a slug or an alias); default: the orchestrator").option("--harness <id>", "claude-code | copilot | codex | openclaw | cron | other; default: detected, else other").exitOverride(exitZero).action(async (opts) => {
12714
+ const { requiredAction, needsYou, ...rest } = opts;
12715
+ const run = await runPresenceReport(process.cwd(), { ...rest, requiredActions: [...requiredAction, ...needsYou] });
12388
12716
  process.exitCode = 0;
12389
12717
  if (run.timedOut) process.exit(0);
12390
12718
  });