@mnemom/mnemom 0.16.1 → 0.16.3

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/README.md CHANGED
@@ -43,6 +43,7 @@ That's it. `mnemom init` detects your configured AI provider API keys (Anthropic
43
43
  | `mnemom card show` | Display active alignment card |
44
44
  | `mnemom card publish <file>` | Publish alignment card from JSON file |
45
45
  | `mnemom card validate <file>` | Validate card JSON locally |
46
+ | `mnemom usage --org <id>` | Show per-person token and request consumption |
46
47
 
47
48
  ## How It Works
48
49
 
@@ -35,3 +35,17 @@ export declare function deriveHashProof(apiKey: string, agentName?: string): str
35
35
  * listing your claimable orgs.
36
36
  */
37
37
  export declare function agentsClaimCommand(idOrName: string, options?: AgentsClaimOptions): Promise<void>;
38
+ export interface AgentsMoveOptions {
39
+ /** Destination org slug or id — REQUIRED and always explicit (a move is an
40
+ * administrative action; it deliberately does NOT default to the active org). */
41
+ to?: string;
42
+ json?: boolean;
43
+ }
44
+ /**
45
+ * `mnemom agents move <id-or-name> --to <slug|id>`
46
+ *
47
+ * Role-based relocation between orgs (the counterpart to re-claim, which is
48
+ * possession-based). The server requires the caller to be an owner/admin of
49
+ * BOTH the agent's current org and the destination — no agent key involved.
50
+ */
51
+ export declare function agentsMoveCommand(idOrName: string, options?: AgentsMoveOptions): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { listAgents, listOrgAgents, listMyOrgs, getAgent, getAgentByName, claimAgent, MnemomApiError, } from "../lib/api.js";
2
+ import { listAgents, listOrgAgents, listMyOrgs, getAgent, getAgentByName, claimAgent, moveAgent, MnemomApiError, } from "../lib/api.js";
3
3
  import { requireAuth } from "../lib/auth.js";
4
+ import { getActiveOrg } from "../lib/cli-config.js";
4
5
  import { fmt } from "../lib/format.js";
5
6
  export async function agentsListCommand(options = {}) {
6
7
  const cred = await requireAuth();
@@ -182,7 +183,11 @@ export async function agentsClaimCommand(idOrName, options = {}) {
182
183
  process.exit(1);
183
184
  return;
184
185
  }
185
- // 3. Resolve --org slug org_id (omit server default / personal org).
186
+ // 3. Resolve the destination org. Precedence: explicit --org > the active
187
+ // org (`mnemom org use`, lib/cli-config.ts) > server default (personal
188
+ // org) — the last LOUDLY, because login binds no org and a silent
189
+ // personal-org default is the classic "why is my agent in Personal?"
190
+ // footgun.
186
191
  let orgId;
187
192
  let orgs = [];
188
193
  if (options.org) {
@@ -205,6 +210,24 @@ export async function agentsClaimCommand(idOrName, options = {}) {
205
210
  return;
206
211
  }
207
212
  }
213
+ else {
214
+ const active = getActiveOrg();
215
+ if (active) {
216
+ // Send the stored org_id directly (validated at `org use` time); if
217
+ // membership has since changed the server 403s with the teaching list.
218
+ orgId = active.org_id;
219
+ // stderr, and skipped in --json mode: stdout stays machine-readable.
220
+ if (!options.json) {
221
+ console.error(fmt.dim(` Using active org ${active.name} (${active.slug}).`) + "\n");
222
+ }
223
+ }
224
+ else if (!options.json) {
225
+ console.error(fmt.warn("No --org and no active org set — this agent will land in your PERSONAL org.") +
226
+ "\n" +
227
+ fmt.dim(" Set a default with `mnemom org use <slug>`, or pass --org <slug> (see: mnemom org list).") +
228
+ "\n");
229
+ }
230
+ }
208
231
  // 4. Claim.
209
232
  let result;
210
233
  try {
@@ -311,3 +334,78 @@ export async function agentsClaimCommand(idOrName, options = {}) {
311
334
  }
312
335
  console.log();
313
336
  }
337
+ /**
338
+ * `mnemom agents move <id-or-name> --to <slug|id>`
339
+ *
340
+ * Role-based relocation between orgs (the counterpart to re-claim, which is
341
+ * possession-based). The server requires the caller to be an owner/admin of
342
+ * BOTH the agent's current org and the destination — no agent key involved.
343
+ */
344
+ export async function agentsMoveCommand(idOrName, options = {}) {
345
+ await requireAuth();
346
+ if (!options.to) {
347
+ console.log(fmt.error("--to <org-slug-or-id> is required.") + "\n");
348
+ console.log(fmt.dim(" A move is explicit by design — it never defaults to the active org. See: mnemom org list") + "\n");
349
+ process.exit(1);
350
+ return;
351
+ }
352
+ // Resolve the agent. Moves act on agents already in your fleet, so a
353
+ // name-shaped arg resolves through the fleet like `agents list` sees it.
354
+ let agentId = idOrName;
355
+ if (!AGENT_ID_RE.test(idOrName)) {
356
+ const found = await getAgentByName(idOrName).catch(() => null);
357
+ if (!found) {
358
+ console.log(fmt.error(`Could not resolve '${idOrName}' to an agent in your fleet.`) + "\n");
359
+ process.exit(1);
360
+ return;
361
+ }
362
+ agentId = found.id;
363
+ }
364
+ // Resolve --to against the caller's memberships (teaching error otherwise).
365
+ let orgs;
366
+ try {
367
+ orgs = await listMyOrgs();
368
+ }
369
+ catch (err) {
370
+ const msg = err instanceof Error ? err.message : String(err);
371
+ console.log(fmt.error(`Failed to read your orgs: ${msg}`) + "\n");
372
+ process.exit(1);
373
+ return;
374
+ }
375
+ let destOrgId;
376
+ try {
377
+ destOrgId = resolveOrgId(options.to, orgs);
378
+ }
379
+ catch (err) {
380
+ const msg = err instanceof Error ? err.message : String(err);
381
+ console.log(fmt.error(msg) + "\n");
382
+ process.exit(1);
383
+ return;
384
+ }
385
+ try {
386
+ const result = await moveAgent(agentId, destOrgId);
387
+ if (options.json) {
388
+ console.log(JSON.stringify(result, null, 2));
389
+ return;
390
+ }
391
+ if (!result.moved) {
392
+ console.log(fmt.warn(`Agent ${agentId} is already in ${options.to} — nothing to do.`) + "\n");
393
+ return;
394
+ }
395
+ console.log(fmt.success(`Moved ${agentId}.`));
396
+ console.log(fmt.label(" From:", result.from_org_id ?? "(unknown)"));
397
+ console.log(fmt.label(" To: ", result.to_org_id ?? destOrgId));
398
+ console.log(fmt.dim(" Note: the destination org's template can floor/cap the agent's composed card — its effective posture may have changed.") + "\n");
399
+ }
400
+ catch (err) {
401
+ if (err instanceof MnemomApiError && err.effectiveStatus === 403) {
402
+ console.log(fmt.error("Move denied: you must be an owner/admin of BOTH the agent's current org and the destination org.") + "\n");
403
+ console.log(fmt.dim(" Check your roles with `mnemom org list`. If you hold the agent's key instead, re-claim can move it: mnemom agents claim <id> --org <slug> --key <key>.") + "\n");
404
+ process.exit(1);
405
+ return;
406
+ }
407
+ const msg = err instanceof Error ? err.message : String(err);
408
+ console.log(fmt.error(`Move failed: ${msg}`) + "\n");
409
+ process.exit(1);
410
+ }
411
+ }
@@ -33,6 +33,49 @@ export declare function cardValidateCommand(file: string, opts?: {
33
33
  offline?: boolean;
34
34
  agent?: string;
35
35
  }): Promise<void>;
36
+ /**
37
+ * Strip system-managed / server-derived keys from a fetched card so it can be
38
+ * round-tripped back into a PUT.
39
+ *
40
+ * `card edit`/`card evaluate` read the server-COMPOSED card, which the composer
41
+ * decorates with system-managed fields (today: `_composition`). The
42
+ * inbound-card validator REJECTS those on write ("System-managed field — cannot
43
+ * be set on inbound cards"), so re-PUTting the composed card verbatim is refused
44
+ * and the edit never persists (MNE-1726). Edit the *inbound* projection, not the
45
+ * *composed* one.
46
+ *
47
+ * Convention: every system-managed key is `_`-prefixed. We strip by prefix (not
48
+ * by an explicit `_composition` allowlist) so future server-added `_`-fields
49
+ * round-trip safely too — the forward-compat lesson from MNE-908, where editors
50
+ * fought the composer over fields the user never authored.
51
+ */
52
+ export declare function stripSystemManagedFields(card: Record<string, unknown>): Record<string, unknown>;
53
+ /**
54
+ * Reconcile the ADR-039 unmapped-tool vocabulary with the embedded policy
55
+ * engine, at the CLI's own validator↔engine boundary.
56
+ *
57
+ * The card validator MANDATES the ADR-039 fields
58
+ * (`enforcement.allow_unmapped_tools` + `enforcement.default_unmapped_severity`)
59
+ * and REJECTS the legacy `enforcement.unmapped_tool_action`. But the embedded
60
+ * `@mnemom/policy-engine` still derives its policy from that legacy field, so a
61
+ * validator-passing card leaves the engine at its `allow` default: `evaluate`
62
+ * never warns/denies on an unmapped tool, even under `--strict` (MNE-1727). The
63
+ * two field vocabularies never met.
64
+ *
65
+ * Project the ADR-039 fields onto the legacy action the engine reads. Severity
66
+ * selects the verdict tier the operator declared:
67
+ *
68
+ * allow_unmapped_tools=true → allow (PASS)
69
+ * allow_unmapped_tools=false + severity high|critical → deny (FAIL, always non-zero)
70
+ * allow_unmapped_tools=false + severity medium|low → warn (WARN, non-zero under --strict)
71
+ * allow_unmapped_tools=false + severity unset → deny (fail-closed default)
72
+ * allow_unmapped_tools unset → card returned untouched
73
+ *
74
+ * Returns a shallow clone (the user's card is never mutated); called AFTER
75
+ * validation, so the legacy field is only ever seen by the engine, never
76
+ * re-emitted onto an inbound card.
77
+ */
78
+ export declare function bridgeUnmappedToolPolicy(card: Record<string, unknown>): Record<string, unknown>;
36
79
  export declare function cardEditCommand(agentName?: string, options?: {
37
80
  idempotencyKey?: string;
38
81
  }): Promise<void>;
@@ -552,6 +552,43 @@ export function validateUnifiedCard(card) {
552
552
  /** @deprecated Use validateUnifiedCard instead */
553
553
  export const validateCardJson = (raw) => validateUnifiedCard(JSON.parse(raw));
554
554
  // ============================================================================
555
+ // Private helpers
556
+ // ============================================================================
557
+ function exitWithError(msg) {
558
+ console.log("\n" + fmt.error(msg) + "\n");
559
+ process.exit(1);
560
+ return undefined; // unreachable; satisfies `never` when process lacks type declarations
561
+ }
562
+ function loadCard(file, noun = "File") {
563
+ const filePath = path.resolve(file);
564
+ if (!fs.existsSync(filePath)) {
565
+ exitWithError(`${noun} not found: ${filePath}`);
566
+ }
567
+ try {
568
+ return parseCardFile(filePath);
569
+ }
570
+ catch (e) {
571
+ const msg = e instanceof Error ? e.message : String(e);
572
+ exitWithError(`Could not parse ${noun.toLowerCase()}: ${msg}`);
573
+ }
574
+ }
575
+ function printChecks(checks) {
576
+ for (const check of checks) {
577
+ if (check.passed) {
578
+ console.log(fmt.success(`${check.name}: ${check.message}`));
579
+ }
580
+ else {
581
+ console.log(fmt.error(`${check.name}: ${check.message}`));
582
+ }
583
+ }
584
+ }
585
+ function checkCardBytes(body) {
586
+ const bodyBytes = Buffer.byteLength(body, "utf-8");
587
+ if (bodyBytes > ALIGNMENT_CARD_MAX_BYTES) {
588
+ exitWithError(`Alignment card is ${bodyBytes} bytes; limit is ${ALIGNMENT_CARD_MAX_BYTES} bytes (128 KB). The API will return 413.`);
589
+ }
590
+ }
591
+ // ============================================================================
555
592
  // Subcommands
556
593
  // ============================================================================
557
594
  export async function cardShowCommand(agentName) {
@@ -581,42 +618,20 @@ export async function cardShowCommand(agentName) {
581
618
  }
582
619
  catch (error) {
583
620
  const message = error instanceof Error ? error.message : String(error);
584
- console.log("\n" + fmt.error(`Failed to fetch card: ${message}`) + "\n");
585
- process.exit(1);
621
+ exitWithError(`Failed to fetch card: ${message}`);
586
622
  }
587
623
  }
588
624
  export async function cardPublishCommand(file, agentName, options = {}) {
589
625
  const agentId = await resolveAgentId(agentName);
590
- // Resolve file path
591
- const filePath = path.resolve(file);
592
- if (!fs.existsSync(filePath)) {
593
- console.log("\n" + fmt.error(`File not found: ${filePath}`) + "\n");
594
- process.exit(1);
595
- }
596
626
  // Parse file (JSON or YAML)
597
- let parsed;
598
- try {
599
- parsed = parseCardFile(filePath);
600
- }
601
- catch (e) {
602
- const msg = e instanceof Error ? e.message : String(e);
603
- console.log("\n" + fmt.error(`Could not parse file: ${msg}`) + "\n");
604
- process.exit(1);
605
- }
627
+ const parsed = loadCard(file);
606
628
  // Validate locally
607
629
  const checks = validateUnifiedCard(parsed.parsed);
608
630
  const allPassed = checks.every((c) => c.passed);
609
631
  console.log(fmt.header("Card Validation"));
610
632
  console.log(fmt.label(" Format:", ` ${parsed.format.toUpperCase()}`));
611
633
  console.log();
612
- for (const check of checks) {
613
- if (check.passed) {
614
- console.log(fmt.success(`${check.name}: ${check.message}`));
615
- }
616
- else {
617
- console.log(fmt.error(`${check.name}: ${check.message}`));
618
- }
619
- }
634
+ printChecks(checks);
620
635
  console.log();
621
636
  if (!allPassed) {
622
637
  console.log(fmt.error("Validation failed. Fix the errors above before publishing.") + "\n");
@@ -637,13 +652,7 @@ export async function cardPublishCommand(file, agentName, options = {}) {
637
652
  console.log("\nPublishing alignment card...");
638
653
  const contentType = parsed.format === "yaml" ? "text/yaml" : "application/json";
639
654
  const body = parsed.format === "yaml" ? parsed.raw : JSON.stringify(parsed.parsed);
640
- const bodyBytes = Buffer.byteLength(body, "utf-8");
641
- if (bodyBytes > ALIGNMENT_CARD_MAX_BYTES) {
642
- console.log("\n" +
643
- fmt.error(`Alignment card is ${bodyBytes} bytes; limit is ${ALIGNMENT_CARD_MAX_BYTES} bytes (128 KB). The API will return 413.`) +
644
- "\n");
645
- process.exit(1);
646
- }
655
+ checkCardBytes(body);
647
656
  const result = await putAlignmentCard(agentId, body, contentType, {
648
657
  idempotencyKey: options.idempotencyKey,
649
658
  });
@@ -656,22 +665,19 @@ export async function cardPublishCommand(file, agentName, options = {}) {
656
665
  catch (error) {
657
666
  if (error instanceof MnemomApiError) {
658
667
  if (error.effectiveStatus === 404) {
659
- console.log("\n" +
660
- fmt.error("Agent found but not writable: you can see this agent locally, but cannot publish to it in its current organization context.") +
661
- "\n");
668
+ exitWithError("Agent found but not writable: you can see this agent locally, but cannot publish to it in its current organization context.");
662
669
  }
663
670
  else if (error.effectiveStatus === 401) {
664
- console.log("\n" + fmt.error(`Authentication failed: ${error.message}`) + "\n");
671
+ exitWithError(`Authentication failed: ${error.message}`);
665
672
  }
666
673
  else {
667
- console.log("\n" + fmt.error(`Failed to publish card: ${error.message}`) + "\n");
674
+ exitWithError(`Failed to publish card: ${error.message}`);
668
675
  }
669
676
  }
670
677
  else {
671
678
  const message = error instanceof Error ? error.message : String(error);
672
- console.log("\n" + fmt.error(`Failed to publish card: ${message}`) + "\n");
679
+ exitWithError(`Failed to publish card: ${message}`);
673
680
  }
674
- process.exit(1);
675
681
  }
676
682
  }
677
683
  const AGENT_ID_RE = /^(smolt-[0-9a-f]{8}|mnm-[0-9a-f-]{36})$/;
@@ -695,22 +701,9 @@ async function softResolveAgentId(agent) {
695
701
  }
696
702
  }
697
703
  export async function cardValidateCommand(file, opts = {}) {
698
- // Resolve file path
699
- const filePath = path.resolve(file);
700
- if (!fs.existsSync(filePath)) {
701
- console.log("\n" + fmt.error(`File not found: ${filePath}`) + "\n");
702
- process.exit(1);
703
- }
704
704
  // Parse file (JSON or YAML)
705
- let parsed;
706
- try {
707
- parsed = parseCardFile(filePath);
708
- }
709
- catch (e) {
710
- const msg = e instanceof Error ? e.message : String(e);
711
- console.log("\n" + fmt.error(`Could not parse file: ${msg}`) + "\n");
712
- process.exit(1);
713
- }
705
+ const filePath = path.resolve(file);
706
+ const parsed = loadCard(file);
714
707
  // Prefer server-authoritative validation (composes against the agent's
715
708
  // org/platform floor — catches conflicts the offline validator cannot) when
716
709
  // online + an agent is available. Fall back to the local validator on
@@ -727,8 +720,7 @@ export async function cardValidateCommand(file, opts = {}) {
727
720
  catch (err) {
728
721
  if (err instanceof MnemomApiError && err.status !== 401) {
729
722
  // 403 / 5xx — a genuine server error, not the offline-fallback case.
730
- console.log("\n" + fmt.error(`Server validation failed: ${err.message}`) + "\n");
731
- process.exit(1);
723
+ exitWithError(`Server validation failed: ${err.message}`);
732
724
  }
733
725
  // 401 or network error → fall through to offline validation.
734
726
  process.stderr.write(fmt.warn("offline validation — server rules may differ") + "\n");
@@ -749,14 +741,7 @@ export async function cardValidateCommand(file, opts = {}) {
749
741
  console.log(fmt.label(" File:", ` ${filePath}`));
750
742
  console.log(fmt.label(" Format:", ` ${parsed.format.toUpperCase()}`));
751
743
  console.log();
752
- for (const check of checks) {
753
- if (check.passed) {
754
- console.log(fmt.success(`${check.name}: ${check.message}`));
755
- }
756
- else {
757
- console.log(fmt.error(`${check.name}: ${check.message}`));
758
- }
759
- }
744
+ printChecks(checks);
760
745
  console.log();
761
746
  if (allPassed) {
762
747
  console.log(fmt.success(`All ${passCount} checks passed`) + "\n");
@@ -798,6 +783,76 @@ function renderServerCardValidation(result, filePath, format) {
798
783
  console.log();
799
784
  process.exit(1);
800
785
  }
786
+ /**
787
+ * Strip system-managed / server-derived keys from a fetched card so it can be
788
+ * round-tripped back into a PUT.
789
+ *
790
+ * `card edit`/`card evaluate` read the server-COMPOSED card, which the composer
791
+ * decorates with system-managed fields (today: `_composition`). The
792
+ * inbound-card validator REJECTS those on write ("System-managed field — cannot
793
+ * be set on inbound cards"), so re-PUTting the composed card verbatim is refused
794
+ * and the edit never persists (MNE-1726). Edit the *inbound* projection, not the
795
+ * *composed* one.
796
+ *
797
+ * Convention: every system-managed key is `_`-prefixed. We strip by prefix (not
798
+ * by an explicit `_composition` allowlist) so future server-added `_`-fields
799
+ * round-trip safely too — the forward-compat lesson from MNE-908, where editors
800
+ * fought the composer over fields the user never authored.
801
+ */
802
+ export function stripSystemManagedFields(card) {
803
+ const clean = {};
804
+ for (const [key, value] of Object.entries(card)) {
805
+ if (key.startsWith("_"))
806
+ continue;
807
+ clean[key] = value;
808
+ }
809
+ return clean;
810
+ }
811
+ /**
812
+ * Reconcile the ADR-039 unmapped-tool vocabulary with the embedded policy
813
+ * engine, at the CLI's own validator↔engine boundary.
814
+ *
815
+ * The card validator MANDATES the ADR-039 fields
816
+ * (`enforcement.allow_unmapped_tools` + `enforcement.default_unmapped_severity`)
817
+ * and REJECTS the legacy `enforcement.unmapped_tool_action`. But the embedded
818
+ * `@mnemom/policy-engine` still derives its policy from that legacy field, so a
819
+ * validator-passing card leaves the engine at its `allow` default: `evaluate`
820
+ * never warns/denies on an unmapped tool, even under `--strict` (MNE-1727). The
821
+ * two field vocabularies never met.
822
+ *
823
+ * Project the ADR-039 fields onto the legacy action the engine reads. Severity
824
+ * selects the verdict tier the operator declared:
825
+ *
826
+ * allow_unmapped_tools=true → allow (PASS)
827
+ * allow_unmapped_tools=false + severity high|critical → deny (FAIL, always non-zero)
828
+ * allow_unmapped_tools=false + severity medium|low → warn (WARN, non-zero under --strict)
829
+ * allow_unmapped_tools=false + severity unset → deny (fail-closed default)
830
+ * allow_unmapped_tools unset → card returned untouched
831
+ *
832
+ * Returns a shallow clone (the user's card is never mutated); called AFTER
833
+ * validation, so the legacy field is only ever seen by the engine, never
834
+ * re-emitted onto an inbound card.
835
+ */
836
+ export function bridgeUnmappedToolPolicy(card) {
837
+ const enforcement = card.enforcement;
838
+ if (!isObj(enforcement))
839
+ return card;
840
+ const allow = enforcement.allow_unmapped_tools;
841
+ if (typeof allow !== "boolean")
842
+ return card;
843
+ let action;
844
+ if (allow) {
845
+ action = "allow";
846
+ }
847
+ else {
848
+ const severity = String(enforcement.default_unmapped_severity ?? "");
849
+ action = severity === "medium" || severity === "low" ? "warn" : "deny";
850
+ }
851
+ return {
852
+ ...card,
853
+ enforcement: { ...enforcement, unmapped_tool_action: action },
854
+ };
855
+ }
801
856
  export async function cardEditCommand(agentName, options = {}) {
802
857
  const agentId = await resolveAgentId(agentName);
803
858
  await requireAuth();
@@ -807,8 +862,26 @@ export async function cardEditCommand(agentName, options = {}) {
807
862
  if (!original) {
808
863
  console.log(fmt.warn("No alignment card found. Creating a template..."));
809
864
  }
810
- const cardYaml = original ||
811
- yaml.dump({
865
+ // The GET returns the server-COMPOSED card, decorated with system-managed
866
+ // `_`-fields (e.g. `_composition`) the inbound-card validator forbids on
867
+ // write. Strip them so the user edits — and re-PUTs — the inbound projection,
868
+ // otherwise the round-trip PUT is rejected and the edit never persists
869
+ // (MNE-1726).
870
+ let cardYaml;
871
+ if (original) {
872
+ let parsedOriginal;
873
+ try {
874
+ parsedOriginal = yaml.load(original);
875
+ }
876
+ catch {
877
+ parsedOriginal = undefined;
878
+ }
879
+ cardYaml = isObj(parsedOriginal)
880
+ ? yaml.dump(stripSystemManagedFields(parsedOriginal), { lineWidth: 120, noRefs: true })
881
+ : original;
882
+ }
883
+ else {
884
+ cardYaml = yaml.dump({
812
885
  card_version: "unified/2026-04-26",
813
886
  agent_id: agentId,
814
887
  autonomy_mode: "observe",
@@ -822,6 +895,7 @@ export async function cardEditCommand(agentName, options = {}) {
822
895
  },
823
896
  audit: { retention_days: 30, queryable: false, trace_format: "otel" },
824
897
  }, { lineWidth: 120, noRefs: true });
898
+ }
825
899
  // Write to a per-invocation temp dir created with mkdtemp (mode 0700,
826
900
  // unpredictable suffix) so the editor file can't be pre-created or
827
901
  // symlink-raced by another user in the shared os.tmpdir().
@@ -833,14 +907,13 @@ export async function cardEditCommand(agentName, options = {}) {
833
907
  console.log(`Opening ${editor}...`);
834
908
  const result = spawnSync(editor, [tmpFile], { stdio: "inherit" });
835
909
  if (result.status !== 0) {
836
- console.log("\n" + fmt.error("Editor exited with an error") + "\n");
837
910
  try {
838
911
  fs.unlinkSync(tmpFile);
839
912
  }
840
913
  catch {
841
914
  /* ignore */
842
915
  }
843
- process.exit(1);
916
+ exitWithError("Editor exited with an error");
844
917
  }
845
918
  // Read back and compare
846
919
  const edited = fs.readFileSync(tmpFile, "utf-8");
@@ -863,8 +936,7 @@ export async function cardEditCommand(agentName, options = {}) {
863
936
  }
864
937
  catch (e) {
865
938
  const msg = e instanceof Error ? e.message : String(e);
866
- console.log("\n" + fmt.error(`Invalid YAML: ${msg}`) + "\n");
867
- process.exit(1);
939
+ exitWithError(`Invalid YAML: ${msg}`);
868
940
  }
869
941
  const checks = validateUnifiedCard(parsed);
870
942
  const allPassed = checks.every((c) => c.passed);
@@ -875,8 +947,7 @@ export async function cardEditCommand(agentName, options = {}) {
875
947
  console.log(fmt.error(`${check.name}: ${check.message}`));
876
948
  }
877
949
  console.log();
878
- console.log(fmt.error("Validation failed. Card not published.") + "\n");
879
- process.exit(1);
950
+ exitWithError("Validation failed. Card not published.");
880
951
  }
881
952
  // Confirm and publish
882
953
  if (isInteractive()) {
@@ -888,13 +959,7 @@ export async function cardEditCommand(agentName, options = {}) {
888
959
  }
889
960
  try {
890
961
  console.log("\nPublishing alignment card...");
891
- const editedBytes = Buffer.byteLength(edited, "utf-8");
892
- if (editedBytes > ALIGNMENT_CARD_MAX_BYTES) {
893
- console.log("\n" +
894
- fmt.error(`Alignment card is ${editedBytes} bytes; limit is ${ALIGNMENT_CARD_MAX_BYTES} bytes (128 KB). The API will return 413.`) +
895
- "\n");
896
- process.exit(1);
897
- }
962
+ checkCardBytes(edited);
898
963
  const putResult = await putAlignmentCard(agentId, edited, "text/yaml", {
899
964
  idempotencyKey: options.idempotencyKey,
900
965
  });
@@ -906,8 +971,7 @@ export async function cardEditCommand(agentName, options = {}) {
906
971
  }
907
972
  catch (error) {
908
973
  const message = error instanceof Error ? error.message : String(error);
909
- console.log("\n" + fmt.error(`Failed to publish card: ${message}`) + "\n");
910
- process.exit(1);
974
+ exitWithError(`Failed to publish card: ${message}`);
911
975
  }
912
976
  }
913
977
  /**
@@ -920,19 +984,7 @@ export async function cardEditCommand(agentName, options = {}) {
920
984
  export async function cardEvaluateCommand(file, options) {
921
985
  // 1. Read + validate card file
922
986
  const cardPath = path.resolve(file);
923
- if (!fs.existsSync(cardPath)) {
924
- console.log("\n" + fmt.error(`Card file not found: ${cardPath}`) + "\n");
925
- process.exit(1);
926
- }
927
- let parsed;
928
- try {
929
- parsed = parseCardFile(cardPath);
930
- }
931
- catch (e) {
932
- const msg = e instanceof Error ? e.message : String(e);
933
- console.log("\n" + fmt.error(`Could not parse card file: ${msg}`) + "\n");
934
- process.exit(1);
935
- }
987
+ const parsed = loadCard(file, "Card file");
936
988
  const checks = validateUnifiedCard(parsed.parsed);
937
989
  const allPassed = checks.every((c) => c.passed);
938
990
  if (!allPassed) {
@@ -954,8 +1006,7 @@ export async function cardEvaluateCommand(file, options) {
954
1006
  else if (options.toolManifest) {
955
1007
  const manifestPath = path.resolve(options.toolManifest);
956
1008
  if (!fs.existsSync(manifestPath)) {
957
- console.log("\n" + fmt.error(`Tool manifest file not found: ${manifestPath}`) + "\n");
958
- process.exit(1);
1009
+ exitWithError(`Tool manifest file not found: ${manifestPath}`);
959
1010
  }
960
1011
  try {
961
1012
  const manifestRaw = fs.readFileSync(manifestPath, "utf-8");
@@ -966,18 +1017,18 @@ export async function cardEvaluateCommand(file, options) {
966
1017
  }
967
1018
  catch (e) {
968
1019
  const msg = e instanceof Error ? e.message : String(e);
969
- console.log("\n" + fmt.error(`Could not read tool manifest: ${msg}`) + "\n");
970
- process.exit(1);
1020
+ exitWithError(`Could not read tool manifest: ${msg}`);
971
1021
  }
972
1022
  }
973
1023
  if (tools.length === 0) {
974
- console.log("\n" + fmt.error("No tools specified. Use --tools or --tool-manifest") + "\n");
975
- process.exit(1);
1024
+ exitWithError("No tools specified. Use --tools or --tool-manifest");
976
1025
  }
977
- // 3. Run evaluation -- card IS the policy source
1026
+ // 3. Run evaluation -- card IS the policy source. Bridge the ADR-039
1027
+ // unmapped-tool fields onto the legacy action the embedded engine reads so
1028
+ // `--strict` actually surfaces unmapped-tool warnings (MNE-1727).
978
1029
  const result = evaluatePolicy({
979
1030
  context: "cicd",
980
- card: parsed.parsed,
1031
+ card: bridgeUnmappedToolPolicy(parsed.parsed),
981
1032
  tools,
982
1033
  });
983
1034
  // 4. Display results
@@ -46,7 +46,17 @@ function displayTrace(trace) {
46
46
  // (The old flat `trace.verified` was always undefined on the nested wire → every
47
47
  // trace was mis-flagged [VIOLATION] and the action rendered as "[object Object]".)
48
48
  const verified = trace.verification?.verified ?? true;
49
- const statusMsg = verified ? fmt.success(timestamp) : fmt.error(`${timestamp} [VIOLATION]`);
49
+ // MNE-596 AAP flagged a structural violation, but the observer's DDR
50
+ // cross-check confirmed AIP independently saw `clear` and no bounded action
51
+ // was ever executed: the agent was attacked and correctly refused. Render
52
+ // this distinctly from a real [VIOLATION] — it's good behavior, not bad —
53
+ // while still surfacing it (never silently swallowed).
54
+ const isBoundedRefusal = trace.verification?.classification === "bounded_refusal";
55
+ const statusMsg = verified
56
+ ? fmt.success(timestamp)
57
+ : isBoundedRefusal
58
+ ? fmt.warn(`${timestamp} [BOUNDED REFUSAL — policy-enforced, not counted]`)
59
+ : fmt.error(`${timestamp} [VIOLATION]`);
50
60
  console.log(`\n ${statusMsg}`);
51
61
  // action.name is the canonical label (the tool name lives here in the AIP-nested
52
62
  // shape); fall back to the action type, then a dash.