ralphflow 0.5.0 → 0.5.2

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.
@@ -0,0 +1,151 @@
1
+ // Notification and decision handling, audio chimes, browser notifications.
2
+
3
+ import { state, actions } from './state.js';
4
+ import { fetchJson, extractNotifMessage, esc } from './utils.js';
5
+
6
+ export async function fetchNotifications() {
7
+ try {
8
+ const data = await fetchJson('/api/notifications');
9
+ state.notificationsList = Array.isArray(data) ? data : [];
10
+ actions.renderSidebar();
11
+ actions.renderContent();
12
+ } catch { /* ignore */ }
13
+ }
14
+
15
+ export async function dismissNotification(id) {
16
+ try {
17
+ await fetch(`/api/notification/${encodeURIComponent(id)}`, { method: 'DELETE' });
18
+ state.notificationsList = state.notificationsList.filter(n => n.id !== id);
19
+ actions.renderSidebar();
20
+ actions.renderContent();
21
+ } catch { /* ignore */ }
22
+ }
23
+
24
+ export function maybeRequestNotifPermission() {
25
+ if (state.notifPermissionRequested) return;
26
+ if (!('Notification' in window)) return;
27
+ if (Notification.permission === 'default') {
28
+ state.notifPermissionRequested = true;
29
+ Notification.requestPermission();
30
+ }
31
+ }
32
+
33
+ export function showBrowserNotification(n) {
34
+ if (!('Notification' in window)) return;
35
+ if (Notification.permission !== 'granted') return;
36
+ if (document.hasFocus()) return;
37
+ const msg = extractNotifMessage(n.payload);
38
+ new Notification('RalphFlow — ' + (n.loop || 'Notification'), { body: msg });
39
+ }
40
+
41
+ export async function fetchDecisions() {
42
+ try {
43
+ const data = await fetchJson('/api/decisions');
44
+ state.decisionsList = Array.isArray(data) ? data : [];
45
+ actions.renderSidebar();
46
+ actions.renderContent();
47
+ } catch { /* ignore */ }
48
+ }
49
+
50
+ export async function dismissDecision(id) {
51
+ try {
52
+ await fetch(`/api/decision/${encodeURIComponent(id)}`, { method: 'DELETE' });
53
+ state.decisionsList = state.decisionsList.filter(d => d.id !== id);
54
+ actions.renderSidebar();
55
+ actions.renderContent();
56
+ } catch { /* ignore */ }
57
+ }
58
+
59
+ export function showBrowserDecisionNotification(d) {
60
+ if (!('Notification' in window)) return;
61
+ if (Notification.permission !== 'granted') return;
62
+ if (document.hasFocus()) return;
63
+ new Notification('RalphFlow — Decision: ' + (d.item || d.loop), {
64
+ body: d.decision + (d.reasoning ? ' — ' + d.reasoning : ''),
65
+ });
66
+ }
67
+
68
+ export function renderDecisionGroups(decisions) {
69
+ // Group by item (STORY-N, TASK-N)
70
+ const byItem = {};
71
+ for (const d of decisions) {
72
+ const key = d.item || 'Other';
73
+ if (!byItem[key]) byItem[key] = [];
74
+ byItem[key].push(d);
75
+ }
76
+
77
+ let html = '';
78
+ for (const [item, itemDecisions] of Object.entries(byItem)) {
79
+ const groupId = 'dg-' + item.replace(/[^a-zA-Z0-9-]/g, '_');
80
+ html += `<div class="decision-group">
81
+ <div class="decision-group-header" data-decision-group="${esc(groupId)}">
82
+ <span class="group-chevron expanded">&#9654;</span>
83
+ <span class="group-label">${esc(item)}</span>
84
+ <span class="group-count">${itemDecisions.length}</span>
85
+ </div>
86
+ <div class="decision-group-body" id="${esc(groupId)}">`;
87
+
88
+ for (const d of itemDecisions) {
89
+ const time = new Date(d.timestamp).toLocaleTimeString();
90
+ html += `<div class="decision-card" data-decision-id="${esc(d.id)}">
91
+ <div class="decision-card-top">
92
+ <span class="decision-time">${esc(time)}</span>
93
+ <span class="decision-agent">${esc(d.agent)}</span>
94
+ <button class="decision-dismiss" data-dismiss-decision-id="${esc(d.id)}">&times;</button>
95
+ </div>
96
+ <div class="decision-summary">${esc(d.decision)}</div>
97
+ ${d.reasoning ? '<div class="decision-reasoning">' + esc(d.reasoning) + '</div>' : ''}
98
+ </div>`;
99
+ }
100
+ html += '</div></div>';
101
+ }
102
+ return html;
103
+ }
104
+
105
+ export function initAudioContext() {
106
+ if (state.audioCtxInitialized) return;
107
+ state.audioCtxInitialized = true;
108
+ try {
109
+ state.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
110
+ } catch (e) {
111
+ // Silent fail — audio is best-effort
112
+ }
113
+ }
114
+
115
+ export function playNotificationChime() {
116
+ if (!state.audioCtx) return;
117
+ try {
118
+ const now = state.audioCtx.currentTime;
119
+ // First tone: E5 (659 Hz), 120ms
120
+ const osc1 = state.audioCtx.createOscillator();
121
+ const gain1 = state.audioCtx.createGain();
122
+ osc1.type = 'sine';
123
+ osc1.frequency.value = 659;
124
+ gain1.gain.setValueAtTime(0.15, now);
125
+ gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
126
+ osc1.connect(gain1);
127
+ gain1.connect(state.audioCtx.destination);
128
+ osc1.start(now);
129
+ osc1.stop(now + 0.12);
130
+ // Second tone: A5 (880 Hz), 150ms, starts 80ms after first
131
+ const osc2 = state.audioCtx.createOscillator();
132
+ const gain2 = state.audioCtx.createGain();
133
+ osc2.type = 'sine';
134
+ osc2.frequency.value = 880;
135
+ gain2.gain.setValueAtTime(0, now + 0.08);
136
+ gain2.gain.linearRampToValueAtTime(0.12, now + 0.1);
137
+ gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
138
+ osc2.connect(gain2);
139
+ gain2.connect(state.audioCtx.destination);
140
+ osc2.start(now + 0.08);
141
+ osc2.stop(now + 0.25);
142
+ } catch (e) {
143
+ // Silent fail
144
+ }
145
+ }
146
+
147
+ export function onFirstInteraction() {
148
+ initAudioContext();
149
+ document.removeEventListener('click', onFirstInteraction);
150
+ document.removeEventListener('keydown', onFirstInteraction);
151
+ }
@@ -0,0 +1,362 @@
1
+ // Prompt configuration form and prompt generation engine.
2
+ // Constants: PROMPT_CAPABILITIES.
3
+
4
+ import { state, dom, actions } from './state.js';
5
+
6
+ export const PROMPT_CAPABILITIES = [
7
+ { id: 'webSearch', label: 'Web search', desc: 'Search the internet for information' },
8
+ { id: 'mcpServers', label: 'MCP servers', desc: 'Connect to specific MCP tools' },
9
+ { id: 'exploreAgents', label: 'Explore agents', desc: 'Use Claude Code\'s Agent tool to explore the codebase' },
10
+ { id: 'fileReadWrite', label: 'File read/write', desc: 'Read or modify specific file types' },
11
+ { id: 'bashCommands', label: 'Bash commands', desc: 'Run shell commands, build, deploy' },
12
+ { id: 'codeEditing', label: 'Code editing', desc: 'Modify source code files' }
13
+ ];
14
+
15
+ export function syncStageConfigs(loop) {
16
+ const existing = loop.stageConfigs || [];
17
+ loop.stageConfigs = loop.stages.map(stageName => {
18
+ const found = existing.find(sc => sc.name === stageName);
19
+ if (found) return found;
20
+ const caps = {};
21
+ PROMPT_CAPABILITIES.forEach(c => { caps[c.id] = false; });
22
+ return { name: stageName, description: '', capabilities: caps };
23
+ });
24
+ }
25
+
26
+ export function createEmptyLoop() {
27
+ return {
28
+ name: '',
29
+ stages: [],
30
+ completion: 'LOOP COMPLETE',
31
+ model: 'claude-sonnet-4-6',
32
+ multi_agent: false,
33
+ max_agents: 3,
34
+ strategy: 'parallel',
35
+ agent_placeholder: '{{AGENT_NAME}}',
36
+ data_files: [],
37
+ entities: [],
38
+ showOptional: false,
39
+ showPrompt: false,
40
+ prompt: '',
41
+ inputFiles: '',
42
+ outputFiles: '',
43
+ stageConfigs: [],
44
+ showPromptForm: true,
45
+ claudeArgs: '',
46
+ skipPermissions: true,
47
+ _outputAutoFilled: true,
48
+ _inputAutoFilled: true
49
+ };
50
+ }
51
+
52
+ export function initTemplateBuilderState() {
53
+ return { name: '', description: '', loops: [createEmptyLoop()] };
54
+ }
55
+
56
+ // -----------------------------------------------------------------------
57
+ // Prompt config form — structured input for prompt generation
58
+ // -----------------------------------------------------------------------
59
+
60
+ export function renderPromptConfigForm(loopIdx, loop, allLoops) {
61
+ let html = '<div class="prompt-config-form" data-config-form-idx="' + loopIdx + '">';
62
+ html += '<div class="prompt-generate-card">';
63
+ html += '<div class="prompt-generate-info">Generate a structured prompt from your loop configuration &mdash; stages, input/output files, and model settings will be combined into a ready-to-use agent prompt.</div>';
64
+ html += `<button class="btn btn-primary generate-prompt-btn" data-generate-prompt="${loopIdx}">Generate Prompt</button>`;
65
+ html += '</div>';
66
+ html += '</div>';
67
+ return html;
68
+ }
69
+
70
+ // -----------------------------------------------------------------------
71
+ // Visual Communication Protocol for generated prompts
72
+ // -----------------------------------------------------------------------
73
+
74
+ function generateVisualProtocol() {
75
+ let s = '';
76
+ s += `## Visual Communication Protocol\n\n`;
77
+ s += `When communicating scope, structure, relationships, or status, render **ASCII diagrams** using Unicode box-drawing characters. These help the user see the full picture at the terminal without scrolling through prose.\n\n`;
78
+ s += `**Character set:** \`┌ ─ ┐ │ └ ┘ ├ ┤ ┬ ┴ ┼ ═ ● ○ ▼ ▶\`\n\n`;
79
+ s += `**Diagram types to use:**\n\n`;
80
+ s += `- **Scope/Architecture Map** — components and their relationships in a bordered grid\n`;
81
+ s += `- **Decomposition Tree** — hierarchical breakdown with \`├──\` and \`└──\` branches\n`;
82
+ s += `- **Data Flow** — arrows (\`──→\`) showing how information moves between components\n`;
83
+ s += `- **Comparison Table** — bordered table for trade-offs and design options\n`;
84
+ s += `- **Status Summary** — bordered box with completion indicators (\`✓\` done, \`◌\` pending)\n\n`;
85
+ s += `**Rules:** Keep diagrams under 20 lines and under 70 characters wide. Populate with real data from current context. Render inside fenced code blocks. Use diagrams to supplement, not replace, prose.\n\n`;
86
+ s += `---\n\n`;
87
+ return s;
88
+ }
89
+
90
+ // -----------------------------------------------------------------------
91
+ // Prompt generation engine
92
+ // -----------------------------------------------------------------------
93
+
94
+ export function generatePromptFromConfig(loop, loopIndex, allLoops) {
95
+ const loopName = loop.name || 'Loop ' + (loopIndex + 1);
96
+ const inputFiles = (loop.inputFiles || '').trim();
97
+ const outputFiles = (loop.outputFiles || '').trim();
98
+ const completion = loop.completion || 'LOOP COMPLETE';
99
+
100
+ // Entity name: use first entity if defined, else "item"
101
+ const entity = (loop.entities && loop.entities.length > 0) ? loop.entities[0] : '';
102
+ const entityUpper = entity ? entity.toUpperCase() : 'ITEM';
103
+ const entityLower = entity ? entity.toLowerCase() : 'item';
104
+ const entityTitle = entityUpper.charAt(0) + entityLower.slice(1);
105
+ const entityPlural = entityLower.endsWith('s') ? entityLower : entityLower + 's';
106
+ const entityKey = entityUpper + '-{N}';
107
+
108
+ // Loop directory name: matches createCustomTemplate convention
109
+ const loopKey = (loopName || 'loop-' + loopIndex)
110
+ .toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
111
+ const loopKeyFull = loopKey.endsWith('-loop') ? loopKey : loopKey + '-loop';
112
+ const dirPrefix = String(loopIndex).padStart(2, '0');
113
+ const loopDir = dirPrefix + '-' + loopKeyFull;
114
+
115
+ // Pipeline display
116
+ const pipelineIn = inputFiles || 'input.md';
117
+ const pipelineOut = outputFiles || 'output.md';
118
+
119
+ // Primary input file for read-only references
120
+ const primaryInputFile = inputFiles ? inputFiles.split(',')[0].trim() : '';
121
+
122
+ let p = '';
123
+
124
+ // ── HEADER / IDENTITY ──
125
+ p += `# ${loopName} Loop\n\n`;
126
+ p += `**App:** \`{{APP_NAME}}\` — all flow files live under \`.ralph-flow/{{APP_NAME}}/\`.\n\n`;
127
+ if (loop.multi_agent) {
128
+ p += `**You are agent \`{{AGENT_NAME}}\`.** Multiple agents may work in parallel.\n`;
129
+ p += `Coordinate via \`tracker.md\` — the single source of truth.\n`;
130
+ p += `*(If you see the literal text \`{{AGENT_NAME}}\` above — i.e., it was not substituted — treat your name as \`agent-1\`.)*\n\n`;
131
+ }
132
+ p += `Read \`.ralph-flow/{{APP_NAME}}/${loopDir}/tracker.md\` FIRST to determine where you are.\n\n`;
133
+ p += `> **PROJECT CONTEXT.** Read \`CLAUDE.md\` for architecture, stack, conventions, commands, and URLs.\n\n`;
134
+ p += `**Pipeline:** \`${pipelineIn} → YOU → ${pipelineOut}\`\n\n`;
135
+ p += `---\n\n`;
136
+
137
+ // ── VISUAL COMMUNICATION PROTOCOL ──
138
+ p += generateVisualProtocol();
139
+
140
+ // ── MULTI-AGENT SECTIONS (conditional) ──
141
+ if (loop.multi_agent) {
142
+ // Tracker Lock Protocol
143
+ p += `## Tracker Lock Protocol\n\n`;
144
+ p += `Before ANY write to \`tracker.md\`, you MUST acquire the lock:\n\n`;
145
+ p += `**Lock file:** \`.ralph-flow/{{APP_NAME}}/${loopDir}/.tracker-lock\`\n\n`;
146
+ p += `### Acquire Lock\n`;
147
+ p += `1. Check if \`.tracker-lock\` exists\n`;
148
+ p += ` - Exists AND file is < 60 seconds old → sleep 2s, retry (up to 5 retries)\n`;
149
+ p += ` - Exists AND file is ≥ 60 seconds old → stale lock, delete it (agent crashed mid-write)\n`;
150
+ p += ` - Does not exist → continue\n`;
151
+ p += `2. Write lock: \`echo "{{AGENT_NAME}} $(date -u +%Y-%m-%dT%H:%M:%SZ)" > .ralph-flow/{{APP_NAME}}/${loopDir}/.tracker-lock\`\n`;
152
+ p += `3. Sleep 500ms (\`sleep 0.5\`)\n`;
153
+ p += `4. Re-read \`.tracker-lock\` — verify YOUR agent name (\`{{AGENT_NAME}}\`) is in it\n`;
154
+ p += ` - Your name → you own the lock, proceed to write \`tracker.md\`\n`;
155
+ p += ` - Other name → you lost the race, retry from step 1\n`;
156
+ p += `5. Write your changes to \`tracker.md\`\n`;
157
+ p += `6. Delete \`.tracker-lock\` immediately: \`rm .ralph-flow/{{APP_NAME}}/${loopDir}/.tracker-lock\`\n`;
158
+ p += `7. Never leave a lock held — if your write fails, delete the lock in your error handler\n\n`;
159
+ p += `### When to Lock\n`;
160
+ p += `- Claiming a ${entityLower} (pending → in_progress)\n`;
161
+ p += `- Completing a ${entityLower} (in_progress → completed, unblocking dependents)\n`;
162
+ p += `- Updating stage transitions\n`;
163
+ p += `- Heartbeat updates (bundled with other writes, not standalone)\n\n`;
164
+ p += `### When NOT to Lock\n`;
165
+ p += `- Reading \`tracker.md\` — read-only access needs no lock\n`;
166
+ if (primaryInputFile) {
167
+ p += `- Reading \`${primaryInputFile}\` — always read-only\n`;
168
+ }
169
+ p += `\n---\n\n`;
170
+
171
+ // Item Selection Algorithm
172
+ p += `## ${entityTitle} Selection Algorithm\n\n`;
173
+ p += `1. **Parse tracker** — read \`completed_${entityPlural}\`, \`## Dependencies\`, ${entityTitle}s Queue metadata \`{agent, status}\`, Agent Status table\n`;
174
+ p += `2. **Update blocked→pending** — for each ${entityLower} with \`status: blocked\`, check if ALL its dependencies (from \`## Dependencies\`) are in \`completed_${entityPlural}\`. If yes, acquire lock and update to \`status: pending\`\n`;
175
+ p += `3. **Resume own work** — if any ${entityLower} has \`{agent: {{AGENT_NAME}}, status: in_progress}\`, resume it (skip to the current stage)\n`;
176
+ p += `4. **Find claimable** — filter ${entityPlural} where \`status: pending\` AND \`agent: -\`\n`;
177
+ p += `5. **Claim** — acquire lock, set \`{agent: {{AGENT_NAME}}, status: in_progress}\`, update your Agent Status row, update \`last_heartbeat\`, release lock, log the claim\n`;
178
+ p += `6. **Nothing available:**\n`;
179
+ p += ` - All ${entityPlural} completed → emit \`<promise>${completion}</promise>\`\n`;
180
+ p += ` - All remaining ${entityPlural} are blocked or claimed by others → log "{{AGENT_NAME}}: waiting — all ${entityPlural} blocked or claimed", exit: \`kill -INT $PPID\`\n\n`;
181
+ p += `### New ${entityTitle} Discovery\n\n`;
182
+ p += `If you find a ${entityLower} in the Queue without \`{agent, status}\` metadata:\n`;
183
+ p += `1. Read its \`**Depends on:**\` field\n`;
184
+ p += `2. Add the dependency to \`## Dependencies\` section if not already there (skip if \`Depends on: None\`)\n`;
185
+ p += `3. Set status to \`pending\` (all deps in \`completed_${entityPlural}\`) or \`blocked\` (deps incomplete)\n`;
186
+ p += `4. Set agent to \`-\`\n\n`;
187
+ p += `---\n\n`;
188
+
189
+ // Anti-Hijacking Rules
190
+ p += `## Anti-Hijacking Rules\n\n`;
191
+ p += `1. **Never touch another agent's \`in_progress\` ${entityLower}** — do not modify, complete, or reassign it\n`;
192
+ p += `2. **Respect ownership** — if another agent is active in a group, leave remaining group ${entityPlural} for them\n`;
193
+ p += `3. **Note file overlap conflicts** — if your ${entityLower} modifies files that another agent's active ${entityLower} also modifies, log a WARNING in the tracker\n\n`;
194
+ p += `---\n\n`;
195
+
196
+ // Heartbeat Protocol
197
+ p += `## Heartbeat Protocol\n\n`;
198
+ p += `Every tracker write includes updating your \`last_heartbeat\` to current ISO 8601 timestamp in the Agent Status table. If another agent's heartbeat is **30+ minutes stale**, log a WARNING in the tracker log but do NOT auto-reclaim their ${entityLower} — user must manually reset.\n\n`;
199
+ p += `---\n\n`;
200
+
201
+ // Crash Recovery
202
+ p += `## Crash Recovery (Self)\n\n`;
203
+ p += `On fresh start, if your agent name has an \`in_progress\` ${entityLower} but you have no memory of it:\n`;
204
+ const lastStage = loop.stages.length > 1 ? loop.stages[loop.stages.length - 1] : 'last';
205
+ const firstStage = loop.stages[0] || 'first';
206
+ p += `- Work committed for that ${entityLower} → resume at ${lastStage.toUpperCase()} stage\n`;
207
+ p += `- No work found → restart from ${firstStage.toUpperCase()} stage\n\n`;
208
+ p += `---\n\n`;
209
+ }
210
+
211
+ // ── STATE MACHINE ──
212
+ const stageCount = loop.stages.length;
213
+ p += `## State Machine (${stageCount} stage${stageCount !== 1 ? 's' : ''} per ${entityLower})\n\n\`\`\`\n`;
214
+ loop.stages.forEach((stage, i) => {
215
+ const sc = loop.stageConfigs[i];
216
+ const desc = (sc && sc.description) ? sc.description.split('\n')[0].substring(0, 55) : 'Complete this stage';
217
+ const next = i < stageCount - 1 ? `→ stage: ${loop.stages[i + 1]}` : `→ next ${entityLower}`;
218
+ p += `${stage.toUpperCase()} → ${desc} ${next}\n`;
219
+ });
220
+ p += `\`\`\`\n\n`;
221
+ p += `When ALL done: \`<promise>${completion}</promise>\`\n\n`;
222
+ p += `After completing ANY stage, exit: \`kill -INT $PPID\`\n\n`;
223
+ p += `---\n\n`;
224
+
225
+ // ── FIRST-RUN / NEW ITEM DETECTION ──
226
+ {
227
+ const scanFile = primaryInputFile || 'input.md';
228
+ p += `## First-Run / New ${entityTitle} Detection\n\n`;
229
+ if (loop.multi_agent) {
230
+ p += `If ${entityTitle}s Queue in tracker is empty OR all entries are \`[x]\`: read \`${scanFile}\`, scan \`## ${entityKey}:\` headers + \`**Depends on:**\` tags. For any ${entityLower} NOT already in the queue, add as \`- [ ] ${entityKey}: {title}\` with \`{agent: -, status: pending|blocked}\` metadata (compute from Dependencies), then start.\n\n`;
231
+ } else {
232
+ p += `If ${entityTitle}s Queue in tracker is empty OR all entries are \`[x]\`: read \`${scanFile}\`, scan \`## ${entityKey}:\` headers + \`**Depends on:**\` tags. For any ${entityLower} NOT already in the queue, add as \`- [ ] ${entityKey}: {title}\` and update Dependencies. If new ${entityPlural} were added, proceed to process them.\n\n`;
233
+ }
234
+ p += `---\n\n`;
235
+ }
236
+
237
+ // ── STAGE INSTRUCTIONS ──
238
+ loop.stages.forEach((stage, i) => {
239
+ const sc = loop.stageConfigs[i];
240
+ p += `## STAGE ${i + 1}: ${stage.toUpperCase()}\n\n`;
241
+
242
+ if (sc && sc.description) {
243
+ p += `${sc.description}\n\n`;
244
+ }
245
+
246
+ let step = 1;
247
+
248
+ // First stage in multi-agent: run selection algorithm
249
+ if (loop.multi_agent && i === 0) {
250
+ p += `${step++}. Read tracker → **run ${entityLower} selection algorithm** (see above)\n`;
251
+ } else {
252
+ p += `${step++}. Read tracker → determine current state\n`;
253
+ }
254
+
255
+ // Capability-driven steps
256
+ if (sc && sc.capabilities) {
257
+ if (sc.capabilities.fileReadWrite) {
258
+ const files = primaryInputFile ? ` (\`${primaryInputFile}\`)` : '';
259
+ p += `${step++}. Read relevant input files${files} and explore affected areas\n`;
260
+ }
261
+ if (sc.capabilities.exploreAgents) {
262
+ p += `${step++}. Use the Agent tool to explore the codebase — read **40+ files** across affected areas, dependencies, patterns\n`;
263
+ }
264
+ if (sc.capabilities.webSearch) {
265
+ p += `${step++}. Use WebSearch for 5-10 queries to gather external information\n`;
266
+ }
267
+ if (sc.capabilities.mcpServers) {
268
+ p += `${step++}. Use MCP tools for specialized operations\n`;
269
+ }
270
+ if (sc.capabilities.codeEditing) {
271
+ p += `${step++}. Implement changes, matching existing patterns per \`CLAUDE.md\`\n`;
272
+ }
273
+ if (sc.capabilities.bashCommands) {
274
+ p += `${step++}. Run build/deploy commands to verify changes\n`;
275
+ }
276
+ }
277
+
278
+ // Visual diagram trigger
279
+ if (i === 0) {
280
+ p += `${step++}. **Render a Scope Diagram** — output an ASCII architecture/scope map showing the areas this ${entityLower} touches, dependencies, and what needs to change\n`;
281
+ }
282
+ if (i === stageCount - 1 && stageCount > 1) {
283
+ p += `${step++}. **Render a Completion Summary** — output an ASCII status diagram showing what was built/changed, verification results, and how this ${entityLower} fits in overall progress\n`;
284
+ }
285
+
286
+ // Tracker update
287
+ if (loop.multi_agent) {
288
+ p += `${step++}. Acquire lock → update tracker: stage, \`last_heartbeat\`, log entry → release lock\n`;
289
+ } else {
290
+ p += `${step++}. Update tracker with progress\n`;
291
+ }
292
+ p += `${step++}. Exit: \`kill -INT $PPID\`\n\n`;
293
+
294
+ if (i < stageCount - 1) p += `---\n\n`;
295
+ });
296
+
297
+ // ── OUTPUT FORMAT (when output files and entities are configured) ──
298
+ if (outputFiles && entity) {
299
+ p += `---\n\n`;
300
+ p += `## Output Format\n\n`;
301
+ p += `Write to \`${outputFiles.split(',')[0].trim()}\` using this format:\n\n`;
302
+ p += `\`\`\`markdown\n`;
303
+ p += `## ${entityKey}: {Title}\n\n`;
304
+ p += `**Depends on:** {dependency or "None"}\n\n`;
305
+ p += `### Description\n`;
306
+ p += `{Content for this ${entityLower}}\n\n`;
307
+ p += `### Acceptance Criteria\n`;
308
+ p += `- [ ] {Criterion 1}\n`;
309
+ p += `- [ ] {Criterion 2}\n`;
310
+ p += `\`\`\`\n\n`;
311
+ }
312
+
313
+ // ── RULES ──
314
+ p += `---\n\n## Rules\n\n`;
315
+ p += `- One ${entityLower} at a time${loop.multi_agent ? ' per agent' : ''}. One stage per iteration.\n`;
316
+ p += `- Read tracker first, update tracker last.${loop.multi_agent ? ' Always use lock protocol for writes.' : ''}\n`;
317
+ p += `- Read \`CLAUDE.md\` for all project-specific context.\n`;
318
+ p += `- Thorough exploration before making changes.\n`;
319
+ if (loop.multi_agent) {
320
+ p += `- **Multi-agent: never touch another agent's in_progress ${entityLower}. Coordinate via tracker.md.**\n`;
321
+ }
322
+
323
+ // ── CLOSING ──
324
+ p += `\n---\n\n`;
325
+ p += `Read \`.ralph-flow/{{APP_NAME}}/${loopDir}/tracker.md\` now and begin.\n`;
326
+
327
+ return p;
328
+ }
329
+
330
+ // -----------------------------------------------------------------------
331
+ // Event bindings for prompt config form
332
+ // -----------------------------------------------------------------------
333
+
334
+ export function bindPromptConfigFormEvents() {
335
+ // "Generate Prompt" button
336
+ dom.content.querySelectorAll('[data-generate-prompt]').forEach(btn => {
337
+ btn.addEventListener('click', () => {
338
+ capturePromptConfigFormInputs();
339
+ const idx = parseInt(btn.dataset.generatePrompt);
340
+ const loop = state.templateBuilderState.loops[idx];
341
+ loop.prompt = generatePromptFromConfig(loop, idx, state.templateBuilderState.loops);
342
+ loop.showPromptForm = false;
343
+ actions.renderTemplateBuilder();
344
+ });
345
+ });
346
+
347
+ // "Generate New" button (switch back to form)
348
+ dom.content.querySelectorAll('[data-show-prompt-form]').forEach(btn => {
349
+ btn.addEventListener('click', () => {
350
+ actions.captureBuilderInputs();
351
+ const idx = parseInt(btn.dataset.showPromptForm);
352
+ const loop = state.templateBuilderState.loops[idx];
353
+ loop.showPromptForm = true;
354
+ loop.prompt = '';
355
+ actions.renderTemplateBuilder();
356
+ });
357
+ });
358
+ }
359
+
360
+ export function capturePromptConfigFormInputs() {
361
+ // No-op — form inputs are now captured via the loop card's captureBuilderInputs
362
+ }
@@ -0,0 +1,97 @@
1
+ // Sidebar rendering, app/loop selection, navigation.
2
+
3
+ import { state, dom, actions } from './state.js';
4
+ import { calculatePipelineProgress, esc } from './utils.js';
5
+
6
+ export function renderSidebar() {
7
+ let html = '';
8
+ for (const app of state.apps) {
9
+ const appActive = state.selectedApp && state.selectedApp.appName === app.appName;
10
+ const appProgress = calculatePipelineProgress(app.loops);
11
+ html += `<div class="sidebar-item app-item${appActive ? ' active' : ''}" data-app="${esc(app.appName)}">
12
+ ${esc(app.appName)}
13
+ <span class="badge">${esc(app.appType)}</span>
14
+ </div>`;
15
+ html += `<div class="sidebar-progress">
16
+ <div class="sidebar-progress-bar"><div class="sidebar-progress-fill" style="width:${appProgress.percentage}%"></div></div>
17
+ <span class="sidebar-progress-text">${appProgress.percentage}%</span>
18
+ </div>`;
19
+ if (app.loops) {
20
+ for (const loop of app.loops) {
21
+ const loopActive = appActive && state.selectedLoop === loop.key;
22
+ const loopNotifCount = state.notificationsList.filter(n => n.app === app.appName && n.loop === loop.key).length;
23
+ const loopDecisionCount = state.decisionsList.filter(d => d.app === app.appName && d.loop === loop.key).length;
24
+ const notifBadge = loopNotifCount > 0 ? ` <span class="notif-badge">${loopNotifCount}</span>` : '';
25
+ const decisionBadge = loopDecisionCount > 0 ? ` <span class="decision-badge">${loopDecisionCount}</span>` : '';
26
+ html += `<div class="sidebar-item loop-item${loopActive ? ' active' : ''}" data-app="${esc(app.appName)}" data-loop="${esc(loop.key)}">
27
+ ${esc(loop.name)}${notifBadge}${decisionBadge}
28
+ </div>`;
29
+ }
30
+ }
31
+ }
32
+ dom.sidebarApps.innerHTML = html;
33
+
34
+ // "+ New App" button
35
+ const newAppBtn = document.createElement('button');
36
+ newAppBtn.className = 'new-app-btn';
37
+ newAppBtn.innerHTML = '+ New App';
38
+ newAppBtn.addEventListener('click', () => actions.openCreateAppModal());
39
+ dom.sidebarApps.appendChild(newAppBtn);
40
+
41
+ // Event delegation
42
+ dom.sidebarApps.querySelectorAll('.app-item').forEach(el => {
43
+ el.addEventListener('click', () => {
44
+ const app = state.apps.find(a => a.appName === el.dataset.app);
45
+ if (app) selectApp(app);
46
+ });
47
+ });
48
+ dom.sidebarApps.querySelectorAll('.loop-item').forEach(el => {
49
+ el.addEventListener('click', () => {
50
+ const app = state.apps.find(a => a.appName === el.dataset.app);
51
+ if (app) {
52
+ selectApp(app);
53
+ selectLoop(el.dataset.loop);
54
+ }
55
+ });
56
+ });
57
+
58
+ // Update Templates nav active state
59
+ const templatesNav = document.getElementById('templatesNav');
60
+ if (templatesNav) {
61
+ templatesNav.classList.toggle('active', state.currentPage === 'templates');
62
+ }
63
+ }
64
+
65
+ export function selectApp(app) {
66
+ state.currentPage = 'app';
67
+ state.selectedApp = app;
68
+ state.selectedLoop = app.loops.length > 0 ? app.loops[0].key : null;
69
+ state.promptDirty = false;
70
+ state.promptOriginal = '';
71
+ state.promptViewMode = 'read';
72
+ state.activeEditTab = 'prompt';
73
+ state.cachedPromptValue = null;
74
+ state.activeAppTab = 'loops';
75
+ state.archivesData = [];
76
+ state.expandedArchive = null;
77
+ state.archiveFilesCache = {};
78
+ state.viewingArchiveFile = null;
79
+ state.viewingTemplateName = null;
80
+ state.viewingTemplateConfig = null;
81
+ state.viewingTemplatePrompts = {};
82
+ document.title = app.appName + ' - RalphFlow Dashboard';
83
+ renderSidebar();
84
+ actions.renderContent();
85
+ actions.fetchAppStatus(app.appName);
86
+ }
87
+
88
+ export function selectLoop(loopKey) {
89
+ state.selectedLoop = loopKey;
90
+ state.promptDirty = false;
91
+ state.promptOriginal = '';
92
+ state.promptViewMode = 'read';
93
+ state.activeEditTab = 'prompt';
94
+ state.cachedPromptValue = null;
95
+ renderSidebar();
96
+ actions.renderContent();
97
+ }
@@ -0,0 +1,54 @@
1
+ // Dashboard global state and DOM references
2
+ // All mutable state lives here so modules can share it via import.
3
+
4
+ export const $ = (sel) => document.querySelector(sel);
5
+
6
+ export const dom = {
7
+ hostDisplay: $('#hostDisplay'),
8
+ sidebarApps: $('#sidebarApps'),
9
+ content: $('#content'),
10
+ statusDot: $('#statusDot'),
11
+ statusText: $('#statusText'),
12
+ lastUpdate: $('#lastUpdate'),
13
+ eventCountEl: $('#eventCount'),
14
+ };
15
+
16
+ export const state = {
17
+ apps: [],
18
+ selectedApp: null,
19
+ selectedLoop: null,
20
+ eventCounter: 0,
21
+ promptDirty: false,
22
+ promptOriginal: '',
23
+ ws: null,
24
+ reconnectDelay: 1000,
25
+ activeEditTab: 'prompt',
26
+ promptViewMode: 'read',
27
+ cachedPromptValue: null,
28
+ notificationsList: [],
29
+ decisionsList: [],
30
+ notifPermissionRequested: false,
31
+ audioCtx: null,
32
+ audioCtxInitialized: false,
33
+ activeAppTab: 'loops',
34
+ archivesData: [],
35
+ expandedArchive: null,
36
+ archiveFilesCache: {},
37
+ viewingArchiveFile: null,
38
+ currentPage: 'app',
39
+ templatesList: [],
40
+ showTemplateBuilder: false,
41
+ templateBuilderState: null,
42
+ editingTemplateName: null,
43
+ selectedBuilderLoop: 0,
44
+ viewingTemplateName: null,
45
+ viewingTemplateConfig: null,
46
+ viewingTemplatePrompts: {},
47
+ showTemplateWizard: false,
48
+ wizardStep: 0,
49
+ wizardData: null,
50
+ };
51
+
52
+ // Cross-module function registry.
53
+ // Modules register callable functions here to avoid circular imports.
54
+ export const actions = {};