rewritable 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/seed.mjs CHANGED
@@ -14,11 +14,34 @@ export async function loadSeed(candidates) {
14
14
  const UUID_RE = /const DOC_UUID = '[0-9a-f-]{36}';/;
15
15
  const TITLE_RE = /<title>[^<]*<\/title>/;
16
16
  const FILE_RE = /(FILE\s*:\s*)'[^']*'/;
17
+ // R9-minimal v0.1.1: per-product-kind substitution sites. The seed hoists
18
+ // the lens-copy strings to const declarations (single source of truth across
19
+ // the textarea declaration, the legacy palette, and releaseAnchor's reset
20
+ // path) so a per-kind override lands everywhere. The PRODUCT HEADER region
21
+ // is a comment block at the top of the bootstrap that names the kind and
22
+ // flags substrate-vs-graph caveats for non-document kinds (workflow today,
23
+ // app/workspace later). Each region is anchored on its marker pair so a
24
+ // seed-side rename can't silently break substitution.
25
+ const LENS_PLACEHOLDER_RE = /const LENS_PLACEHOLDER = '[^']*';/;
26
+ const LEGACY_PAL_PLACEHOLDER_RE = /const LEGACY_PAL_PLACEHOLDER = '[^']*';/;
27
+ const PRODUCT_HEADER_RE = /\/\/ === PRODUCT HEADER ===[\s\S]*?\/\/ === END PRODUCT HEADER ===/;
28
+ // Audit R1: the active product kind selects which SYSTEM_PROMPTS entry the
29
+ // agent loop uses. Substituting just the kind name (not the full prompt body)
30
+ // keeps the registry visible in every emitted file — agents and humans alike
31
+ // can introspect what alternates exist.
32
+ const PRODUCT_KIND_RE = /const PRODUCT_KIND = '[^']*';/;
33
+ // Audit R3 scoped: boolean toggle for click-to-anchor inside the doc mount.
34
+ // `true` for prose-doc kinds; `false` for kinds where every block is
35
+ // anchorable and a stray click would lock the lens onto an item.
36
+ const LENS_CLICK_TO_ANCHOR_RE = /const LENS_CLICK_TO_ANCHOR = (?:true|false);/;
17
37
 
18
- export function applySeedSubs(seed, { uuid, title, fileMeta }) {
19
- // All three substitution sites must appear exactly once. A regression in the
20
- // seed (title removed, FILE renamed, etc.) would otherwise silently no-op
21
- // and ship a CLI emitting partially-substituted containers.
38
+ export function applySeedSubs(seed, { uuid, title, fileMeta, lensPlaceholder, palPlaceholder, productHeader, productKind, lensClickToAnchor }) {
39
+ // All three required substitution sites must appear exactly once. A
40
+ // regression in the seed (title removed, FILE renamed, etc.) would
41
+ // otherwise silently no-op and ship a CLI emitting partially-substituted
42
+ // containers. Optional per-kind subs are verified the same way but only
43
+ // when a caller passes an override — keeps backwards compatibility with
44
+ // existing newCmd / importCmd callers that don't set them.
22
45
  for (const { re, label } of [
23
46
  { re: UUID_RE, label: 'DOC_UUID' },
24
47
  { re: TITLE_RE, label: '<title>' },
@@ -29,12 +52,1344 @@ export function applySeedSubs(seed, { uuid, title, fileMeta }) {
29
52
  throw new Error(`seed must contain exactly one ${label} line, found ${matches.length}`);
30
53
  }
31
54
  }
55
+ for (const { value, re, label } of [
56
+ { value: lensPlaceholder, re: LENS_PLACEHOLDER_RE, label: 'LENS_PLACEHOLDER const' },
57
+ { value: palPlaceholder, re: LEGACY_PAL_PLACEHOLDER_RE, label: 'LEGACY_PAL_PLACEHOLDER const' },
58
+ { value: productHeader, re: PRODUCT_HEADER_RE, label: 'PRODUCT HEADER block' },
59
+ { value: productKind, re: PRODUCT_KIND_RE, label: 'PRODUCT_KIND const' },
60
+ { value: lensClickToAnchor, re: LENS_CLICK_TO_ANCHOR_RE, label: 'LENS_CLICK_TO_ANCHOR const' },
61
+ ]) {
62
+ if (value == null) continue;
63
+ const matches = seed.match(new RegExp(re.source, 'g')) || [];
64
+ if (matches.length !== 1) {
65
+ throw new Error(`seed must contain exactly one ${label}, found ${matches.length}`);
66
+ }
67
+ }
32
68
  let out = seed.replace(UUID_RE, `const DOC_UUID = '${uuid}';`);
33
69
  if (title != null) out = out.replace(TITLE_RE, `<title>${escapeHtml(title)}</title>`);
34
70
  if (fileMeta != null) out = out.replace(FILE_RE, (_m, prefix) => `${prefix}'${escapeJsString(fileMeta)}'`);
71
+ // Function-form replacements so a `$` in the substitute value isn't
72
+ // interpreted by String.replace as a backreference.
73
+ if (lensPlaceholder != null) {
74
+ out = out.replace(LENS_PLACEHOLDER_RE, () => `const LENS_PLACEHOLDER = '${escapeJsString(lensPlaceholder)}';`);
75
+ }
76
+ if (palPlaceholder != null) {
77
+ out = out.replace(LEGACY_PAL_PLACEHOLDER_RE, () => `const LEGACY_PAL_PLACEHOLDER = '${escapeJsString(palPlaceholder)}';`);
78
+ }
79
+ if (productHeader != null) {
80
+ out = out.replace(PRODUCT_HEADER_RE, () => productHeader);
81
+ }
82
+ if (productKind != null) {
83
+ out = out.replace(PRODUCT_KIND_RE, () => `const PRODUCT_KIND = '${escapeJsString(productKind)}';`);
84
+ }
85
+ if (lensClickToAnchor != null) {
86
+ // Boolean literal — coerce to a literal token, not a JSON string.
87
+ out = out.replace(LENS_CLICK_TO_ANCHOR_RE, () => `const LENS_CLICK_TO_ANCHOR = ${lensClickToAnchor ? 'true' : 'false'};`);
88
+ }
35
89
  return out;
36
90
  }
37
91
 
92
+ // R9-minimal: per-product-kind starter scaffolds. Each entry supplies the
93
+ // INLINE_DOC body and the lens placeholder text. The substrate is unchanged;
94
+ // kinds are CLI-side substitutions at emit time.
95
+ //
96
+ // Scope discipline (the cheapest viable scaffold):
97
+ // - INLINE_DOC body: a recognizable first-paint shape that names the
98
+ // primary stance. No JS, no interactive UI — just structure.
99
+ // - Lens placeholder: one line of plain-English copy framed for the kind.
100
+ // - Title bar / FILE / DOC_UUID: untouched; come from basename and crypto
101
+ // as before.
102
+ //
103
+ // SYSTEM_PROMPT is intentionally NOT varied here — that's audit R1's job
104
+ // (parameterize the prompt registry); R9-minimal stops at the substitution
105
+ // layer so R1 has a second concrete prompt to parameterize over.
106
+ const KIND_DOCUMENT_LENS = 'Write, or describe what you want.';
107
+
108
+ // v0.2 workflow stub. Implements the UX design at
109
+ // docs/plans/2026-05-18-workflow-ux-design.md. Semantic <ol> / <li>
110
+ // structure, collapsible <details> code, per-step <output> slots, an
111
+ // async function run(ctx, prev) step contract, and a frozen runner.
112
+ // Replaces v1's wf-canvas / wf-node / <script type="text/workflow-node">
113
+ // shape wholesale.
114
+ const KIND_WORKFLOW_BODY = `<!-- rwa:frozen:begin wf-style -->
115
+ <style>
116
+ .rwa-workflow{max-width:920px;margin:0 auto;padding:24px 24px 64px;}
117
+ .rwa-workflow > header{margin-bottom:1.5em;}
118
+ .rwa-workflow > header h1{margin:0 0 .25em;}
119
+ .rwa-workflow > header > p{margin:0;color:var(--gray-600);font-size:14px;line-height:1.5;}
120
+ .rwa-workflow .placeholder{color:var(--gray-400);font-style:italic;margin:1.5em 0;line-height:1.5;}
121
+ .rwa-flow{list-style:none;padding:0;margin:1em 0;display:flex;flex-direction:column;gap:.5em;}
122
+ .rwa-step{border:1px solid var(--gray-200);border-radius:8px;padding:14px 16px;background:var(--gray-50);position:relative;transition:border-color .15s,background .15s;}
123
+ .rwa-step > header{margin:0;}
124
+ .rwa-step > header h3{margin:0 0 .25em;font-size:1rem;font-weight:600;color:var(--gray-900);}
125
+ .rwa-step > header p{margin:0;color:var(--gray-600);font-size:13px;line-height:1.45;}
126
+ .rwa-step details{margin:.5em 0 0;}
127
+ .rwa-step summary{cursor:pointer;font-family:var(--font-mono);font-size:11px;color:var(--gray-500);padding:2px 0;list-style:none;display:inline-block;user-select:none;}
128
+ .rwa-step summary::before{content:"▸ ";display:inline-block;width:1em;}
129
+ .rwa-step details[open] > summary::before{content:"▾ ";}
130
+ .rwa-step summary::-webkit-details-marker{display:none;}
131
+ .rwa-step summary:hover{color:var(--gray-700);}
132
+ .rwa-step details > script{display:block;white-space:pre-wrap;padding:.5em .75em;margin:.25em 0 0;background:#fff;border:1px solid var(--gray-200);border-radius:4px;font-family:var(--font-mono);font-size:12px;line-height:1.5;color:var(--gray-800);overflow-x:auto;}
133
+ .rwa-step-output{display:block;margin-top:.5em;padding:.5em .75em;background:#fff;border:1px solid var(--gray-200);border-radius:4px;font-family:var(--font-mono);font-size:11px;line-height:1.4;color:var(--gray-800);white-space:pre-wrap;overflow-x:auto;max-height:200px;overflow-y:auto;}
134
+ .rwa-step-output:empty{display:none;}
135
+ .rwa-step.running{border-color:var(--blue);background:#fff;}
136
+ .rwa-step.done{border-color:var(--green);}
137
+ .rwa-step.done::after{content:"✓";position:absolute;top:14px;right:16px;color:var(--green);font-family:var(--font-mono);font-size:14px;}
138
+ .rwa-step.failed{border-color:var(--red);}
139
+ .rwa-step.failed::after{content:"✗";position:absolute;top:14px;right:16px;color:var(--red);font-family:var(--font-mono);font-size:14px;}
140
+ .rwa-step.failed .rwa-step-output{color:var(--red);}
141
+ .rwa-step.dragging{opacity:.4;}
142
+ .rwa-step.drop-target{border-color:var(--blue);background:#eef6ff;}
143
+ .rwa-step-delete{position:absolute;top:8px;right:8px;width:24px;height:24px;border:none;background:transparent;color:var(--gray-300);cursor:pointer;font-size:18px;line-height:1;border-radius:4px;padding:0;opacity:0;transition:color .15s,background .15s,opacity .15s;}
144
+ .rwa-step:hover .rwa-step-delete{opacity:1;color:var(--gray-500);}
145
+ .rwa-step-delete:hover{background:var(--gray-200);color:var(--red);}
146
+ .rwa-step.running .rwa-step-delete,.rwa-step.done .rwa-step-delete,.rwa-step.failed .rwa-step-delete{display:none;}
147
+ .rwa-step-insert{display:block;margin:0 auto;padding:2px 14px;background:transparent;color:var(--gray-300);border:1px dashed var(--gray-200);border-radius:4px;cursor:pointer;font-size:14px;line-height:1.2;font-weight:400;font-family:var(--font-mono);opacity:0;transition:color .15s,border-color .15s,background .15s,opacity .15s;}
148
+ .rwa-flow:hover .rwa-step-insert,.rwa-step-insert:focus{opacity:1;}
149
+ .rwa-step-insert:hover{color:var(--gray-700);border-color:var(--gray-400);background:var(--gray-50);}
150
+ .rwa-workflow-footer{margin-top:1.5em;display:flex;align-items:center;gap:1em;}
151
+ .rwa-run{padding:8px 16px;background:var(--gray-900);color:#fff;border:none;border-radius:6px;cursor:pointer;font-family:var(--font-ui);font-size:14px;font-weight:500;transition:background .15s;}
152
+ .rwa-run.rwa-run-cancel{background:var(--red);}
153
+ .rwa-run.rwa-run-cancel:hover{background:#dc2626;}
154
+ .rwa-run:hover{background:var(--gray-700);}
155
+ .rwa-run:disabled{background:var(--gray-300);cursor:not-allowed;}
156
+ .rwa-run-status{font-family:var(--font-mono);font-size:11px;color:var(--gray-500);min-height:1.4em;letter-spacing:.3px;}
157
+ .rwa-step.pinned{border-left:3px solid var(--blue);padding-left:13px;}
158
+ .rwa-step.stale{border-left:3px solid var(--yellow);padding-left:13px;}
159
+ .rwa-step.pinned.stale{border-left:3px solid var(--blue);}
160
+ .rwa-step.rwa-foreach{border-left:3px dashed var(--gray-400);padding-left:13px;}
161
+ .rwa-step.rwa-foreach > ol.rwa-flow{margin:.5em 0 0;padding-left:0;}
162
+ .rwa-iter-count{display:inline-block;margin-left:6px;padding:1px 6px;font-size:10px;font-weight:500;border-radius:3px;background:var(--gray-700);color:#fff;font-family:var(--font-mono);letter-spacing:.3px;}
163
+ .rwa-parallel{width:100%;border-collapse:separate;border-spacing:8px;margin:.5em 0;position:relative;}
164
+ .rwa-parallel.pinned > tbody{outline:2px solid var(--blue);outline-offset:6px;border-radius:6px;}
165
+ .rwa-parallel-caption{caption-side:top;text-align:left;padding:0 0 4px 0;}
166
+ .rwa-foreach.pinned{border-left-color:var(--blue);}
167
+ .rwa-parallel > tbody > tr > td.rwa-step{vertical-align:top;width:1%;min-width:200px;max-width:340px;}
168
+ .rwa-parallel > tbody > tr > td.rwa-step::before{content:attr(data-rwa-label);position:absolute;top:-10px;left:8px;padding:1px 6px;background:var(--gray-100);border:1px solid var(--gray-200);border-radius:3px;font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--gray-600);text-transform:uppercase;letter-spacing:.4px;}
169
+ @media (max-width:720px){.rwa-parallel,.rwa-parallel>tbody,.rwa-parallel>tbody>tr,.rwa-parallel>tbody>tr>td.rwa-step{display:block;width:auto;max-width:none;}.rwa-parallel>tbody>tr>td.rwa-step{margin:.5em 0;}}
170
+ .rwa-step-badge{display:inline-block;margin-left:6px;padding:1px 6px;font-size:10px;font-weight:500;border-radius:3px;letter-spacing:.3px;text-transform:uppercase;vertical-align:middle;font-family:var(--font-mono);}
171
+ .rwa-badge-pinned{background:var(--blue);color:#fff;}
172
+ .rwa-badge-stale{background:var(--yellow);color:#fff;}
173
+ .rwa-step-toolbar{position:absolute;top:6px;right:36px;display:flex;gap:2px;opacity:0;transition:opacity .15s;z-index:1;}
174
+ .rwa-step:hover .rwa-step-toolbar,.rwa-step:focus-within .rwa-step-toolbar{opacity:1;}
175
+ .rwa-step-toolbar button{width:22px;height:22px;border:1px solid var(--gray-200);background:#fff;border-radius:4px;cursor:pointer;font-size:11px;padding:0;line-height:1;color:var(--gray-600);display:inline-flex;align-items:center;justify-content:center;}
176
+ .rwa-step-toolbar button:hover:not([disabled]){background:var(--gray-50);color:var(--gray-900);}
177
+ .rwa-step-toolbar button[disabled]{opacity:.4;cursor:not-allowed;}
178
+ .rwa-step.pinned .rwa-pin-btn{color:var(--blue);border-color:var(--blue);}
179
+ .rwa-step.running .rwa-step-toolbar{display:none;}
180
+ @media print{.placeholder,.rwa-run,.rwa-run-status,.rwa-step-delete,.rwa-step-insert,.rwa-step-toolbar{display:none;} .rwa-step::after{display:none;}}
181
+ </style>
182
+ <!-- rwa:frozen:end wf-style -->
183
+ <article class="rwa-workflow">
184
+ <header>
185
+ <h1>Untitled workflow</h1>
186
+ </header>
187
+ <p class="placeholder">Describe what you want this workflow to do — the agent will scaffold the steps. For example: <em>"fetch the last 5 issues from anthropics/anthropic-sdk-python and summarize each in two sentences"</em>.</p>
188
+ </article>
189
+ <!-- rwa:frozen:begin runner -->
190
+ <script>
191
+ (function(){
192
+ 'use strict';
193
+ // Workflow runner (frozen). Walks <li class="rwa-step"> in DOM order,
194
+ // compiles each step's <script type="text/rwa-step"> body — which must
195
+ // define an async function named "run" with signature run(ctx, prev) —
196
+ // and invokes them in sequence, threading return values as "prev".
197
+ // Writes results into the step's <output class="rwa-step-output">.
198
+ // Sets .running / .done / .failed classes on each <li>. Stops on first
199
+ // error. State surviving renderDoc lives on window.__rwaWorkflow.
200
+ var NS = (window.__rwaWorkflow = window.__rwaWorkflow || { running: false, lastResult: null });
201
+
202
+ function setStatus(s) {
203
+ var el = document.querySelector('.rwa-run-status');
204
+ if (el) el.textContent = s || '';
205
+ }
206
+
207
+ function clearStepStates() {
208
+ // v0.4: also clears parallel cells and foreach containers.
209
+ document.querySelectorAll('li.rwa-step, td.rwa-step').forEach(function(node){
210
+ node.classList.remove('running', 'done', 'failed');
211
+ var out = node.querySelector(':scope > output.rwa-step-output')
212
+ || node.querySelector('output.rwa-step-output');
213
+ if (out) out.textContent = '';
214
+ });
215
+ // Remove any stale iter-count chips from previous run.
216
+ document.querySelectorAll('.rwa-iter-count').forEach(function (c) { c.remove(); });
217
+ }
218
+ function compile(scriptEl) {
219
+ var src = scriptEl.textContent;
220
+ return new Function('ctx', 'prev',
221
+ '"use strict"; return (async () => { ' + src + '\\nreturn typeof run === "function" ? run(ctx, prev) : undefined; })();');
222
+ }
223
+ function renderOutput(value) {
224
+ if (value === undefined) return '';
225
+ if (typeof value === 'string') return value;
226
+ try { return JSON.stringify(value, null, 2); }
227
+ catch (_) { return String(value); }
228
+ }
229
+
230
+ // v0.3: pin / dirty / test-step. State lives on the <li>:
231
+ // data-pinned-output — JSON string; runner short-circuits run().
232
+ // data-last-output — JSON string; cached for the per-step ▶ button.
233
+ // data-last-run-hash — 8-char hex; mismatch with current ⇒ .stale.
234
+ // 32-bit FNV-1a hash (Math.imul guards against overflow on 32-bit ints).
235
+ function hashStr(s) {
236
+ var h = 0x811c9dc5;
237
+ for (var i = 0; i < s.length; i++) {
238
+ h ^= s.charCodeAt(i);
239
+ h = Math.imul(h, 16777619) >>> 0;
240
+ }
241
+ return ('00000000' + h.toString(16)).slice(-8);
242
+ }
243
+ function stepBodyOf(li) {
244
+ var sc = li.querySelector('script[type="text/rwa-step"]');
245
+ return sc ? sc.textContent : '';
246
+ }
247
+ // v0.8: recursive structural fingerprint for staleness on containers.
248
+ // For leaves: just the script body. For foreach: 'foreach:' + the join
249
+ // of inner-node fingerprints. For parallel: 'parallel:' + each cell's
250
+ // 'label=fp/F-or-A' joined in DOM order. Adding / removing / reordering
251
+ // / editing inner nodes shifts the fingerprint; same for toggling
252
+ // data-allow-failure on a cell.
253
+ function nodeFingerprint(node) {
254
+ if (node && node.matches && node.matches('li.rwa-step.rwa-foreach')) {
255
+ var innerOl = node.querySelector(':scope > ol.rwa-flow');
256
+ if (!innerOl) return 'foreach:';
257
+ var inner = Array.from(innerOl.children).filter(function (c) {
258
+ return c.matches('li.rwa-step, table.rwa-parallel');
259
+ });
260
+ return 'foreach:' + inner.map(nodeFingerprint).join('|');
261
+ }
262
+ if (node && node.matches && node.matches('table.rwa-parallel')) {
263
+ var cells = Array.from(node.querySelectorAll(':scope > tbody > tr > td.rwa-step'));
264
+ return 'parallel:' + cells.map(function (c) {
265
+ var allow = c.dataset.allowFailure === 'true' ? 'A' : 'F';
266
+ return (c.dataset.rwaLabel || '?') + '=' + nodeFingerprint(c) + '/' + allow;
267
+ }).join('|');
268
+ }
269
+ // Leaf
270
+ return stepBodyOf(node);
271
+ }
272
+ // v0.8: runner state cache, keyed by data-rwa-id. Mirrors the on-DOM
273
+ // dataset values so they survive applyEnvelope's re-render (which wipes
274
+ // any DOM mutations not in the IDB doc). Lost on reload — that's fine:
275
+ // hashes are tied to a session of running + editing.
276
+ var SESSION = (window.__rwa_workflow_session = window.__rwa_workflow_session || {
277
+ lastOutput: new Map(),
278
+ lastRunHash: new Map(),
279
+ });
280
+ function cacheOutput(li, value) {
281
+ try {
282
+ var s = JSON.stringify(value);
283
+ if (s !== undefined) {
284
+ li.dataset.lastOutput = s;
285
+ if (li.dataset.rwaId) SESSION.lastOutput.set(li.dataset.rwaId, s);
286
+ }
287
+ } catch (_) { /* not JSON-serializable; skip */ }
288
+ }
289
+ function persistLastRunHash(node, hash) {
290
+ node.dataset.lastRunHash = hash;
291
+ if (node.dataset.rwaId) SESSION.lastRunHash.set(node.dataset.rwaId, hash);
292
+ }
293
+ function restoreSessionState() {
294
+ document.querySelectorAll('[data-rwa-id]').forEach(function (node) {
295
+ var id = node.dataset.rwaId;
296
+ if (!id) return;
297
+ if (!node.dataset.lastOutput && SESSION.lastOutput.has(id)) {
298
+ node.dataset.lastOutput = SESSION.lastOutput.get(id);
299
+ }
300
+ if (!node.dataset.lastRunHash && SESSION.lastRunHash.has(id)) {
301
+ node.dataset.lastRunHash = SESSION.lastRunHash.get(id);
302
+ }
303
+ });
304
+ }
305
+ function prevHashFor(li, allSteps) {
306
+ var idx = allSteps.indexOf(li);
307
+ if (idx === 0) return 'init';
308
+ var prevLi = allSteps[idx - 1];
309
+ if (prevLi.dataset.pinnedOutput != null) {
310
+ return hashStr('pin:' + prevLi.dataset.pinnedOutput);
311
+ }
312
+ return prevLi.dataset.lastRunHash || 'never';
313
+ }
314
+ function currentHashFor(node, allSteps) {
315
+ // v0.8: containers use nodeFingerprint (recursive) instead of just
316
+ // their script body (which they don't have). Leaves behave identically
317
+ // since nodeFingerprint of a leaf returns its body.
318
+ return hashStr(nodeFingerprint(node) + '::' + prevHashFor(node, allSteps));
319
+ }
320
+ function syncBadges(node) {
321
+ // For <li>/<td>: badge lives in the node's <header>. For <table>:
322
+ // <header> isn't a valid <table> child; we use a <caption> instead.
323
+ var host;
324
+ if (node.tagName === 'TABLE') {
325
+ host = node.querySelector(':scope > caption.rwa-parallel-caption');
326
+ if (!host && (node.classList.contains('pinned') || node.classList.contains('stale'))) {
327
+ host = document.createElement('caption');
328
+ host.className = 'rwa-parallel-caption';
329
+ // <caption> must be the first child of <table>
330
+ node.insertBefore(host, node.firstChild);
331
+ }
332
+ } else {
333
+ host = node.querySelector(':scope > header');
334
+ }
335
+ if (!host) return;
336
+ var existing = host.querySelectorAll(':scope > .rwa-step-badge');
337
+ existing.forEach(function (e) { e.remove(); });
338
+ if (node.classList.contains('pinned')) {
339
+ var bP = document.createElement('span');
340
+ bP.className = 'rwa-step-badge rwa-badge-pinned';
341
+ bP.textContent = 'pinned';
342
+ host.appendChild(bP);
343
+ }
344
+ if (node.classList.contains('stale')) {
345
+ var bS = document.createElement('span');
346
+ bS.className = 'rwa-step-badge rwa-badge-stale';
347
+ bS.textContent = 'stale';
348
+ host.appendChild(bS);
349
+ }
350
+ }
351
+ function syncPinnedClasses() {
352
+ // v0.5: includes container nodes (foreach <li>, parallel <table>).
353
+ document.querySelectorAll('li.rwa-step, td.rwa-step, table.rwa-parallel').forEach(function (node) {
354
+ if (node.dataset.pinnedOutput != null) node.classList.add('pinned');
355
+ else node.classList.remove('pinned');
356
+ });
357
+ }
358
+ // Pin button enabled when there's something to pin (cached or already
359
+ // pinned). The attached handler reads current state, so we just keep the
360
+ // disabled attribute and title in sync with dataset.
361
+ function refreshPinButtonStates() {
362
+ document.querySelectorAll('li.rwa-step, td.rwa-step, table.rwa-parallel').forEach(function (node) {
363
+ var btn = node.querySelector(':scope > .rwa-step-toolbar > .rwa-pin-btn');
364
+ if (!btn) return;
365
+ var isPinned = node.dataset.pinnedOutput != null;
366
+ var hasCache = node.dataset.lastOutput != null;
367
+ var isContainer = node.matches('li.rwa-step.rwa-foreach, table.rwa-parallel');
368
+ var needsId = (node.tagName === 'TABLE' || node.tagName === 'TD') && !node.dataset.rwaId;
369
+ btn.disabled = needsId || (!isPinned && !hasCache);
370
+ btn.title = needsId
371
+ ? 'No data-rwa-id on this node yet — commit (⌘S) to populate it'
372
+ : (isPinned
373
+ ? 'Unpin'
374
+ : (hasCache
375
+ ? (isContainer ? 'Pin this container\\'s output' : 'Pin this step\\'s output')
376
+ : (isContainer ? 'Run the workflow first to enable pinning' : 'Run this step first to enable pinning')));
377
+ });
378
+ }
379
+ function recomputeStaleness() {
380
+ // v0.4: top-level linear steps get a proper chain.
381
+ // v0.8: containers (foreach, parallel) also get hashes via
382
+ // nodeFingerprint. Top-level chain includes both leaves and containers.
383
+ // Nested nodes (inside foreach body or parallel cells) use prevHash='init'.
384
+ var topLevelChain = Array.from(
385
+ document.querySelectorAll('article.rwa-workflow > ol.rwa-flow > li.rwa-step, article.rwa-workflow > ol.rwa-flow > table.rwa-parallel')
386
+ );
387
+ var allNodes = Array.from(document.querySelectorAll('li.rwa-step, td.rwa-step, table.rwa-parallel'));
388
+ allNodes.forEach(function (node) {
389
+ var stored = node.dataset.lastRunHash;
390
+ if (!stored) { node.classList.remove('stale'); syncBadges(node); return; }
391
+ var current;
392
+ if (topLevelChain.indexOf(node) >= 0) {
393
+ current = currentHashFor(node, topLevelChain);
394
+ } else {
395
+ current = hashStr(nodeFingerprint(node) + '::init');
396
+ }
397
+ if (stored !== current) node.classList.add('stale');
398
+ else node.classList.remove('stale');
399
+ syncBadges(node);
400
+ });
401
+ }
402
+
403
+ // v0.11: AbortController per Run, exposed as ctx.signal. The base ctx
404
+ // is built once; signal is overwritten at each Run via Object.assign so
405
+ // a stale signal from a previous Run doesn't leak into the next.
406
+ var ctx = {
407
+ credentials: {
408
+ get: async function (name) {
409
+ var key = 'rwa_cred_' + name;
410
+ var v = sessionStorage.getItem(key);
411
+ if (v) return v;
412
+ var entered = prompt('Credential for "' + name + '" (sessionStorage; cleared on tab close):');
413
+ if (entered) { sessionStorage.setItem(key, entered); return entered; }
414
+ return null;
415
+ },
416
+ },
417
+ signal: undefined, // set at Run-start
418
+ };
419
+ // Helper used by every runner entry-point to bail out cooperatively at
420
+ // step boundaries. Step bodies that pass ctx.signal to fetch() get
421
+ // immediate cancellation; otherwise the next boundary catches it.
422
+ function throwIfAborted(c) {
423
+ if (c && c.signal && c.signal.aborted) {
424
+ var e = new Error('abort_signaled');
425
+ e.code = 'abort_signaled';
426
+ throw e;
427
+ }
428
+ }
429
+
430
+ // v0.4: workflow runner is a recursive tree-walker over three primitives.
431
+ // Spec: docs/specs/rwa-workflow-spec.md.
432
+ // • Linear — <li class="rwa-step"> with a <script type="text/rwa-step">
433
+ // • Foreach — <li class="rwa-step rwa-foreach"> with a nested <ol class="rwa-flow">
434
+ // • Parallel— <table class="rwa-parallel"> with <tbody><tr><td class="rwa-step" data-rwa-label="...">
435
+ // Pin / dirty / test-step (v0.3) apply to LEAF nodes only — container nodes
436
+ // (foreach, parallel) don't carry data-pinned-output / data-last-output /
437
+ // data-last-run-hash in v0.4 (deferred to v0.5).
438
+ function RwaWorkflowError(code, message) {
439
+ var e = new Error(message || code);
440
+ e.code = code;
441
+ return e;
442
+ }
443
+ // Selectors. Top-level children of a <ol class="rwa-flow"> are either
444
+ // step <li> or parallel <table>. Leaf step nodes are <li class="rwa-step">
445
+ // without rwa-foreach, OR <td class="rwa-step"> inside a parallel block.
446
+ function isForeach(node) {
447
+ return node && node.matches && node.matches('li.rwa-step.rwa-foreach');
448
+ }
449
+ function isParallel(node) {
450
+ return node && node.matches && node.matches('table.rwa-parallel');
451
+ }
452
+ function isLeafStep(node) {
453
+ return node && node.matches && (
454
+ node.matches('li.rwa-step:not(.rwa-foreach)') ||
455
+ node.matches('td.rwa-step')
456
+ );
457
+ }
458
+ function flowChildren(ol) {
459
+ return Array.from(ol.children).filter(function (c) {
460
+ return c.matches('li.rwa-step, table.rwa-parallel');
461
+ });
462
+ }
463
+ function parallelCells(table) {
464
+ // All cells in DOM order (top-to-bottom, left-to-right). Used by
465
+ // nodeFingerprint for staleness — order in fingerprint string
466
+ // doesn't need to be column-grouped.
467
+ return Array.from(table.querySelectorAll(':scope > tbody > tr > td.rwa-step'));
468
+ }
469
+ // v0.9: extract columns from a multi-row parallel table. Each column
470
+ // is { label, cells[] } where cells are top-to-bottom in DOM order.
471
+ // Validates row count, column-label consistency, and overall label
472
+ // uniqueness. Single-row degenerates to one cell per column.
473
+ function parallelColumns(table) {
474
+ var labelRe = /^[a-z][a-z0-9_]{0,31}$/;
475
+ var tbody = table.querySelector(':scope > tbody');
476
+ if (!tbody) throw RwaWorkflowError('parallel_empty', 'parallel <table> has no <tbody>');
477
+ var rows = Array.from(tbody.querySelectorAll(':scope > tr'));
478
+ if (rows.length === 0) throw RwaWorkflowError('parallel_empty', 'parallel <table> has no rows');
479
+ // Each row's cells. All rows must be the same length.
480
+ var rowCells = rows.map(function (tr) {
481
+ return Array.from(tr.querySelectorAll(':scope > td.rwa-step'));
482
+ });
483
+ var colCount = rowCells[0].length;
484
+ if (colCount === 0) throw RwaWorkflowError('parallel_empty', 'parallel row has no <td class="rwa-step"> cells');
485
+ for (var r = 1; r < rowCells.length; r++) {
486
+ if (rowCells[r].length !== colCount) {
487
+ throw RwaWorkflowError('parallel_row_mismatch',
488
+ 'row ' + r + ' has ' + rowCells[r].length + ' cells, expected ' + colCount);
489
+ }
490
+ }
491
+ // Build columns. Validate per-column label consistency + format.
492
+ var columns = [];
493
+ for (var c = 0; c < colCount; c++) {
494
+ var colCells = rowCells.map(function (row) { return row[c]; });
495
+ var label = colCells[0].dataset.rwaLabel;
496
+ if (!label || !labelRe.test(label)) {
497
+ throw RwaWorkflowError('parallel_label_invalid',
498
+ 'column ' + c + ': data-rwa-label missing or invalid (got ' + JSON.stringify(label) + ')');
499
+ }
500
+ for (var k = 1; k < colCells.length; k++) {
501
+ if (colCells[k].dataset.rwaLabel !== label) {
502
+ throw RwaWorkflowError('parallel_label_mismatch',
503
+ 'column ' + c + ' row ' + k + ': label "' + colCells[k].dataset.rwaLabel + '" differs from row 0 label "' + label + '"');
504
+ }
505
+ }
506
+ columns.push({ label: label, cells: colCells });
507
+ }
508
+ // Label uniqueness across columns.
509
+ var seen = {};
510
+ for (var ci = 0; ci < columns.length; ci++) {
511
+ if (seen[columns[ci].label]) {
512
+ throw RwaWorkflowError('parallel_label_invalid', 'duplicate column label "' + columns[ci].label + '"');
513
+ }
514
+ seen[columns[ci].label] = true;
515
+ }
516
+ return columns;
517
+ }
518
+ async function runLeaf(node, prev, ctx) {
519
+ // v0.11: cooperative cancellation check at the step boundary.
520
+ throwIfAborted(ctx);
521
+ var sc = node.querySelector(':scope > details > script[type="text/rwa-step"]')
522
+ || node.querySelector('script[type="text/rwa-step"]');
523
+ if (!sc) throw RwaWorkflowError('step_missing_script', 'no <script type="text/rwa-step">');
524
+ // Pin short-circuit.
525
+ if (node.dataset.pinnedOutput != null) {
526
+ var pinned;
527
+ try { pinned = JSON.parse(node.dataset.pinnedOutput); }
528
+ catch (_) { throw RwaWorkflowError('pinned_value_invalid_json', 'pinned value is not valid JSON'); }
529
+ node.classList.add('done');
530
+ var outPin = node.querySelector(':scope > output.rwa-step-output')
531
+ || node.querySelector('output.rwa-step-output');
532
+ if (outPin) outPin.textContent = renderOutput(pinned);
533
+ cacheOutput(node, pinned);
534
+ return pinned;
535
+ }
536
+ node.classList.add('running');
537
+ try {
538
+ var fn = compile(sc);
539
+ var result = await fn(ctx, prev);
540
+ // compile() returns an async function; if the source body had no
541
+ // top-level "run" function, the wrapper resolves to undefined.
542
+ // That's user-acceptable; if the user expected a return, their
543
+ // step body should define run() and return from it.
544
+ node.classList.remove('running');
545
+ node.classList.add('done');
546
+ var out = node.querySelector(':scope > output.rwa-step-output')
547
+ || node.querySelector('output.rwa-step-output');
548
+ if (out) out.textContent = renderOutput(result);
549
+ cacheOutput(node, result);
550
+ // Hash chain. v0.4: top-level linear steps get full chain; nested
551
+ // leaves (inside foreach / parallel cell) use prevHash='init'.
552
+ // v0.8: chain includes foreach + parallel containers as siblings.
553
+ var topLevelChain = Array.from(
554
+ document.querySelectorAll('article.rwa-workflow > ol.rwa-flow > li.rwa-step, article.rwa-workflow > ol.rwa-flow > table.rwa-parallel')
555
+ );
556
+ if (topLevelChain.indexOf(node) >= 0) {
557
+ persistLastRunHash(node, currentHashFor(node, topLevelChain));
558
+ } else {
559
+ persistLastRunHash(node, hashStr(nodeFingerprint(node) + '::init'));
560
+ }
561
+ node.classList.remove('stale');
562
+ return result;
563
+ } catch (e) {
564
+ node.classList.remove('running');
565
+ node.classList.add('failed');
566
+ var outErr = node.querySelector(':scope > output.rwa-step-output')
567
+ || node.querySelector('output.rwa-step-output');
568
+ if (outErr) outErr.textContent = 'Error: ' + (e && e.message || e);
569
+ throw e;
570
+ }
571
+ }
572
+ async function runForeach(node, prev, ctx) {
573
+ throwIfAborted(ctx);
574
+ // v0.5: container pin short-circuit. Skip iteration entirely.
575
+ if (node.dataset.pinnedOutput != null) {
576
+ var pinned;
577
+ try { pinned = JSON.parse(node.dataset.pinnedOutput); }
578
+ catch (_) { throw RwaWorkflowError('pinned_value_invalid_json', 'foreach pinned value is not valid JSON'); }
579
+ node.classList.add('done');
580
+ var outFP = node.querySelector(':scope > output.rwa-step-output');
581
+ if (outFP) outFP.textContent = renderOutput(pinned);
582
+ cacheOutput(node, pinned);
583
+ return pinned;
584
+ }
585
+ if (!Array.isArray(prev)) {
586
+ node.classList.add('failed');
587
+ var outErrFE = node.querySelector(':scope > output.rwa-step-output');
588
+ if (outErrFE) outErrFE.textContent = 'Error: foreach upstream is not an array';
589
+ throw RwaWorkflowError('foreach_upstream_not_array',
590
+ 'foreach requires an array; upstream returned ' + (prev === null ? 'null' : typeof prev));
591
+ }
592
+ var innerOl = node.querySelector(':scope > ol.rwa-flow');
593
+ if (!innerOl) {
594
+ throw RwaWorkflowError('foreach_missing_body', 'foreach <li> has no inner <ol class="rwa-flow">');
595
+ }
596
+ var innerNodes = flowChildren(innerOl);
597
+ var perIter = [];
598
+ node.classList.add('running');
599
+ // Iteration counter chip: shows "1/N", "2/N", ... in the foreach header.
600
+ var header = node.querySelector(':scope > header');
601
+ var counter = null;
602
+ if (header) {
603
+ counter = document.createElement('span');
604
+ counter.className = 'rwa-iter-count';
605
+ counter.textContent = '0/' + prev.length;
606
+ var h3 = header.querySelector('h3');
607
+ if (h3) h3.appendChild(counter);
608
+ else header.appendChild(counter);
609
+ }
610
+ try {
611
+ for (var i = 0; i < prev.length; i++) {
612
+ if (counter) counter.textContent = (i + 1) + '/' + prev.length;
613
+ var iterCtx = Object.assign({}, ctx, {
614
+ iter: { index: i, item: prev[i], total: prev.length, parent: ctx.iter || undefined },
615
+ });
616
+ var innerPrev = prev[i];
617
+ for (var j = 0; j < innerNodes.length; j++) {
618
+ innerPrev = await runNode(innerNodes[j], innerPrev, iterCtx);
619
+ }
620
+ perIter.push(innerPrev);
621
+ }
622
+ node.classList.remove('running');
623
+ node.classList.add('done');
624
+ var outF = node.querySelector(':scope > output.rwa-step-output');
625
+ if (outF) outF.textContent = renderOutput(perIter);
626
+ cacheOutput(node, perIter);
627
+ // v0.8: write data-last-run-hash on the container for stale tracking.
628
+ // Use top-level chain if applicable; nested containers fall back to 'init'.
629
+ var topLevelFE = Array.from(
630
+ document.querySelectorAll('article.rwa-workflow > ol.rwa-flow > li.rwa-step, article.rwa-workflow > ol.rwa-flow > table.rwa-parallel')
631
+ );
632
+ if (topLevelFE.indexOf(node) >= 0) {
633
+ persistLastRunHash(node, currentHashFor(node, topLevelFE));
634
+ } else {
635
+ persistLastRunHash(node, hashStr(nodeFingerprint(node) + '::init'));
636
+ }
637
+ node.classList.remove('stale');
638
+ return perIter;
639
+ } catch (e) {
640
+ node.classList.remove('running');
641
+ node.classList.add('failed');
642
+ throw e;
643
+ }
644
+ }
645
+ async function runParallel(node, prev, ctx) {
646
+ throwIfAborted(ctx);
647
+ // v0.5: container pin short-circuit. Skip Promise.all entirely.
648
+ if (node.dataset.pinnedOutput != null) {
649
+ var pinnedP;
650
+ try { pinnedP = JSON.parse(node.dataset.pinnedOutput); }
651
+ catch (_) { throw RwaWorkflowError('pinned_value_invalid_json', 'parallel pinned value is not valid JSON'); }
652
+ node.classList.add('done');
653
+ cacheOutput(node, pinnedP);
654
+ return pinnedP;
655
+ }
656
+ // v0.9: extract column pipelines (single-row → 1 cell per column).
657
+ var columns = parallelColumns(node);
658
+ node.classList.add('running');
659
+ try {
660
+ // Each column is a sequential pipeline; columns run in parallel.
661
+ // Cell-level allow-failure (v0.6) is honored within each column:
662
+ // a failing cell with the flag substitutes an {__error, __code}
663
+ // object as the next cell's prev value; without the flag, the
664
+ // column promise rejects.
665
+ var settled = await Promise.allSettled(columns.map(async function (col) {
666
+ var colPrev = prev;
667
+ for (var i = 0; i < col.cells.length; i++) {
668
+ var cell = col.cells[i];
669
+ try {
670
+ colPrev = await runLeaf(cell, colPrev, ctx);
671
+ } catch (e) {
672
+ if (cell.dataset.allowFailure === 'true') {
673
+ colPrev = {
674
+ __error: (e && e.message) || String(e),
675
+ __code: (e && e.code) || null,
676
+ };
677
+ continue;
678
+ }
679
+ throw e;
680
+ }
681
+ }
682
+ return colPrev;
683
+ }));
684
+ var obj = {};
685
+ var firstFatal = null;
686
+ columns.forEach(function (col, i) {
687
+ var r = settled[i];
688
+ if (r.status === 'fulfilled') {
689
+ obj[col.label] = r.value;
690
+ } else if (!firstFatal) {
691
+ firstFatal = r.reason;
692
+ }
693
+ });
694
+ if (firstFatal) {
695
+ node.classList.remove('running');
696
+ node.classList.add('failed');
697
+ throw firstFatal;
698
+ }
699
+ node.classList.remove('running');
700
+ node.classList.add('done');
701
+ cacheOutput(node, obj);
702
+ // v0.8: write data-last-run-hash on the parallel container for stale tracking.
703
+ var topLevelP = Array.from(
704
+ document.querySelectorAll('article.rwa-workflow > ol.rwa-flow > li.rwa-step, article.rwa-workflow > ol.rwa-flow > table.rwa-parallel')
705
+ );
706
+ if (topLevelP.indexOf(node) >= 0) {
707
+ persistLastRunHash(node, currentHashFor(node, topLevelP));
708
+ } else {
709
+ persistLastRunHash(node, hashStr(nodeFingerprint(node) + '::init'));
710
+ }
711
+ node.classList.remove('stale');
712
+ return obj;
713
+ } catch (e) {
714
+ node.classList.remove('running');
715
+ node.classList.add('failed');
716
+ throw e;
717
+ }
718
+ }
719
+ async function runNode(node, prev, ctx) {
720
+ if (isForeach(node)) return runForeach(node, prev, ctx);
721
+ if (isParallel(node)) return runParallel(node, prev, ctx);
722
+ if (isLeafStep(node)) return runLeaf(node, prev, ctx);
723
+ throw RwaWorkflowError('unknown_node_type', 'unrecognized workflow node: ' + (node.tagName || '?'));
724
+ }
725
+ async function runWorkflow() {
726
+ if (NS.running) return;
727
+ NS.running = true;
728
+ // v0.11: fresh AbortController per Run. ctx.signal is shared with
729
+ // every step body that opts in (e.g. fetch(url, { signal: ctx.signal })).
730
+ // The runner also checks ctx.signal.aborted at each step boundary
731
+ // so unaware code still halts at the next safe point.
732
+ var controller = new AbortController();
733
+ ctx.signal = controller.signal;
734
+ NS.abortController = controller;
735
+ var btn = document.querySelector('.rwa-run');
736
+ setRunButtonState('running');
737
+ clearStepStates();
738
+ setStatus('● running…');
739
+ try {
740
+ var rootOl = document.querySelector('article.rwa-workflow > ol.rwa-flow')
741
+ || document.querySelector('ol.rwa-flow');
742
+ if (!rootOl) { setStatus('no workflow body to run'); return; }
743
+ var nodes = flowChildren(rootOl);
744
+ if (!nodes.length) { setStatus('no steps to run'); return; }
745
+ var prev;
746
+ for (var i = 0; i < nodes.length; i++) {
747
+ setStatus('● node ' + (i+1) + '/' + nodes.length);
748
+ prev = await runNode(nodes[i], prev, ctx);
749
+ }
750
+ NS.lastResult = prev;
751
+ setStatus('✓ done (' + nodes.length + ' node' + (nodes.length===1?'':'s') + ')');
752
+ } catch (e) {
753
+ var code = (e && (e.code || e.message)) || e;
754
+ if (code === 'abort_signaled') setStatus('✗ cancelled');
755
+ else setStatus('✗ ' + code);
756
+ if (code !== 'abort_signaled') console.error(e);
757
+ } finally {
758
+ NS.running = false;
759
+ NS.abortController = null;
760
+ setRunButtonState('idle');
761
+ recomputeStaleness();
762
+ refreshPinButtonStates();
763
+ }
764
+ }
765
+ // v0.11: toggle the .rwa-run button between Run / Cancel. The button
766
+ // stays the same element so existing click bindings keep working; we
767
+ // just swap its text + a class. Clicking while running aborts.
768
+ function setRunButtonState(state) {
769
+ var btn = document.querySelector('.rwa-run');
770
+ if (!btn) return;
771
+ if (state === 'running') {
772
+ btn.classList.add('rwa-run-cancel');
773
+ btn.textContent = 'Cancel';
774
+ btn.disabled = false;
775
+ } else {
776
+ btn.classList.remove('rwa-run-cancel');
777
+ btn.textContent = btn.dataset.runLabel || 'Run workflow';
778
+ btn.disabled = false;
779
+ }
780
+ }
781
+ function handleRunButtonClick() {
782
+ if (NS.running) {
783
+ if (NS.abortController) NS.abortController.abort();
784
+ } else {
785
+ runWorkflow();
786
+ }
787
+ }
788
+
789
+ // ---- Visual gestures (Phase 4 of workflow v0.2) ----
790
+ // Five gestures wire up here. Show/hide code is native <details> (no JS).
791
+ // Run is the .rwa-run click handler bound below. The remaining three
792
+ // (drag-to-reorder, ⋮-delete, +-insert-between) synthesize apply_edits
793
+ // envelopes and commit through runtime.applyEnvelope so they flow
794
+ // through the substrate's audit log, frozen-zone checks, shape-check,
795
+ // and undo stack. ⌘Z reverts them like any other commit.
796
+
797
+ function findStepInDoc(doc, dataRwaId) {
798
+ // v0.4-5: matches <li>, <td>, or <table> by data-rwa-id, requiring
799
+ // the class attribute to contain rwa-step OR rwa-parallel. Covers:
800
+ // • linear step <li class="rwa-step">
801
+ // • foreach <li class="rwa-step rwa-foreach"> (matches via rwa-step)
802
+ // • parallel cell <td class="rwa-step">
803
+ // • parallel container <table class="rwa-parallel"> (v0.5 — pinned via container pin gesture)
804
+ // Runner-managed attributes (data-pinned-output, data-last-output,
805
+ // data-last-run-hash) may appear in any order on the opening tag.
806
+ var idEscaped = dataRwaId.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
807
+ var classMatch = '\\\\bclass="(?:[^"]*\\\\s)?(?:rwa-step|rwa-parallel)(?:\\\\s[^"]*)?"';
808
+ var re = new RegExp(
809
+ '<(li|td|table)\\\\b(?=[^>]*\\\\bdata-rwa-id="' + idEscaped + '")(?=[^>]*' + classMatch + ')[^>]*>'
810
+ );
811
+ var m = re.exec(doc);
812
+ if (!m) return null;
813
+ var idx = m.index;
814
+ var openTag = m[0];
815
+ var tagName = m[1];
816
+ var closeTag = '</' + tagName + '>';
817
+ var close = doc.indexOf(closeTag, idx + openTag.length);
818
+ if (close < 0) return null;
819
+ return {
820
+ outerHTML: doc.substring(idx, close + closeTag.length),
821
+ openTag: openTag,
822
+ bodyAndClose: doc.substring(idx + openTag.length, close + closeTag.length),
823
+ tagName: tagName,
824
+ start: idx,
825
+ end: close + closeTag.length,
826
+ };
827
+ }
828
+
829
+ // Drag-to-reorder. Mutex via DRAGGED_ID — set on dragstart, read on drop.
830
+ var DRAGGED_ID = null;
831
+ function attachDragReorder(li) {
832
+ if (li.dataset.rwaDragWired === '1') return;
833
+ li.dataset.rwaDragWired = '1';
834
+ li.draggable = true;
835
+ li.addEventListener('dragstart', function (e) {
836
+ var id = li.dataset.rwaId;
837
+ if (!id) { e.preventDefault(); return; }
838
+ DRAGGED_ID = id;
839
+ try { e.dataTransfer.setData('text/rwa-step-id', id); } catch (_) {}
840
+ e.dataTransfer.effectAllowed = 'move';
841
+ li.classList.add('dragging');
842
+ });
843
+ li.addEventListener('dragover', function (e) {
844
+ if (!DRAGGED_ID || DRAGGED_ID === li.dataset.rwaId) return;
845
+ e.preventDefault();
846
+ e.dataTransfer.dropEffect = 'move';
847
+ li.classList.add('drop-target');
848
+ });
849
+ li.addEventListener('dragleave', function () { li.classList.remove('drop-target'); });
850
+ li.addEventListener('dragend', function () {
851
+ li.classList.remove('dragging');
852
+ document.querySelectorAll('.drop-target').forEach(function (el) { el.classList.remove('drop-target'); });
853
+ DRAGGED_ID = null;
854
+ });
855
+ li.addEventListener('drop', async function (e) {
856
+ e.preventDefault();
857
+ li.classList.remove('drop-target');
858
+ var sourceId = DRAGGED_ID || (e.dataTransfer && e.dataTransfer.getData && e.dataTransfer.getData('text/rwa-step-id'));
859
+ var targetId = li.dataset.rwaId;
860
+ if (!sourceId || !targetId || sourceId === targetId) return;
861
+ try {
862
+ var doc = await window.getDoc();
863
+ var srcMatch = findStepInDoc(doc, sourceId);
864
+ var tgtMatch = findStepInDoc(doc, targetId);
865
+ if (!srcMatch || !tgtMatch) { console.error('drag-reorder: step not in doc'); return; }
866
+ // Two-edit envelope: remove src <li>...<\/li>\\n; insert it right before
867
+ // target's opening tag. The two finds resolve against the original doc;
868
+ // replaces apply sequentially (substrate enforces this per rwa-edit-spec §5.1).
869
+ var envelope = {
870
+ version: 'rwa-edit/1',
871
+ edits: [
872
+ { find: srcMatch.outerHTML + '\\n', replace: '' },
873
+ { find: tgtMatch.outerHTML, replace: srcMatch.outerHTML + '\\n' + tgtMatch.outerHTML },
874
+ ],
875
+ };
876
+ await window.runtime.applyEnvelope(envelope, {
877
+ surface: 'visual:wf-drag-reorder',
878
+ instruction: 'reorder step',
879
+ });
880
+ } catch (err) { console.error('drag-reorder failed:', err); }
881
+ });
882
+ }
883
+
884
+ // Delete step (× button). Confirm + commit via apply_edits.
885
+ function attachDeleteButton(li) {
886
+ if (li.querySelector(':scope > .rwa-step-delete')) return;
887
+ var del = document.createElement('button');
888
+ del.type = 'button';
889
+ del.className = 'rwa-step-delete';
890
+ del.title = 'Delete step';
891
+ del.setAttribute('aria-label', 'Delete step');
892
+ del.textContent = '×';
893
+ del.addEventListener('click', async function (e) {
894
+ e.stopPropagation();
895
+ var id = li.dataset.rwaId;
896
+ if (!id) return;
897
+ var h3 = li.querySelector('h3');
898
+ var title = (h3 && h3.textContent) || 'this step';
899
+ if (!confirm('Delete "' + title + '"?')) return;
900
+ try {
901
+ var doc = await window.getDoc();
902
+ var match = findStepInDoc(doc, id);
903
+ if (!match) { console.error('delete: step not in doc'); return; }
904
+ var envelope = {
905
+ version: 'rwa-edit/1',
906
+ edits: [{ find: match.outerHTML + '\\n', replace: '' }],
907
+ };
908
+ await window.runtime.applyEnvelope(envelope, {
909
+ surface: 'visual:wf-delete-step',
910
+ instruction: 'delete step: ' + title,
911
+ });
912
+ } catch (err) { console.error('delete-step failed:', err); }
913
+ });
914
+ li.appendChild(del);
915
+ }
916
+
917
+ // Insert-between (+ button between cards). Opens the lens with a
918
+ // pre-filled prompt that anchors the agent's insertion on the preceding
919
+ // step's title. No substrate change needed — the lens flow handles it.
920
+ function attachInsertButtons() {
921
+ // v0.4: attach + buttons in every <ol class="rwa-flow"> (top-level
922
+ // AND inside foreach bodies). Parallel rows are not <ol>s, so cells
923
+ // don't get them — adding a parallel cell is a different gesture.
924
+ var ols = document.querySelectorAll('ol.rwa-flow');
925
+ ols.forEach(function (ol) {
926
+ ol.querySelectorAll(':scope > .rwa-step-insert').forEach(function (b) { b.remove(); });
927
+ var steps = Array.from(ol.querySelectorAll(':scope > li.rwa-step'));
928
+ steps.forEach(function (li) {
929
+ var h3 = li.querySelector('h3');
930
+ var title = (h3 && h3.textContent) || '';
931
+ var ins = document.createElement('button');
932
+ ins.type = 'button';
933
+ ins.className = 'rwa-step-insert';
934
+ ins.title = 'Insert a step after this one';
935
+ ins.setAttribute('aria-label', 'Insert step here');
936
+ ins.textContent = '+';
937
+ ins.dataset.afterStepTitle = title;
938
+ ins.addEventListener('click', function (ev) {
939
+ var afterTitle = ev.currentTarget.dataset.afterStepTitle || '';
940
+ var input = document.getElementById('rwa-lens-input');
941
+ if (!input) return;
942
+ var prefix = afterTitle
943
+ ? '/insert a step after the "' + afterTitle.replace(/"/g, '\\\\"') + '" step that '
944
+ : '/insert a step that ';
945
+ input.value = prefix;
946
+ input.focus();
947
+ try { input.setSelectionRange(prefix.length, prefix.length); } catch (_) {}
948
+ });
949
+ li.insertAdjacentElement('afterend', ins);
950
+ });
951
+ });
952
+ }
953
+
954
+ // v0.3: rewrite the <li>'s opening tag through the substrate's audit
955
+ // pipeline. Pinning is a user gesture that should be persisted (an
956
+ // unpin tomorrow needs to remember what was pinned today), so route
957
+ // through runtime.applyEnvelope like the other visual gestures.
958
+ //
959
+ // Subtle: runWorkflow mutates the live DOM with data-last-output and
960
+ // data-last-run-hash but those aren't in IDB until the next commit.
961
+ // If we only commit data-pinned-output, the next render replays
962
+ // IDB and wipes the run state — leaving step's .stale check with no
963
+ // baseline. So we snapshot the live <li>'s runner attrs at click time
964
+ // and commit them together with the pin gesture.
965
+ async function setPinnedAttribute(li, valueJson /* string or null */) {
966
+ var id = li.dataset.rwaId;
967
+ if (!id) return false;
968
+ var doc = await window.getDoc();
969
+ var match = findStepInDoc(doc, id);
970
+ if (!match) { console.warn('pin: step not in doc'); return false; }
971
+ // Snapshot the live runner-managed state. valueJson overrides pinned.
972
+ var snap = {};
973
+ if (li.dataset.lastOutput != null) snap['data-last-output'] = li.dataset.lastOutput;
974
+ if (li.dataset.lastRunHash != null) snap['data-last-run-hash'] = li.dataset.lastRunHash;
975
+ if (valueJson != null) snap['data-pinned-output'] = valueJson;
976
+ // Strip any existing runner attrs from the IDB openTag.
977
+ var newOpen = match.openTag
978
+ .replace(/\\s+data-pinned-output="[^"]*"/, '')
979
+ .replace(/\\s+data-last-output="[^"]*"/, '')
980
+ .replace(/\\s+data-last-run-hash="[^"]*"/, '');
981
+ // Re-add the snapshot. Encode HTML entities in values.
982
+ var keys = Object.keys(snap);
983
+ if (keys.length > 0) {
984
+ var attrsStr = '';
985
+ for (var k = 0; k < keys.length; k++) {
986
+ var name = keys[k];
987
+ var encoded = String(snap[name])
988
+ .replace(/&/g, '&amp;')
989
+ .replace(/"/g, '&quot;')
990
+ .replace(/</g, '&lt;');
991
+ attrsStr += ' ' + name + '="' + encoded + '"';
992
+ }
993
+ newOpen = newOpen.replace(/>$/, attrsStr + '>');
994
+ }
995
+ var newOuter = newOpen + match.bodyAndClose;
996
+ if (newOuter === match.outerHTML) return true;
997
+ var envelope = {
998
+ version: 'rwa-edit/1',
999
+ edits: [{ find: match.outerHTML, replace: newOuter }],
1000
+ };
1001
+ await window.runtime.applyEnvelope(envelope, {
1002
+ surface: 'visual:wf-' + (valueJson != null ? 'pin' : 'unpin') + '-step',
1003
+ instruction: (valueJson != null ? 'pin' : 'unpin') + ' step',
1004
+ });
1005
+ return true;
1006
+ }
1007
+
1008
+ // ▶ Test runs ONE step against the upstream's cached (or pinned) output.
1009
+ // v0.4: upstream lookup respects nesting.
1010
+ // • For a leaf <li.rwa-step> inside an <ol class="rwa-flow"> (top-level
1011
+ // OR nested in a foreach body): upstream = previous flow-child of
1012
+ // the same <ol> (either a <li.rwa-step> or a <table.rwa-parallel>).
1013
+ // • For a parallel cell <td.rwa-step>: upstream = previous flow-child
1014
+ // of the <ol> that contains the parallel <table>.
1015
+ // • If upstream lookup yields nothing, prev = undefined.
1016
+ // Does not persist through applyEnvelope — the run is transient like
1017
+ // runWorkflow's mutations. ⌘S captures it if the user wants to.
1018
+ function findTestUpstream(node) {
1019
+ var target;
1020
+ if (node.tagName === 'LI') {
1021
+ target = node;
1022
+ } else if (node.tagName === 'TD') {
1023
+ // Walk up to the parallel <table>; that's the unit positioned in
1024
+ // the containing flow.
1025
+ var table = node.closest('table.rwa-parallel');
1026
+ if (!table) return null;
1027
+ target = table;
1028
+ } else {
1029
+ return null;
1030
+ }
1031
+ var sibling = target.previousElementSibling;
1032
+ while (sibling) {
1033
+ if (sibling.matches && (sibling.matches('li.rwa-step') || sibling.matches('table.rwa-parallel'))) {
1034
+ return sibling;
1035
+ }
1036
+ sibling = sibling.previousElementSibling;
1037
+ }
1038
+ return null;
1039
+ }
1040
+ async function testStep(node) {
1041
+ if (NS.running) return;
1042
+ // v0.7: container test-step. Dispatch on node type — for foreach /
1043
+ // parallel containers, call their dedicated runners against the
1044
+ // upstream's cached value. The runner functions own classes /
1045
+ // output rendering / caching, so this path is just wiring.
1046
+ if (isForeach(node) || isParallel(node)) {
1047
+ return testContainer(node);
1048
+ }
1049
+ var sc = node.querySelector(':scope > details > script[type="text/rwa-step"]')
1050
+ || node.querySelector('script[type="text/rwa-step"]');
1051
+ if (!sc) return;
1052
+ node.classList.remove('done', 'failed', 'stale');
1053
+ node.classList.add('running');
1054
+ syncBadges(node);
1055
+ try {
1056
+ var prev;
1057
+ var upstream = findTestUpstream(node);
1058
+ if (upstream) {
1059
+ var src = upstream.dataset.pinnedOutput != null
1060
+ ? upstream.dataset.pinnedOutput
1061
+ : upstream.dataset.lastOutput;
1062
+ if (src != null) {
1063
+ try { prev = JSON.parse(src); } catch (_) { prev = src; }
1064
+ }
1065
+ }
1066
+ var fn = compile(sc);
1067
+ var result = await fn(ctx, prev);
1068
+ node.classList.remove('running');
1069
+ node.classList.add('done');
1070
+ var out = node.querySelector(':scope > output.rwa-step-output')
1071
+ || node.querySelector('output.rwa-step-output');
1072
+ if (out) out.textContent = renderOutput(result);
1073
+ cacheOutput(node, result);
1074
+ // Hash chain — see runLeaf for the top-level vs nested branch.
1075
+ var topLevelLinear = Array.from(
1076
+ document.querySelectorAll('article.rwa-workflow > ol.rwa-flow > li.rwa-step, article.rwa-workflow > ol.rwa-flow > table.rwa-parallel')
1077
+ );
1078
+ if (topLevelLinear.indexOf(node) >= 0) {
1079
+ persistLastRunHash(node, currentHashFor(node, topLevelLinear));
1080
+ } else {
1081
+ persistLastRunHash(node, hashStr(nodeFingerprint(node) + '::init'));
1082
+ }
1083
+ recomputeStaleness();
1084
+ refreshPinButtonStates();
1085
+ } catch (e) {
1086
+ node.classList.remove('running');
1087
+ node.classList.add('failed');
1088
+ var outErr = node.querySelector(':scope > output.rwa-step-output')
1089
+ || node.querySelector('output.rwa-step-output');
1090
+ if (outErr) outErr.textContent = 'Error: ' + (e && e.message || e);
1091
+ recomputeStaleness();
1092
+ refreshPinButtonStates();
1093
+ }
1094
+ }
1095
+
1096
+ // v0.7: container test-step. Runs the whole subtree against the
1097
+ // upstream's cached value via the same runForeach / runParallel paths
1098
+ // that runWorkflow uses.
1099
+ async function testContainer(node) {
1100
+ if (NS.running) return;
1101
+ NS.running = true;
1102
+ var btn = document.querySelector('.rwa-run');
1103
+ if (btn) btn.disabled = true;
1104
+ try {
1105
+ var prev;
1106
+ var upstream = findTestUpstream(node);
1107
+ if (upstream) {
1108
+ var src = upstream.dataset.pinnedOutput != null
1109
+ ? upstream.dataset.pinnedOutput
1110
+ : upstream.dataset.lastOutput;
1111
+ if (src != null) {
1112
+ try { prev = JSON.parse(src); } catch (_) { prev = src; }
1113
+ }
1114
+ }
1115
+ // Clear classes/output on this subtree before running. We don't
1116
+ // clear the WHOLE doc — only the descendants of this container.
1117
+ var subtreeNodes = node.querySelectorAll('li.rwa-step, td.rwa-step, table.rwa-parallel');
1118
+ subtreeNodes.forEach(function (n) {
1119
+ n.classList.remove('running', 'done', 'failed');
1120
+ var out = null;
1121
+ for (var k = 0; k < n.children.length; k++) {
1122
+ if (n.children[k].tagName === 'OUTPUT') { out = n.children[k]; break; }
1123
+ }
1124
+ if (out) out.textContent = '';
1125
+ });
1126
+ // Also clear the container's own visible output + class.
1127
+ node.classList.remove('done', 'failed');
1128
+ for (var k0 = 0; k0 < node.children.length; k0++) {
1129
+ if (node.children[k0].tagName === 'OUTPUT') { node.children[k0].textContent = ''; break; }
1130
+ }
1131
+ await runNode(node, prev, ctx);
1132
+ recomputeStaleness();
1133
+ refreshPinButtonStates();
1134
+ } catch (e) {
1135
+ console.error('test-container failed:', e);
1136
+ // runForeach / runParallel already marked the node .failed
1137
+ } finally {
1138
+ NS.running = false;
1139
+ if (btn) btn.disabled = false;
1140
+ }
1141
+ }
1142
+
1143
+ // ▶/📌 toolbar on each step. Hover-revealed, sits left of the existing
1144
+ // × delete button. Pin button is disabled when there's nothing to pin
1145
+ // (no cached output yet and not currently pinned).
1146
+ // v0.5: containers (foreach <li>, parallel <table>) get a pin button.
1147
+ // v0.7: containers also get a test (▶) button — runs the container's
1148
+ // subtree against upstream's cached value.
1149
+ function attachStepToolbar(node) {
1150
+ if (node.querySelector(':scope > .rwa-step-toolbar')) return;
1151
+ var isContainer = (node.matches && node.matches('li.rwa-step.rwa-foreach, table.rwa-parallel'));
1152
+ var toolbar = document.createElement('span');
1153
+ toolbar.className = 'rwa-step-toolbar';
1154
+ var testBtn = document.createElement('button');
1155
+ testBtn.type = 'button';
1156
+ testBtn.className = 'rwa-test-btn';
1157
+ testBtn.title = isContainer
1158
+ ? 'Test this container (runs subtree against upstream\\'s cached output)'
1159
+ : 'Test this step (uses upstream\\'s cached output)';
1160
+ testBtn.setAttribute('aria-label', isContainer ? 'Test this container' : 'Test this step');
1161
+ testBtn.textContent = '▶';
1162
+ testBtn.addEventListener('click', function (e) {
1163
+ e.stopPropagation();
1164
+ testStep(node);
1165
+ });
1166
+ toolbar.appendChild(testBtn);
1167
+ var pinBtn = document.createElement('button');
1168
+ pinBtn.type = 'button';
1169
+ pinBtn.className = 'rwa-pin-btn';
1170
+ var isPinned = node.dataset.pinnedOutput != null;
1171
+ pinBtn.title = isPinned ? 'Unpin' : (isContainer ? 'Pin this container\\'s output' : 'Pin this step\\'s output');
1172
+ pinBtn.setAttribute('aria-label', 'Pin or unpin');
1173
+ pinBtn.textContent = '📌';
1174
+ if (!isPinned && node.dataset.lastOutput == null) {
1175
+ pinBtn.disabled = true;
1176
+ pinBtn.title = isContainer ? 'Run the workflow first to enable pinning' : 'Run this step first to enable pinning';
1177
+ }
1178
+ // Pin commits via findStepInDoc which requires data-rwa-id. Substrate
1179
+ // 0.11 added TABLE/TD to ANCHORABLE_TAGS so the auto-backfill covers
1180
+ // these in fresh containers. Legacy containers without ids gain them
1181
+ // on first commit. As a defensive backstop, disable the pin button if
1182
+ // an id is missing — the user just needs to ⌘S once to populate it.
1183
+ if ((node.tagName === 'TABLE' || node.tagName === 'TD') && !node.dataset.rwaId) {
1184
+ pinBtn.disabled = true;
1185
+ pinBtn.title = 'No data-rwa-id on this node yet — commit (⌘S) to populate it';
1186
+ }
1187
+ pinBtn.addEventListener('click', async function (e) {
1188
+ e.stopPropagation();
1189
+ try {
1190
+ if (node.dataset.pinnedOutput != null) {
1191
+ await setPinnedAttribute(node, null);
1192
+ } else {
1193
+ var cached = node.dataset.lastOutput;
1194
+ if (cached == null) return;
1195
+ try { JSON.parse(cached); } catch (_) { return; }
1196
+ await setPinnedAttribute(node, cached);
1197
+ }
1198
+ } catch (err) {
1199
+ console.error('pin-toggle failed:', err);
1200
+ }
1201
+ });
1202
+ toolbar.appendChild(pinBtn);
1203
+ node.appendChild(toolbar);
1204
+ }
1205
+
1206
+ function attachGestures() {
1207
+ // v0.8: restore runner state from the session-state cache before
1208
+ // anything else. applyEnvelope-driven re-renders wipe data-last-output
1209
+ // and data-last-run-hash from the DOM (they live in live mutations,
1210
+ // not IDB); the cache survives renders so stale detection still works
1211
+ // after an edit lands.
1212
+ restoreSessionState();
1213
+ syncPinnedClasses();
1214
+ // Drag-reorder: top-level <li.rwa-step> only (v0.4 doesn't reorder
1215
+ // inside foreach bodies or across parallel cells).
1216
+ document.querySelectorAll('article.rwa-workflow > ol.rwa-flow > li.rwa-step').forEach(function (li) {
1217
+ attachDragReorder(li);
1218
+ });
1219
+ // Delete button: any step node (linear, foreach container, parallel cell).
1220
+ document.querySelectorAll('li.rwa-step, td.rwa-step').forEach(function (node) {
1221
+ attachDeleteButton(node);
1222
+ });
1223
+ // Toolbar: leaves get ▶ test + 📌 pin; containers get just 📌 pin (v0.5).
1224
+ document.querySelectorAll('li.rwa-step:not(.rwa-foreach), td.rwa-step, li.rwa-step.rwa-foreach, table.rwa-parallel').forEach(function (node) {
1225
+ attachStepToolbar(node);
1226
+ });
1227
+ attachInsertButtons();
1228
+ recomputeStaleness();
1229
+ refreshPinButtonStates();
1230
+ }
1231
+
1232
+ // Re-bind on every renderDoc — the previous button is gone after the
1233
+ // innerHTML swap. The renderer re-executes inline scripts on every render
1234
+ // (per docs/specs/rwa-artifact-conventions.md §6.1), so this IIFE runs
1235
+ // again and re-attaches the click handler + all gestures to the freshly
1236
+ // rendered DOM.
1237
+ var btn = document.querySelector('.rwa-run');
1238
+ if (btn) {
1239
+ // v0.11: stash the idle label so the Cancel/Run toggle can restore it.
1240
+ btn.dataset.runLabel = (btn.textContent || '').trim() || 'Run workflow';
1241
+ btn.addEventListener('click', handleRunButtonClick);
1242
+ }
1243
+ attachGestures();
1244
+ })();
1245
+ </script>
1246
+ <!-- rwa:frozen:end runner -->`;
1247
+ const KIND_WORKFLOW_LENS = 'Describe what you want this workflow to do.';
1248
+ const KIND_WORKFLOW_PAL = 'describe what this workflow does...';
1249
+
1250
+ // PRODUCT HEADER for the workflow kind (v0.2 — UX-design alignment).
1251
+ // Names the canonical shape (ordered list of step cards with inline async
1252
+ // run(ctx, prev)), the credential surface (ctx.credentials.get), the CORS
1253
+ // reality, and the deliberate v0.2 omissions.
1254
+ const KIND_WORKFLOW_HEADER = `// === PRODUCT HEADER ===
1255
+ // Product: workflow (substrate-layer scaffold, v0.2).
1256
+ //
1257
+ // The file renders as a vertical <ol class="rwa-flow"> of step cards
1258
+ // (<li class="rwa-step">). Each step has a <header> (title + one-sentence
1259
+ // description), a collapsible <details> wrapping an inert
1260
+ // <script type="text/rwa-step"> with the step's inline JS, and an
1261
+ // <output class="rwa-step-output"> slot for the last-run result. A frozen
1262
+ // runner at the bottom walks the steps on Run click, compiles each
1263
+ // script's body (which must define an async function "run" with signature
1264
+ // run(ctx, prev)), and threads return values as "prev" into the next
1265
+ // step. The runner writes results into the step's output, marks the
1266
+ // step .done / .failed, and stops the chain on first error.
1267
+ //
1268
+ // Credentials: ctx.credentials.get("name") reads sessionStorage with
1269
+ // prompt-on-first-use. Never persisted in INLINE_DOC; cleared on tab
1270
+ // close. Use readable names: "gmail", "stripe", "github".
1271
+ //
1272
+ // CORS reality: browser fetch is CORS-bound. CORS-friendly APIs work
1273
+ // (Stripe, OpenAI, GitHub, Slack, Linear, OpenRouter). Most consumer
1274
+ // SaaS without OAuth proxies (Gmail, etc.) does not.
1275
+ //
1276
+ // v0.2 deliberately ships WITHOUT: credential vault encryption (sessionStorage
1277
+ // only), skill library / cross-workflow reuse, trigger model (manual Run
1278
+ // only), Worker isolation, branches / parallel execution, scheduling,
1279
+ // retry / resume. The trust anchor is workflow review at creation: the
1280
+ // user sees each generated step's JS in the collapsible <details> before
1281
+ // accepting. See docs/specs/re-write-able-actions-spec-v0.7.md and its
1282
+ // lineage for where the omitted features are designed; see
1283
+ // docs/plans/2026-05-18-workflow-ux-design.md for the v0.2 UX spec.
1284
+ // === END PRODUCT HEADER ===`;
1285
+
1286
+ const KIND_PRESENTATION_LENS = 'Add a slide, or describe a change.';
1287
+ const KIND_PRESENTATION_PAL = 'edit this deck...';
1288
+
1289
+ // PRODUCT HEADER for the presentation kind (render mode, spec §5.10).
1290
+ const KIND_PRESENTATION_HEADER = `// === PRODUCT HEADER ===
1291
+ // Product: presentation (substrate layer, render mode — spec §5.10).
1292
+ //
1293
+ // The stored document is ORDINARY prose HTML — one <article> of <h1>/<h2>
1294
+ // headings and prose. A first-party 'view' provider (bootstrap-resident) DISPLAYS
1295
+ // it as a slide deck by wrapping content at each <h1>/<h2> boundary into a
1296
+ // <section class="rwa-slide"> at render time. The wrapping is display-only: it
1297
+ // never reaches rwa_doc, never the agent (Invariants 8-9). Toggle 'Present' in
1298
+ // the status bar to activate; ArrowLeft/Right and PageUp/Down navigate; printing
1299
+ // renders the deck as a linear document. The agent edits the prose, not the
1300
+ // slides — "add a slide" = add an <h2> + body. See docs/specs/rwa-product-types.md
1301
+ // and re-write-able-spec.md §5.10.
1302
+ // === END PRODUCT HEADER ===`;
1303
+
1304
+ // Real starter content (never lorem). Three slides keyed on h1/h2 boundaries.
1305
+ const KIND_PRESENTATION_BODY = `<article>
1306
+ <h1>re-write-able</h1>
1307
+ <p>A single self-contained <code>.html</code> file that renders, stores, edits, and exports itself — no server, no build step.</p>
1308
+ <p>One file is the whole application and the whole archive.</p>
1309
+
1310
+ <h2>The rewrite loop</h2>
1311
+ <p>Press the lens. Hand the document to a model. Get back surgical edits on unique anchors, committed atomically, then re-render.</p>
1312
+ <p>The bootstrap never moves; only the inline document snapshot changes between commits.</p>
1313
+
1314
+ <h2>One substrate, many views</h2>
1315
+ <p>The same prose can render as a document or — through a registered view provider — as this slide deck.</p>
1316
+ <p>The view is a pure re-presentation at render time; the bytes on disk never change shape.</p>
1317
+ </article>`;
1318
+
1319
+ // ── skill-host (v0.8 actions spec §2) ──────────────────────────────────────
1320
+ const KIND_SKILLHOST_LENS = 'Describe a change to this skill host.';
1321
+ const KIND_SKILLHOST_PAL = 'edit this skill host...';
1322
+
1323
+ const KIND_SKILLHOST_HEADER = `// === PRODUCT HEADER ===
1324
+ // Product: skill-host (skill layer — docs/specs/re-write-able-actions-spec-v0.8.md).
1325
+ //
1326
+ // Hosts permission-gated SKILLS installed from .rwa-skill.json files. Each
1327
+ // installed skill's {manifest, code, signature} is stored, base64-encoded, in the
1328
+ // runtime-owned frozen zone <div data-rwa-frozen id="rwa-skills"> — the agent/lens
1329
+ // can never write it (the data-rwa-frozen snapshot guard); only the runtime
1330
+ // rewrites it on install/update/uninstall via a registry-aware commit. Every skill
1331
+ // runs in a Web Worker (compute = bridgeless; network:/vault: = bridged), so the
1332
+ // install dialog's "a skill cannot reach an origin or vault namespace it didn't
1333
+ // declare" holds for every kind. Installed skills are reported through
1334
+ // self-description/1 as tool/compute providers (provenance:'installed'). See the
1335
+ // v0.8 spec §§2,5-8 and docs/plans/2026-06-03-skill-layer-v08-build-plan.md.
1336
+ // === END PRODUCT HEADER ===`;
1337
+
1338
+ // Stub: an editable intro article + the EMPTY runtime-owned frozen skill zone.
1339
+ const KIND_SKILLHOST_BODY = `<article>
1340
+ <h1>Skill host</h1>
1341
+ <p>This is a re-writeable <strong>skill host</strong>. Install skills from a <code>.rwa-skill.json</code> file; each runs in an isolated worker, limited to the network and credential permissions you approve at install.</p>
1342
+ <p><button onclick="runtime.promptInstall()" style="padding:9px 16px;border:none;border-radius:10px;background:var(--gray-900,#111);color:#fff;font:inherit;cursor:pointer">Install a skill…</button></p>
1343
+ </article>
1344
+ <div data-rwa-frozen id="rwa-skills"></div>`;
1345
+
1346
+ const KIND_TABLE = {
1347
+ document: {
1348
+ body: null, // pass through seed default
1349
+ lensPlaceholder: null, // pass through seed default
1350
+ palPlaceholder: null, // pass through seed default
1351
+ productHeader: null, // pass through seed default
1352
+ lensClickToAnchor: null, // pass through seed default (true)
1353
+ },
1354
+ workflow: {
1355
+ body: KIND_WORKFLOW_BODY,
1356
+ lensPlaceholder: KIND_WORKFLOW_LENS,
1357
+ palPlaceholder: KIND_WORKFLOW_PAL,
1358
+ productHeader: KIND_WORKFLOW_HEADER,
1359
+ lensClickToAnchor: false, // audit R3 scoped — workflow stages are <li>-anchorable
1360
+ },
1361
+ presentation: {
1362
+ body: KIND_PRESENTATION_BODY,
1363
+ lensPlaceholder: KIND_PRESENTATION_LENS,
1364
+ palPlaceholder: KIND_PRESENTATION_PAL,
1365
+ productHeader: KIND_PRESENTATION_HEADER,
1366
+ // Whole-deck lens semantics: edits go through the docked lens, not by
1367
+ // anchoring on a slide's paragraph. The provider CODE is bootstrap-resident
1368
+ // (spec §5.10); this kind only sets PRODUCT_KIND + starter/framing/lens.
1369
+ lensClickToAnchor: false,
1370
+ },
1371
+ 'skill-host': {
1372
+ body: KIND_SKILLHOST_BODY,
1373
+ lensPlaceholder: KIND_SKILLHOST_LENS,
1374
+ palPlaceholder: KIND_SKILLHOST_PAL,
1375
+ productHeader: KIND_SKILLHOST_HEADER,
1376
+ // Not prose-anchored: the editable surface is the intro; installed skills live
1377
+ // in the runtime-owned frozen zone, not authored by clicking a paragraph.
1378
+ lensClickToAnchor: false,
1379
+ },
1380
+ // app, workspace: reserved — wire when the templates land. The CLI rejects
1381
+ // unknown kinds explicitly rather than silently emitting a document.
1382
+ };
1383
+
1384
+ export const KNOWN_KINDS = Object.keys(KIND_TABLE);
1385
+
1386
+ export function kindOverrides(kind) {
1387
+ if (!KIND_TABLE[kind]) {
1388
+ throw new Error(`unknown kind "${kind}". Known kinds: ${KNOWN_KINDS.join(', ')}`);
1389
+ }
1390
+ return KIND_TABLE[kind];
1391
+ }
1392
+
38
1393
  // Mirrors the bootstrap's escapeTL — keep in sync with seeds/rewritable.html.
39
1394
  // LF-canonicalizes first; rwa-edit/1 invariant is that on-disk docs are LF-only.
40
1395
  const canonLF = s => s == null ? '' : String(s).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
@@ -60,10 +1415,37 @@ export function replaceInlineDoc(seed, newDoc) {
60
1415
  return seed.slice(0, cs) + escapeTL(newDoc) + seed.slice(i);
61
1416
  }
62
1417
 
1418
+ // Inverse of replaceInlineDoc — walk the INLINE_DOC backticks and return the
1419
+ // body string with escapeTL's substitutions reversed. Pairs with the runtime
1420
+ // agent contract: the agent only ever sees the unescaped doc bytes.
1421
+ export function extractInlineDoc(seed) {
1422
+ const start = seed.indexOf(INLINE_DOC_MARKER);
1423
+ if (start < 0) throw new Error('cannot locate INLINE_DOC marker in seed');
1424
+ const cs = start + INLINE_DOC_MARKER.length;
1425
+ let i = cs;
1426
+ while (i < seed.length) {
1427
+ if (seed[i] === '\\') { i += 2; continue; }
1428
+ if (seed[i] === '`') break;
1429
+ i++;
1430
+ }
1431
+ if (i >= seed.length) throw new Error('unterminated INLINE_DOC literal in seed');
1432
+ const body = seed.slice(cs, i);
1433
+ // Inverse of escapeTL — order matters (mirror reverse).
1434
+ return body
1435
+ .replace(/<\\\/script/gi, '</script')
1436
+ .replace(/\\\$\{/g, '${')
1437
+ .replace(/\\`/g, '`')
1438
+ .replace(/\\\\/g, '\\');
1439
+ }
1440
+
63
1441
  function escapeHtml(s) {
64
1442
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
65
1443
  }
66
1444
 
67
1445
  function escapeJsString(s) {
68
- return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1446
+ // </script must be escaped — escapeJsString is used to inject filename and
1447
+ // placeholder values into JS string literals that live inside the bootstrap
1448
+ // <script> block. A value containing </script> would close the tag early and
1449
+ // turn the rest into HTML (stored XSS). Matches escapeTL's </script handling.
1450
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/<\/script/gi, '<\\/script');
69
1451
  }