capdag 1.222.603 → 1.233.649

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.
@@ -590,6 +590,18 @@ function buildStylesheet() {
590
590
  'target-arrow-shape': 'tee',
591
591
  },
592
592
  },
593
+ {
594
+ // A convergence (fan-in) edge: a second producer feeding a cap's
595
+ // non-main argument. Dotted + diamond-tail to read as a merging
596
+ // side-input distinct from the solid main backbone edge.
597
+ selector: 'edge.strand-convergence',
598
+ style: {
599
+ 'line-style': 'dotted',
600
+ 'width': 2,
601
+ 'source-arrow-shape': 'diamond',
602
+ 'source-arrow-color': machineEdgeColor || 'data(color)',
603
+ },
604
+ },
593
605
  {
594
606
  selector: 'edge.faded',
595
607
  style: { 'opacity': fadedEdgeOpacity },
@@ -762,6 +774,28 @@ function validateStrandStep(step, path) {
762
774
  if (typeof body.output_is_sequence !== 'boolean') {
763
775
  throw new Error(`CapFabRenderer: ${path}.step_type.Cap.output_is_sequence must be a boolean`);
764
776
  }
777
+ // `inputs` (capdag CapInput list) carries the data-flow graph: the main
778
+ // input plus any convergence inputs. Optional on legacy payloads, but
779
+ // when present it must be well-formed — the fan-in edges are drawn from
780
+ // it, so a malformed entry is a hard error, not a silent skip.
781
+ if (body.inputs !== undefined) {
782
+ assertArray(body.inputs, `${path}.step_type.Cap.inputs`);
783
+ body.inputs.forEach((input, inputIdx) => {
784
+ const inputPath = `${path}.step_type.Cap.inputs[${inputIdx}]`;
785
+ assertObject(input, inputPath);
786
+ assertString(input.arg_urn, `${inputPath}.arg_urn`);
787
+ // serde: unit variant → the string "StrandInput"; struct variant →
788
+ // { Step: { token_id } }. Anything else is malformed.
789
+ if (input.source === 'StrandInput') return;
790
+ if (input.source && typeof input.source === 'object' && input.source.Step) {
791
+ assertString(input.source.Step.token_id, `${inputPath}.source.Step.token_id`);
792
+ return;
793
+ }
794
+ throw new Error(
795
+ `CapFabRenderer: ${inputPath}.source must be "StrandInput" or { Step: { token_id } }`
796
+ );
797
+ });
798
+ }
765
799
  } else {
766
800
  assertString(body.media_def, `${path}.step_type.${variant}.media_def`);
767
801
  }
@@ -1091,7 +1125,7 @@ function buildBrowseGraphData(capabilities) {
1091
1125
  // Parsing the URN canonicalizes it and validates it — fail hard on
1092
1126
  // malformed registry data.
1093
1127
  const parsedUrn = CapUrn.fromString(capData.urn);
1094
- const cap = createCap(parsedUrn, title, capData.command || '');
1128
+ const cap = createCap(parsedUrn, title, capData.aliases || []);
1095
1129
  const capFabEdgeIndex = capFab.edges.length;
1096
1130
  capFab.addCap(cap, 'registry');
1097
1131
 
@@ -1309,6 +1343,17 @@ function buildStrandGraphData(data) {
1309
1343
  return outerExit;
1310
1344
  }
1311
1345
 
1346
+ // Map every step's stable token_id to its output node id. A cap
1347
+ // step's node (`step_${i}`) IS its `to_spec` output, so a convergence
1348
+ // input naming a producer by token resolves to that producer's output
1349
+ // node — the value flowing into the fan-in arg.
1350
+ const tokenToNodeId = new Map();
1351
+ data.steps.forEach((step, i) => {
1352
+ if (step && typeof step.token_id === 'string' && step.token_id.length > 0) {
1353
+ tokenToNodeId.set(step.token_id, `step_${i}`);
1354
+ }
1355
+ });
1356
+
1312
1357
  data.steps.forEach((step, i) => {
1313
1358
  const variant = Object.keys(step.step_type)[0];
1314
1359
  const nodeId = `step_${i}`;
@@ -1330,8 +1375,43 @@ function buildStrandGraphData(data) {
1330
1375
  if (cardinality !== '1\u21921') {
1331
1376
  label = `${label} (${cardinality})`;
1332
1377
  }
1378
+ // Resolve this cap's actual data-flow producers from `body.inputs`
1379
+ // (capdag CapInput serde shape): each `source` is the string
1380
+ // "StrandInput" (fed by the strand's input anchor → the input slot
1381
+ // node) or `{ Step: { token_id } }` (fed by a producing cap → that
1382
+ // step's output node). This is the authoritative topology; step
1383
+ // ORDER is not — the realizer emits edges greedily in dependency
1384
+ // order, so `prevNodeId` (the previously-emitted step) need not be
1385
+ // one of this cap's inputs at all.
1386
+ const capInputs = Array.isArray(body.inputs) ? body.inputs : [];
1387
+ const producerNodeIds = [];
1388
+ for (const input of capInputs) {
1389
+ const src = input && input.source;
1390
+ if (src === 'StrandInput') {
1391
+ producerNodeIds.push(inputSlotId);
1392
+ continue;
1393
+ }
1394
+ const producerToken = src && typeof src === 'object' && src.Step && src.Step.token_id;
1395
+ if (typeof producerToken !== 'string' || producerToken.length === 0) continue;
1396
+ const producerNodeId = tokenToNodeId.get(producerToken);
1397
+ if (producerNodeId) producerNodeIds.push(producerNodeId);
1398
+ }
1399
+
1400
+ // Pick the source of the single labeled "backbone" edge (the one
1401
+ // carrying the cap title + cardinality). Inside a ForEach body — or
1402
+ // when the cap declares no inputs — keep the linear `prevNodeId`
1403
+ // thread so the ForEach boundary handling and its (1→n) entry marker
1404
+ // are preserved exactly. Otherwise anchor the backbone on a REAL
1405
+ // input: `prevNodeId` if it is genuinely one, else the first actual
1406
+ // producer — so a non-input step ordered just before this cap can
1407
+ // never become a spurious backbone edge.
1408
+ const isForeachContext = isForeachEntry || insideForEachBody !== null;
1409
+ let backboneSource = prevNodeId;
1410
+ if (!isForeachContext && producerNodeIds.length > 0 && !producerNodeIds.includes(prevNodeId)) {
1411
+ backboneSource = producerNodeIds[0];
1412
+ }
1333
1413
  addEdge(
1334
- prevNodeId,
1414
+ backboneSource,
1335
1415
  nodeId,
1336
1416
  label,
1337
1417
  body.title,
@@ -1340,6 +1420,25 @@ function buildStrandGraphData(data) {
1340
1420
  { foreachEntry: isForeachEntry }
1341
1421
  );
1342
1422
 
1423
+ // Convergence (fan-in): draw an edge from every other producer that
1424
+ // isn't the backbone source, so a cap fed by several producers renders
1425
+ // as a DAG, not a chain. Deduplicated against the backbone edge and
1426
+ // against a self-loop.
1427
+ const drawnSources = new Set([backboneSource, nodeId]);
1428
+ for (const producerNodeId of producerNodeIds) {
1429
+ if (drawnSources.has(producerNodeId)) continue;
1430
+ drawnSources.add(producerNodeId);
1431
+ addEdge(
1432
+ producerNodeId,
1433
+ nodeId,
1434
+ '',
1435
+ body.title,
1436
+ body.cap_urn,
1437
+ 'strand-convergence',
1438
+ {}
1439
+ );
1440
+ }
1441
+
1343
1442
  if (insideForEachBody !== null) {
1344
1443
  if (bodyEntry === null) bodyEntry = nodeId;
1345
1444
  bodyExit = nodeId;
package/capdag.js CHANGED
@@ -2530,21 +2530,22 @@ class Cap {
2530
2530
  * @param {Object|null} metadataJson - Optional arbitrary metadata as JSON object
2531
2531
  * @param {string|null} documentation - Optional long-form markdown documentation. Rendered in capability info panels, the cap navigator, capdag-dot-com, and anywhere else a rich-text explanation of the cap is useful.
2532
2532
  */
2533
- constructor(urn, title, command, capDescription = null, metadata = {}, metadataJson = null, documentation = null) {
2533
+ constructor(urn, title, aliases, capDescription = null, metadata = {}, metadataJson = null, documentation = null) {
2534
2534
  if (!(urn instanceof CapUrn)) {
2535
2535
  throw new Error('URN must be a CapUrn instance');
2536
2536
  }
2537
2537
  if (!title || typeof title !== 'string') {
2538
2538
  throw new Error('Title is required and must be a string');
2539
2539
  }
2540
- if (!command || typeof command !== 'string') {
2541
- throw new Error('Command is required and must be a string');
2540
+ if (!Array.isArray(aliases) || aliases.length === 0) {
2541
+ throw new Error('Aliases are required and must be a non-empty array');
2542
2542
  }
2543
2543
 
2544
2544
  this.urn = urn;
2545
2545
  this.version = 0;
2546
2546
  this.title = title;
2547
- this.command = command;
2547
+ this.aliases = aliases;
2548
+ this.is_abstract = false;
2548
2549
  this.cap_description = capDescription;
2549
2550
  this.documentation = documentation;
2550
2551
  this.metadata = metadata || {};
@@ -2752,7 +2753,8 @@ class Cap {
2752
2753
 
2753
2754
  return this.urn.equals(other.urn) &&
2754
2755
  this.title === other.title &&
2755
- this.command === other.command &&
2756
+ JSON.stringify([...this.aliases].sort()) === JSON.stringify([...other.aliases].sort()) &&
2757
+ this.is_abstract === other.is_abstract &&
2756
2758
  this.cap_description === other.cap_description &&
2757
2759
  this.documentation === other.documentation &&
2758
2760
  JSON.stringify(this.metadata) === JSON.stringify(other.metadata) &&
@@ -2772,7 +2774,7 @@ class Cap {
2772
2774
  const result = {
2773
2775
  urn: this.urn.toString(),
2774
2776
  title: this.title,
2775
- command: this.command,
2777
+ aliases: this.aliases,
2776
2778
  cap_description: this.cap_description,
2777
2779
  metadata: this.metadata,
2778
2780
  args: this.args.map(a => a.toJSON()),
@@ -2783,6 +2785,10 @@ class Cap {
2783
2785
  result.version = this.version;
2784
2786
  }
2785
2787
 
2788
+ if (this.is_abstract) {
2789
+ result.abstract = true;
2790
+ }
2791
+
2786
2792
  // Long-form markdown documentation. Only emitted when set, to match
2787
2793
  // the Rust serializer which skips this field when None.
2788
2794
  if (typeof this.documentation === 'string' && this.documentation.length > 0) {
@@ -2821,7 +2827,11 @@ class Cap {
2821
2827
  const documentation = (typeof json.documentation === 'string' && json.documentation.length > 0)
2822
2828
  ? json.documentation
2823
2829
  : null;
2824
- const cap = new Cap(urn, json.title, json.command, json.cap_description, json.metadata, json.metadata_json, documentation);
2830
+ if (!Array.isArray(json.aliases) || json.aliases.length === 0) {
2831
+ throw new Error(`cap '${json.urn}' must declare at least one alias (the 'aliases' field is required and non-empty)`);
2832
+ }
2833
+ const cap = new Cap(urn, json.title, json.aliases, json.cap_description, json.metadata, json.metadata_json, documentation);
2834
+ cap.is_abstract = json.abstract === true;
2825
2835
  cap.version = (typeof json.version === 'number' && json.version !== 0) ? json.version : 0;
2826
2836
  // Parse args (new format)
2827
2837
  if (json.args && Array.isArray(json.args)) {
@@ -3046,20 +3056,20 @@ class CapManifest {
3046
3056
  /**
3047
3057
  * Helper functions for creating capabilities
3048
3058
  */
3049
- function createCap(urn, title, command) {
3050
- return new Cap(urn, title, command);
3059
+ function createCap(urn, title, aliases) {
3060
+ return new Cap(urn, title, aliases);
3051
3061
  }
3052
3062
 
3053
- function createCapWithDescription(urn, title, command, description) {
3054
- return new Cap(urn, title, command, description);
3063
+ function createCapWithDescription(urn, title, aliases, description) {
3064
+ return new Cap(urn, title, aliases, description);
3055
3065
  }
3056
3066
 
3057
- function createCapWithMetadata(urn, title, command, metadata) {
3058
- return new Cap(urn, title, command, null, metadata);
3067
+ function createCapWithMetadata(urn, title, aliases, metadata) {
3068
+ return new Cap(urn, title, aliases, null, metadata);
3059
3069
  }
3060
3070
 
3061
- function createCapWithDescriptionAndMetadata(urn, title, command, description, metadata) {
3062
- return new Cap(urn, title, command, description, metadata);
3071
+ function createCapWithDescriptionAndMetadata(urn, title, aliases, description, metadata) {
3072
+ return new Cap(urn, title, aliases, description, metadata);
3063
3073
  }
3064
3074
 
3065
3075
  // ============================================================================
@@ -3747,9 +3757,9 @@ class CapValidator {
3747
3757
  });
3748
3758
  }
3749
3759
 
3750
- if (!cap.command || typeof cap.command !== 'string') {
3760
+ if (!Array.isArray(cap.aliases) || cap.aliases.length === 0) {
3751
3761
  throw new ValidationError('InvalidCapSchema', capUrn, {
3752
- issue: 'Cap must have a valid command'
3762
+ issue: 'Cap must have at least one alias'
3753
3763
  });
3754
3764
  }
3755
3765
 
@@ -4355,37 +4365,55 @@ class CartridgeCapGroup {
4355
4365
  // in synchronous contexts must await.
4356
4366
 
4357
4367
  const DEV_SLUG = "dev";
4358
- const SLUG_HEX_LEN = 16;
4359
4368
 
4360
4369
  /**
4361
- * Compute the on-disk slug for a registry URL.
4370
+ * The authority (host[:port]) of a registry URL: after `://` up to the next
4371
+ * `/`, `?`, or `#` (path/query/fragment discarded).
4372
+ */
4373
+ function authorityOfRegistryUrl(url) {
4374
+ const i = url.indexOf("://");
4375
+ const afterScheme = i >= 0 ? url.slice(i + 3) : url;
4376
+ const m = afterScheme.search(/[/?#]/);
4377
+ return m >= 0 ? afterScheme.slice(0, m) : afterScheme;
4378
+ }
4379
+
4380
+ /**
4381
+ * Compute the on-disk slug for a registry URL. Mirrors
4382
+ * capdag::cartridge_slug::slug_for byte-for-byte: `DEV_SLUG` for null/undefined,
4383
+ * otherwise a path-safe transform of the URL's authority (host[:port]) —
4384
+ * ASCII-lowercased, every character outside `[a-z0-9.-]` replaced by `-`.
4385
+ * Depends ONLY on the authority; path/version/query/trailing-slash/host-case
4386
+ * do not change it.
4362
4387
  *
4363
- * @param {string|null|undefined} registryUrl - The registry URL, or
4364
- * null/undefined for dev installs.
4365
- * @returns {Promise<string>} The slug — `DEV_SLUG` for null,
4366
- * otherwise the first 16 lowercase-hex characters of
4367
- * sha256(registryUrl).
4388
+ * @param {string|null|undefined} registryUrl
4389
+ * @returns {string}
4368
4390
  */
4369
- async function slugForRegistryUrl(registryUrl) {
4391
+ function slugForRegistryUrlSync(registryUrl) {
4370
4392
  if (registryUrl === null || registryUrl === undefined) {
4371
4393
  return DEV_SLUG;
4372
4394
  }
4373
- const bytes = new TextEncoder().encode(registryUrl);
4374
- // Web Crypto exposes SHA-256 via crypto.subtle. Node 16+ exposes
4375
- // it through globalThis.crypto.subtle.
4376
- const digestBuffer = await crypto.subtle.digest("SHA-256", bytes);
4377
- const digestBytes = new Uint8Array(digestBuffer);
4378
- let hex = "";
4379
- for (const b of digestBytes) {
4380
- hex += b.toString(16).padStart(2, "0");
4395
+ let out = "";
4396
+ for (const ch of authorityOfRegistryUrl(registryUrl)) {
4397
+ const lc = ch >= "A" && ch <= "Z" ? ch.toLowerCase() : ch;
4398
+ out += (lc >= "a" && lc <= "z") || (lc >= "0" && lc <= "9") || lc === "." || lc === "-" ? lc : "-";
4381
4399
  }
4382
- return hex.slice(0, SLUG_HEX_LEN);
4400
+ return out;
4401
+ }
4402
+
4403
+ /**
4404
+ * Async wrapper retained for callers that `await` the slug. The computation is
4405
+ * synchronous (no hashing) — the authority transform needs no crypto.
4406
+ * @returns {Promise<string>}
4407
+ */
4408
+ async function slugForRegistryUrl(registryUrl) {
4409
+ return slugForRegistryUrlSync(registryUrl);
4383
4410
  }
4384
4411
 
4385
4412
  function isRegistrySlug(s) {
4386
4413
  return typeof s === "string"
4387
- && s.length === SLUG_HEX_LEN
4388
- && /^[0-9a-f]+$/.test(s);
4414
+ && s.length > 0
4415
+ && s !== DEV_SLUG
4416
+ && /^[a-z0-9.-]+$/.test(s);
4389
4417
  }
4390
4418
 
4391
4419
  // =============================================================================
@@ -4744,6 +4772,18 @@ class CartridgeRepoClient {
4744
4772
  if (data.schemaVersion !== '5.0') {
4745
4773
  throw new Error(`Cartridge registry from ${repoUrl} has schemaVersion '${data.schemaVersion}'; required: 5.0`);
4746
4774
  }
4775
+ // Cartridge registry regime version. v0 was the pre-versioning legacy at
4776
+ // the bare /manifest path; this speaks only v1 at /v1/manifest. A manifest
4777
+ // from a different regime version is rejected, not reinterpreted.
4778
+ if (data.registryVersion !== 1) {
4779
+ throw new Error(`Cartridge registry from ${repoUrl} has registryVersion '${data.registryVersion}'; this build speaks v1`);
4780
+ }
4781
+ // A cartridge registry must declare the fabric its caps resolve against. The
4782
+ // client cross-checks this equals its own fabric (so every registry it uses
4783
+ // shares one fabric); here we require it is at least present.
4784
+ if (typeof data.fabricRegistryUrl !== 'string' || data.fabricRegistryUrl.length === 0) {
4785
+ throw new Error(`Cartridge registry from ${repoUrl}: missing required 'fabricRegistryUrl' (the fabric its caps resolve against)`);
4786
+ }
4747
4787
  // Self-referential check: the manifest declares its own URL via
4748
4788
  // `registryUrl`. It must match the URL we just fetched from
4749
4789
  // byte-for-byte — a mismatch is a manifest-corruption signal
@@ -5021,6 +5061,12 @@ class CartridgeRepoServer {
5021
5061
  if (this.registry.schemaVersion !== '5.0') {
5022
5062
  throw new Error(`Unsupported registry schema version: ${this.registry.schemaVersion}. Required: 5.0`);
5023
5063
  }
5064
+ if (this.registry.registryVersion !== 1) {
5065
+ throw new Error(`Unsupported cartridge registry version: ${this.registry.registryVersion}. This build speaks v1.`);
5066
+ }
5067
+ if (typeof this.registry.fabricRegistryUrl !== 'string' || this.registry.fabricRegistryUrl.length === 0) {
5068
+ throw new Error('Registry must declare a non-empty `fabricRegistryUrl` (the fabric its caps resolve against)');
5069
+ }
5024
5070
  if (typeof this.registry.registryUrl !== 'string' || this.registry.registryUrl.length === 0) {
5025
5071
  throw new Error('Registry must have a non-empty top-level `registryUrl` field (self-referential URL)');
5026
5072
  }
@@ -5450,24 +5496,15 @@ class InstalledCartridgeRecord {
5450
5496
  /**
5451
5497
  * Synchronous on-disk slug for a registry URL. Mirrors
5452
5498
  * capdag::bifaci::cartridge_slug::slug_for byte-for-byte: `null`/`undefined`
5453
- * (a dev cartridge) → the literal `DEV_SLUG`; otherwise the first
5454
- * SLUG_HEX_LEN lowercase-hex characters of sha256(url bytes).
5455
- *
5456
- * The async `slugForRegistryUrl` above uses Web Crypto for browser parity;
5457
- * discovery runs only under Node and needs a synchronous slug in tight
5458
- * per-file loops, so this uses Node's synchronous `crypto.createHash`. Both
5459
- * produce identical output.
5499
+ * (a dev cartridge) → the literal `DEV_SLUG`; otherwise a path-safe transform
5500
+ * of the URL's authority (host[:port]). Identical output to the async
5501
+ * `slugForRegistryUrl` above — no hashing, so both are trivially in sync.
5460
5502
  *
5461
5503
  * @param {string|null|undefined} registryUrl
5462
5504
  * @returns {string}
5463
5505
  */
5464
5506
  function slugForSync(registryUrl) {
5465
- if (registryUrl === null || registryUrl === undefined) {
5466
- return DEV_SLUG;
5467
- }
5468
- const crypto = require('crypto');
5469
- const hex = crypto.createHash('sha256').update(registryUrl, 'utf8').digest('hex');
5470
- return hex.slice(0, SLUG_HEX_LEN);
5507
+ return slugForRegistryUrlSync(registryUrl);
5471
5508
  }
5472
5509
 
5473
5510
  /**
@@ -5838,14 +5875,21 @@ class DiscoveryIdentity {
5838
5875
  * @param {string|null} opts.registryUrl - Some(url) for release/nightly
5839
5876
  * hosts, null for dev hosts.
5840
5877
  * @param {number} opts.fabricManifestVersion
5878
+ * @param {number} opts.cartridgeRegistryVersion - The registry regime version
5879
+ * this host speaks; an on-disk PATH level ({slug}/v{N}/{channel}/…), pinned
5880
+ * like the channel so a v1 host never scans a v2 tree.
5841
5881
  */
5842
- constructor({ channel, registryUrl, fabricManifestVersion }) {
5882
+ constructor({ channel, registryUrl, fabricManifestVersion, cartridgeRegistryVersion }) {
5843
5883
  if (channel !== CartridgeChannel.Release && channel !== CartridgeChannel.Nightly) {
5844
5884
  throw new Error(`DiscoveryIdentity: invalid channel '${channel}'`);
5845
5885
  }
5886
+ if (!Number.isInteger(cartridgeRegistryVersion) || cartridgeRegistryVersion < 1) {
5887
+ throw new Error(`DiscoveryIdentity: cartridgeRegistryVersion must be an integer >= 1, got '${cartridgeRegistryVersion}'`);
5888
+ }
5846
5889
  this.channel = channel;
5847
5890
  this.registryUrl = registryUrl !== undefined ? registryUrl : null;
5848
5891
  this.fabricManifestVersion = fabricManifestVersion;
5892
+ this.cartridgeRegistryVersion = cartridgeRegistryVersion;
5849
5893
  }
5850
5894
 
5851
5895
  /** This host's own baked-registry slug (`dev` when registryUrl is null). */
@@ -6175,7 +6219,9 @@ async function discoverCartridges(cartridgesRoot, identity) {
6175
6219
  continue;
6176
6220
  }
6177
6221
  const expectedSlug = slugEntry.name;
6178
- const scanRoot = path.join(slugDir, identity.channel);
6222
+ // {slug}/v{cartridgeRegistryVersion}/{channel}/… — the registry regime
6223
+ // version is a path level pinned to the host's version (like the channel).
6224
+ const scanRoot = path.join(slugDir, `v${identity.cartridgeRegistryVersion}`, identity.channel);
6179
6225
  let scanStat;
6180
6226
  try { scanStat = fs.statSync(scanRoot); } catch (_e) { continue; }
6181
6227
  if (!scanStat.isDirectory()) continue;
@@ -6388,7 +6434,9 @@ async function scanChannelRoot(scanRoot, expectedSlug, identity, discovered) {
6388
6434
  // [alias cap:in="...";op=...;out="..."] — header (defines a cap with alias)
6389
6435
  // [src -> alias -> dst] — wiring (connects nodes via cap)
6390
6436
  // [(a, b) -> alias -> dst] — fan-in wiring
6391
- // [src -> LOOP alias -> dst] — loop wiring (ForEach semantics)
6437
+ //
6438
+ // Per-item map (`is_loop`) is a derived cardinality property, never authored
6439
+ // syntax — there is no LOOP marker in the grammar.
6392
6440
  // ============================================================================
6393
6441
 
6394
6442
  /**
@@ -6504,7 +6552,9 @@ class MachineEdge {
6504
6552
  */
6505
6553
  toString() {
6506
6554
  const sources = this.sources.map(s => s.toString()).join(', ');
6507
- const loopPrefix = this.isLoop ? 'LOOP ' : '';
6555
+ // Debug-only marker for a per-item map edge. Not notation syntax (the
6556
+ // `LOOP` keyword is retired); `is_loop` is a derived cardinality property.
6557
+ const loopPrefix = this.isLoop ? 'map ' : '';
6508
6558
  return `(${sources}) -${loopPrefix}${this.capUrn}-> ${this.target}`;
6509
6559
  }
6510
6560
  }
@@ -6703,13 +6753,13 @@ class Machine {
6703
6753
  const targetKey = edge.target.toString();
6704
6754
  const targetName = nodeNames.get(targetKey);
6705
6755
 
6706
- const loopPrefix = edge.isLoop ? 'LOOP ' : '';
6707
-
6756
+ // `is_loop` is NOT emitted into notation text: it is a derived cardinality
6757
+ // property, not authored syntax (the `LOOP` keyword is retired).
6708
6758
  if (sources.length === 1) {
6709
- output += `[${sources[0]} -> ${loopPrefix}${alias} -> ${targetName}]`;
6759
+ output += `[${sources[0]} -> ${alias} -> ${targetName}]`;
6710
6760
  } else {
6711
6761
  const group = sources.join(', ');
6712
- output += `[(${group}) -> ${loopPrefix}${alias} -> ${targetName}]`;
6762
+ output += `[(${group}) -> ${alias} -> ${targetName}]`;
6713
6763
  }
6714
6764
  }
6715
6765
 
@@ -6756,13 +6806,13 @@ class Machine {
6756
6806
  const targetKey = edge.target.toString();
6757
6807
  const targetName = nodeNames.get(targetKey);
6758
6808
 
6759
- const loopPrefix = edge.isLoop ? 'LOOP ' : '';
6760
-
6809
+ // `is_loop` is NOT emitted into notation text: it is a derived cardinality
6810
+ // property, not authored syntax (the `LOOP` keyword is retired).
6761
6811
  if (sources.length === 1) {
6762
- lines.push(`[${sources[0]} -> ${loopPrefix}${alias} -> ${targetName}]`);
6812
+ lines.push(`[${sources[0]} -> ${alias} -> ${targetName}]`);
6763
6813
  } else {
6764
6814
  const group = sources.join(', ');
6765
- lines.push(`[(${group}) -> ${loopPrefix}${alias} -> ${targetName}]`);
6815
+ lines.push(`[(${group}) -> ${alias} -> ${targetName}]`);
6766
6816
  }
6767
6817
  }
6768
6818
 
@@ -6814,13 +6864,13 @@ class Machine {
6814
6864
  const targetKey = edge.target.toString();
6815
6865
  const targetName = nodeNames.get(targetKey);
6816
6866
 
6817
- const loopPrefix = edge.isLoop ? 'LOOP ' : '';
6818
-
6867
+ // `is_loop` is NOT emitted into notation text: it is a derived cardinality
6868
+ // property, not authored syntax (the `LOOP` keyword is retired).
6819
6869
  if (sources.length === 1) {
6820
- lines.push(`${open}${sources[0]} -> ${loopPrefix}${alias} -> ${targetName}${close}`);
6870
+ lines.push(`${open}${sources[0]} -> ${alias} -> ${targetName}${close}`);
6821
6871
  } else {
6822
6872
  const group = sources.join(', ');
6823
- lines.push(`${open}(${group}) -> ${loopPrefix}${alias} -> ${targetName}${close}`);
6873
+ lines.push(`${open}(${group}) -> ${alias} -> ${targetName}${close}`);
6824
6874
  }
6825
6875
  }
6826
6876
 
@@ -6896,13 +6946,14 @@ class Machine {
6896
6946
 
6897
6947
  const sources = edge.sources.map(s => nodeNames.get(s.toString()));
6898
6948
  const targetName = nodeNames.get(edge.target.toString());
6899
- const loopPrefix = edge.isLoop ? 'LOOP ' : '';
6900
6949
 
6950
+ // `is_loop` is NOT emitted into notation text: it is a derived cardinality
6951
+ // property, not authored syntax (the `LOOP` keyword is retired).
6901
6952
  if (sources.length === 1) {
6902
- lines.push(`${open}${sources[0]} -> ${loopPrefix}${token} -> ${targetName}${close}`);
6953
+ lines.push(`${open}${sources[0]} -> ${token} -> ${targetName}${close}`);
6903
6954
  } else {
6904
6955
  const group = sources.join(', ');
6905
- lines.push(`${open}(${group}) -> ${loopPrefix}${token} -> ${targetName}${close}`);
6956
+ lines.push(`${open}(${group}) -> ${token} -> ${targetName}${close}`);
6906
6957
  }
6907
6958
  }
6908
6959
 
@@ -6990,7 +7041,8 @@ class Machine {
6990
7041
  * - Leaf targets: stadium-shaped nodes (rounded)
6991
7042
  * - Intermediate nodes: rectangular
6992
7043
  * - Edge labels: op= tag value (or full cap URN if no op)
6993
- * - LOOP edges: dotted line style with "LOOP" prefix on label
7044
+ * - Per-item map edges (`is_loop`): dotted line style — `is_loop` is a
7045
+ * derived render property, never the retired `LOOP` keyword
6994
7046
  * - Node labels: derived MediaUrn type
6995
7047
  *
6996
7048
  * @returns {string} Mermaid flowchart definition
@@ -7045,8 +7097,9 @@ class Machine {
7045
7097
  const srcName = nodeNames.get(srcKey);
7046
7098
 
7047
7099
  if (edge.isLoop) {
7048
- // Dotted line for LOOP edges
7049
- lines.push(` ${srcName} -. "LOOP ${label}" .-> ${targetName}`);
7100
+ // Dotted line renders the derived per-item map (`is_loop`) property.
7101
+ // No "LOOP" text — that keyword is retired.
7102
+ lines.push(` ${srcName} -. "${label}" .-> ${targetName}`);
7050
7103
  } else {
7051
7104
  lines.push(` ${srcName} -- "${label}" --> ${targetName}`);
7052
7105
  }
@@ -7164,7 +7217,6 @@ function _parseMachineInternal(input) {
7164
7217
  sources: stmt.sources,
7165
7218
  capAlias: stmt.capAlias,
7166
7219
  target: stmt.target,
7167
- isLoop: stmt.isLoop,
7168
7220
  position: i,
7169
7221
  location: stmt.location,
7170
7222
  sourceLocations: stmt.sourceLocations,
@@ -7286,7 +7338,13 @@ function _parseMachineInternal(input) {
7286
7338
  assignOrCheckNode(wiring.target, capOutMedia, nodeMedia, wiring.position,
7287
7339
  wiring.targetLocation || wiring.location);
7288
7340
 
7289
- edges.push(new MachineEdge(sourceUrns, capUrn, capOutMedia, wiring.isLoop));
7341
+ // `is_loop` is a derived cardinality property, not authored syntax (the
7342
+ // `LOOP` keyword is retired). The pure-JS parse path has no cap definitions
7343
+ // — it derives media only from the cap URN's in=/out= specs — so it cannot
7344
+ // evaluate the ForEach rule (a sequence source feeding a scalar-input cap).
7345
+ // Editors obtain the derived `is_loop` from the engine server's resolved
7346
+ // graph; here it defaults to false.
7347
+ edges.push(new MachineEdge(sourceUrns, capUrn, capOutMedia, false));
7290
7348
  }
7291
7349
 
7292
7350
  return {
@@ -7413,7 +7471,8 @@ class FabricRegistryEntry {
7413
7471
  constructor(data) {
7414
7472
  this.urn = data.urn;
7415
7473
  this.title = data.title || '';
7416
- this.command = data.command || '';
7474
+ this.aliases = data.aliases || [];
7475
+ this.isAbstract = data.abstract === true;
7417
7476
  this.description = data.cap_description || '';
7418
7477
  this.args = data.args || [];
7419
7478
  this.output = data.output || null;
@@ -7868,8 +7927,30 @@ class Manifest {
7868
7927
  }
7869
7928
  }
7870
7929
 
7930
+ // Unified configurable planner vocabulary (mirrors Rust plan_space.rs and the
7931
+ // PlanMachines/DiscoverConvergentTargets proto surface).
7932
+ const planner = require('./planner.js');
7933
+
7871
7934
  // Export for CommonJS
7872
7935
  module.exports = {
7936
+ // Planner plan-space vocabulary
7937
+ PlanStateError: planner.PlanStateError,
7938
+ ConvergencePresence: planner.ConvergencePresence,
7939
+ ConvergenceLocation: planner.ConvergenceLocation,
7940
+ ConvergenceMechanism: planner.ConvergenceMechanism,
7941
+ ConvergenceArity: planner.ConvergenceArity,
7942
+ DivergencePresence: planner.DivergencePresence,
7943
+ DivergenceLocation: planner.DivergenceLocation,
7944
+ RankPolicy: planner.RankPolicy,
7945
+ PlanMode: planner.PlanMode,
7946
+ PlanRequest: planner.PlanRequest,
7947
+ parsePlanApex: planner.parsePlanApex,
7948
+ parsePlanProfile: planner.parsePlanProfile,
7949
+ parsePlanCost: planner.parsePlanCost,
7950
+ parsePlanCandidate: planner.parsePlanCandidate,
7951
+ parsePlanCandidates: planner.parsePlanCandidates,
7952
+ parseConvergentTarget: planner.parseConvergentTarget,
7953
+ parseConvergentTargets: planner.parseConvergentTargets,
7873
7954
  ALIAS_TARGET_CAP,
7874
7955
  ALIAS_TARGET_MEDIA,
7875
7956
  tokenIsUrn,
@@ -8075,7 +8156,7 @@ module.exports = {
8075
8156
  discoverCartridges,
8076
8157
  // Registry slug
8077
8158
  DEV_SLUG,
8078
- SLUG_HEX_LEN,
8159
+ slugForRegistryUrlSync,
8079
8160
  slugForRegistryUrl,
8080
8161
  isRegistrySlug,
8081
8162
  // Machine notation