@unbrained/pm-web 2026.6.9 → 2026.6.13-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,279 @@
1
+ type PlanStepInput = {
2
+ id?: string;
3
+ ref?: string;
4
+ title?: string;
5
+ description?: string;
6
+ status?: string;
7
+ blockedReason?: string;
8
+ blocked_reason?: string;
9
+ dependsOn?: string[];
10
+ depends_on?: string[];
11
+ };
12
+
13
+ type PlanInput = {
14
+ id?: string;
15
+ title?: string;
16
+ description?: string;
17
+ scope?: string;
18
+ status?: string;
19
+ };
20
+
21
+ export type AnalyzedPlanStep = {
22
+ ref: string;
23
+ title: string;
24
+ description: string;
25
+ status: string;
26
+ blockedReason: string;
27
+ dependsOn: string[];
28
+ unresolvedDependencies: string[];
29
+ incompleteDependencies: string[];
30
+ isDone: boolean;
31
+ isBlocked: boolean;
32
+ original: PlanStepInput;
33
+ };
34
+
35
+ export type PlanExecutionSnapshot = {
36
+ totalSteps: number;
37
+ completedSteps: number;
38
+ blockedSteps: number;
39
+ waitingSteps: AnalyzedPlanStep[];
40
+ readySteps: AnalyzedPlanStep[];
41
+ blockedStepDetails: AnalyzedPlanStep[];
42
+ allSteps: AnalyzedPlanStep[];
43
+ stepByRef: Record<string, AnalyzedPlanStep>;
44
+ completionPct: number;
45
+ nextReadyStep: AnalyzedPlanStep | null;
46
+ };
47
+
48
+ const DONE_STATUSES = new Set(['done', 'completed', 'closed']);
49
+ const BLOCKED_STATUSES = new Set(['blocked']);
50
+
51
+ function normalizeStatus(status?: string): string {
52
+ const value = (status || 'pending').trim().toLowerCase().replace(/\s+/g, '_');
53
+ return value || 'pending';
54
+ }
55
+
56
+ function normalizeStepRef(step: PlanStepInput, index: number): string {
57
+ const candidate = (step.ref || step.id || '').trim();
58
+ return candidate || `step-${index + 1}`;
59
+ }
60
+
61
+ function normalizeDependsOn(step: PlanStepInput): string[] {
62
+ const raw = Array.isArray(step.dependsOn)
63
+ ? step.dependsOn
64
+ : (Array.isArray(step.depends_on) ? step.depends_on : []);
65
+ const unique = new Set<string>();
66
+ for (const dep of raw) {
67
+ const trimmed = String(dep || '').trim();
68
+ if (trimmed) unique.add(trimmed);
69
+ }
70
+ return Array.from(unique);
71
+ }
72
+
73
+ function formatStepLine(step: AnalyzedPlanStep): string {
74
+ return `- [${step.ref}] ${step.title}`;
75
+ }
76
+
77
+ function formatWaitReason(step: AnalyzedPlanStep): string {
78
+ const blockers = [
79
+ ...step.incompleteDependencies,
80
+ ...step.unresolvedDependencies.map(dep => `${dep} (missing)`),
81
+ ];
82
+ if (blockers.length === 0) return 'waiting';
83
+ return `waiting on ${blockers.join(', ')}`;
84
+ }
85
+
86
+ function stepDependencyBlockers(step: AnalyzedPlanStep): string[] {
87
+ return [
88
+ ...step.incompleteDependencies,
89
+ ...step.unresolvedDependencies.map(dep => `${dep} (missing)`),
90
+ ];
91
+ }
92
+
93
+ export function buildPlanExecutionSnapshot(steps: PlanStepInput[]): PlanExecutionSnapshot {
94
+ const normalized: AnalyzedPlanStep[] = steps.map((step, index) => {
95
+ const status = normalizeStatus(step.status);
96
+ return {
97
+ ref: normalizeStepRef(step, index),
98
+ title: (step.title || '(untitled)').trim() || '(untitled)',
99
+ description: (step.description || '').trim(),
100
+ status,
101
+ blockedReason: (step.blockedReason || step.blocked_reason || '').trim(),
102
+ dependsOn: normalizeDependsOn(step),
103
+ unresolvedDependencies: [],
104
+ incompleteDependencies: [],
105
+ isDone: DONE_STATUSES.has(status),
106
+ isBlocked: BLOCKED_STATUSES.has(status),
107
+ original: step,
108
+ };
109
+ });
110
+
111
+ const stepByRef: Record<string, AnalyzedPlanStep> = {};
112
+ for (const step of normalized) {
113
+ if (!stepByRef[step.ref]) stepByRef[step.ref] = step;
114
+ }
115
+
116
+ const completedRefs = new Set(normalized.filter(step => step.isDone).map(step => step.ref));
117
+
118
+ for (const step of normalized) {
119
+ if (step.isDone || step.isBlocked) continue;
120
+ for (const dep of step.dependsOn) {
121
+ if (!stepByRef[dep]) {
122
+ step.unresolvedDependencies.push(dep);
123
+ } else if (!completedRefs.has(dep)) {
124
+ step.incompleteDependencies.push(dep);
125
+ }
126
+ }
127
+ }
128
+
129
+ const readySteps: AnalyzedPlanStep[] = [];
130
+ const waitingSteps: AnalyzedPlanStep[] = [];
131
+ const blockedStepDetails: AnalyzedPlanStep[] = [];
132
+
133
+ for (const step of normalized) {
134
+ if (step.isDone) continue;
135
+ if (step.isBlocked) {
136
+ blockedStepDetails.push(step);
137
+ continue;
138
+ }
139
+ if (step.incompleteDependencies.length === 0 && step.unresolvedDependencies.length === 0) {
140
+ readySteps.push(step);
141
+ } else {
142
+ waitingSteps.push(step);
143
+ }
144
+ }
145
+
146
+ const totalSteps = normalized.length;
147
+ const completedSteps = normalized.filter(step => step.isDone).length;
148
+ const blockedSteps = blockedStepDetails.length;
149
+ const completionPct = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
150
+
151
+ return {
152
+ totalSteps,
153
+ completedSteps,
154
+ blockedSteps,
155
+ waitingSteps,
156
+ readySteps,
157
+ blockedStepDetails,
158
+ allSteps: normalized,
159
+ stepByRef,
160
+ completionPct,
161
+ nextReadyStep: readySteps[0] || null,
162
+ };
163
+ }
164
+
165
+ export function buildPlanAgentBrief(plan: PlanInput, snapshot: PlanExecutionSnapshot): string {
166
+ const title = (plan.title || plan.id || '(untitled)').trim();
167
+ const lines: string[] = [
168
+ '# Plan execution brief',
169
+ `Plan: ${title}`,
170
+ ];
171
+ if (plan.id) lines.push(`Plan ID: ${plan.id}`);
172
+ if (plan.status) lines.push(`Status: ${plan.status}`);
173
+ if (plan.scope) lines.push(`Scope: ${plan.scope}`);
174
+ if (plan.description) lines.push('', 'Description:', plan.description.trim());
175
+
176
+ lines.push(
177
+ '',
178
+ 'Execution summary:',
179
+ `- Completed: ${snapshot.completedSteps}/${snapshot.totalSteps} (${snapshot.completionPct}%)`,
180
+ `- Ready now: ${snapshot.readySteps.length}`,
181
+ `- Waiting on dependencies: ${snapshot.waitingSteps.length}`,
182
+ `- Explicitly blocked: ${snapshot.blockedStepDetails.length}`
183
+ );
184
+
185
+ if (snapshot.readySteps.length > 0) {
186
+ lines.push('', `Ready now (${snapshot.readySteps.length}):`);
187
+ for (const step of snapshot.readySteps) lines.push(formatStepLine(step));
188
+ } else {
189
+ lines.push('', 'Ready now: none');
190
+ }
191
+
192
+ if (snapshot.waitingSteps.length > 0) {
193
+ lines.push('', `Waiting (${snapshot.waitingSteps.length}):`);
194
+ for (const step of snapshot.waitingSteps) {
195
+ lines.push(`${formatStepLine(step)} (${formatWaitReason(step)})`);
196
+ }
197
+ }
198
+
199
+ if (snapshot.blockedStepDetails.length > 0) {
200
+ lines.push('', `Blocked (${snapshot.blockedStepDetails.length}):`);
201
+ for (const step of snapshot.blockedStepDetails) {
202
+ const reason = step.blockedReason ? `: ${step.blockedReason}` : '';
203
+ lines.push(`${formatStepLine(step)}${reason}`);
204
+ }
205
+ }
206
+
207
+ lines.push(
208
+ '',
209
+ 'Instruction:',
210
+ 'Pick the top ready step, execute it end-to-end, and report tests/evidence before moving to the next.'
211
+ );
212
+
213
+ return lines.join('\n');
214
+ }
215
+
216
+ export function buildNextStepPrompt(
217
+ plan: PlanInput,
218
+ snapshot: PlanExecutionSnapshot,
219
+ stepRef?: string
220
+ ): string {
221
+ const step = stepRef ? snapshot.stepByRef[stepRef] : snapshot.nextReadyStep;
222
+ if (!step) {
223
+ const planName = (plan.title || plan.id || '(untitled)').trim();
224
+ return [
225
+ `No ready step is currently available for plan "${planName}".`,
226
+ 'Please inspect waiting and blocked steps, resolve dependencies, and refresh the plan.',
227
+ ].join('\n');
228
+ }
229
+
230
+ const planName = (plan.title || plan.id || '(untitled)').trim();
231
+ const dependencyBlockers = stepDependencyBlockers(step);
232
+ const isWaiting = dependencyBlockers.length > 0 && !step.isBlocked;
233
+ const readinessLabel = step.isBlocked ? 'blocked' : (isWaiting ? 'waiting' : 'ready');
234
+ const lines: string[] = [
235
+ '# Next step execution prompt',
236
+ `You are working on plan "${planName}"${plan.id ? ` (${plan.id})` : ''}.`,
237
+ `Current progress: ${snapshot.completedSteps}/${snapshot.totalSteps} complete.`,
238
+ `Step readiness: ${readinessLabel}.`,
239
+ '',
240
+ `Execute step [${step.ref}] ${step.title}.`,
241
+ ];
242
+
243
+ if (step.description) {
244
+ lines.push(`Step details: ${step.description}`);
245
+ }
246
+
247
+ if (step.isBlocked) {
248
+ lines.push(
249
+ `Blocked reason: ${step.blockedReason || 'No reason provided.'}`,
250
+ 'This step is explicitly blocked. Resolve the blocker before implementation.'
251
+ );
252
+ } else if (isWaiting) {
253
+ lines.push(
254
+ `Pending dependencies: ${dependencyBlockers.join(', ')}`,
255
+ 'This step is waiting on dependencies. Do not start implementation until dependencies are complete.'
256
+ );
257
+ } else if (step.dependsOn.length > 0) {
258
+ lines.push(`Dependencies already satisfied: ${step.dependsOn.join(', ')}`);
259
+ }
260
+
261
+ lines.push('', 'Execution requirements:');
262
+ if (step.isBlocked || isWaiting) {
263
+ lines.push(
264
+ '- Resolve blockers/dependencies first, then refresh this plan snapshot.',
265
+ '- Once ready, execute the step in production-ready quality.',
266
+ '- Report exact commands/outcomes and evidence collected.',
267
+ '- Update dependency and step status notes after each change.'
268
+ );
269
+ } else {
270
+ lines.push(
271
+ '- Implement in production-ready quality.',
272
+ '- Add or update tests where feasible.',
273
+ '- Report the exact commands and outcomes.',
274
+ '- Update the step status when done.'
275
+ );
276
+ }
277
+
278
+ return lines.join('\n');
279
+ }
@@ -6,8 +6,11 @@ import { api } from '../api.js';
6
6
  import { escHtml } from '../utils.js';
7
7
  import { toast } from '../components/toast.js';
8
8
  import { showModal, hideModal, createModal, confirmDialog } from '../components/modals.js';
9
+ import { buildPlanExecutionSnapshot, buildPlanAgentBrief, buildNextStepPrompt } from './plan-execution.js';
9
10
  // ─── State ───────────────────────────────────────────────────
10
11
  let currentPlanId = null;
12
+ let currentPlanData = null;
13
+ let currentExecutionSnapshot = null;
11
14
  // ─── Helpers ─────────────────────────────────────────────────
12
15
  function stepRef(step) {
13
16
  return step.ref || step.id || '';
@@ -25,10 +28,91 @@ function stepStatusBadge(status) {
25
28
  const color = colors[s] || 'var(--text-muted)';
26
29
  return `<span style="font-size:11px;padding:2px 7px;border-radius:4px;background:color-mix(in srgb,${color} 18%,transparent);color:${color};font-weight:600;letter-spacing:.3px">${escHtml(s)}</span>`;
27
30
  }
28
- function renderStepRow(step, planId) {
31
+ function metricBadge(label, value, color) {
32
+ return `
33
+ <div style="padding:8px 10px;border-radius:8px;background:color-mix(in srgb,${color} 14%, var(--bg-card));border:1px solid color-mix(in srgb,${color} 28%, var(--border));min-width:95px">
34
+ <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.4px">${escHtml(label)}</div>
35
+ <div style="font-size:14px;font-weight:600;color:${color};margin-top:2px">${escHtml(value)}</div>
36
+ </div>`;
37
+ }
38
+ function renderDependencyHint(analyzed) {
39
+ if (!analyzed || analyzed.dependsOn.length === 0 || analyzed.isDone || analyzed.isBlocked)
40
+ return '';
41
+ if (analyzed.incompleteDependencies.length === 0 && analyzed.unresolvedDependencies.length === 0) {
42
+ return `<div style="font-size:12px;color:var(--text-muted);margin-top:3px">Dependencies complete: ${escHtml(analyzed.dependsOn.join(', '))}</div>`;
43
+ }
44
+ const blockers = [
45
+ ...analyzed.incompleteDependencies,
46
+ ...analyzed.unresolvedDependencies.map(dep => `${dep} (missing)`),
47
+ ];
48
+ return `<div style="font-size:12px;color:var(--warning,#f59e0b);margin-top:3px">Waiting on: ${escHtml(blockers.join(', '))}</div>`;
49
+ }
50
+ function renderExecutionFocus(planId, snapshot) {
51
+ const next = snapshot.nextReadyStep;
52
+ const waitingPreview = snapshot.waitingSteps.slice(0, 2).map(step => {
53
+ const blockers = [
54
+ ...step.incompleteDependencies,
55
+ ...step.unresolvedDependencies.map(dep => `${dep} (missing)`),
56
+ ];
57
+ return `
58
+ <li style="font-size:12px;color:var(--text-secondary);line-height:1.5">
59
+ <span style="font-family:'JetBrains Mono',monospace;color:var(--text-muted)">[${escHtml(step.ref)}]</span>
60
+ ${escHtml(step.title)} - waiting on ${escHtml(blockers.join(', '))}
61
+ </li>`;
62
+ }).join('');
63
+ const blockedPreview = snapshot.blockedStepDetails.slice(0, 2).map(step => `
64
+ <li style="font-size:12px;color:var(--text-secondary);line-height:1.5">
65
+ <span style="font-family:'JetBrains Mono',monospace;color:var(--text-muted)">[${escHtml(step.ref)}]</span>
66
+ ${escHtml(step.title)}
67
+ ${step.blockedReason ? `<span style="color:var(--status-blocked)"> - ${escHtml(step.blockedReason)}</span>` : ''}
68
+ </li>`).join('');
69
+ return `
70
+ <div style="margin-bottom:12px;padding:12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-elevated)">
71
+ <div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;align-items:flex-start">
72
+ <div>
73
+ <div style="font-size:13px;font-weight:600">Execution Focus</div>
74
+ <div style="font-size:12px;color:var(--text-muted);margin-top:2px">Dependency-aware summary to guide agent execution.</div>
75
+ </div>
76
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
77
+ <button class="btn btn-ghost btn-sm" onclick="window.__app.copyPlanAgentBrief('${escHtml(planId)}')" title="Copy dependency and status summary">Copy agent brief</button>
78
+ ${next ? `<button class="btn btn-secondary btn-sm" onclick="window.__app.copyPlanNextStepPrompt('${escHtml(planId)}','${escHtml(next.ref)}')" title="Copy prompt for the next ready step">Copy next-step prompt</button>` : ''}
79
+ </div>
80
+ </div>
81
+ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
82
+ ${metricBadge('Complete', `${snapshot.completedSteps}/${snapshot.totalSteps}`, 'var(--status-closed)')}
83
+ ${metricBadge('Ready', String(snapshot.readySteps.length), 'var(--accent)')}
84
+ ${metricBadge('Waiting', String(snapshot.waitingSteps.length), 'var(--warning,#f59e0b)')}
85
+ ${metricBadge('Blocked', String(snapshot.blockedStepDetails.length), 'var(--status-blocked)')}
86
+ </div>
87
+ ${next
88
+ ? `<div style="margin-top:10px;font-size:12px;color:var(--text-secondary)">Next ready step: <span style="font-family:'JetBrains Mono',monospace;color:var(--text-muted)">[${escHtml(next.ref)}]</span> <strong>${escHtml(next.title)}</strong></div>`
89
+ : '<div style="margin-top:10px;font-size:12px;color:var(--text-muted)">No ready steps right now. Resolve blockers or dependencies to continue.</div>'}
90
+ ${waitingPreview
91
+ ? `<div style="margin-top:8px">
92
+ <div style="font-size:12px;font-weight:600;color:var(--text-secondary)">Waiting queue</div>
93
+ <ul style="margin:6px 0 0 16px;padding:0;display:flex;flex-direction:column;gap:4px">${waitingPreview}</ul>
94
+ ${snapshot.waitingSteps.length > 2 ? `<div style="font-size:11px;color:var(--text-muted);margin-top:4px">+ ${snapshot.waitingSteps.length - 2} more waiting step(s)</div>` : ''}
95
+ </div>`
96
+ : ''}
97
+ ${blockedPreview
98
+ ? `<div style="margin-top:8px">
99
+ <div style="font-size:12px;font-weight:600;color:var(--status-blocked)">Blocked steps</div>
100
+ <ul style="margin:6px 0 0 16px;padding:0;display:flex;flex-direction:column;gap:4px">${blockedPreview}</ul>
101
+ ${snapshot.blockedStepDetails.length > 2 ? `<div style="font-size:11px;color:var(--text-muted);margin-top:4px">+ ${snapshot.blockedStepDetails.length - 2} more blocked step(s)</div>` : ''}
102
+ </div>`
103
+ : ''}
104
+ </div>`;
105
+ }
106
+ function renderStepRow(step, planId, analyzed) {
29
107
  const ref = stepRef(step);
30
108
  const isDone = ['done', 'completed'].includes((step.status || '').toLowerCase());
31
109
  const isBlocked = (step.status || '').toLowerCase() === 'blocked';
110
+ const isWaiting = !!analyzed && !analyzed.isDone && !analyzed.isBlocked
111
+ && (analyzed.incompleteDependencies.length > 0 || analyzed.unresolvedDependencies.length > 0);
112
+ const promptTitle = isBlocked
113
+ ? 'Copy blocker-resolution prompt'
114
+ : (isWaiting ? 'Copy dependency-resolution prompt' : 'Copy execution prompt');
115
+ const dependencyHint = renderDependencyHint(analyzed);
32
116
  return `
33
117
  <div class="plan-step-row" data-step-ref="${escHtml(ref)}">
34
118
  <div style="flex:1;min-width:0">
@@ -38,9 +122,11 @@ function renderStepRow(step, planId) {
38
122
  ${stepStatusBadge(step.status)}
39
123
  </div>
40
124
  ${step.description ? `<div style="font-size:12px;color:var(--text-muted);margin-top:3px">${escHtml(step.description)}</div>` : ''}
125
+ ${dependencyHint}
41
126
  ${isBlocked && (step.blockedReason || step.blocked_reason) ? `<div style="font-size:12px;color:var(--status-blocked);margin-top:3px">Blocked: ${escHtml(step.blockedReason || step.blocked_reason || '')}</div>` : ''}
42
127
  </div>
43
128
  <div style="display:flex;gap:6px;flex-shrink:0">
129
+ ${!isDone ? `<button class="btn btn-ghost btn-sm" onclick="window.__app.copyPlanNextStepPrompt('${escHtml(planId)}','${escHtml(ref)}')" title="${escHtml(promptTitle)}" ${isBlocked ? 'style="color:var(--status-blocked)"' : ''}>⧉</button>` : ''}
44
130
  ${!isDone ? `<button class="btn btn-ghost btn-sm" onclick="window.__app.planCompleteStep('${escHtml(planId)}','${escHtml(ref)}')" title="Mark complete">✓</button>` : ''}
45
131
  ${!isBlocked && !isDone ? `<button class="btn btn-ghost btn-sm" onclick="window.__app.planBlockStepPrompt('${escHtml(planId)}','${escHtml(ref)}')" title="Block">⊘</button>` : ''}
46
132
  <button class="btn btn-ghost btn-sm" onclick="window.__app.planRemoveStep('${escHtml(planId)}','${escHtml(ref)}')" title="Remove" style="color:var(--danger,#f87171)">✕</button>
@@ -75,6 +161,9 @@ export async function initPlanView() {
75
161
  const el = document.getElementById('content-plan');
76
162
  if (!el)
77
163
  return;
164
+ currentPlanId = null;
165
+ currentPlanData = null;
166
+ currentExecutionSnapshot = null;
78
167
  if (!state.currentProject) {
79
168
  el.innerHTML = '<div class="empty-state"><div class="empty-state-text">No project selected</div></div>';
80
169
  return;
@@ -95,6 +184,9 @@ async function loadPlanList() {
95
184
  if (subEl)
96
185
  subEl.textContent = state.currentProject.name;
97
186
  if (items.length === 0) {
187
+ currentPlanId = null;
188
+ currentPlanData = null;
189
+ currentExecutionSnapshot = null;
98
190
  listEl.innerHTML = '<div class="empty-state" style="padding:16px"><div class="empty-state-text">No plans yet</div></div>';
99
191
  return;
100
192
  }
@@ -130,37 +222,127 @@ export async function openPlanDetail(planId) {
130
222
  const data = await api('GET', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}`);
131
223
  const plan = (data.plan || data);
132
224
  const steps = plan.steps || [];
225
+ const snapshot = buildPlanExecutionSnapshot(steps);
226
+ currentPlanData = plan;
227
+ currentExecutionSnapshot = snapshot;
133
228
  const isApproved = !!(plan.approvedAt || plan.approved_at);
134
- detailEl.innerHTML = `
135
- <div class="card">
136
- <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px">
137
- <div>
138
- <div class="card-title">${escHtml(plan.title || planId)}</div>
139
- <div style="font-size:12px;color:var(--text-muted);margin-top:2px;font-family:'JetBrains Mono',monospace">${escHtml(plan.id || planId)}</div>
140
- </div>
141
- <div style="display:flex;gap:8px;flex-wrap:wrap">
142
- ${!isApproved ? `<button class="btn btn-secondary btn-sm" onclick="window.__app.planApprove('${escHtml(planId)}')">✓ Approve</button>` : '<span style="font-size:12px;color:var(--status-closed)">✓ Approved</span>'}
143
- <button class="btn btn-primary btn-sm" onclick="window.__app.planMaterializePrompt('${escHtml(planId)}')">⇗ Materialize</button>
144
- <button class="btn btn-ghost btn-sm" onclick="window.__app.planEditPrompt('${escHtml(planId)}','${escHtml(plan.title || '')}')" title="Edit plan">✎</button>
145
- <button class="btn btn-ghost btn-sm" style="color:var(--danger,#f87171)" onclick="window.__app.planDeletePrompt('${escHtml(planId)}')" title="Delete plan">✕</button>
146
- </div>
147
- </div>
148
- <div class="card-body">
149
- ${plan.description ? `<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">${escHtml(plan.description)}</div>` : ''}
150
- ${plan.scope ? `<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Scope: ${escHtml(plan.scope)}</div>` : ''}
151
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
152
- <div style="font-size:13px;font-weight:600">Steps (${steps.length})</div>
153
- <button class="btn btn-ghost btn-sm" onclick="window.__app.openAddStepModal('${escHtml(planId)}')">+ Add Step</button>
154
- </div>
155
- <div id="plan-steps-list">
156
- ${steps.length === 0
157
- ? '<div style="font-size:13px;color:var(--text-muted);padding:8px 0">No steps yet. Add the first step to get started.</div>'
158
- : steps.map(s => renderStepRow(s, planId)).join('')}
159
- </div>
160
- </div>
161
- </div>`;
229
+ const card = document.createElement('div');
230
+ card.className = 'card';
231
+ const header = document.createElement('div');
232
+ header.className = 'card-header';
233
+ header.style.display = 'flex';
234
+ header.style.alignItems = 'center';
235
+ header.style.justifyContent = 'space-between';
236
+ header.style.flexWrap = 'wrap';
237
+ header.style.gap = '8px';
238
+ const heading = document.createElement('div');
239
+ const titleEl = document.createElement('div');
240
+ titleEl.className = 'card-title';
241
+ titleEl.textContent = plan.title || planId;
242
+ const idEl = document.createElement('div');
243
+ idEl.style.fontSize = '12px';
244
+ idEl.style.color = 'var(--text-muted)';
245
+ idEl.style.marginTop = '2px';
246
+ idEl.style.fontFamily = "'JetBrains Mono',monospace";
247
+ idEl.textContent = plan.id || planId;
248
+ heading.append(titleEl, idEl);
249
+ const actions = document.createElement('div');
250
+ actions.style.display = 'flex';
251
+ actions.style.gap = '8px';
252
+ actions.style.flexWrap = 'wrap';
253
+ if (!isApproved) {
254
+ const approveBtn = document.createElement('button');
255
+ approveBtn.className = 'btn btn-secondary btn-sm';
256
+ approveBtn.textContent = '✓ Approve';
257
+ approveBtn.addEventListener('click', () => { void planApprove(planId); });
258
+ actions.appendChild(approveBtn);
259
+ }
260
+ else {
261
+ const approvedLabel = document.createElement('span');
262
+ approvedLabel.style.fontSize = '12px';
263
+ approvedLabel.style.color = 'var(--status-closed)';
264
+ approvedLabel.textContent = '✓ Approved';
265
+ actions.appendChild(approvedLabel);
266
+ }
267
+ const materializeBtn = document.createElement('button');
268
+ materializeBtn.className = 'btn btn-primary btn-sm';
269
+ materializeBtn.textContent = '⇗ Materialize';
270
+ materializeBtn.addEventListener('click', () => planMaterializePrompt(planId));
271
+ actions.appendChild(materializeBtn);
272
+ const editBtn = document.createElement('button');
273
+ editBtn.className = 'btn btn-ghost btn-sm';
274
+ editBtn.title = 'Edit plan';
275
+ editBtn.textContent = '✎';
276
+ editBtn.addEventListener('click', () => planEditPrompt(planId, plan.title || ''));
277
+ actions.appendChild(editBtn);
278
+ const deleteBtn = document.createElement('button');
279
+ deleteBtn.className = 'btn btn-ghost btn-sm';
280
+ deleteBtn.title = 'Delete plan';
281
+ deleteBtn.style.color = 'var(--danger,#f87171)';
282
+ deleteBtn.textContent = '✕';
283
+ deleteBtn.addEventListener('click', () => planDeletePrompt(planId));
284
+ actions.appendChild(deleteBtn);
285
+ header.append(heading, actions);
286
+ const body = document.createElement('div');
287
+ body.className = 'card-body';
288
+ if (plan.description) {
289
+ const descEl = document.createElement('div');
290
+ descEl.style.fontSize = '13px';
291
+ descEl.style.color = 'var(--text-secondary)';
292
+ descEl.style.marginBottom = '12px';
293
+ descEl.textContent = plan.description;
294
+ body.appendChild(descEl);
295
+ }
296
+ if (plan.scope) {
297
+ const scopeEl = document.createElement('div');
298
+ scopeEl.style.fontSize = '12px';
299
+ scopeEl.style.color = 'var(--text-muted)';
300
+ scopeEl.style.marginBottom = '8px';
301
+ scopeEl.textContent = `Scope: ${plan.scope}`;
302
+ body.appendChild(scopeEl);
303
+ }
304
+ const focusTemplate = document.createElement('template');
305
+ focusTemplate.innerHTML = renderExecutionFocus(planId, snapshot);
306
+ body.appendChild(focusTemplate.content);
307
+ const stepsHeader = document.createElement('div');
308
+ stepsHeader.style.display = 'flex';
309
+ stepsHeader.style.alignItems = 'center';
310
+ stepsHeader.style.justifyContent = 'space-between';
311
+ stepsHeader.style.marginBottom = '8px';
312
+ const stepsTitle = document.createElement('div');
313
+ stepsTitle.style.fontSize = '13px';
314
+ stepsTitle.style.fontWeight = '600';
315
+ stepsTitle.textContent = `Steps (${steps.length})`;
316
+ const addStepBtn = document.createElement('button');
317
+ addStepBtn.className = 'btn btn-ghost btn-sm';
318
+ addStepBtn.textContent = '+ Add Step';
319
+ addStepBtn.addEventListener('click', () => openAddStepModal(planId));
320
+ stepsHeader.append(stepsTitle, addStepBtn);
321
+ body.appendChild(stepsHeader);
322
+ const stepsList = document.createElement('div');
323
+ stepsList.id = 'plan-steps-list';
324
+ if (steps.length === 0) {
325
+ const emptySteps = document.createElement('div');
326
+ emptySteps.style.fontSize = '13px';
327
+ emptySteps.style.color = 'var(--text-muted)';
328
+ emptySteps.style.padding = '8px 0';
329
+ emptySteps.textContent = 'No steps yet. Add the first step to get started.';
330
+ stepsList.appendChild(emptySteps);
331
+ }
332
+ else {
333
+ steps.forEach((step, index) => {
334
+ const rowTemplate = document.createElement('template');
335
+ rowTemplate.innerHTML = renderStepRow(step, planId, snapshot.allSteps[index]);
336
+ stepsList.appendChild(rowTemplate.content);
337
+ });
338
+ }
339
+ body.appendChild(stepsList);
340
+ card.append(header, body);
341
+ detailEl.replaceChildren(card);
162
342
  }
163
343
  catch (err) {
344
+ currentPlanData = null;
345
+ currentExecutionSnapshot = null;
164
346
  detailEl.innerHTML = `<div class="empty-state"><div class="empty-state-text">Error: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
165
347
  }
166
348
  }
@@ -391,6 +573,60 @@ export async function submitMaterializePlan(planId) {
391
573
  toast(err instanceof Error ? err.message : 'Failed to materialize plan', 'error');
392
574
  }
393
575
  }
576
+ async function copyTextWithFallback(modalTitle, text, successMessage) {
577
+ if (navigator.clipboard?.writeText) {
578
+ try {
579
+ await navigator.clipboard.writeText(text);
580
+ toast(successMessage, 'success');
581
+ return;
582
+ }
583
+ catch {
584
+ // Fall through to manual-copy modal.
585
+ }
586
+ }
587
+ const modalId = 'plan-copy-fallback-modal';
588
+ createModal(modalId, modalTitle, `
589
+ <p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Clipboard access is unavailable in this browser context. Copy manually:</p>
590
+ <textarea class="form-textarea" id="plan-copy-fallback-text" rows="12" spellcheck="false"></textarea>`, `<button class="btn btn-primary" onclick="window.__app.hideModal('${modalId}')">Close</button>`);
591
+ showModal(modalId);
592
+ const el = document.getElementById('plan-copy-fallback-text');
593
+ if (el) {
594
+ el.value = text;
595
+ el.focus();
596
+ el.select();
597
+ }
598
+ toast('Clipboard blocked. Opened manual copy panel.', 'info');
599
+ }
600
+ function getCurrentPlanContext(planId) {
601
+ const activeId = currentPlanData?.id || currentPlanId;
602
+ if (!currentPlanData || !currentExecutionSnapshot || activeId !== planId) {
603
+ toast('Open this plan first to generate prompts', 'info');
604
+ return null;
605
+ }
606
+ return { plan: currentPlanData, snapshot: currentExecutionSnapshot };
607
+ }
608
+ export async function copyPlanAgentBrief(planId) {
609
+ const ctx = getCurrentPlanContext(planId);
610
+ if (!ctx)
611
+ return;
612
+ const brief = buildPlanAgentBrief(ctx.plan, ctx.snapshot);
613
+ await copyTextWithFallback('Plan Agent Brief', brief, 'Agent brief copied');
614
+ }
615
+ export async function copyPlanNextStepPrompt(planId, stepRef) {
616
+ const ctx = getCurrentPlanContext(planId);
617
+ if (!ctx)
618
+ return;
619
+ if (stepRef && !ctx.snapshot.stepByRef[stepRef]) {
620
+ toast(`Step ${stepRef} not found`, 'error');
621
+ return;
622
+ }
623
+ if (!stepRef && !ctx.snapshot.nextReadyStep) {
624
+ toast('No ready step available right now', 'info');
625
+ return;
626
+ }
627
+ const prompt = buildNextStepPrompt(ctx.plan, ctx.snapshot, stepRef);
628
+ await copyTextWithFallback('Next Step Prompt', prompt, 'Next-step prompt copied');
629
+ }
394
630
  export function planEditPrompt(planId, currentTitle) {
395
631
  createModal('edit-plan-modal', 'Edit Plan', `
396
632
  <div class="form-group">
@@ -439,6 +675,8 @@ export function planDeletePrompt(planId) {
439
675
  await api('DELETE', `/projects/${state.currentProject.id}/pm/plan/${encodeURIComponent(planId)}`, {});
440
676
  toast('Plan deleted', 'success');
441
677
  currentPlanId = null;
678
+ currentPlanData = null;
679
+ currentExecutionSnapshot = null;
442
680
  const detailEl = document.getElementById('plan-detail-panel');
443
681
  if (detailEl)
444
682
  detailEl.innerHTML = '<div class="empty-state"><div class="empty-state-text">Select a plan to view its steps</div></div>';