@neoloopy/cld-canvas 0.1.4 → 0.1.8

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 neoloopy
3
+ Copyright (c) 2026 Abraham Kim
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -472,21 +472,40 @@ import { buildMermaid, render } from "@neoloopy/cld-canvas";
472
472
  <sub>The same loop detection these helpers expose, surfaced in a neoloopy surface — a project-dynamics model with its reinforcing and balancing loops and the insight panel.</sub>
473
473
 
474
474
  ```ts
475
- import { LoopGraph, endogeneity } from "@neoloopy/cld-canvas";
475
+ import { LoopGraph, discoverCanvasLoops, endogeneity } from "@neoloopy/cld-canvas";
476
476
 
477
477
  const loopGraph = new LoopGraph(graph.nodes);
478
- const loops = loopGraph.detectLoops();
478
+ const qualitative = loopGraph.detectLoops();
479
+ const { loops, analysisError } = discoverCanvasLoops(graph.nodes, qualitative, {
480
+ manifest: graph.manifest,
481
+ });
479
482
  const metrics = loopGraph.metrics();
480
483
  const summary = endogeneity(graph.nodes, loops);
481
484
  ```
482
485
 
486
+ `discoverCanvasLoops` first enriches each declared qualitative loop whose whole
487
+ authored route resolves to exact visible connectors or material pipe legs, even
488
+ when quantitative analysis is unavailable or incomplete. It adds a synthesized
489
+ quantitative loop only when its complete executable route resolves just as
490
+ exactly.
491
+ Material endpoints may be explicit or use the unique, direct, signed legacy
492
+ flow-to-stock encoding already supported by SFD rendering. It preserves
493
+ qualitative loop identity and deduplicates exact counterparts. Quantitative
494
+ discovery failures surface as `analysisError` and add no partial quantitative
495
+ badge. A declared qualitative loop whose exact canvas route cannot be resolved
496
+ keeps its established CLD identity and badge but is excluded from SFD; that
497
+ qualitative exclusion by itself does not set `analysisError`. SFD retains every
498
+ exactly resolved qualitative loop; a quantitative-only SFD badge additionally
499
+ requires at least one first-class material leg.
500
+
483
501
  ## What is exported
484
502
 
485
503
  The package exports the public modules from `src/index.ts`, including:
486
504
 
487
505
  - Engine: `NativeEngine`, `NeoloopyEngine`, `MemoryStorage`, `VaultStorage`
488
506
  - Domain types: `VariableFile`, `VaultLink`, `ModelManifest`, `GraphView`
489
- - Graph logic: `LoopGraph`, `DetectedLoop`, `LoopType`, `labelLoopsByKey`
507
+ - Graph logic: `LoopGraph`, `DetectedLoop`, `LoopType`, `labelLoopsByKey`,
508
+ `discoverCanvasLoops`
490
509
  - Rendering: `paint`, `SceneCache`, `Camera`, `LIGHT`, `DARK`, geometry helpers
491
510
  - File codecs: `parseNote`, `serializeNote`, `manifestFromJson`
492
511
  - Exporters: `render`, `buildMermaid`, `loopNoteKey`
@@ -34,6 +34,8 @@ export interface GraphView {
34
34
  /** loop.key -> "R1" / "B2" badge label. */
35
35
  labels: Map<string, string>;
36
36
  quant: boolean;
37
+ /** Non-null when quantitative canvas-loop discovery could not complete. */
38
+ analysisError?: string | null;
37
39
  }
38
40
  export interface NewVariable {
39
41
  label: string;
@@ -158,7 +160,17 @@ export interface NeoloopyEngine {
158
160
  setEquation(folder: string, id: string, patch: QuantPatch): Promise<VariableFile>;
159
161
  /** Cosmetic position change — never bumps rev/modified. */
160
162
  moveVariable(folder: string, id: string, x: number, y: number): Promise<void>;
163
+ /** Cosmetic SFD-view position change (`extra.sfd`) — never bumps rev/modified. */
164
+ moveVariableSfd(folder: string, id: string, x: number, y: number): Promise<void>;
165
+ /**
166
+ * If a model has no authored SFD coordinates, persist the deterministic SFD
167
+ * fallback positions into `extra.sfd` so the first manual SFD edit does not
168
+ * make the diagram jump back to CLD coordinates.
169
+ */
170
+ pinSfdLayout(folder: string): Promise<void>;
161
171
  removeVariable(folder: string, id: string): Promise<void>;
172
+ /** Validated SFD material topology write for a flow note (`extra.flow`). */
173
+ setFlowEndpoints(folder: string, flowId: string, from: string, to: string): Promise<void>;
162
174
  /**
163
175
  * Link (or clear, with `child=null`) a node's subsystem anchor — the same
164
176
  * `[[../<childDir>/System|<Child Name>]]` wikilink the app/CLI write, so the
@@ -177,10 +189,10 @@ export interface NeoloopyEngine {
177
189
  export(folder: string, format: ExportFormat): Promise<Rendered>;
178
190
  /**
179
191
  * Loop notes — one `Loops/<slug>.md` file per feedback loop, identity-anchored
180
- * in frontmatter (the same files the Dart app/CLI write). Resolved into a map
181
- * keyed by `<R|B>:<sorted unique variable names>` so both surfaces agree.
182
- * `getLoopNotes` returns the body text per live loop; `setLoopNote` writes it
183
- * into the identity-matched file (preserving title/valence/archetype).
192
+ * in frontmatter (the same files the Dart app/CLI write). Qualitative entries
193
+ * retain the established `<R|B>:<sorted unique variable names>` map key;
194
+ * quantitative-only entries use their exact directed `loop.key` so distinct
195
+ * routes cannot collide. `setLoopNote` accepts that one resolved key.
184
196
  */
185
197
  getLoopNotes(folder: string): Promise<Record<string, string>>;
186
198
  setLoopNote(folder: string, key: string, text: string): Promise<void>;
@@ -16,7 +16,7 @@ export interface ExportEdge {
16
16
  id?: string;
17
17
  source: string;
18
18
  target: string;
19
- polarity?: number;
19
+ polarity?: number | "?";
20
20
  delay?: boolean;
21
21
  curvature?: number;
22
22
  dashed?: boolean;
@@ -26,7 +26,7 @@ export function buildMermaid(nodes, edges) {
26
26
  const t = key.get(String(e.target));
27
27
  if (!s || !t)
28
28
  continue;
29
- const sym = (e.polarity ?? 1) >= 0 ? "+" : "−";
29
+ const sym = e.polarity === "?" ? "?" : (e.polarity ?? 1) >= 0 ? "+" : "−";
30
30
  const arrow = e.delay === true ? "-.->" : "-->";
31
31
  lines.push(` ${s} ${arrow}|${sym}| ${t}`);
32
32
  }
@@ -23,9 +23,9 @@ export interface VarMetrics {
23
23
  loopCount: number;
24
24
  }
25
25
  /**
26
- * Deterministic loop labels: reinforcing -> R1,R2,… and balancing -> B1,B2,…,
27
- * ordered within each type by case-insensitive sorted variable names. Returns a
28
- * map from `DetectedLoop.key` to its label.
26
+ * Deterministic loop labels: reinforcing -> R1,R2,… and balancing -> B1,B2,….
27
+ * Existing qualitative loops keep their historical ordering and numbering;
28
+ * quantitative-only loops follow them in the same deterministic name order.
29
29
  */
30
30
  export declare function labelLoopsByKey(loops: DetectedLoop[], name: (id: string) => string): Map<string, string>;
31
31
  export declare class LoopGraph {
@@ -37,7 +37,11 @@ export declare class LoopGraph {
37
37
  adjacency(reverse?: boolean): Map<string, TracedEdge[]>;
38
38
  /** Direct (loop-relevant) adjacency: source -> [target, polaritySign]. */
39
39
  private directAdjacency;
40
- /** All simple directed cycles, R/B classified, deduped to one per node-set. */
40
+ /**
41
+ * All simple directed cycles, R/B classified, deduped by the legacy node
42
+ * set. When distinct directed routes collapse to that compatibility key, the
43
+ * retained CLD loop is marked ambiguous so SFD resolution can fail closed.
44
+ */
41
45
  detectLoops(): DetectedLoop[];
42
46
  /**
43
47
  * The Shortest Independent Loop Set (SILS): the minimum-weight basis of the
Binary file
@@ -1,3 +1,4 @@
1
+ import { DetectedLoop } from "./types";
1
2
  /**
2
3
  * The resolved-map key for a feedback loop: `<R|B>:<sorted unique member
3
4
  * labels>`. This is the key every UI consumer indexes loop notes by; it is NOT
@@ -9,6 +10,14 @@
9
10
  * its own copy.
10
11
  */
11
12
  export declare function loopKey(labels: string[], type: string): string;
13
+ /**
14
+ * The one UI/engine lookup key for a detected loop note. Existing qualitative
15
+ * loops, including a qualitative counterpart enriched with a quantitative
16
+ * canvas path, retain the sorted-label key shared with earlier releases.
17
+ * Quantitative-only loops use exact directed id identity so different routes
18
+ * through the same displayed members remain independent.
19
+ */
20
+ export declare function resolvedLoopNoteKey(loop: DetectedLoop, nameOf: (id: string) => string): string;
12
21
  /**
13
22
  * Human-readable, non-link echo for a loop note's `loop:` frontmatter field:
14
23
  * `<R|B> · <sorted unique labels joined by " | ">`. Same membership rule as
@@ -1,3 +1,4 @@
1
+ import { LoopType } from "./types";
1
2
  /**
2
3
  * The resolved-map key for a feedback loop: `<R|B>:<sorted unique member
3
4
  * labels>`. This is the key every UI consumer indexes loop notes by; it is NOT
@@ -13,6 +14,19 @@ export function loopKey(labels, type) {
13
14
  const uniq = [...new Set(labels.map((l) => String(l)))].sort();
14
15
  return `${letter}:${uniq.join("|")}`;
15
16
  }
17
+ /**
18
+ * The one UI/engine lookup key for a detected loop note. Existing qualitative
19
+ * loops, including a qualitative counterpart enriched with a quantitative
20
+ * canvas path, retain the sorted-label key shared with earlier releases.
21
+ * Quantitative-only loops use exact directed id identity so different routes
22
+ * through the same displayed members remain independent.
23
+ */
24
+ export function resolvedLoopNoteKey(loop, nameOf) {
25
+ if (loop.identityMode === "quantitative")
26
+ return loop.exactKey;
27
+ const type = loop.type === LoopType.reinforcing ? "R" : "B";
28
+ return loopKey(loop.nodeIds.map(nameOf), type);
29
+ }
16
30
  /**
17
31
  * Human-readable, non-link echo for a loop note's `loop:` frontmatter field:
18
32
  * `<R|B> · <sorted unique labels joined by " | ">`. Same membership rule as
@@ -85,13 +85,17 @@ export declare class NativeEngine implements NeoloopyEngine {
85
85
  name: string;
86
86
  } | null): Promise<void>;
87
87
  moveVariable(folder: string, id: string, x: number, y: number): Promise<void>;
88
+ moveVariableSfd(folder: string, id: string, x: number, y: number): Promise<void>;
89
+ pinSfdLayout(folder: string): Promise<void>;
88
90
  removeVariable(folder: string, id: string): Promise<void>;
91
+ setFlowEndpoints(folder: string, flowId: string, from: string, to: string): Promise<void>;
89
92
  addLink(folder: string, from: string, to: string, init?: LinkInit): Promise<void>;
90
93
  updateLink(folder: string, from: string, to: string, patch: LinkPatch): Promise<void>;
91
94
  removeLink(folder: string, from: string, to: string): Promise<void>;
92
95
  buildModel(folder: string, spec: BuildSpec): Promise<void>;
93
96
  relayout(folder: string): Promise<void>;
94
97
  setViewport(folder: string, viewport: Viewport): Promise<void>;
98
+ private liveLoopContext;
95
99
  getLoopNotes(folder: string): Promise<Record<string, string>>;
96
100
  setLoopNote(folder: string, key: string, text: string): Promise<void>;
97
101
  /**
@@ -112,6 +116,8 @@ export declare class NativeEngine implements NeoloopyEngine {
112
116
  /** Pre-`Nodes/` location of a variable note, flat at the model root. */
113
117
  private legacyNotePath;
114
118
  private readNote;
119
+ private removeNoteById;
120
+ private detachStockFlowEndpoints;
115
121
  private writeNote;
116
122
  private readManifest;
117
123
  private writeManifest;
@@ -136,13 +142,13 @@ export declare class NativeEngine implements NeoloopyEngine {
136
142
  * One-way, idempotent migration of legacy loop annotations from the
137
143
  * `model.json` maps into `Loops/*.md` files. Guard: if any `Loops/` file
138
144
  * exists the model is treated as migrated and this returns immediately. Each
139
- * legacy key is matched to a live loop by its `loopKey`; a matched key keeps
145
+ * legacy key is matched to a live loop by its resolved note key; a match keeps
140
146
  * the loop's canonical member ids, an unmatched key becomes a pre-flagged
141
147
  * orphan (`members: []`, original key kept in `loop:`) so nothing is lost.
142
148
  * After writing, the four legacy keys are stripped from the manifest.
143
149
  */
144
150
  private migrateLoopNotesIfNeeded;
145
- /** Find the live loop whose `loopKey` == `key`, then upsert its file. No-op
151
+ /** Find the live loop whose resolved note key == `key`, then upsert its file. No-op
146
152
  * (and no file) when the graph carries no such loop. */
147
153
  private upsertLoopFileByKey;
148
154
  /** Find-or-create the identity-matched `Loops/` file and apply `fields`
@@ -21,20 +21,25 @@
21
21
  */
22
22
  import { emptyVariable, manifestFromJson, manifestToJson, normalizeBasis, normalizeConfidence, } from "./types";
23
23
  import { LoopGraph, labelLoopsByKey } from "./loopGraph";
24
+ import { discoverCanvasLoops } from "./quantCanvasLoops";
24
25
  import { parseNote, serializeNote, splitFrontmatter } from "./noteCodec";
25
26
  import { noteSlug, noteUnslug } from "./noteNaming";
26
27
  import { canonicalLoopMembers, loopMatchesNote, loopSlug, parseLoopNote, serializeLoopNote, } from "./loopNote";
27
28
  import { contentSignature, stampMeta, WRITE_SOURCE_PLUGIN } from "./specHash";
28
- import { loopEchoLabel, loopKey } from "./loopKey";
29
+ import { loopEchoLabel, resolvedLoopNoteKey } from "./loopKey";
29
30
  import { autoLayout } from "./layout";
30
31
  import { render, } from "./exporters";
31
32
  import { LoopType } from "./types";
32
33
  import { baseName, joinPath, parentPath, } from "./storage";
33
34
  import { deriveParentAnchors, linkPointsToModel, parseSubsystemLink, } from "./subsystemLinks";
34
35
  import { isPublicInput, isPublicOutput, } from "./publicInterface";
36
+ import { SINK_CLOUD, SOURCE_CLOUD, extraWithFlow, extraWithSfdPosition, extraWithoutFlow, flowOf, hasAuthoredSfd, sfdPositionsFor, validateFlowEndpoints, } from "./sfd";
35
37
  /** Non-variable notes that share a model folder. */
36
38
  const SPECIAL_NOTES = new Set(["System.md", "Futures.md", "CLA.md"]);
37
39
  const MAX_SCAN_DEPTH = 12;
40
+ function loopTypeLetter(loop) {
41
+ return loop.type === LoopType.reinforcing ? "R" : "B";
42
+ }
38
43
  export class NativeEngine {
39
44
  constructor(storage, yaml, opts) {
40
45
  this.storage = storage;
@@ -70,10 +75,19 @@ export class NativeEngine {
70
75
  const manifest = await this.readManifest(folder);
71
76
  const nodes = await this.loadNotes(folder);
72
77
  const graph = new LoopGraph(nodes);
73
- const loops = graph.detectLoops();
78
+ const discovered = discoverCanvasLoops(nodes, graph.detectLoops(), { manifest });
79
+ const loops = discovered.loops;
74
80
  const labels = labelLoopsByKey(loops, (id) => graph.node(id)?.label ?? id);
75
81
  const quant = manifestIsQuant(manifest) || nodes.some((n) => "quant" in n.extra);
76
- return { folder, manifest, nodes, loops, labels, quant };
82
+ return {
83
+ folder,
84
+ manifest,
85
+ nodes,
86
+ loops,
87
+ labels,
88
+ quant,
89
+ analysisError: discovered.analysisError,
90
+ };
77
91
  }
78
92
  // ---- model lifecycle -----------------------------------------------------
79
93
  async createModel(name) {
@@ -306,7 +320,13 @@ export class NativeEngine {
306
320
  next.tags = [...patch.tags];
307
321
  if (patch.body !== undefined)
308
322
  next.body = patch.body;
323
+ if (patch.type !== undefined && patch.type !== "flow" && prev.type === "flow") {
324
+ next.extra = extraWithoutFlow(next.extra);
325
+ }
309
326
  const out = await this.writeNote(folder, prev, next);
327
+ if (prev.type === "stock" && patch.type !== undefined && patch.type !== "stock") {
328
+ await this.detachStockFlowEndpoints(folder, prev.id);
329
+ }
310
330
  await this.touchManifest(folder);
311
331
  return out;
312
332
  }
@@ -351,26 +371,94 @@ export class NativeEngine {
351
371
  // stampMeta treats an x/y-only change as cosmetic → rev/modified preserved.
352
372
  await this.writeNote(folder, prev, { ...prev, x, y });
353
373
  }
354
- async removeVariable(folder, id) {
355
- // The note is named by its label now, so resolve its real path by id; also
356
- // clear any stale id-named copies (a legacy flat note or an old Nodes/<id>.md).
357
- const resolved = (await this.noteFilesById(folder)).get(id);
358
- if (resolved)
359
- await this.storage.remove(resolved);
360
- for (const p of [this.notePath(folder, id), this.legacyNotePath(folder, id)]) {
361
- if (p !== resolved)
362
- await this.storage.remove(p);
363
- }
364
- // Drop inbound links so loop detection and the manifest stay consistent.
374
+ async moveVariableSfd(folder, id, x, y) {
375
+ const prev = await this.readNote(folder, id);
376
+ await this.writeNote(folder, prev, {
377
+ ...prev,
378
+ extra: extraWithSfdPosition(prev.extra, x, y),
379
+ });
380
+ }
381
+ async pinSfdLayout(folder) {
365
382
  const notes = await this.loadNotes(folder);
383
+ if (hasAuthoredSfd(notes))
384
+ return;
385
+ const pos = sfdPositionsFor(notes);
366
386
  for (const n of notes) {
367
- if (n.links.some((l) => l.to === id)) {
387
+ const p = pos.get(n.id);
388
+ if (p) {
368
389
  await this.writeNote(folder, n, {
369
390
  ...n,
370
- links: n.links.filter((l) => l.to !== id),
391
+ extra: extraWithSfdPosition(n.extra, p.x, p.y),
371
392
  });
372
393
  }
373
394
  }
395
+ }
396
+ async removeVariable(folder, id) {
397
+ const notes = await this.loadNotes(folder);
398
+ const removed = notes.find((n) => n.id === id);
399
+ const removeIds = new Set([id]);
400
+ if (removed?.type === "stock") {
401
+ for (const n of notes) {
402
+ if (n.type !== "flow" || n.id === id)
403
+ continue;
404
+ const spec = flowOf(n);
405
+ if (!spec || (spec.from !== id && spec.to !== id))
406
+ continue;
407
+ const from = spec.from === id ? SOURCE_CLOUD : spec.from;
408
+ const to = spec.to === id ? SINK_CLOUD : spec.to;
409
+ if (from === SOURCE_CLOUD && to === SINK_CLOUD)
410
+ removeIds.add(n.id);
411
+ }
412
+ }
413
+ for (const rid of removeIds)
414
+ await this.removeNoteById(folder, rid);
415
+ // Drop inbound links to removed notes and recloud flow endpoints that touched
416
+ // a removed stock, so the SFD topology remains valid after deletion.
417
+ for (const n of notes) {
418
+ if (removeIds.has(n.id))
419
+ continue;
420
+ let changed = false;
421
+ const links = n.links.filter((l) => !removeIds.has(l.to));
422
+ let next = n;
423
+ if (links.length !== n.links.length) {
424
+ next = { ...next, links };
425
+ changed = true;
426
+ }
427
+ if (removed?.type === "stock" && n.type === "flow") {
428
+ const spec = flowOf(n);
429
+ if (spec && (spec.from === id || spec.to === id)) {
430
+ next = {
431
+ ...next,
432
+ extra: extraWithFlow(next.extra, {
433
+ from: spec.from === id ? SOURCE_CLOUD : spec.from,
434
+ to: spec.to === id ? SINK_CLOUD : spec.to,
435
+ }),
436
+ };
437
+ changed = true;
438
+ }
439
+ }
440
+ if (changed)
441
+ await this.writeNote(folder, n, next);
442
+ }
443
+ await this.touchManifest(folder);
444
+ }
445
+ async setFlowEndpoints(folder, flowId, from, to) {
446
+ const flow = await this.readNote(folder, flowId);
447
+ if (flow.type !== "flow")
448
+ throw new Error(`Flow endpoints apply to flow variables only: ${flow.label || flow.id}`);
449
+ const notes = await this.loadNotes(folder);
450
+ const byId = new Map(notes.map((n) => [n.id, n]));
451
+ const check = validateFlowEndpoints(from, to, byId);
452
+ if (!check.ok)
453
+ throw new Error(check.error);
454
+ const next = {
455
+ ...flow,
456
+ extra: extraWithFlow(flow.extra, { from, to }),
457
+ // Once the pipe is explicit, legacy flow->stock links become redundant
458
+ // material edges. Preserve all remaining information connectors.
459
+ links: flow.links.filter((l) => byId.get(l.to)?.type !== "stock"),
460
+ };
461
+ await this.writeNote(folder, flow, next);
374
462
  await this.touchManifest(folder);
375
463
  }
376
464
  // ---- links ---------------------------------------------------------------
@@ -446,8 +534,18 @@ export class NativeEngine {
446
534
  // filename or the labels. The legacy `model.json` maps (loopNotes/loopTitles/
447
535
  // loopValence/loopArchetypes) are auto-migrated to files on first touch. This
448
536
  // mirrors `core/lib/cli/vault_engine.dart` so the plugin and the app/CLI agree
449
- // on one vault. Resolved maps stay keyed by the legacy `<R|B>:<sorted labels>`
450
- // key (see `loopKey`) so existing UI consumers are unchanged.
537
+ // on one vault. Qualitative resolved maps retain the legacy sorted-label key;
538
+ // quantitative-only loops use their exact directed id so routes cannot fold.
539
+ async liveLoopContext(folder) {
540
+ const manifest = await this.readManifest(folder);
541
+ const nodes = await this.loadNotes(folder);
542
+ const graph = new LoopGraph(nodes);
543
+ const loops = discoverCanvasLoops(nodes, graph.detectLoops(), { manifest }).loops;
544
+ return {
545
+ loops,
546
+ nameOf: (id) => graph.node(id)?.label ?? id,
547
+ };
548
+ }
451
549
  async getLoopNotes(folder) {
452
550
  await this.migrateLoopNotesIfNeeded(folder);
453
551
  const files = await this.listLoopNoteFiles(folder);
@@ -461,12 +559,11 @@ export class NativeEngine {
461
559
  }
462
560
  return out;
463
561
  }
464
- const graph = new LoopGraph(await this.loadNotes(folder));
465
- const nameOf = (id) => graph.node(id)?.label ?? id;
562
+ const { loops, nameOf } = await this.liveLoopContext(folder);
466
563
  const out = {};
467
564
  const matched = new Set();
468
- for (const l of graph.detectLoops()) {
469
- const type = l.type === LoopType.reinforcing ? "R" : "B";
565
+ for (const l of loops) {
566
+ const type = loopTypeLetter(l);
470
567
  let note;
471
568
  for (const f of files) {
472
569
  if (matched.has(f))
@@ -481,7 +578,7 @@ export class NativeEngine {
481
578
  if (!note)
482
579
  continue;
483
580
  if (note.body.trim().length > 0) {
484
- out[loopKey(l.nodeIds.map(nameOf), type)] = note.body;
581
+ out[resolvedLoopNoteKey(l, nameOf)] = note.body;
485
582
  }
486
583
  }
487
584
  return out;
@@ -497,23 +594,21 @@ export class NativeEngine {
497
594
  */
498
595
  async loopNotePath(folder, key) {
499
596
  await this.migrateLoopNotesIfNeeded(folder);
500
- const graph = new LoopGraph(await this.loadNotes(folder));
501
- const nameOf = (id) => graph.node(id)?.label ?? id;
502
- for (const l of graph.detectLoops()) {
503
- const type = l.type === LoopType.reinforcing ? "R" : "B";
504
- const memberLabels = l.nodeIds.map(nameOf);
505
- if (loopKey(memberLabels, type) !== key)
506
- continue;
507
- for (const f of await this.listLoopNoteFiles(folder)) {
508
- const parsed = parseLoopNote((await this.readLoopNoteFile(folder, f)) ?? "", this.yaml);
509
- if (loopMatchesNote(type, l.nodeIds, parsed)) {
510
- return joinPath(this.loopsDir(folder), f);
511
- }
597
+ const { loops, nameOf } = await this.liveLoopContext(folder);
598
+ const matches = loops.filter((loop) => resolvedLoopNoteKey(loop, nameOf) === key);
599
+ if (matches.length !== 1)
600
+ return null;
601
+ const l = matches[0];
602
+ const type = loopTypeLetter(l);
603
+ const memberLabels = l.nodeIds.map(nameOf);
604
+ for (const f of await this.listLoopNoteFiles(folder)) {
605
+ const parsed = parseLoopNote((await this.readLoopNoteFile(folder, f)) ?? "", this.yaml);
606
+ if (loopMatchesNote(type, l.nodeIds, parsed)) {
607
+ return joinPath(this.loopsDir(folder), f);
512
608
  }
513
- const file = await this.writeLoopFile(folder, l.nodeIds, type, memberLabels, {});
514
- return joinPath(this.loopsDir(folder), file);
515
609
  }
516
- return null;
610
+ const file = await this.writeLoopFile(folder, l.nodeIds, type, memberLabels, {});
611
+ return joinPath(this.loopsDir(folder), file);
517
612
  }
518
613
  async ensureSystemNote(folder) {
519
614
  const path = joinPath(folder, "System.md");
@@ -585,6 +680,45 @@ export class NativeEngine {
585
680
  const path = (await this.noteFilesById(folder)).get(id) ?? this.notePath(folder, id);
586
681
  return parseNote(await this.storage.read(path), this.yaml, id);
587
682
  }
683
+ async removeNoteById(folder, id) {
684
+ const resolved = (await this.noteFilesById(folder)).get(id);
685
+ if (resolved)
686
+ await this.storage.remove(resolved);
687
+ for (const p of [this.notePath(folder, id), this.legacyNotePath(folder, id)]) {
688
+ if (p !== resolved)
689
+ await this.storage.remove(p);
690
+ }
691
+ }
692
+ async detachStockFlowEndpoints(folder, stockId) {
693
+ const notes = await this.loadNotes(folder);
694
+ const removeIds = new Set();
695
+ for (const n of notes) {
696
+ if (n.type !== "flow")
697
+ continue;
698
+ const spec = flowOf(n);
699
+ if (!spec || (spec.from !== stockId && spec.to !== stockId))
700
+ continue;
701
+ const from = spec.from === stockId ? SOURCE_CLOUD : spec.from;
702
+ const to = spec.to === stockId ? SINK_CLOUD : spec.to;
703
+ if (from === SOURCE_CLOUD && to === SINK_CLOUD) {
704
+ removeIds.add(n.id);
705
+ }
706
+ else {
707
+ await this.writeNote(folder, n, { ...n, extra: extraWithFlow(n.extra, { from, to }) });
708
+ }
709
+ }
710
+ if (removeIds.size === 0)
711
+ return;
712
+ for (const id of removeIds)
713
+ await this.removeNoteById(folder, id);
714
+ for (const n of notes) {
715
+ if (removeIds.has(n.id))
716
+ continue;
717
+ const links = n.links.filter((l) => !removeIds.has(l.to));
718
+ if (links.length !== n.links.length)
719
+ await this.writeNote(folder, n, { ...n, links });
720
+ }
721
+ }
588
722
  async writeNote(folder, prev, next) {
589
723
  const stamped = stampMeta(prev, next, WRITE_SOURCE_PLUGIN);
590
724
  // The file is named after the label (so the vault reads like the diagram);
@@ -739,7 +873,7 @@ export class NativeEngine {
739
873
  * One-way, idempotent migration of legacy loop annotations from the
740
874
  * `model.json` maps into `Loops/*.md` files. Guard: if any `Loops/` file
741
875
  * exists the model is treated as migrated and this returns immediately. Each
742
- * legacy key is matched to a live loop by its `loopKey`; a matched key keeps
876
+ * legacy key is matched to a live loop by its resolved note key; a match keeps
743
877
  * the loop's canonical member ids, an unmatched key becomes a pre-flagged
744
878
  * orphan (`members: []`, original key kept in `loop:`) so nothing is lost.
745
879
  * After writing, the four legacy keys are stripped from the manifest.
@@ -771,16 +905,15 @@ export class NativeEngine {
771
905
  ...Object.keys(valence),
772
906
  ...Object.keys(archetypes),
773
907
  ]);
774
- const graph = new LoopGraph(await this.loadNotes(folder));
775
- const nameOf = (id) => graph.node(id)?.label ?? id;
908
+ const { loops, nameOf } = await this.liveLoopContext(folder);
776
909
  const liveByKey = new Map();
777
- for (const l of graph.detectLoops()) {
778
- const type = l.type === LoopType.reinforcing ? "R" : "B";
779
- liveByKey.set(loopKey(l.nodeIds.map(nameOf), type), l);
910
+ for (const l of loops) {
911
+ const key = resolvedLoopNoteKey(l, nameOf);
912
+ liveByKey.set(key, liveByKey.has(key) ? null : l);
780
913
  }
781
914
  for (const key of allKeys) {
782
915
  const type = key.startsWith("B") ? "B" : "R";
783
- const live = liveByKey.get(key);
916
+ const live = liveByKey.get(key) ?? undefined;
784
917
  const members = live ? canonicalLoopMembers(live.nodeIds) : [];
785
918
  const labels = live
786
919
  ? live.nodeIds.map(nameOf)
@@ -810,20 +943,16 @@ export class NativeEngine {
810
943
  modified: new Date().toISOString(),
811
944
  });
812
945
  }
813
- /** Find the live loop whose `loopKey` == `key`, then upsert its file. No-op
946
+ /** Find the live loop whose resolved note key == `key`, then upsert its file. No-op
814
947
  * (and no file) when the graph carries no such loop. */
815
948
  async upsertLoopFileByKey(folder, key, fields) {
816
949
  await this.migrateLoopNotesIfNeeded(folder);
817
- const graph = new LoopGraph(await this.loadNotes(folder));
818
- const nameOf = (id) => graph.node(id)?.label ?? id;
819
- for (const l of graph.detectLoops()) {
820
- const type = l.type === LoopType.reinforcing ? "R" : "B";
821
- const memberLabels = l.nodeIds.map(nameOf);
822
- if (loopKey(memberLabels, type) !== key)
823
- continue;
824
- await this.writeLoopFile(folder, l.nodeIds, type, memberLabels, fields);
950
+ const { loops, nameOf } = await this.liveLoopContext(folder);
951
+ const matches = loops.filter((loop) => resolvedLoopNoteKey(loop, nameOf) === key);
952
+ if (matches.length !== 1)
825
953
  return;
826
- }
954
+ const loop = matches[0];
955
+ await this.writeLoopFile(folder, loop.nodeIds, loopTypeLetter(loop), loop.nodeIds.map(nameOf), fields);
827
956
  }
828
957
  /** Find-or-create the identity-matched `Loops/` file and apply `fields`
829
958
  * (undefined leaves a field unchanged; `archetype: ""` clears it). Returns the
@@ -1007,10 +1136,18 @@ export class NativeEngine {
1007
1136
  // ---- pure helpers ----------------------------------------------------------
1008
1137
  /** A copy of `v` with its id and every link target re-pointed through `idMap`. */
1009
1138
  function remapVariableIds(v, idMap) {
1139
+ const flow = flowOf(v);
1140
+ const extra = flow
1141
+ ? extraWithFlow(v.extra, {
1142
+ from: idMap.get(flow.from) ?? flow.from,
1143
+ to: idMap.get(flow.to) ?? flow.to,
1144
+ })
1145
+ : v.extra;
1010
1146
  return {
1011
1147
  ...v,
1012
1148
  id: idMap.get(v.id) ?? v.id,
1013
1149
  links: v.links.map((l) => ({ ...l, to: idMap.get(l.to) ?? l.to })),
1150
+ extra,
1014
1151
  };
1015
1152
  }
1016
1153
  function makeLink(to, init) {
@@ -1074,7 +1211,7 @@ function toExportGraph(view) {
1074
1211
  id: `${n.id}__${l.to}`,
1075
1212
  source: n.id,
1076
1213
  target: l.to,
1077
- polarity: l.polarity === "-" ? -1 : 1,
1214
+ polarity: l.polarity === "-" ? -1 : l.polarity === "+" ? 1 : "?",
1078
1215
  delay: l.delay,
1079
1216
  curvature: l.curvature,
1080
1217
  dashed: l.indirect,