arcane-cli 0.29.5 → 0.30.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.
package/dist/index.js CHANGED
@@ -112,6 +112,14 @@ function validateSubjectRoot(value, filePath) {
112
112
  throw new ManifestInvalidFieldError(filePath, "subject_root", value);
113
113
  }
114
114
  }
115
+ function isValidSecretsScanExcludePrefixes(value) {
116
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.length > 0);
117
+ }
118
+ function validateSecretsScanExcludePrefixes(value, filePath) {
119
+ if (!isValidSecretsScanExcludePrefixes(value)) {
120
+ throw new ManifestInvalidFieldError(filePath, "secretsScanExcludePrefixes", value);
121
+ }
122
+ }
115
123
  function manifestPath(targetDir) {
116
124
  return path2.join(targetDir, MANIFEST_FILE);
117
125
  }
@@ -138,6 +146,9 @@ function validateTrackingFields(manifest, filePath) {
138
146
  }
139
147
  validateSubjectRoot(manifest.subject_root, filePath);
140
148
  }
149
+ if (manifest.secretsScanExcludePrefixes !== void 0) {
150
+ validateSecretsScanExcludePrefixes(manifest.secretsScanExcludePrefixes, filePath);
151
+ }
141
152
  }
142
153
  async function readManifest(targetDir) {
143
154
  const filePath = manifestPath(targetDir);
@@ -159,6 +170,26 @@ async function readManifest(targetDir) {
159
170
  validateTrackingFields(manifest, filePath);
160
171
  return manifest;
161
172
  }
173
+ function isRecord(value) {
174
+ return typeof value === "object" && value !== null && !Array.isArray(value);
175
+ }
176
+ async function resolveSecretsScanExcludePrefixes(targetDir) {
177
+ try {
178
+ const manifest = await readManifest(targetDir);
179
+ return manifest.secretsScanExcludePrefixes ?? [];
180
+ } catch {
181
+ }
182
+ try {
183
+ const raw = await readFile(path2.join(targetDir, "src", "assets", ".arcane.json"), "utf-8");
184
+ const parsed = JSON.parse(raw);
185
+ if (isRecord(parsed) && parsed["selfHosted"] === true && parsed["tracking_mode"] === "internal") {
186
+ const prefixes = parsed["secretsScanExcludePrefixes"];
187
+ if (isValidSecretsScanExcludePrefixes(prefixes)) return prefixes;
188
+ }
189
+ } catch {
190
+ }
191
+ return [];
192
+ }
162
193
  async function writeManifest(targetDir, manifest) {
163
194
  const filePath = manifestPath(targetDir);
164
195
  await writeFile(filePath, JSON.stringify(manifest, null, 2), "utf-8");
@@ -1100,11 +1131,11 @@ var AgentConfigValidationError = class extends Error {
1100
1131
  this.name = "AgentConfigValidationError";
1101
1132
  }
1102
1133
  };
1103
- function isRecord(value) {
1134
+ function isRecord2(value) {
1104
1135
  return typeof value === "object" && value !== null && !Array.isArray(value);
1105
1136
  }
1106
1137
  function requireRecord(value, path4, issues) {
1107
- if (!isRecord(value)) {
1138
+ if (!isRecord2(value)) {
1108
1139
  issues.push(`${path4} must be an object`);
1109
1140
  return void 0;
1110
1141
  }
@@ -3624,8 +3655,8 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
3624
3655
  }
3625
3656
 
3626
3657
  // src/commands/doctor.ts
3627
- import { access as access4, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3628
- import { join as join11, dirname as dirname3 } from "path";
3658
+ import { access as access4, readFile as readFile7, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3659
+ import { join as join12, dirname as dirname3 } from "path";
3629
3660
  import { execFile as execFile3 } from "child_process";
3630
3661
  import { promisify as promisify2 } from "util";
3631
3662
 
@@ -3694,6 +3725,111 @@ function evaluateIncidentGate(records) {
3694
3725
  };
3695
3726
  }
3696
3727
 
3728
+ // src/modules/denylist-scan.ts
3729
+ import { readFile as readFile6, readdir as readdir5 } from "fs/promises";
3730
+ import { join as join11, relative } from "path";
3731
+ function escapeRegExp(value) {
3732
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3733
+ }
3734
+ function createDenylistRules(tokens, labelPrefix) {
3735
+ return tokens.map((token, index) => ({
3736
+ label: `${labelPrefix}-${index + 1}`,
3737
+ pattern: new RegExp(
3738
+ `(?:^|[^A-Za-z0-9])${escapeRegExp(token)}(?=$|[^A-Za-z0-9])`,
3739
+ "i"
3740
+ )
3741
+ }));
3742
+ }
3743
+ async function scanFile(absolutePath, displayPath, rules) {
3744
+ const findings = [];
3745
+ let content;
3746
+ try {
3747
+ content = await readFile6(absolutePath, "utf8");
3748
+ } catch {
3749
+ return findings;
3750
+ }
3751
+ const lines = content.split("\n");
3752
+ for (let index = 0; index < lines.length; index++) {
3753
+ const searchable = lines[index].replace(/\{[A-Z][A-Z0-9_]*\}/g, "");
3754
+ for (const rule of rules) {
3755
+ if (rule.pattern.test(searchable)) {
3756
+ findings.push({ file: displayPath, line: index + 1, rule: rule.label });
3757
+ break;
3758
+ }
3759
+ }
3760
+ }
3761
+ return findings;
3762
+ }
3763
+ var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
3764
+ "node_modules",
3765
+ ".git",
3766
+ "dist",
3767
+ "coverage",
3768
+ ".vitest",
3769
+ ".claude"
3770
+ ]);
3771
+ var DEFAULT_SCANNABLE_EXTENSIONS = [".md", ".ts", ".js", ".json", ".yml", ".yaml"];
3772
+ async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT_SCANNABLE_EXTENSIONS) {
3773
+ const collected = [];
3774
+ let entries;
3775
+ try {
3776
+ entries = await readdir5(root, { withFileTypes: true });
3777
+ } catch {
3778
+ return collected;
3779
+ }
3780
+ for (const entry of entries) {
3781
+ const absolutePath = join11(root, entry.name);
3782
+ if (entry.isDirectory()) {
3783
+ if (SKIP_DIRECTORIES.has(entry.name)) continue;
3784
+ collected.push(...await collectScannableFiles(absolutePath, repoRoot, extensions));
3785
+ } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
3786
+ const displayPath = relative(repoRoot, absolutePath).replace(/\\/g, "/");
3787
+ collected.push([absolutePath, displayPath]);
3788
+ }
3789
+ }
3790
+ return collected;
3791
+ }
3792
+ async function scanRepository(repoRoot, rules, extensions, excludePrefixes = []) {
3793
+ if (rules.length === 0) return [];
3794
+ const findings = [];
3795
+ for (const [absolutePath, displayPath] of await collectScannableFiles(repoRoot, repoRoot, extensions)) {
3796
+ if (excludePrefixes.some((prefix) => displayPath.includes(prefix))) continue;
3797
+ findings.push(...await scanFile(absolutePath, displayPath, rules));
3798
+ }
3799
+ return findings;
3800
+ }
3801
+
3802
+ // src/modules/secrets-scan.ts
3803
+ var SECRETS_RULES = [
3804
+ { label: "api-key", pattern: /API[_-]KEY\s*[:=]\s*\S+/i },
3805
+ // Case-sensitive and requiring an all-caps/underscore identifier (SECRET,
3806
+ // MY_SECRET, API_SECRET_KEY...) -- the real config/env-var convention for a
3807
+ // credential-holding key. Widening this repository-wide (ARC-037 decision 3)
3808
+ // surfaced that the original case-insensitive form matched ANY lowercase
3809
+ // `secret`/`token` code identifier (e.g. `const token = value.trim();`),
3810
+ // which is overwhelmingly a parsed/resolved value, never a literal
3811
+ // credential. The `(?!\$?\{)` guard additionally excludes any
3812
+ // brace-delimited placeholder -- GitHub Actions/templating references
3813
+ // (`TOKEN: ${{ secrets.X }}`, a reference to a secret store, never the
3814
+ // secret itself) and this codebase's own documented `{UPPER_SNAKE}`
3815
+ // placeholder convention (denylist-scan.ts's `scanFile` already strips
3816
+ // those before matching for the sibling org-token scan; a bare single
3817
+ // brace, with no `scanFile`-style stripping in the copy-time call path,
3818
+ // needed the same exclusion here rather than only the double-brace case).
3819
+ // A minimum length of 8 further excludes short non-secret values like
3820
+ // `write` (`id-token: write`).
3821
+ { label: "generic-secret", pattern: /\b[A-Z0-9_]*SECRET[A-Z0-9_]*\s*[:=]\s*(?!\$?\{)\S{8,}/ },
3822
+ { label: "generic-token", pattern: /\b[A-Z0-9_]*TOKEN[A-Z0-9_]*\s*[:=]\s*(?!\$?\{)\S{8,}/ },
3823
+ { label: "bearer-token", pattern: /Bearer\s+[A-Za-z0-9._~+/-]{20,}/ },
3824
+ // real tokens are long
3825
+ { label: "openai-key", pattern: /sk-[A-Za-z0-9]{20,}/ },
3826
+ { label: "pem-key", pattern: /-----BEGIN [A-Z ]+-----/ },
3827
+ { label: "slack-token", pattern: /xox[bpars]-[A-Za-z0-9-]+/ },
3828
+ { label: "aws-access-key", pattern: /AKIA[0-9A-Z]{16}/ },
3829
+ { label: "github-pat", pattern: /ghp_[A-Za-z0-9]{36}/ }
3830
+ ];
3831
+ var SECRETS_PATTERNS = SECRETS_RULES.map((rule) => rule.pattern);
3832
+
3697
3833
  // src/modules/platform-policy.ts
3698
3834
  import { execFile as execFile2 } from "child_process";
3699
3835
  import { promisify } from "util";
@@ -3865,6 +4001,9 @@ function parseAdoRemote(url) {
3865
4001
 
3866
4002
  // src/commands/doctor.ts
3867
4003
  var execFileAsync2 = promisify2(execFile3);
4004
+ function isRecord3(value) {
4005
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4006
+ }
3868
4007
  async function checkNodeVersion() {
3869
4008
  const name = "Node.js version";
3870
4009
  const raw = process.version;
@@ -3882,8 +4021,8 @@ async function checkVSCodeExtension(id, label) {
3882
4021
  const name = `VS Code extension: ${label}`;
3883
4022
  const home = process.env["HOME"] ?? "";
3884
4023
  const extDirs = [
3885
- join11(home, ".vscode", "extensions"),
3886
- join11(home, ".vscode-insiders", "extensions")
4024
+ join12(home, ".vscode", "extensions"),
4025
+ join12(home, ".vscode-insiders", "extensions")
3887
4026
  ];
3888
4027
  for (const dir of extDirs) {
3889
4028
  try {
@@ -3917,15 +4056,15 @@ async function checkVSCodeExtension(id, label) {
3917
4056
  }
3918
4057
  async function checkArcaneManifest(targetDir) {
3919
4058
  const name = "Arcane manifest (.arcane.json)";
3920
- const manifestPath2 = join11(targetDir, ".arcane.json");
4059
+ const manifestPath2 = join12(targetDir, ".arcane.json");
3921
4060
  try {
3922
4061
  await access4(manifestPath2);
3923
4062
  } catch {
3924
- const selfHostedManifestPath = join11(targetDir, "src", "assets", ".arcane.json");
4063
+ const selfHostedManifestPath = join12(targetDir, "src", "assets", ".arcane.json");
3925
4064
  try {
3926
- const raw = await readFile6(selfHostedManifestPath, "utf8");
4065
+ const raw = await readFile7(selfHostedManifestPath, "utf8");
3927
4066
  const manifest = JSON.parse(raw);
3928
- if (manifest.selfHosted === true && manifest.tracking_mode === "internal") {
4067
+ if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
3929
4068
  return {
3930
4069
  name,
3931
4070
  passed: true,
@@ -3942,9 +4081,9 @@ async function checkArcaneManifest(targetDir) {
3942
4081
  };
3943
4082
  }
3944
4083
  try {
3945
- const raw = await readFile6(manifestPath2, "utf8");
4084
+ const raw = await readFile7(manifestPath2, "utf8");
3946
4085
  const manifest = JSON.parse(raw);
3947
- if (!manifest.version || !Array.isArray(manifest.components)) {
4086
+ if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
3948
4087
  return {
3949
4088
  name,
3950
4089
  passed: false,
@@ -3954,7 +4093,7 @@ async function checkArcaneManifest(targetDir) {
3954
4093
  return {
3955
4094
  name,
3956
4095
  passed: true,
3957
- message: `v${manifest.version} \u2014 ${manifest.components.length} component(s) installed`
4096
+ message: `v${manifest["version"]} \u2014 ${manifest["components"].length} component(s) installed`
3958
4097
  };
3959
4098
  } catch {
3960
4099
  return {
@@ -4003,6 +4142,20 @@ function checkIncidentReleaseGate() {
4003
4142
  message: `${result.checked} incidents classified${accepted}`
4004
4143
  };
4005
4144
  }
4145
+ async function checkSecrets(targetDir) {
4146
+ const name = "Secrets scan (ARC-037)";
4147
+ const excludePrefixes = await resolveSecretsScanExcludePrefixes(targetDir);
4148
+ const findings = await scanRepository(targetDir, SECRETS_RULES, void 0, excludePrefixes);
4149
+ if (findings.length === 0) {
4150
+ return { name, passed: true, message: "no credential-shaped patterns found" };
4151
+ }
4152
+ const summary = findings.map((f) => `${f.file}:${f.line} [${f.rule}]`).join("; ");
4153
+ return {
4154
+ name,
4155
+ passed: false,
4156
+ message: `${findings.length} credential-shaped pattern(s) found: ${summary}`
4157
+ };
4158
+ }
4006
4159
  var SESSION_CONTINUITY_FILES = [
4007
4160
  "TODO.md",
4008
4161
  "DECISIONS.md",
@@ -4013,7 +4166,7 @@ async function checkSessionContinuity(targetDir) {
4013
4166
  const results = [];
4014
4167
  for (const file of SESSION_CONTINUITY_FILES) {
4015
4168
  const name = `Session file: ${file}`;
4016
- const filePath = join11(targetDir, file);
4169
+ const filePath = join12(targetDir, file);
4017
4170
  try {
4018
4171
  await access4(filePath);
4019
4172
  results.push({ name, passed: true, message: "present" });
@@ -4031,11 +4184,11 @@ async function checkSessionContinuity(targetDir) {
4031
4184
  async function fixSessionContinuity(targetDir, assetsDir) {
4032
4185
  const created = [];
4033
4186
  for (const file of SESSION_CONTINUITY_FILES) {
4034
- const destPath = join11(targetDir, file);
4187
+ const destPath = join12(targetDir, file);
4035
4188
  try {
4036
4189
  await access4(destPath);
4037
4190
  } catch {
4038
- const srcPath = join11(assetsDir, file);
4191
+ const srcPath = join12(assetsDir, file);
4039
4192
  await mkdir6(dirname3(destPath), { recursive: true });
4040
4193
  await fsCopyFile2(srcPath, destPath);
4041
4194
  created.push(file);
@@ -4096,16 +4249,16 @@ async function checkPushPolicy(targetDir) {
4096
4249
  }
4097
4250
  async function checkMcpConfig(targetDir) {
4098
4251
  const name = "MCP config (per-server timeout, I12)";
4099
- const filePath = join11(targetDir, ".mcp.json");
4252
+ const filePath = join12(targetDir, ".mcp.json");
4100
4253
  let raw;
4101
4254
  try {
4102
- raw = await readFile6(filePath, "utf8");
4255
+ raw = await readFile7(filePath, "utf8");
4103
4256
  } catch {
4104
4257
  return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
4105
4258
  }
4106
- let parsed;
4259
+ let rawParsed;
4107
4260
  try {
4108
- parsed = JSON.parse(raw);
4261
+ rawParsed = JSON.parse(raw);
4109
4262
  } catch {
4110
4263
  return {
4111
4264
  name,
@@ -4114,6 +4267,15 @@ async function checkMcpConfig(targetDir) {
4114
4267
  message: ".mcp.json exists but contains invalid JSON"
4115
4268
  };
4116
4269
  }
4270
+ if (!isRecord3(rawParsed)) {
4271
+ return {
4272
+ name,
4273
+ passed: false,
4274
+ blocking: false,
4275
+ message: ".mcp.json exists but is not a JSON object"
4276
+ };
4277
+ }
4278
+ const parsed = rawParsed;
4117
4279
  const servers = parsed.mcpServers ?? {};
4118
4280
  const serverNames = Object.keys(servers);
4119
4281
  if (serverNames.length === 0) {
@@ -4135,18 +4297,24 @@ async function checkMcpConfig(targetDir) {
4135
4297
  message: `${serverNames.length} server(s) configured, all with a timeout set`
4136
4298
  };
4137
4299
  }
4300
+ function isDelegation(value) {
4301
+ return isRecord3(value) && typeof value["id"] === "string" && typeof value["status"] === "string" && typeof value["scope"] === "string" && Array.isArray(value["excludedActions"]);
4302
+ }
4303
+ function isDelegationsFile(value) {
4304
+ return isRecord3(value) && Array.isArray(value["delegations"]) && value["delegations"].every(isDelegation);
4305
+ }
4138
4306
  async function checkDelegations(targetDir) {
4139
4307
  const name = "Standing delegations (T13)";
4140
- const filePath = join11(targetDir, ".arcane", "delegations.json");
4308
+ const filePath = join12(targetDir, ".arcane", "delegations.json");
4141
4309
  let raw;
4142
4310
  try {
4143
- raw = await readFile6(filePath, "utf8");
4311
+ raw = await readFile7(filePath, "utf8");
4144
4312
  } catch {
4145
4313
  return { name, passed: true, blocking: false, message: "no delegations recorded" };
4146
4314
  }
4147
- let parsed;
4315
+ let rawParsed;
4148
4316
  try {
4149
- parsed = JSON.parse(raw);
4317
+ rawParsed = JSON.parse(raw);
4150
4318
  } catch {
4151
4319
  return {
4152
4320
  name,
@@ -4155,7 +4323,16 @@ async function checkDelegations(targetDir) {
4155
4323
  message: `.arcane/delegations.json exists but contains invalid JSON`
4156
4324
  };
4157
4325
  }
4158
- const active = (parsed.delegations ?? []).filter((d) => d.status === "active");
4326
+ if (!isDelegationsFile(rawParsed)) {
4327
+ return {
4328
+ name,
4329
+ passed: false,
4330
+ blocking: false,
4331
+ message: `.arcane/delegations.json exists but is not a valid delegations file (expected a "delegations" array of well-formed entries)`
4332
+ };
4333
+ }
4334
+ const parsed = rawParsed;
4335
+ const active = parsed.delegations.filter((d) => d.status === "active");
4159
4336
  if (active.length === 0) {
4160
4337
  return { name, passed: true, blocking: false, message: "no active delegations" };
4161
4338
  }
@@ -4212,6 +4389,19 @@ async function checkPlatformBranchPolicy(targetDir) {
4212
4389
  }
4213
4390
  async function runDoctor(targetDir, options = {}, assetsDir) {
4214
4391
  console.log("\nspell doctor \u2014 checking your Arcane environment\n");
4392
+ if (options.leaks) {
4393
+ const result = await checkSecrets(targetDir);
4394
+ const icon = result.passed ? "\u2713" : "\u2717";
4395
+ console.log(` ${icon} [${result.passed ? "pass" : "FAIL"}] ${result.name}`);
4396
+ if (!result.passed) {
4397
+ console.log(` ${result.message}`);
4398
+ console.log("\n One or more checks failed. Fix the issues above before proceeding.\n");
4399
+ process.exitCode = 1;
4400
+ } else {
4401
+ console.log("\n All checks passed. Your environment is ready.\n");
4402
+ }
4403
+ return;
4404
+ }
4215
4405
  const results = await Promise.all([
4216
4406
  checkNodeVersion(),
4217
4407
  checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)"),
@@ -4268,52 +4458,6 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
4268
4458
  // src/modules/ward.ts
4269
4459
  import { readdir as readdir6, readFile as readFile8 } from "fs/promises";
4270
4460
  import { join as join13, relative as relative2 } from "path";
4271
-
4272
- // src/modules/denylist-scan.ts
4273
- import { readFile as readFile7, readdir as readdir5 } from "fs/promises";
4274
- import { join as join12, relative } from "path";
4275
- function escapeRegExp(value) {
4276
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4277
- }
4278
- function createDenylistRules(tokens, labelPrefix) {
4279
- return tokens.map((token, index) => ({
4280
- label: `${labelPrefix}-${index + 1}`,
4281
- pattern: new RegExp(
4282
- `(?:^|[^A-Za-z0-9])${escapeRegExp(token)}(?=$|[^A-Za-z0-9])`,
4283
- "i"
4284
- )
4285
- }));
4286
- }
4287
- var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
4288
- "node_modules",
4289
- ".git",
4290
- "dist",
4291
- "coverage",
4292
- ".vitest"
4293
- ]);
4294
- var DEFAULT_SCANNABLE_EXTENSIONS = [".md", ".ts", ".js", ".json", ".yml", ".yaml"];
4295
- async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT_SCANNABLE_EXTENSIONS) {
4296
- const collected = [];
4297
- let entries;
4298
- try {
4299
- entries = await readdir5(root, { withFileTypes: true });
4300
- } catch {
4301
- return collected;
4302
- }
4303
- for (const entry of entries) {
4304
- const absolutePath = join12(root, entry.name);
4305
- if (entry.isDirectory()) {
4306
- if (SKIP_DIRECTORIES.has(entry.name)) continue;
4307
- collected.push(...await collectScannableFiles(absolutePath, repoRoot, extensions));
4308
- } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
4309
- const displayPath = relative(repoRoot, absolutePath).replace(/\\/g, "/");
4310
- collected.push([absolutePath, displayPath]);
4311
- }
4312
- }
4313
- return collected;
4314
- }
4315
-
4316
- // src/modules/ward.ts
4317
4461
  async function scanFileAllRules(absolutePath, displayPath, rules) {
4318
4462
  const findings = [];
4319
4463
  let content;
@@ -4639,8 +4783,8 @@ program.command("uninstall").description("Remove all installed Arcane framework
4639
4783
  program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
4640
4784
  await runUnblockPush(process.cwd());
4641
4785
  });
4642
- program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").action(async (opts) => {
4643
- await runDoctor(process.cwd(), { fix: opts.fix }, ASSETS_DIR);
4786
+ 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) => {
4787
+ await runDoctor(process.cwd(), { fix: opts.fix, leaks: opts.leaks }, ASSETS_DIR);
4644
4788
  });
4645
4789
  program.command("ward").description("Scan the repo for leaked identifiers -- third-party trademarks and the operator's own personal identifiers alike (T18)").option(
4646
4790
  "--terms <terms>",