amalgm 0.1.246 → 0.1.247

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +5 -3
  2. package/lib/cli.js +92 -4
  3. package/lib/shared-realtime-tunnel.js +82 -19
  4. package/package.json +1 -1
  5. package/runtime/scripts/amalgm-mcp/automations/deliveries.js +276 -0
  6. package/runtime/scripts/amalgm-mcp/automations/rest.js +51 -0
  7. package/runtime/scripts/amalgm-mcp/automations/scheduler.js +11 -67
  8. package/runtime/scripts/amalgm-mcp/automations/store.js +67 -20
  9. package/runtime/scripts/amalgm-mcp/events/ingress.js +69 -215
  10. package/runtime/scripts/amalgm-mcp/events/processor.js +189 -0
  11. package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +41 -0
  12. package/runtime/scripts/amalgm-mcp/lib/email-md.js +107 -1
  13. package/runtime/scripts/amalgm-mcp/lib/generated/email-render.json +10 -2
  14. package/runtime/scripts/amalgm-mcp/observer/README.md +8 -6
  15. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +262 -22
  16. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +86 -0
  17. package/runtime/scripts/amalgm-mcp/registration/index.js +4 -1
  18. package/runtime/scripts/amalgm-mcp/registration/service.js +102 -0
  19. package/runtime/scripts/amalgm-mcp/registration/tree.js +19 -5
  20. package/runtime/scripts/amalgm-mcp/registry/index.js +16 -1
  21. package/runtime/scripts/amalgm-mcp/registry/store.js +58 -7
  22. package/runtime/scripts/amalgm-mcp/server/routes/automations.js +8 -0
  23. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +56 -0
  24. package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +14 -0
  25. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +1 -1
  26. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +2 -2
  27. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/citations.html +5 -5
  28. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/links.html +3 -3
  29. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/notification-shell.html +1 -1
  30. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/share-invite.html +13 -0
  31. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/text.html +1 -1
  32. package/runtime/scripts/amalgm-mcp/tests/email-render.test.js +17 -1
  33. package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +189 -0
  34. package/runtime/scripts/amalgm-mcp/tests/entity-content.test.js +126 -0
  35. package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +118 -0
  36. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +126 -11
  37. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +20 -6
  38. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +8 -0
  39. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +8 -1
  40. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +630 -102
  41. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +45 -10
  42. package/runtime/scripts/amalgm-mcp/tests/workspace-materialize.test.js +54 -0
  43. package/runtime/scripts/amalgm-mcp/workspace/materialize.js +292 -0
  44. package/runtime/scripts/chat-core/contract.js +21 -3
  45. package/runtime/scripts/chat-core/engine.js +8 -0
  46. package/runtime/scripts/chat-core/server.js +11 -1
  47. package/runtime/scripts/chat-core/tests/workspace-preflight.test.js +116 -0
  48. package/runtime/scripts/chat-core/workspace-preflight.js +89 -0
  49. package/runtime/scripts/local-gateway.js +0 -1
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pure delivery-to-run transition for a durable event inbox row.
5
+ *
6
+ * The HTTP route never calls this directly before persistence. A thrown error
7
+ * means the delivery is retryable; returned terminalStatus values mean the
8
+ * immutable input has reached a terminal disposition.
9
+ */
10
+
11
+ const {
12
+ extractSignature,
13
+ matchAllBySignature,
14
+ matchBySignature,
15
+ matchesEventRef,
16
+ pickSourceLabel,
17
+ collectPassthroughHeaders,
18
+ } = require('./matcher');
19
+ const {
20
+ listEventTriggersForIngress,
21
+ markTriggerFired,
22
+ recordAutomationRun,
23
+ } = require('../automations/store');
24
+ const { executeAutomationForTrigger } = require('../automations/runner');
25
+ const ring = require('./ring-buffer');
26
+
27
+ function cleanRefPart(value) {
28
+ if (typeof value !== 'string') return '';
29
+ const trimmed = value.trim();
30
+ if (!trimmed || trimmed.length > 128) return '';
31
+ return trimmed;
32
+ }
33
+
34
+ function dayStamp(date = new Date()) {
35
+ return date.toISOString().slice(0, 10).replace(/-/g, '');
36
+ }
37
+
38
+ function recordUnmatchedEventRuns(authenticated, eventSource, eventName, timestamp) {
39
+ const refSlug = `${eventSource}.${eventName}`.toLowerCase().replace(/[^a-z0-9._-]+/g, '_').slice(0, 80);
40
+ const seenAutomations = new Set();
41
+ for (const trigger of authenticated) {
42
+ if (!trigger.automationId || seenAutomations.has(trigger.automationId)) continue;
43
+ seenAutomations.add(trigger.automationId);
44
+ try {
45
+ recordAutomationRun(trigger.automationId, {
46
+ runId: `unmatched-${trigger.automationId}-${refSlug}-${dayStamp()}`,
47
+ automationId: trigger.automationId,
48
+ triggerId: trigger.id,
49
+ triggerName: trigger.name,
50
+ status: 'unmatched',
51
+ startedAt: timestamp,
52
+ finishedAt: timestamp,
53
+ output: { received: { source: eventSource, event: eventName, receivedAt: timestamp } },
54
+ error: `Received ${eventSource}.${eventName} — authenticated, but no trigger selector matched (trigger "${trigger.name}" wants ${trigger.source || '*'}.${trigger.event || '*'}).`,
55
+ }, { source: 'automation_runs:event-unmatched' });
56
+ } catch (error) {
57
+ console.warn('[AmalgmMCP:Events] Failed to record unmatched event run:', error.message || error);
58
+ }
59
+ }
60
+ }
61
+
62
+ function eventReference(headers, parsedBody) {
63
+ const githubEvent = headers['x-github-event'];
64
+ const vendorSource = pickSourceLabel(headers);
65
+ const vendorEvent = githubEvent
66
+ || headers['x-linear-event']
67
+ || headers['x-gitlab-event']
68
+ || null;
69
+ let source = vendorSource;
70
+ let event = vendorEvent || 'webhook';
71
+ let fallback = null;
72
+
73
+ if (vendorSource === 'external' && !vendorEvent) {
74
+ const headerSource = cleanRefPart(headers['x-amalgm-source']);
75
+ const headerEvent = cleanRefPart(headers['x-amalgm-event']);
76
+ const bodySource = cleanRefPart(typeof parsedBody === 'object' ? parsedBody?.source : null);
77
+ const bodyEvent = cleanRefPart(typeof parsedBody === 'object' ? parsedBody?.event : null);
78
+ if (headerSource || headerEvent) {
79
+ source = headerSource || 'external';
80
+ event = headerEvent || 'webhook';
81
+ } else if (bodySource || bodyEvent) {
82
+ source = bodySource || 'external';
83
+ event = bodyEvent || 'webhook';
84
+ fallback = { source: 'external', event: 'webhook' };
85
+ }
86
+ }
87
+
88
+ return { source, event, fallback, githubPing: githubEvent === 'ping' };
89
+ }
90
+
91
+ async function processEventEnvelope(input = {}) {
92
+ const rawBody = typeof input.rawBody === 'string' ? input.rawBody : '';
93
+ const headers = input.headers && typeof input.headers === 'object' ? input.headers : {};
94
+ const signature = extractSignature(headers);
95
+ if (!signature) {
96
+ return {
97
+ terminalStatus: 'rejected',
98
+ disposition: 'unsigned',
99
+ reason: 'Webhook signature required',
100
+ };
101
+ }
102
+
103
+ const triggers = listEventTriggersForIngress();
104
+ let parsedBody;
105
+ try {
106
+ parsedBody = JSON.parse(rawBody);
107
+ } catch {
108
+ parsedBody = rawBody;
109
+ }
110
+ const ref = eventReference(headers, parsedBody);
111
+
112
+ if (ref.githubPing) {
113
+ const pingTrigger = matchBySignature(triggers, signature, rawBody);
114
+ return pingTrigger
115
+ ? { terminalStatus: 'completed', disposition: 'ping', triggerIds: [] }
116
+ : { terminalStatus: 'rejected', disposition: 'invalid-signature', reason: 'Invalid webhook signature' };
117
+ }
118
+
119
+ const authenticated = matchAllBySignature(triggers, signature, rawBody);
120
+ if (!authenticated?.length) {
121
+ return {
122
+ terminalStatus: 'rejected',
123
+ disposition: 'invalid-signature',
124
+ reason: 'Invalid webhook signature',
125
+ };
126
+ }
127
+
128
+ let source = ref.source;
129
+ let event = ref.event;
130
+ let matched = authenticated.filter((trigger) => matchesEventRef(trigger, { source, event }));
131
+ if (!matched.length && ref.fallback) {
132
+ const fallbackMatches = authenticated.filter((trigger) => matchesEventRef(trigger, ref.fallback));
133
+ if (fallbackMatches.length > 0) {
134
+ source = ref.fallback.source;
135
+ event = ref.fallback.event;
136
+ matched = fallbackMatches;
137
+ }
138
+ }
139
+
140
+ const timestamp = input.receivedAt || new Date().toISOString();
141
+ const eventObj = {
142
+ id: input.deliveryId || null,
143
+ source,
144
+ event,
145
+ payload: parsedBody,
146
+ headers: collectPassthroughHeaders(headers),
147
+ timestamp,
148
+ receivedAt: timestamp,
149
+ };
150
+
151
+ if (!matched.length) {
152
+ ring.push({ ...eventObj, disposition: 'unmatched' });
153
+ recordUnmatchedEventRuns(authenticated, source, event, timestamp);
154
+ return {
155
+ terminalStatus: 'completed',
156
+ disposition: 'unmatched',
157
+ event: eventObj,
158
+ triggerIds: [],
159
+ authenticatedTriggerIds: authenticated.map((trigger) => trigger.id),
160
+ };
161
+ }
162
+
163
+ ring.push(eventObj);
164
+ const completed = new Set(Array.isArray(input.completedTriggerIds) ? input.completedTriggerIds : []);
165
+ for (const trigger of matched) {
166
+ if (completed.has(trigger.id)) continue;
167
+ const runs = await executeAutomationForTrigger(trigger.id, eventObj);
168
+ const failed = runs.find((run) => run && ['failed', 'rejected', 'interrupted'].includes(run.status));
169
+ if (failed) {
170
+ throw new Error(failed.error || `Automation run ${failed.id || trigger.id} failed`);
171
+ }
172
+ markTriggerFired(trigger.id, { source: 'events:delivery-completed', firedAt: timestamp });
173
+ input.onTriggerComplete?.(trigger.id);
174
+ completed.add(trigger.id);
175
+ }
176
+
177
+ return {
178
+ terminalStatus: 'completed',
179
+ disposition: 'triggered',
180
+ event: eventObj,
181
+ triggerIds: matched.map((trigger) => trigger.id),
182
+ completedTriggerIds: [...completed],
183
+ };
184
+ }
185
+
186
+ module.exports = {
187
+ eventReference,
188
+ processEventEnvelope,
189
+ };
@@ -482,6 +482,46 @@ function renderEntityTile(label, type, id, renderContext = {}) {
482
482
  );
483
483
  }
484
484
 
485
+ /**
486
+ * Full-width share-invite tile — the approved email CTA form of ChatRow.
487
+ * The whole row is one link, with the canonical entity/file icon on the left
488
+ * and the canonical ExternalLink icon on the right. Tables keep the three
489
+ * cells aligned in Gmail and Outlook.
490
+ */
491
+ function renderShareEntityTile(label, type, href) {
492
+ const safeLabel = escapeText(label || entityTypeLabel(type));
493
+ const normalizedType = type === 'symlink' ? 'file' : type;
494
+ const iconDescriptor = normalizedType === 'file'
495
+ ? resolveFileIconDescriptor(label)
496
+ : normalizedType === 'folder'
497
+ ? resolveFileIconDescriptor('', { isDirectory: true })
498
+ : resolveEntityIconDescriptor(normalizedType);
499
+ const openDescriptor = {
500
+ kind: 'lucide',
501
+ name: 'ExternalLink',
502
+ colorClass: 'text-white',
503
+ color: COLOR.text,
504
+ };
505
+ const size = CHROME.entityTileIconSize;
506
+ const iconContentIdValue = iconContentId(iconDescriptor);
507
+ const openContentId = iconContentId(openDescriptor);
508
+ const iconStyle = `display:inline-block;width:${size}px;height:${size}px;border:0`;
509
+ return (
510
+ `<a href="${escapeAttr(href)}" target="_blank" ` +
511
+ `style="text-decoration:none;display:block;background:${CHROME.entityTileBg};` +
512
+ `border:1px solid ${CHROME.entityTileBorder};border-radius:${CHROME.entityTileRadius}px;` +
513
+ `padding:0;margin:${CHROME.entityTileMarginY}px 0">` +
514
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse"><tr>` +
515
+ `<td style="padding:${CHROME.entityTilePaddingY}px 0 ${CHROME.entityTilePaddingY}px ${CHROME.entityTilePaddingX}px;vertical-align:middle;line-height:0;width:${size}px">` +
516
+ `<img src="cid:${iconContentIdValue}" alt="" width="${size}" height="${size}" style="${iconStyle}"></td>` +
517
+ `<td style="padding:${CHROME.entityTilePaddingY}px ${CHROME.entityTilePaddingX}px ${CHROME.entityTilePaddingY}px ${CHROME.entityTileGap}px;vertical-align:middle">` +
518
+ `<span style="font-size:${CHROME.entityTileFontSize}px;line-height:${CHROME.entityTileLineHeight}px;color:${CHROME.entityTileText};font-weight:${CHROME.entityTileFontWeight};white-space:nowrap">${safeLabel}</span></td>` +
519
+ `<td align="right" style="padding:${CHROME.entityTilePaddingY}px ${CHROME.entityTilePaddingX}px ${CHROME.entityTilePaddingY}px 0;vertical-align:middle;line-height:0;width:${size}px">` +
520
+ `<img src="cid:${openContentId}" alt="" width="${size}" height="${size}" style="${iconStyle}"></td>` +
521
+ `</tr></table></a>`
522
+ );
523
+ }
524
+
485
525
  /**
486
526
  * Expanded entity — the email form of the full: render. Email can't iframe
487
527
  * the live app, so it's the EmbedChrome card with the entity identity and a
@@ -527,4 +567,5 @@ module.exports = {
527
567
  renderFileCardEmbed,
528
568
  renderMediaEmbed,
529
569
  renderEntityEmbed,
570
+ renderShareEntityTile,
530
571
  };
@@ -26,6 +26,7 @@ const { buildEmailRenderContext, normalizeEmailLink } = require('./email-deeplin
26
26
  // (or Citation.tsx for the templates below) and regenerate.
27
27
  const RENDER_ASSETS = require('./generated/email-render.json');
28
28
  const EMAIL_TOKENS = RENDER_ASSETS.tokens;
29
+ const CHROME = RENDER_ASSETS.chrome;
29
30
  const TEMPLATES = RENDER_ASSETS.templates;
30
31
  // Markdown block metrics — the same values app/generated/markdown-surface.css
31
32
  // is generated from, so blocks land at identical positions in both surfaces.
@@ -418,4 +419,109 @@ function formatNotificationEmail(message, link, renderContextInput = {}) {
418
419
  </body></html>`;
419
420
  }
420
421
 
421
- module.exports = { markdownToEmailHtml, formatNotificationEmail };
422
+ const SHARE_ENTITY_COPY = {
423
+ file: { phrase: 'a file', noun: 'file' },
424
+ symlink: { phrase: 'a file', noun: 'file' },
425
+ folder: { phrase: 'a folder', noun: 'folder' },
426
+ agent: { phrase: 'an agent', noun: 'agent' },
427
+ automation: { phrase: 'an automation', noun: 'automation' },
428
+ event: { phrase: 'an automation', noun: 'automation' },
429
+ trigger: { phrase: 'an automation', noun: 'automation' },
430
+ chat: { phrase: 'a chat', noun: 'chat' },
431
+ conversation: { phrase: 'a chat', noun: 'chat' },
432
+ app: { phrase: 'an app', noun: 'app' },
433
+ artifact: { phrase: 'an app', noun: 'app' },
434
+ service: { phrase: 'an app', noun: 'app' },
435
+ task: { phrase: 'a task', noun: 'task' },
436
+ preview: { phrase: 'a preview', noun: 'preview' },
437
+ };
438
+
439
+ function shareEntityCopy(entityType) {
440
+ return SHARE_ENTITY_COPY[entityType] || { phrase: 'something', noun: 'item' };
441
+ }
442
+
443
+ function shareInviteContextHtml({ entityType, access, context }) {
444
+ const paragraph = (content) =>
445
+ `<p style="margin:0;line-height:${BLOCKS.paragraph.lineHeight};color:${EMAIL_TOKENS.color.text}">${content}</p>`;
446
+ if (context && typeof context === 'object') {
447
+ const before = escapeHtmlText(context.before || '');
448
+ const emphasis = escapeHtmlText(context.emphasis || '');
449
+ const after = escapeHtmlText(context.after || '');
450
+ return paragraph(
451
+ before +
452
+ (emphasis
453
+ ? `<strong style="color:${EMAIL_TOKENS.color.text};font-weight:600">${emphasis}</strong>`
454
+ : '') +
455
+ after,
456
+ );
457
+ }
458
+ if (typeof context === 'string' && context.trim()) {
459
+ return paragraph(escapeHtmlText(context.trim()));
460
+ }
461
+ if (access === 'edit' || access === 'view') {
462
+ const strong = `<strong style="color:${EMAIL_TOKENS.color.text};font-weight:600">${access}</strong>`;
463
+ return entityType === 'folder'
464
+ ? paragraph(`You can ${strong} everything inside.`)
465
+ : paragraph(`You can ${strong} this ${shareEntityCopy(entityType).noun}.`);
466
+ }
467
+ if (entityType === 'agent') {
468
+ return paragraph('Its tools and skills come with it &mdash; everything installs when you accept.');
469
+ }
470
+ return '';
471
+ }
472
+
473
+ /**
474
+ * Approved share/invite email shell. It uses the same generated tokens,
475
+ * Markdown block metrics, entity tile, and CID icon pipeline as notify_user,
476
+ * while keeping the tile as the only CTA.
477
+ */
478
+ function formatShareInviteEmail({
479
+ sharerName,
480
+ recipientEmail,
481
+ entityName,
482
+ entityType,
483
+ inviteUrl,
484
+ access,
485
+ context,
486
+ }) {
487
+ const normalizedType = String(entityType || 'entity').toLowerCase();
488
+ const copy = shareEntityCopy(normalizedType);
489
+ const headline =
490
+ `<h2 style="margin:0;font-size:${EMAIL_TOKENS.type.headings['2']}px;font-weight:400;` +
491
+ `color:${EMAIL_TOKENS.color.text};line-height:${BLOCKS.heading.lineHeight}">` +
492
+ `<strong style="font-weight:${CHROME.entityTileFontWeight}">${escapeHtmlText(sharerName)}</strong> ` +
493
+ `shared ${copy.phrase} with you</h2>`;
494
+ const tile = embeds.renderShareEntityTile(entityName, normalizedType, inviteUrl);
495
+ const contextHtml = shareInviteContextHtml({ entityType: normalizedType, access, context });
496
+ const finePrint =
497
+ `<blockquote style="margin:0;line-height:${BLOCKS.blockquote.lineHeight};` +
498
+ `${BLOCKS.blockquote.italic ? 'font-style:italic;' : ''}` +
499
+ `padding:0 0 0 ${BLOCKS.blockquote.paddingLeft}px;` +
500
+ `border-left:${BLOCKS.blockquote.borderWidth}px solid ${BLOCKS.blockquote.borderColor};` +
501
+ `color:${BLOCKS.blockquote.color}">${escapeHtmlText(recipientEmail)} &mdash; ` +
502
+ `sign in with this address to accept.</blockquote>`;
503
+ const bodyBlocks = [headline, tile, contextHtml, finePrint].filter(Boolean);
504
+ const bodyHtml = bodyBlocks
505
+ .map((block, index) =>
506
+ index === 0 ? block : `<div style="margin:${BLOCKS.blockGap}px 0 0">${block}</div>`,
507
+ )
508
+ .join('\n');
509
+ const textFont = EMAIL_TOKENS.font.text.replace(/"/g, "'");
510
+
511
+ return `<!DOCTYPE html>
512
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
513
+ <body style="margin:0;padding:0;background:${EMAIL_TOKENS.color.bg};font-family:${textFont}">
514
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${EMAIL_TOKENS.color.bg}"><tr><td align="center" style="padding:0">
515
+ <table role="presentation" width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background:${EMAIL_TOKENS.color.bg};border:1px solid ${EMAIL_TOKENS.color.tableBorder};border-radius:8px;border-collapse:separate;overflow:hidden"><tr><td style="padding:40px 28px 48px">
516
+ <div style="font-size:${EMAIL_TOKENS.type.body}px;color:${EMAIL_TOKENS.color.text}">${bodyHtml}</div>
517
+ <p style="margin:48px 0 0;font-size:12px;color:${EMAIL_TOKENS.color.text}">amalgm.ai</p>
518
+ </td></tr></table>
519
+ </td></tr></table>
520
+ </body></html>`;
521
+ }
522
+
523
+ module.exports = {
524
+ markdownToEmailHtml,
525
+ formatNotificationEmail,
526
+ formatShareInviteEmail,
527
+ };
@@ -51,8 +51,16 @@
51
51
  "frameBg": "#000000",
52
52
  "entityTileCss": "text-decoration:none;display:inline-block;background:#000000;border:1px solid #1c1917;border-radius:12px;padding:8px 12px;margin:2px 0;vertical-align:middle",
53
53
  "entityTileFontSize": 14,
54
+ "entityTileLineHeight": 18,
54
55
  "entityTileFontWeight": 650,
55
56
  "entityTileIconSize": 14,
57
+ "entityTilePaddingX": 12,
58
+ "entityTilePaddingY": 8,
59
+ "entityTileGap": 10,
60
+ "entityTileMarginY": 2,
61
+ "entityTileRadius": 12,
62
+ "entityTileBorder": "#1c1917",
63
+ "entityTileBg": "#000000",
56
64
  "entityTileText": "#ffffff",
57
65
  "citationIconSize": 12
58
66
  },
@@ -146,8 +154,8 @@
146
154
  },
147
155
  "templates": {
148
156
  "citationLink": "<a href=\"__HREF__\" target=\"_blank\" rel=\"noreferrer\" title=\"__TITLE__\" class=\"render-citation\" style=\"display:inline;vertical-align:baseline;font:inherit;font-size:inherit;line-height:inherit;color:#60a5fa;text-decoration:none;white-space:nowrap;padding:0;background:transparent;border:none;\">__LABEL__</a>",
149
- "citationLinkWithIcon": "<a href=\"__HREF__\" target=\"_blank\" rel=\"noreferrer\" title=\"__TITLE__\" class=\"render-citation\" style=\"display:inline;vertical-align:baseline;font:inherit;font-size:inherit;line-height:inherit;color:#60a5fa;text-decoration:none;white-space:nowrap;padding:0;background:transparent;border:none;\"><span style=\"display:inline-block;width:12px;height:12px;font-size:12px;line-height:12px;margin-right:0.3em;vertical-align:middle\">__ICON__</span>__LABEL__</a>",
157
+ "citationLinkWithIcon": "<a href=\"__HREF__\" target=\"_blank\" rel=\"noreferrer\" title=\"__TITLE__\" class=\"render-citation\" style=\"display:inline;vertical-align:baseline;font:inherit;font-size:inherit;line-height:inherit;color:#60a5fa;text-decoration:none;white-space:nowrap;padding:0;background:transparent;border:none;\"><span style=\"display:inline-block;width:12px;height:12px;font-size:12px;line-height:12px;margin-right:0.3em;vertical-align:-0.1em\">__ICON__</span>__LABEL__</a>",
150
158
  "citationText": "<span title=\"__TITLE__\" class=\"render-citation\" style=\"display:inline;vertical-align:baseline;font:inherit;font-size:inherit;line-height:inherit;color:#60a5fa;text-decoration:none;white-space:nowrap;padding:0;background:transparent;border:none;\">__LABEL__</span>",
151
- "citationTextWithIcon": "<span title=\"__TITLE__\" class=\"render-citation\" style=\"display:inline;vertical-align:baseline;font:inherit;font-size:inherit;line-height:inherit;color:#60a5fa;text-decoration:none;white-space:nowrap;padding:0;background:transparent;border:none;\"><span style=\"display:inline-block;width:12px;height:12px;font-size:12px;line-height:12px;margin-right:0.3em;vertical-align:middle\">__ICON__</span>__LABEL__</span>"
159
+ "citationTextWithIcon": "<span title=\"__TITLE__\" class=\"render-citation\" style=\"display:inline;vertical-align:baseline;font:inherit;font-size:inherit;line-height:inherit;color:#60a5fa;text-decoration:none;white-space:nowrap;padding:0;background:transparent;border:none;\"><span style=\"display:inline-block;width:12px;height:12px;font-size:12px;line-height:12px;margin-right:0.3em;vertical-align:-0.1em\">__ICON__</span>__LABEL__</span>"
152
160
  }
153
161
  }
@@ -72,8 +72,10 @@ never merges, never decides truth — it emits facts for the layers above.
72
72
  enrolls as repo territory; a `.git` appearing inside a live files root
73
73
  closes the file channels under it and births a repo root
74
74
  (`repo.discovered`); a `.git` vanishing returns the ground to files
75
- (`repo.removed`) and the channels are reborn. A repo's changes travel as
76
- one card, never as thousands of file events. Enrollment names ground
75
+ (`repo.removed`) and the channels are reborn. A repo's raw changes travel
76
+ as one Card + Checkpoint state, never as thousands of realtime file
77
+ events; its registered child channels reconcile only after that state
78
+ settles. Enrollment names ground
77
79
  by ADDRESS: the parent chain resolves, the last segment is inspected,
78
80
  never traversed — an explicitly enrolled symlink enrolls as the
79
81
  pointer itself.
@@ -114,8 +116,8 @@ never merges, never decides truth — it emits facts for the layers above.
114
116
  and the edge-following enrollment operation (`edges.js`), not any
115
117
  walk, is what turns unclaimed targets into ground. Every root kind
116
118
  exposes its edges (`linkEdges`): files ground from detected truth,
117
- repo ground from a fresh worktree census — a repo's changes travel
118
- as one card, but its edges are enrollment-policy inputs like any
119
+ repo ground from a fresh worktree census — a repo's raw changes travel
120
+ as one Card + Checkpoint state, but its edges are enrollment-policy inputs like any
119
121
  other root's.
120
122
 
121
123
  ## Shape
@@ -179,8 +181,8 @@ never merges, never decides truth — it emits facts for the layers above.
179
181
  The registration boundary above (`registration/`) is what makes
180
182
  watched ground REGISTERED ground — a tree per top-level root, keyed
181
183
  by permanent rootId — and owns derived link resolution across trees
182
- (answering with the OWNING entity: repo territory resolves to its
183
- boundary's record).
184
+ (answering with the OWNING entity: a repo boundary changes realtime
185
+ semantics, but an address in it resolves to its registered child).
184
186
 
185
187
  The observer imports nothing from the rest of the runtime. Enrollment policy
186
188
  (`shouldEnroll`) is injected; the default is the workspace inclusion rules.