@openplaybooks/converge-studio 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.js +2986 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2986 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { execFile } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import { mkdir, readdir, readFile, writeFile, rm, cp, rename } from 'fs/promises';
|
|
5
|
+
import { existsSync } from 'fs';
|
|
6
|
+
import { join, resolve, dirname } from 'path';
|
|
7
|
+
import { parse, stringify } from 'yaml';
|
|
8
|
+
import { plan } from '@openplaybooks/converge-core';
|
|
9
|
+
import { loadHumanReviewHandoffById, ensureHumanReviewHandoff, getHumanReviewHandoffRoute } from '@openplaybooks/converge-core/task/review';
|
|
10
|
+
import { loadPlaybookFromFolder } from '@openplaybooks/converge-core/playbook';
|
|
11
|
+
import { createServer } from 'http';
|
|
12
|
+
import { Socket, isIP } from 'net';
|
|
13
|
+
|
|
14
|
+
// src/add-ui.ts
|
|
15
|
+
|
|
16
|
+
// src/add-ui-templates.json
|
|
17
|
+
var add_ui_templates_default = {
|
|
18
|
+
templateGroups: [
|
|
19
|
+
{
|
|
20
|
+
label: "Start blank",
|
|
21
|
+
templates: [
|
|
22
|
+
{
|
|
23
|
+
id: "",
|
|
24
|
+
label: "Blank",
|
|
25
|
+
workflowInstruction: []
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
label: "Research",
|
|
31
|
+
templates: [
|
|
32
|
+
{
|
|
33
|
+
id: "deep-research",
|
|
34
|
+
label: "Deep research",
|
|
35
|
+
workflowInstruction: [
|
|
36
|
+
"Deep research playbook",
|
|
37
|
+
"Goal: Turn a research topic into a structured investigation with one folder per question, evidence gathering, synthesis, and a final report.",
|
|
38
|
+
"Write the playbook in natural language, but make the structure explicit so a planner can turn it into tasks without guessing.",
|
|
39
|
+
"Use these sections:\n- Goal and outcome\n- Workflow phases\n- Static tasks\n- Dynamic tasks / templates\n- Catalogs for spawning\n- Spawn rules and parent/child relationships\n- Helper scripts\n- Outputs and checks",
|
|
40
|
+
"Workflow guidance:\n- Start by framing the core question and listing the unknowns that matter most.\n- Break the investigation into question folders under `questions/`, with static setup tasks and spawned follow-up tasks where needed.\n- Use one task to gather evidence, another to compare sources, and a final task to synthesize the result into a concise report.\n- Keep the workflow linear when possible, but allow spawned child tasks when a question requires a deeper dive.",
|
|
41
|
+
"Static tasks:\n- Topic intake and scope definition.\n- Question framing and prioritization.\n- Final synthesis and report assembly.",
|
|
42
|
+
"Dynamic tasks / templates:\n- Per-question analysis tasks.\n- Source comparison tasks when evidence diverges.\n- Follow-up tasks for unresolved gaps.",
|
|
43
|
+
"Catalogs for dynamic spawning:\n- Use a `questions` catalog to enumerate research questions and spawn children from it.\n- Use a sources catalog to keep references, evidence snippets, and status per question.",
|
|
44
|
+
"Spawn rules:\n- Spawn one child task per research question, and spawn follow-up tasks only when the evidence surface shows a real gap.\n- Keep spawned tasks narrowly scoped to a single question or evidence thread.",
|
|
45
|
+
"Helper scripts:\n- Use helper scripts for running the research pipeline and cleaning/resetting local state.\n- If a script can automate question discovery or report assembly, call it out explicitly.",
|
|
46
|
+
"Outputs and checks:\n- Every question folder has a clear answer and the evidence behind it.\n- The final report summarizes what was learned, what remains uncertain, and why the conclusion is credible."
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "market-research",
|
|
51
|
+
label: "Market research",
|
|
52
|
+
workflowInstruction: [
|
|
53
|
+
"Market research playbook",
|
|
54
|
+
"Goal: Compare competitors, collect product evidence, and produce a decision memo that a team can act on.",
|
|
55
|
+
"Write the playbook in natural language, but make the structure explicit so a planner can turn it into tasks without guessing.",
|
|
56
|
+
"Use these sections:\n- Goal and outcome\n- Workflow phases\n- Static tasks\n- Dynamic tasks / templates\n- Catalogs for spawning\n- Spawn rules and parent/child relationships\n- Helper scripts\n- Outputs and checks",
|
|
57
|
+
"Workflow guidance:\n- Start with the market question, target audience, and success criteria.\n- Split the work into evidence collection, competitor comparison, and synthesis phases.\n- Spawn tasks for distinct market segments or competitor slices when the surface area is large.\n- Finish with a memo that states the recommendation and the tradeoffs behind it.",
|
|
58
|
+
"Static tasks:\n- Scope definition and research plan.\n- Evidence collection and source validation.\n- Memo writing and recommendation review.",
|
|
59
|
+
"Dynamic tasks / templates:\n- Competitor-specific analysis templates.\n- Feature comparison tasks for each segment or persona.",
|
|
60
|
+
"Catalogs for dynamic spawning:\n- Use a competitor catalog to spawn follow-up tasks for each product or segment.\n- Use an evidence catalog to track what was found, where it came from, and whether it is trustworthy.",
|
|
61
|
+
"Spawn rules:\n- Spawn child tasks when a competitor or segment needs its own focused analysis.\n- Prefer a shallow tree unless the market surface clearly needs deeper decomposition.",
|
|
62
|
+
"Helper scripts:\n- Use helper scripts for collecting snapshots, normalizing evidence, or building the final memo.",
|
|
63
|
+
"Outputs and checks:\n- The recommendation is backed by evidence, not just opinion.\n- The memo explains why the selected option is better than the alternatives."
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
label: "Build",
|
|
70
|
+
templates: [
|
|
71
|
+
{
|
|
72
|
+
id: "build-flutter-app",
|
|
73
|
+
label: "Build Flutter app",
|
|
74
|
+
workflowInstruction: [
|
|
75
|
+
"Flutter app playbook",
|
|
76
|
+
"Goal: Turn an app idea into a Flutter implementation plan with architecture, screens, state, tests, and rollout steps.",
|
|
77
|
+
"Write the playbook in natural language, but make the structure explicit so a planner can turn it into tasks without guessing.",
|
|
78
|
+
"Use these sections:\n- Goal and outcome\n- Workflow phases\n- Static tasks\n- Dynamic tasks / templates\n- Catalogs for spawning\n- Spawn rules and parent/child relationships\n- Helper scripts\n- Outputs and checks",
|
|
79
|
+
"Workflow guidance:\n- Start with product intent, user flows, and the app structure.\n- Use static tasks for architecture, routing, data modeling, and design decisions.\n- Use dynamic tasks for each screen, form, feature slice, or state-management concern.\n- Spawn tasks from a screen catalog or feature catalog so large apps can fan out cleanly.",
|
|
80
|
+
"Static tasks:\n- App architecture and routing plan.\n- Shared design system and data model decisions.\n- Test and release readiness review.",
|
|
81
|
+
"Dynamic tasks / templates:\n- Screen implementation templates.\n- Form, state, navigation, and API integration tasks.\n- Accessibility and testing tasks per screen or feature slice.",
|
|
82
|
+
"Catalogs for dynamic spawning:\n- Use a screen catalog to spawn per-screen tasks.\n- Use a feature catalog for feature slices that need their own implementation track.\n- Use a helper-asset catalog if the app needs shared icons, themes, or generated code.",
|
|
83
|
+
"Spawn rules:\n- Spawn one child per screen or feature slice when the app is broad enough to benefit from parallel work.\n- Keep state, validation, and testing work attached to the screen or feature that owns it.",
|
|
84
|
+
"Helper scripts:\n- Use helper scripts for bootstrap, formatting, analysis, code generation, and test runs.\n- Call out any playbook helper script that keeps Flutter-specific setup repeatable.",
|
|
85
|
+
"Outputs and checks:\n- The app has the expected screens, routing, and shared UI pieces.\n- Tests cover the most important flows and the plan includes a verification step for each major slice."
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "build-web-app",
|
|
90
|
+
label: "Build web app",
|
|
91
|
+
workflowInstruction: [
|
|
92
|
+
"Web app playbook",
|
|
93
|
+
"Goal: Break a web app build into setup, UI, data flow, and verification tasks that can fan out cleanly.",
|
|
94
|
+
"Write the playbook in natural language, but make the structure explicit so a planner can turn it into tasks without guessing.",
|
|
95
|
+
"Use these sections:\n- Goal and outcome\n- Workflow phases\n- Static tasks\n- Dynamic tasks / templates\n- Catalogs for spawning\n- Spawn rules and parent/child relationships\n- Helper scripts\n- Outputs and checks",
|
|
96
|
+
"Workflow guidance:\n- Start with product goals, information architecture, and the primary user journeys.\n- Use static tasks for setup, architecture, shared layout, and release checks.\n- Use dynamic tasks for page-level, component-level, or integration slices.\n- Spawn work from catalogs when the app has multiple pages, flows, or content types.",
|
|
97
|
+
"Static tasks:\n- Project setup and architecture.\n- Shared layout and design system decisions.\n- Release verification and regression checks.",
|
|
98
|
+
"Dynamic tasks / templates:\n- Page implementation templates.\n- Integration tasks for data flow and API wiring.\n- Testing and polish tasks per page or feature.",
|
|
99
|
+
"Catalogs for dynamic spawning:\n- Use a page catalog to spawn page work.\n- Use a feature catalog to split implementation across bigger slices.",
|
|
100
|
+
"Spawn rules:\n- Spawn child tasks when a page, flow, or integration deserves its own implementation track.\n- Avoid spawning trivial tasks unless they materially reduce ambiguity.",
|
|
101
|
+
"Helper scripts:\n- Use helper scripts for local setup, linting, tests, and asset generation.",
|
|
102
|
+
"Outputs and checks:\n- The app is wired end to end with the expected routes, data flow, and tests.\n- Each major page or flow has a clear completion criterion."
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "build-api",
|
|
107
|
+
label: "Build API",
|
|
108
|
+
workflowInstruction: [
|
|
109
|
+
"API build playbook",
|
|
110
|
+
"Goal: Design and implement a backend API with schema, endpoints, auth, tests, and rollout steps.",
|
|
111
|
+
"Write the playbook in natural language, but make the structure explicit so a planner can turn it into tasks without guessing.",
|
|
112
|
+
"Use these sections:\n- Goal and outcome\n- Workflow phases\n- Static tasks\n- Dynamic tasks / templates\n- Catalogs for spawning\n- Spawn rules and parent/child relationships\n- Helper scripts\n- Outputs and checks",
|
|
113
|
+
"Workflow guidance:\n- Start with the domain model and the API contract.\n- Use static tasks for schema design, shared middleware, and deployment concerns.\n- Use dynamic tasks for endpoint groups, integration checks, and consumer-specific work.\n- Spawn child tasks from a route or resource catalog when the API surface is broad.",
|
|
114
|
+
"Static tasks:\n- Schema and contract design.\n- Shared infrastructure and auth decisions.\n- Release and rollback planning.",
|
|
115
|
+
"Dynamic tasks / templates:\n- Endpoint implementation templates.\n- Integration and contract test tasks.",
|
|
116
|
+
"Catalogs for dynamic spawning:\n- Use a route catalog to spawn endpoint-specific tasks.\n- Use a resource catalog when multiple entity types need separate treatment.",
|
|
117
|
+
"Spawn rules:\n- Spawn one child per route group or resource family where that separation clarifies ownership.",
|
|
118
|
+
"Helper scripts:\n- Use helper scripts for schema checks, test runs, and migration or rollout support.",
|
|
119
|
+
"Outputs and checks:\n- The API contract is explicit and the implementation matches it.\n- Tests cover the key routes, auth boundaries, and rollback path."
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
label: "Fix and improve",
|
|
126
|
+
templates: [
|
|
127
|
+
{
|
|
128
|
+
id: "bug-fix",
|
|
129
|
+
label: "Bug fix",
|
|
130
|
+
workflowInstruction: [
|
|
131
|
+
"Bug-fix playbook",
|
|
132
|
+
"Goal: Reproduce a bug, isolate the cause, patch it, and lock in the regression test so the fix stays true.",
|
|
133
|
+
"Write the playbook in natural language, but make the structure explicit so a planner can turn it into tasks without guessing.",
|
|
134
|
+
"Use these sections:\n- Goal and outcome\n- Workflow phases\n- Static tasks\n- Dynamic tasks / templates\n- Catalogs for spawning\n- Spawn rules and parent/child relationships\n- Helper scripts\n- Outputs and checks",
|
|
135
|
+
"Workflow guidance:\n- Begin with reproduction and a crisp statement of the failure mode.\n- Use static tasks for diagnosis, patch planning, and regression strategy.\n- Use dynamic tasks for subsystem-specific debugging or test coverage when the bug spans multiple layers.",
|
|
136
|
+
"Static tasks:\n- Reproduction and failure characterization.\n- Root-cause analysis.\n- Regression test and verification.",
|
|
137
|
+
"Dynamic tasks / templates:\n- Subsystem-specific diagnosis templates.\n- Patch validation tasks.",
|
|
138
|
+
"Catalogs for dynamic spawning:\n- Use a reproduction catalog if there are multiple failing cases or environments.\n- Use a regression catalog when several tests or scenarios need to be added.",
|
|
139
|
+
"Spawn rules:\n- Spawn follow-up work only when the failure surface is broad enough to need separate diagnosis tracks.",
|
|
140
|
+
"Helper scripts:\n- Use helper scripts for reproducing the bug and validating the fix.",
|
|
141
|
+
"Outputs and checks:\n- The failure is reproducible before the fix and gone after it.\n- A regression test proves the issue stays fixed."
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "refactor",
|
|
146
|
+
label: "Refactor code",
|
|
147
|
+
workflowInstruction: [
|
|
148
|
+
"Refactor playbook",
|
|
149
|
+
"Goal: Break a code refactor into safe steps that preserve behavior and add validation after each stage.",
|
|
150
|
+
"Write the playbook in natural language, but make the structure explicit so a planner can turn it into tasks without guessing.",
|
|
151
|
+
"Use these sections:\n- Goal and outcome\n- Workflow phases\n- Static tasks\n- Dynamic tasks / templates\n- Catalogs for spawning\n- Spawn rules and parent/child relationships\n- Helper scripts\n- Outputs and checks",
|
|
152
|
+
"Workflow guidance:\n- Document the current shape before changing it.\n- Use static tasks for decomposition, dependency cleanup, and behavior preservation.\n- Use dynamic tasks for modules, subsystems, or slices that deserve separate treatment.",
|
|
153
|
+
"Static tasks:\n- Current-state analysis.\n- Refactor plan and safety checks.\n- Post-refactor verification.",
|
|
154
|
+
"Dynamic tasks / templates:\n- Module-level refactor templates.\n- Targeted cleanup or extraction tasks.",
|
|
155
|
+
"Catalogs for dynamic spawning:\n- Use a module catalog to split the refactor by subsystem.",
|
|
156
|
+
"Spawn rules:\n- Spawn children only when a subsystem has enough complexity to justify its own track.",
|
|
157
|
+
"Helper scripts:\n- Use helper scripts for formatting, test runs, and structural validation.",
|
|
158
|
+
"Outputs and checks:\n- Behavior remains intact after each step.\n- The final shape is easier to understand and still passes the expected checks."
|
|
159
|
+
]
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "performance",
|
|
163
|
+
label: "Performance pass",
|
|
164
|
+
workflowInstruction: [
|
|
165
|
+
"Performance pass playbook",
|
|
166
|
+
"Goal: Measure the bottleneck, change one thing at a time, and verify the improvement with repeatable checks.",
|
|
167
|
+
"Write the playbook in natural language, but make the structure explicit so a planner can turn it into tasks without guessing.",
|
|
168
|
+
"Use these sections:\n- Goal and outcome\n- Workflow phases\n- Static tasks\n- Dynamic tasks / templates\n- Catalogs for spawning\n- Spawn rules and parent/child relationships\n- Helper scripts\n- Outputs and checks",
|
|
169
|
+
"Workflow guidance:\n- Start by identifying the user-visible slowdown or resource bottleneck.\n- Use static tasks for measurement, hypothesis, and verification planning.\n- Use dynamic tasks for each suspected hotspot or optimization candidate.",
|
|
170
|
+
"Static tasks:\n- Baseline measurement and profiling plan.\n- Optimization hypothesis selection.\n- Post-change verification.",
|
|
171
|
+
"Dynamic tasks / templates:\n- Hotspot investigation tasks.\n- Candidate optimization validation tasks.",
|
|
172
|
+
"Catalogs for dynamic spawning:\n- Use a hotspot catalog to organize the specific areas under investigation.",
|
|
173
|
+
"Spawn rules:\n- Spawn one child per hotspot or optimization candidate so each change remains isolated.",
|
|
174
|
+
"Helper scripts:\n- Use helper scripts for benchmark runs and before/after comparisons.",
|
|
175
|
+
"Outputs and checks:\n- The optimization has a measured benefit.\n- The change does not regress correctness or stability."
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
]
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
label: "Automation",
|
|
182
|
+
templates: [
|
|
183
|
+
{
|
|
184
|
+
id: "workflow-automation",
|
|
185
|
+
label: "Workflow automation",
|
|
186
|
+
workflowInstruction: [
|
|
187
|
+
"Workflow automation playbook",
|
|
188
|
+
"Goal: Automate a repeated workflow with discovery, implementation, validation, and rollout tasks.",
|
|
189
|
+
"Write the playbook in natural language, but make the structure explicit so a planner can turn it into tasks without guessing.",
|
|
190
|
+
"Use these sections:\n- Goal and outcome\n- Workflow phases\n- Static tasks\n- Dynamic tasks / templates\n- Catalogs for spawning\n- Spawn rules and parent/child relationships\n- Helper scripts\n- Outputs and checks",
|
|
191
|
+
"Workflow guidance:\n- Start by describing the repeated workflow in plain language.\n- Use static tasks for discovery, rule design, implementation, and rollout planning.\n- Use dynamic tasks for each automation branch, integration point, or exception path.",
|
|
192
|
+
"Static tasks:\n- Workflow discovery and mapping.\n- Automation design and implementation plan.\n- Validation and rollout.",
|
|
193
|
+
"Dynamic tasks / templates:\n- Automation branch templates.\n- Exception-handling tasks.",
|
|
194
|
+
"Catalogs for dynamic spawning:\n- Use a step catalog to enumerate the repeated actions in the workflow.",
|
|
195
|
+
"Spawn rules:\n- Spawn a child for each branch or exception path that needs independent treatment.",
|
|
196
|
+
"Helper scripts:\n- Use helper scripts for repeatable execution, cleanup, and validation.",
|
|
197
|
+
"Outputs and checks:\n- The automation covers the repeated workflow end to end.\n- Manual work is reduced without losing control or auditability."
|
|
198
|
+
]
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "docs-site",
|
|
202
|
+
label: "Docs site",
|
|
203
|
+
workflowInstruction: [
|
|
204
|
+
"Docs site playbook",
|
|
205
|
+
"Goal: Plan a documentation site with information architecture, content drafting, navigation, and publishing checks.",
|
|
206
|
+
"Write the playbook in natural language, but make the structure explicit so a planner can turn it into tasks without guessing.",
|
|
207
|
+
"Use these sections:\n- Goal and outcome\n- Workflow phases\n- Static tasks\n- Dynamic tasks / templates\n- Catalogs for spawning\n- Spawn rules and parent/child relationships\n- Helper scripts\n- Outputs and checks",
|
|
208
|
+
"Workflow guidance:\n- Start with the information architecture and the primary audiences.\n- Use static tasks for site structure, content standards, and publishing workflow.\n- Use dynamic tasks for doc pages, topic clusters, and review cycles.",
|
|
209
|
+
"Static tasks:\n- Information architecture.\n- Content standards and navigation plan.\n- Publishing and review checks.",
|
|
210
|
+
"Dynamic tasks / templates:\n- Page-writing templates.\n- Topic-specific review tasks.",
|
|
211
|
+
"Catalogs for dynamic spawning:\n- Use a page catalog to spawn documentation pages and subsections.\n- Use a topic catalog for content clusters that need separate ownership.",
|
|
212
|
+
"Spawn rules:\n- Spawn per page or topic cluster where separate ownership keeps the plan clear.",
|
|
213
|
+
"Helper scripts:\n- Use helper scripts for build, link checks, and publishing validation.",
|
|
214
|
+
"Outputs and checks:\n- The site structure is easy to navigate.\n- Each page has a clear owner, review step, and publishing check."
|
|
215
|
+
]
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "integration-test",
|
|
219
|
+
label: "Integration test",
|
|
220
|
+
workflowInstruction: [
|
|
221
|
+
"Integration-test playbook",
|
|
222
|
+
"Goal: Create an integration-test workflow with fixtures, harness setup, main scenarios, and regression checks.",
|
|
223
|
+
"Write the playbook in natural language, but make the structure explicit so a planner can turn it into tasks without guessing.",
|
|
224
|
+
"Use these sections:\n- Goal and outcome\n- Workflow phases\n- Static tasks\n- Dynamic tasks / templates\n- Catalogs for spawning\n- Spawn rules and parent/child relationships\n- Helper scripts\n- Outputs and checks",
|
|
225
|
+
"Workflow guidance:\n- Start with the system boundaries and the scenario matrix.\n- Use static tasks for fixture setup, harness construction, and reporting.\n- Use dynamic tasks for individual scenarios or environment combinations.",
|
|
226
|
+
"Static tasks:\n- Fixture and harness setup.\n- Scenario grouping and ownership.\n- Regression reporting.",
|
|
227
|
+
"Dynamic tasks / templates:\n- Scenario templates.\n- Environment-specific verification tasks.",
|
|
228
|
+
"Catalogs for dynamic spawning:\n- Use a scenario catalog to spawn per-scenario tests or fixtures.",
|
|
229
|
+
"Spawn rules:\n- Spawn children for each important scenario or environment combination.",
|
|
230
|
+
"Helper scripts:\n- Use helper scripts for running the test matrix and collecting results.",
|
|
231
|
+
"Outputs and checks:\n- The main scenarios are exercised end to end.\n- The regression checks make failures obvious and repeatable."
|
|
232
|
+
]
|
|
233
|
+
}
|
|
234
|
+
]
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
};
|
|
238
|
+
var DEFAULT_STATE_FILE = join(".converge", "ui", "studio-server.json");
|
|
239
|
+
var DEFAULT_COOKIE = "converge_ui_token";
|
|
240
|
+
var DEFAULT_HEADER = "x-converge-ui-token";
|
|
241
|
+
async function createHtmlServerManager(options) {
|
|
242
|
+
const projectDir = resolve(options.projectDir);
|
|
243
|
+
const host = options.host ?? "127.0.0.1";
|
|
244
|
+
const statePath = join(projectDir, options.stateFileName ?? DEFAULT_STATE_FILE);
|
|
245
|
+
const cookieName = options.authCookieName ?? DEFAULT_COOKIE;
|
|
246
|
+
const headerName = (options.authHeaderName ?? DEFAULT_HEADER).toLowerCase();
|
|
247
|
+
const token = randomUUID().replace(/-/g, "");
|
|
248
|
+
const serverId = randomUUID();
|
|
249
|
+
await ensureNoLiveState(statePath);
|
|
250
|
+
const server = createServer((req, res) => {
|
|
251
|
+
void handleManagedRequest({
|
|
252
|
+
req,
|
|
253
|
+
res,
|
|
254
|
+
token,
|
|
255
|
+
cookieName,
|
|
256
|
+
headerName,
|
|
257
|
+
onRequest: options.onRequest,
|
|
258
|
+
onUnauthorized: options.onUnauthorized
|
|
259
|
+
}).catch((err) => {
|
|
260
|
+
const message = err?.stack || err?.message || String(err);
|
|
261
|
+
res.statusCode = 500;
|
|
262
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
263
|
+
res.end(`Server error
|
|
264
|
+
|
|
265
|
+
${message}`);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
const port = options.port ?? 0;
|
|
269
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
270
|
+
server.once("error", rejectListen);
|
|
271
|
+
server.listen(port, host, () => resolveListen());
|
|
272
|
+
});
|
|
273
|
+
const address = server.address();
|
|
274
|
+
const actualPort = typeof address === "object" && address && "port" in address ? address.port : port;
|
|
275
|
+
const baseUrl = `http://${host}:${actualPort}`;
|
|
276
|
+
const state = {
|
|
277
|
+
id: serverId,
|
|
278
|
+
pid: process.pid,
|
|
279
|
+
projectDir,
|
|
280
|
+
host,
|
|
281
|
+
port: actualPort,
|
|
282
|
+
token,
|
|
283
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
284
|
+
};
|
|
285
|
+
await mkdir(dirname(statePath), { recursive: true });
|
|
286
|
+
await writeFile(statePath, JSON.stringify(state, null, 2), "utf8");
|
|
287
|
+
return {
|
|
288
|
+
url: baseUrl,
|
|
289
|
+
authUrl: buildAuthUrl(baseUrl, token, "/"),
|
|
290
|
+
token,
|
|
291
|
+
host,
|
|
292
|
+
port: actualPort,
|
|
293
|
+
withAuth(path = "/") {
|
|
294
|
+
return buildAuthUrl(baseUrl, token, path);
|
|
295
|
+
},
|
|
296
|
+
async close() {
|
|
297
|
+
await new Promise((resolveClose, rejectClose) => {
|
|
298
|
+
server.close((err) => {
|
|
299
|
+
if (err) rejectClose(err);
|
|
300
|
+
else resolveClose();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
await removeLiveState(statePath, serverId);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
async function readHtmlServerState(projectDir, stateFileName) {
|
|
308
|
+
const statePath = join(resolve(projectDir), DEFAULT_STATE_FILE);
|
|
309
|
+
return await readState(statePath);
|
|
310
|
+
}
|
|
311
|
+
async function handleManagedRequest(args) {
|
|
312
|
+
const { req, res, token, cookieName, headerName, onRequest, onUnauthorized } = args;
|
|
313
|
+
const requestUrl = new URL(req.url || "/", "http://127.0.0.1");
|
|
314
|
+
const requestToken = requestUrl.searchParams.get("token") || readCookie(req.headers.cookie, cookieName) || readHeader(req.headers[headerName], headerName);
|
|
315
|
+
if (requestToken !== token) {
|
|
316
|
+
if (onUnauthorized) {
|
|
317
|
+
await onUnauthorized({ req, res });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
res.statusCode = 401;
|
|
321
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
322
|
+
res.end("Unauthorized");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (requestUrl.searchParams.get("token")) {
|
|
326
|
+
res.setHeader(
|
|
327
|
+
"set-cookie",
|
|
328
|
+
`${cookieName}=${token}; Path=/; HttpOnly; SameSite=Lax`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
await onRequest({ req, res });
|
|
332
|
+
}
|
|
333
|
+
async function ensureNoLiveState(statePath) {
|
|
334
|
+
if (!existsSync(statePath)) return;
|
|
335
|
+
const current = await readState(statePath);
|
|
336
|
+
if (!current) {
|
|
337
|
+
await rm(statePath, { force: true });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const live = await isPortOpen(current.host, current.port);
|
|
341
|
+
if (live) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`Studio server already appears to be running at http://${current.host}:${current.port} (pid ${current.pid}, started ${current.startedAt}). Stop it first or close the existing server.`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
await rm(statePath, { force: true });
|
|
347
|
+
}
|
|
348
|
+
async function removeLiveState(statePath, serverId) {
|
|
349
|
+
if (!existsSync(statePath)) return;
|
|
350
|
+
const current = await readState(statePath);
|
|
351
|
+
if (!current || current.id !== serverId) return;
|
|
352
|
+
await rm(statePath, { force: true });
|
|
353
|
+
}
|
|
354
|
+
async function readState(statePath) {
|
|
355
|
+
try {
|
|
356
|
+
const raw = JSON.parse(await readFile(statePath, "utf8"));
|
|
357
|
+
if (typeof raw !== "object" || raw === null || typeof raw.id !== "string" || typeof raw.pid !== "number" || typeof raw.projectDir !== "string" || typeof raw.host !== "string" || typeof raw.port !== "number" || typeof raw.token !== "string" || typeof raw.startedAt !== "string") {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
return raw;
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async function isPortOpen(host, port) {
|
|
366
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return false;
|
|
367
|
+
if (!host || host === "0.0.0.0" || host === "::") return false;
|
|
368
|
+
return await new Promise((resolveProbe) => {
|
|
369
|
+
const socket = new Socket();
|
|
370
|
+
let settled = false;
|
|
371
|
+
const finish = (value) => {
|
|
372
|
+
if (settled) return;
|
|
373
|
+
settled = true;
|
|
374
|
+
socket.destroy();
|
|
375
|
+
resolveProbe(value);
|
|
376
|
+
};
|
|
377
|
+
socket.setTimeout(250);
|
|
378
|
+
socket.once("connect", () => finish(true));
|
|
379
|
+
socket.once("timeout", () => finish(false));
|
|
380
|
+
socket.once("error", () => finish(false));
|
|
381
|
+
socket.connect(port, isIP(host) === 6 ? host : host);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
function buildAuthUrl(baseUrl, token, path = "/") {
|
|
385
|
+
const url = new URL(path, baseUrl);
|
|
386
|
+
url.searchParams.set("token", token);
|
|
387
|
+
return url.toString();
|
|
388
|
+
}
|
|
389
|
+
function readCookie(cookieHeader, name) {
|
|
390
|
+
if (!cookieHeader) return null;
|
|
391
|
+
for (const part of cookieHeader.split(";")) {
|
|
392
|
+
const [rawKey, ...rawValue] = part.trim().split("=");
|
|
393
|
+
if (!rawKey || rawKey !== name) continue;
|
|
394
|
+
const value = rawValue.join("=");
|
|
395
|
+
return value || null;
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
function readHeader(value, headerName) {
|
|
400
|
+
if (!value) return null;
|
|
401
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
402
|
+
if (!raw) return null;
|
|
403
|
+
return headerName === DEFAULT_HEADER ? raw.trim() : raw.trim();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/add-ui.ts
|
|
407
|
+
var execFileAsync = promisify(execFile);
|
|
408
|
+
var SESSIONS_DIR = join(".converge", "ui", "add");
|
|
409
|
+
function findTemplateSpec(templateId) {
|
|
410
|
+
if (templateId === void 0) return void 0;
|
|
411
|
+
for (const group of add_ui_templates_default.templateGroups) {
|
|
412
|
+
const match = group.templates.find((template) => template.id === templateId);
|
|
413
|
+
if (match) return match;
|
|
414
|
+
}
|
|
415
|
+
return void 0;
|
|
416
|
+
}
|
|
417
|
+
function resolveTemplateSpec(templateValue) {
|
|
418
|
+
const trimmed = templateValue?.trim();
|
|
419
|
+
if (!trimmed) return void 0;
|
|
420
|
+
const byId = findTemplateSpec(trimmed);
|
|
421
|
+
if (byId) return byId;
|
|
422
|
+
for (const group of add_ui_templates_default.templateGroups) {
|
|
423
|
+
const match = group.templates.find((template) => template.label === trimmed);
|
|
424
|
+
if (match) return match;
|
|
425
|
+
}
|
|
426
|
+
return void 0;
|
|
427
|
+
}
|
|
428
|
+
var TEMPLATE_GROUPS = add_ui_templates_default.templateGroups.map((group) => ({
|
|
429
|
+
label: group.label,
|
|
430
|
+
templates: group.templates.map((template) => ({
|
|
431
|
+
id: template.id,
|
|
432
|
+
label: template.label,
|
|
433
|
+
workflowInstruction: template.workflowInstruction
|
|
434
|
+
}))
|
|
435
|
+
}));
|
|
436
|
+
var announcedHumanReviewArtifacts = /* @__PURE__ */ new Set();
|
|
437
|
+
async function runAddStudio(options) {
|
|
438
|
+
const server = await createAddStudioServer(options);
|
|
439
|
+
console.log(`
|
|
440
|
+
\u{1F310} Browser studio running at ${server.authUrl}`);
|
|
441
|
+
console.log(" Press Ctrl-C to stop.\n");
|
|
442
|
+
if (options.openBrowser !== false) {
|
|
443
|
+
await tryOpenBrowser(server.authUrl).catch(() => {
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
await new Promise((resolveStop) => {
|
|
447
|
+
let closed = false;
|
|
448
|
+
const stop = async () => {
|
|
449
|
+
if (closed) return;
|
|
450
|
+
closed = true;
|
|
451
|
+
await server.close().catch(() => {
|
|
452
|
+
});
|
|
453
|
+
resolveStop();
|
|
454
|
+
};
|
|
455
|
+
process.once("SIGINT", stop);
|
|
456
|
+
process.once("SIGTERM", stop);
|
|
457
|
+
process.once("SIGHUP", stop);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
async function createAddStudioServer(options) {
|
|
461
|
+
const projectDir = resolve(options.projectDir);
|
|
462
|
+
const rootDir = join(projectDir, SESSIONS_DIR);
|
|
463
|
+
await mkdir(rootDir, { recursive: true });
|
|
464
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
465
|
+
await loadSessionsFromDisk(rootDir, sessions);
|
|
466
|
+
const manager = await createHtmlServerManager({
|
|
467
|
+
projectDir,
|
|
468
|
+
port: options.port,
|
|
469
|
+
host: options.host,
|
|
470
|
+
onRequest: async ({ req, res }) => {
|
|
471
|
+
await handleRequest({
|
|
472
|
+
req,
|
|
473
|
+
res,
|
|
474
|
+
projectDir,
|
|
475
|
+
rootDir,
|
|
476
|
+
sessions,
|
|
477
|
+
plannerAgentfn: options.plannerAgentfn
|
|
478
|
+
}).catch((err) => {
|
|
479
|
+
sendHtml(
|
|
480
|
+
res,
|
|
481
|
+
500,
|
|
482
|
+
renderLayout("Converge Studio", [
|
|
483
|
+
`<section class="panel error"><h1>Server error</h1><pre>${escapeHtml(err?.stack || err?.message || String(err))}</pre></section>`
|
|
484
|
+
])
|
|
485
|
+
);
|
|
486
|
+
});
|
|
487
|
+
},
|
|
488
|
+
onUnauthorized: ({ res }) => {
|
|
489
|
+
sendHtml(
|
|
490
|
+
res,
|
|
491
|
+
401,
|
|
492
|
+
renderLayout("Unauthorized", [
|
|
493
|
+
panel("Unauthorized", "Open the browser studio with the authenticated URL or provide the access token.")
|
|
494
|
+
])
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
return manager;
|
|
499
|
+
}
|
|
500
|
+
async function handleRequest(args) {
|
|
501
|
+
const { req, res, projectDir, rootDir, sessions, plannerAgentfn } = args;
|
|
502
|
+
const method = (req.method || "GET").toUpperCase();
|
|
503
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
504
|
+
const path = url.pathname;
|
|
505
|
+
if (method === "GET" && path === "/help") {
|
|
506
|
+
const view = await buildHelpView();
|
|
507
|
+
sendHtml(res, 200, renderLayout("Planner help", view));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (method === "GET" && path === "/") {
|
|
511
|
+
const view = await buildHomeView();
|
|
512
|
+
sendHtml(res, 200, renderLayout("Converge Studio", view));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (method === "POST" && path === "/api/sessions") {
|
|
516
|
+
const body = await readForm(req);
|
|
517
|
+
const goal = body.goal?.trim();
|
|
518
|
+
const playbookInstruction = body.playbookInstruction?.trim() || "";
|
|
519
|
+
const templateValue = body.template?.trim() || "";
|
|
520
|
+
const name = body.name?.trim() || slugify(body.goal || "plan");
|
|
521
|
+
if (!goal) {
|
|
522
|
+
sendJson(res, 400, { error: "goal is required" });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const template = resolveTemplateSpec(templateValue);
|
|
526
|
+
const session = await createSession(rootDir, sessions, {
|
|
527
|
+
goal,
|
|
528
|
+
name,
|
|
529
|
+
projectDir,
|
|
530
|
+
playbookInstruction,
|
|
531
|
+
templateId: template?.id ?? templateValue,
|
|
532
|
+
templateLabel: template?.label ?? (templateValue || void 0)
|
|
533
|
+
});
|
|
534
|
+
void schedulePlanning(session, plannerAgentfn, sessions, rootDir);
|
|
535
|
+
sendJson(res, 201, {
|
|
536
|
+
id: session.id,
|
|
537
|
+
url: `/sessions/${session.id}`,
|
|
538
|
+
status: session.status
|
|
539
|
+
});
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (method === "POST" && path === "/sessions") {
|
|
543
|
+
const body = await readForm(req);
|
|
544
|
+
const goal = body.goal?.trim();
|
|
545
|
+
const playbookInstruction = body.playbookInstruction?.trim() || "";
|
|
546
|
+
const templateValue = body.template?.trim() || "";
|
|
547
|
+
const name = body.name?.trim() || slugify(body.goal || "plan");
|
|
548
|
+
if (!goal) {
|
|
549
|
+
sendHtml(
|
|
550
|
+
res,
|
|
551
|
+
400,
|
|
552
|
+
renderLayout("Converge Studio", [panel("Missing goal", "Goal is required.")])
|
|
553
|
+
);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const template = resolveTemplateSpec(templateValue);
|
|
557
|
+
const session = await createSession(rootDir, sessions, {
|
|
558
|
+
goal,
|
|
559
|
+
name,
|
|
560
|
+
projectDir,
|
|
561
|
+
playbookInstruction,
|
|
562
|
+
templateId: template?.id ?? templateValue,
|
|
563
|
+
templateLabel: template?.label ?? (templateValue || void 0)
|
|
564
|
+
});
|
|
565
|
+
void schedulePlanning(session, plannerAgentfn, sessions, rootDir);
|
|
566
|
+
redirect(res, `/sessions/${session.id}`);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (method === "GET" && path.startsWith("/sessions/")) {
|
|
570
|
+
const sessionId = path.split("/")[2];
|
|
571
|
+
const session = sessions.get(sessionId);
|
|
572
|
+
if (!session) {
|
|
573
|
+
sendHtml(res, 404, renderLayout("Not found", [panel("Not found", "Unknown session.")]));
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const view = await buildSessionView(session);
|
|
577
|
+
const refresh = session.status === "planning" || session.status === "publishing";
|
|
578
|
+
sendHtml(res, 200, renderLayout(`Plan \xB7 ${escapeHtml(session.name)}`, view, refresh));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (method === "GET" && path.startsWith("/api/sessions/") && path.endsWith("/status")) {
|
|
582
|
+
const sessionId = path.split("/")[3];
|
|
583
|
+
const session = sessions.get(sessionId);
|
|
584
|
+
if (!session) {
|
|
585
|
+
sendJson(res, 404, { error: "not found" });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
sendJson(res, 200, await serializeSession(session));
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (method === "POST" && path.startsWith("/sessions/") && path.endsWith("/feedback")) {
|
|
592
|
+
const sessionId = path.split("/")[2];
|
|
593
|
+
const session = sessions.get(sessionId);
|
|
594
|
+
if (!session) {
|
|
595
|
+
sendHtml(res, 404, renderLayout("Not found", [panel("Not found", "Unknown session.")]));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const body = await readForm(req);
|
|
599
|
+
const feedback = body.feedback?.trim();
|
|
600
|
+
if (!feedback) {
|
|
601
|
+
redirect(res, `/sessions/${session.id}`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
await appendFeedback(session, rootDir, feedback);
|
|
605
|
+
void schedulePlanning(session, plannerAgentfn, sessions, rootDir);
|
|
606
|
+
redirect(res, `/sessions/${session.id}`);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (method === "POST" && path.startsWith("/sessions/") && path.endsWith("/accept")) {
|
|
610
|
+
const sessionId = path.split("/")[2];
|
|
611
|
+
const session = sessions.get(sessionId);
|
|
612
|
+
if (!session) {
|
|
613
|
+
sendHtml(res, 404, renderLayout("Not found", [panel("Not found", "Unknown session.")]));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
await publishSession(session);
|
|
618
|
+
redirect(res, `/playbooks/${encodeURIComponent(session.name)}`);
|
|
619
|
+
} catch (err) {
|
|
620
|
+
sendHtml(
|
|
621
|
+
res,
|
|
622
|
+
400,
|
|
623
|
+
renderLayout("Publish failed", [
|
|
624
|
+
panel(
|
|
625
|
+
"Could not publish plan",
|
|
626
|
+
`<pre>${escapeHtml(err?.message || String(err))}</pre>`
|
|
627
|
+
),
|
|
628
|
+
sessionFooter(session)
|
|
629
|
+
])
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (method === "POST" && path.startsWith("/studio/handoff/")) {
|
|
635
|
+
const handoffId = decodeURIComponent(path.split("/")[3] || "");
|
|
636
|
+
const handoff = await loadHumanReviewHandoffById(projectDir, handoffId);
|
|
637
|
+
if (!handoff) {
|
|
638
|
+
sendHtml(res, 404, renderLayout("Not found", [panel("Not found", "Unknown human review page.")]));
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const body = await readForm(req);
|
|
642
|
+
const review = normalizeHumanReview(body, {
|
|
643
|
+
playbook: handoff.playbook,
|
|
644
|
+
taskId: handoff.taskId
|
|
645
|
+
});
|
|
646
|
+
await appendHumanReview(projectDir, review);
|
|
647
|
+
await writeHumanReportArtifact(projectDir, handoff.playbook, handoff.taskId);
|
|
648
|
+
redirect(res, `/studio/handoff/${encodeURIComponent(handoff.id)}`);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (method === "POST" && path.startsWith("/playbooks/") && path.endsWith("/report")) {
|
|
652
|
+
const parts = path.split("/");
|
|
653
|
+
const playbook = decodeURIComponent(parts[2] || "");
|
|
654
|
+
const taskId = decodeURIComponent(parts.slice(4, -1).join("/"));
|
|
655
|
+
const body = await readForm(req);
|
|
656
|
+
const review = normalizeHumanReview(body, { playbook, taskId });
|
|
657
|
+
await appendHumanReview(projectDir, review);
|
|
658
|
+
await writeHumanReportArtifact(projectDir, playbook, taskId);
|
|
659
|
+
redirect(
|
|
660
|
+
res,
|
|
661
|
+
`/studio/handoff/${encodeURIComponent((await ensureHumanReviewHandoff(projectDir, playbook, taskId)).id)}`
|
|
662
|
+
);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (method === "GET" && path.startsWith("/studio/handoff/")) {
|
|
666
|
+
const handoffId = decodeURIComponent(path.split("/")[3] || "");
|
|
667
|
+
const handoff = await loadHumanReviewHandoffById(projectDir, handoffId);
|
|
668
|
+
if (!handoff) {
|
|
669
|
+
sendHtml(res, 404, renderLayout("Not found", [panel("Not found", "Unknown human review page.")]));
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const report = await loadOrCreateHumanReportArtifact(projectDir, handoff.playbook, handoff.taskId);
|
|
673
|
+
if (!report) {
|
|
674
|
+
sendHtml(res, 404, renderLayout("Not found", [panel("Not found", "Unknown human review page.")]));
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
sendHtml(
|
|
678
|
+
res,
|
|
679
|
+
200,
|
|
680
|
+
renderHumanReviewPageHtml({
|
|
681
|
+
playbook: handoff.playbook,
|
|
682
|
+
taskId: handoff.taskId,
|
|
683
|
+
reportContentHtml: report,
|
|
684
|
+
submitPath: path
|
|
685
|
+
})
|
|
686
|
+
);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (method === "GET" && path.startsWith("/playbooks/")) {
|
|
690
|
+
const name = decodeURIComponent(path.split("/")[2] || "");
|
|
691
|
+
const tail = path.split("/").slice(3).join("/");
|
|
692
|
+
if (tail === "run") {
|
|
693
|
+
const view2 = await buildRunView(projectDir, name);
|
|
694
|
+
if (!view2) {
|
|
695
|
+
sendHtml(res, 404, renderLayout("Not found", [panel("Not found", "Unknown run state.")]));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
sendHtml(res, 200, renderLayout(`Run \xB7 ${escapeHtml(name)}`, view2, true));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (tail.startsWith("tasks/") && tail.endsWith("/report")) {
|
|
702
|
+
const taskId = decodeURIComponent(tail.slice("tasks/".length, -"/report".length));
|
|
703
|
+
const report = await loadOrCreateHumanReportArtifact(projectDir, name, taskId);
|
|
704
|
+
if (!report) {
|
|
705
|
+
sendHtml(res, 404, renderLayout("Not found", [panel("Not found", "Unknown human review page.")]));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
sendHtml(
|
|
709
|
+
res,
|
|
710
|
+
200,
|
|
711
|
+
renderHumanReviewPageHtml({
|
|
712
|
+
playbook: name,
|
|
713
|
+
taskId,
|
|
714
|
+
reportContentHtml: report,
|
|
715
|
+
submitPath: path
|
|
716
|
+
})
|
|
717
|
+
);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const view = await buildPlaybookView(projectDir, name);
|
|
721
|
+
if (!view) {
|
|
722
|
+
sendHtml(res, 404, renderLayout("Not found", [panel("Not found", "Unknown playbook.")]));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
sendHtml(res, 200, renderLayout(`Playbook \xB7 ${escapeHtml(name)}`, view));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
sendHtml(res, 404, renderLayout("Not found", [panel("Not found", "The requested page does not exist.")]));
|
|
729
|
+
}
|
|
730
|
+
async function createSession(rootDir, sessions, opts) {
|
|
731
|
+
const id = randomUUID();
|
|
732
|
+
const sessionDir = join(rootDir, id);
|
|
733
|
+
const draftDir = join(sessionDir, "draft");
|
|
734
|
+
const finalDir = join(opts.projectDir, ".converge", "playbooks", opts.name);
|
|
735
|
+
const session = {
|
|
736
|
+
id,
|
|
737
|
+
projectDir: opts.projectDir,
|
|
738
|
+
name: opts.name,
|
|
739
|
+
templateId: opts.templateId,
|
|
740
|
+
templateLabel: opts.templateLabel,
|
|
741
|
+
playbookInstruction: opts.playbookInstruction,
|
|
742
|
+
goal: opts.goal,
|
|
743
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
744
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
745
|
+
revision: 0,
|
|
746
|
+
status: "idle",
|
|
747
|
+
draftDir,
|
|
748
|
+
finalDir,
|
|
749
|
+
feedback: [],
|
|
750
|
+
activeRun: null,
|
|
751
|
+
rerunRequested: true
|
|
752
|
+
};
|
|
753
|
+
sessions.set(id, session);
|
|
754
|
+
await persistSession(rootDir, session);
|
|
755
|
+
return session;
|
|
756
|
+
}
|
|
757
|
+
async function appendFeedback(session, rootDir, feedback) {
|
|
758
|
+
session.feedback.push({ ts: (/* @__PURE__ */ new Date()).toISOString(), message: feedback });
|
|
759
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
760
|
+
session.rerunRequested = true;
|
|
761
|
+
await persistSession(rootDir, session);
|
|
762
|
+
await appendFeedbackEntry(rootDir, session.id, feedback);
|
|
763
|
+
}
|
|
764
|
+
async function schedulePlanning(session, plannerAgentfn, sessions, rootDir) {
|
|
765
|
+
session.rerunRequested = true;
|
|
766
|
+
if (session.activeRun) return session.activeRun;
|
|
767
|
+
const running = (async () => {
|
|
768
|
+
while (session.rerunRequested) {
|
|
769
|
+
session.rerunRequested = false;
|
|
770
|
+
await performPlanning(session, plannerAgentfn, sessions);
|
|
771
|
+
}
|
|
772
|
+
})().catch(async (err) => {
|
|
773
|
+
session.status = "failed";
|
|
774
|
+
session.lastError = err?.message || String(err);
|
|
775
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
776
|
+
await persistSession(rootDir, session);
|
|
777
|
+
});
|
|
778
|
+
session.activeRun = running.finally(() => {
|
|
779
|
+
session.activeRun = null;
|
|
780
|
+
});
|
|
781
|
+
return session.activeRun;
|
|
782
|
+
}
|
|
783
|
+
async function performPlanning(session, plannerAgentfn, sessions) {
|
|
784
|
+
const rootDir = join(session.projectDir, SESSIONS_DIR);
|
|
785
|
+
const runDir = join(
|
|
786
|
+
rootDir,
|
|
787
|
+
session.id,
|
|
788
|
+
"runs",
|
|
789
|
+
`rev-${String(session.revision + 1).padStart(2, "0")}`
|
|
790
|
+
);
|
|
791
|
+
const hasDraft = existsSync(session.draftDir);
|
|
792
|
+
session.status = "planning";
|
|
793
|
+
session.workDir = runDir;
|
|
794
|
+
session.lastError = void 0;
|
|
795
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
796
|
+
await persistSession(rootDir, session);
|
|
797
|
+
await rm(runDir, { recursive: true, force: true });
|
|
798
|
+
await mkdir(runDir, { recursive: true });
|
|
799
|
+
if (hasDraft) {
|
|
800
|
+
await cp(session.draftDir, runDir, { recursive: true });
|
|
801
|
+
}
|
|
802
|
+
await rm(join(session.projectDir, ".converge", "inventory"), {
|
|
803
|
+
recursive: true,
|
|
804
|
+
force: true
|
|
805
|
+
});
|
|
806
|
+
const goal = buildPlannerPrompt(session);
|
|
807
|
+
const previousOutputDir = process.env.CONVERGE_PLAN_OUTPUT_DIR;
|
|
808
|
+
process.env.CONVERGE_PLAN_OUTPUT_DIR = runDir;
|
|
809
|
+
try {
|
|
810
|
+
await plan({
|
|
811
|
+
goal,
|
|
812
|
+
name: session.name,
|
|
813
|
+
projectDir: session.projectDir,
|
|
814
|
+
outputDir: runDir,
|
|
815
|
+
update: hasDraft,
|
|
816
|
+
plannerAgentfn
|
|
817
|
+
});
|
|
818
|
+
await sanitizePlaybookYml(runDir);
|
|
819
|
+
await rm(session.draftDir, { recursive: true, force: true });
|
|
820
|
+
await rename(runDir, session.draftDir);
|
|
821
|
+
session.revision += 1;
|
|
822
|
+
session.status = "awaiting-feedback";
|
|
823
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
824
|
+
session.workDir = void 0;
|
|
825
|
+
await persistSession(rootDir, session);
|
|
826
|
+
} catch (err) {
|
|
827
|
+
session.status = "failed";
|
|
828
|
+
session.lastError = err?.message || String(err);
|
|
829
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
830
|
+
session.workDir = runDir;
|
|
831
|
+
await persistSession(rootDir, session);
|
|
832
|
+
sessions.set(session.id, session);
|
|
833
|
+
} finally {
|
|
834
|
+
if (previousOutputDir === void 0) {
|
|
835
|
+
delete process.env.CONVERGE_PLAN_OUTPUT_DIR;
|
|
836
|
+
} else {
|
|
837
|
+
process.env.CONVERGE_PLAN_OUTPUT_DIR = previousOutputDir;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
async function publishSession(session) {
|
|
842
|
+
const src = session.draftDir;
|
|
843
|
+
if (!existsSync(src)) {
|
|
844
|
+
throw new Error("No accepted draft exists yet.");
|
|
845
|
+
}
|
|
846
|
+
if (session.activeRun) {
|
|
847
|
+
throw new Error("The planner is still running.");
|
|
848
|
+
}
|
|
849
|
+
if (existsSync(session.finalDir)) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
`Playbook "${session.name}" already exists at ${session.finalDir}. Remove it or choose a different name.`
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
session.status = "publishing";
|
|
855
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
856
|
+
await persistSession(join(session.projectDir, SESSIONS_DIR), session);
|
|
857
|
+
await mkdir(join(session.projectDir, ".converge", "playbooks"), { recursive: true });
|
|
858
|
+
await cp(src, session.finalDir, { recursive: true });
|
|
859
|
+
session.status = "published";
|
|
860
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
861
|
+
await persistSession(join(session.projectDir, SESSIONS_DIR), session);
|
|
862
|
+
}
|
|
863
|
+
function buildPlannerPrompt(session) {
|
|
864
|
+
const lines = [];
|
|
865
|
+
const template = findTemplateSpec(session.templateId);
|
|
866
|
+
if (template && template.id !== "") {
|
|
867
|
+
lines.push(`Workflow template: ${template.label}`);
|
|
868
|
+
lines.push(template.workflowInstruction.join("\n\n"));
|
|
869
|
+
lines.push("");
|
|
870
|
+
} else if (session.templateLabel?.trim()) {
|
|
871
|
+
lines.push(`Workflow template: ${session.templateLabel.trim()}`);
|
|
872
|
+
lines.push(
|
|
873
|
+
"Treat the workflow text as the playbook shape and keep it separate from the goal or app description."
|
|
874
|
+
);
|
|
875
|
+
lines.push("");
|
|
876
|
+
}
|
|
877
|
+
if (session.playbookInstruction?.trim()) {
|
|
878
|
+
lines.push("Playbook instruction:");
|
|
879
|
+
lines.push(session.playbookInstruction.trim());
|
|
880
|
+
lines.push("");
|
|
881
|
+
}
|
|
882
|
+
lines.push(session.goal.trim());
|
|
883
|
+
if (session.feedback.length > 0) {
|
|
884
|
+
lines.push("", "User feedback so far:");
|
|
885
|
+
for (const item of session.feedback) {
|
|
886
|
+
lines.push(`- ${item.ts}: ${item.message}`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
lines.push("", "The plan should be revisable in the browser.");
|
|
890
|
+
return lines.join("\n");
|
|
891
|
+
}
|
|
892
|
+
async function buildHomeView(projectDir, rootDir, sessions) {
|
|
893
|
+
return [
|
|
894
|
+
`<section class="compose-shell">
|
|
895
|
+
<div class="compose-intro">
|
|
896
|
+
<div class="eyebrow">Converge</div>
|
|
897
|
+
<h1>Playbook planner</h1>
|
|
898
|
+
<p class="lede">Choose the playbook shape first, then describe the app or outcome it should produce.</p>
|
|
899
|
+
</div>
|
|
900
|
+
<form method="post" action="/sessions" class="compose-card stack">
|
|
901
|
+
<div class="compose-section compose-panel">
|
|
902
|
+
<span class="section-kicker">How</span>
|
|
903
|
+
<div class="compose-stack-left">
|
|
904
|
+
<label class="template-select-field">
|
|
905
|
+
<span>Template</span>
|
|
906
|
+
<div class="template-select-shell">
|
|
907
|
+
<select
|
|
908
|
+
name="template"
|
|
909
|
+
data-template-select
|
|
910
|
+
>
|
|
911
|
+
${TEMPLATE_GROUPS.map(
|
|
912
|
+
(group) => `
|
|
913
|
+
<optgroup label="${escapeHtml(group.label)}">
|
|
914
|
+
${group.templates.map(
|
|
915
|
+
(template) => `<option value="${escapeHtml(template.id)}" data-instruction="${escapeHtml(template.workflowInstruction.join("\n\n"))}">${escapeHtml(template.label)}</option>`
|
|
916
|
+
).join("")}
|
|
917
|
+
</optgroup>`
|
|
918
|
+
).join("")}
|
|
919
|
+
</select>
|
|
920
|
+
</div>
|
|
921
|
+
</label>
|
|
922
|
+
<label class="playbook-field">
|
|
923
|
+
<span>Playbook instruction</span>
|
|
924
|
+
<textarea
|
|
925
|
+
name="playbookInstruction"
|
|
926
|
+
rows="2"
|
|
927
|
+
placeholder="Describe how this playbook should be structured and what it should prioritize"
|
|
928
|
+
data-autoresize
|
|
929
|
+
data-template-instruction
|
|
930
|
+
></textarea>
|
|
931
|
+
</label>
|
|
932
|
+
</div>
|
|
933
|
+
</div>
|
|
934
|
+
<div class="compose-section compose-panel">
|
|
935
|
+
<span class="section-kicker">What</span>
|
|
936
|
+
<label class="prompt-field">
|
|
937
|
+
<span>Goal / app description</span>
|
|
938
|
+
<textarea
|
|
939
|
+
name="goal"
|
|
940
|
+
rows="2"
|
|
941
|
+
placeholder="Describe the app, workflow, or outcome you want the playbook to produce"
|
|
942
|
+
data-template-prompt
|
|
943
|
+
data-autoresize
|
|
944
|
+
></textarea>
|
|
945
|
+
</label>
|
|
946
|
+
</div>
|
|
947
|
+
<div class="compose-actions">
|
|
948
|
+
<p class="hint">The workflow shapes the playbook. The goal tells it what to build.</p>
|
|
949
|
+
<button type="submit">Start plan</button>
|
|
950
|
+
</div>
|
|
951
|
+
</form>
|
|
952
|
+
<p class="help-link help-link-bottom"><a href="/help">How this planner works</a></p>
|
|
953
|
+
</section>`,
|
|
954
|
+
`<script>
|
|
955
|
+
(() => {
|
|
956
|
+
const select = document.querySelector('[data-template-select]');
|
|
957
|
+
const instruction = document.querySelector('[data-template-instruction]');
|
|
958
|
+
if (!(select instanceof HTMLSelectElement)) return;
|
|
959
|
+
|
|
960
|
+
const resize = (field) => {
|
|
961
|
+
if (!(field instanceof HTMLTextAreaElement)) return;
|
|
962
|
+
const styles = getComputedStyle(field);
|
|
963
|
+
const lineHeight = Number.parseFloat(styles.lineHeight) || 24;
|
|
964
|
+
const borderY =
|
|
965
|
+
Number.parseFloat(styles.borderTopWidth) +
|
|
966
|
+
Number.parseFloat(styles.borderBottomWidth);
|
|
967
|
+
const paddingY =
|
|
968
|
+
Number.parseFloat(styles.paddingTop) +
|
|
969
|
+
Number.parseFloat(styles.paddingBottom);
|
|
970
|
+
const maxHeight = lineHeight * 10 + paddingY + borderY;
|
|
971
|
+
field.style.height = "0px";
|
|
972
|
+
const nextHeight = Math.min(field.scrollHeight + borderY, maxHeight);
|
|
973
|
+
field.style.height = \`\${nextHeight}px\`;
|
|
974
|
+
field.style.overflowY = field.scrollHeight + borderY > maxHeight ? "auto" : "hidden";
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
const fields = document.querySelectorAll('textarea[data-autoresize]');
|
|
978
|
+
fields.forEach((field) => {
|
|
979
|
+
if (!(field instanceof HTMLTextAreaElement)) return;
|
|
980
|
+
const sync = () => resize(field);
|
|
981
|
+
field.addEventListener("input", sync);
|
|
982
|
+
sync();
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
const applyTemplate = () => {
|
|
986
|
+
if (!(instruction instanceof HTMLTextAreaElement)) return;
|
|
987
|
+
const option = select.selectedOptions[0];
|
|
988
|
+
const nextValue = option?.dataset.instruction ?? "";
|
|
989
|
+
instruction.value = nextValue;
|
|
990
|
+
instruction.dispatchEvent(new Event("input", { bubbles: true }));
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
select.addEventListener("change", applyTemplate);
|
|
994
|
+
applyTemplate();
|
|
995
|
+
})();
|
|
996
|
+
</script>`
|
|
997
|
+
];
|
|
998
|
+
}
|
|
999
|
+
async function buildSessionView(session, projectDir) {
|
|
1000
|
+
const data = await serializeSession(session);
|
|
1001
|
+
const playbook = await loadDraftPlaybook(session).catch(() => null);
|
|
1002
|
+
const reportUrl = `/playbooks/${encodeURIComponent(session.name)}/tasks/manager-report/report`;
|
|
1003
|
+
const published = existsSync(session.finalDir);
|
|
1004
|
+
const feed = buildSessionFeed(session, data.planMarkdown, playbook, {
|
|
1005
|
+
reportUrl,
|
|
1006
|
+
published
|
|
1007
|
+
});
|
|
1008
|
+
return [
|
|
1009
|
+
`<section class="hero compact">
|
|
1010
|
+
<div>
|
|
1011
|
+
<div class="eyebrow">Planner feed</div>
|
|
1012
|
+
<h1>${escapeHtml(session.name)}</h1>
|
|
1013
|
+
<p class="lede">${escapeHtml(shorten(session.goal, 180))}</p>
|
|
1014
|
+
</div>
|
|
1015
|
+
<div class="status-stack">
|
|
1016
|
+
<div class="badge ${session.status}">${escapeHtml(session.status)}</div>
|
|
1017
|
+
<div class="metric">Revision <strong>${session.revision}</strong></div>
|
|
1018
|
+
<div class="metric">Feedback <strong>${session.feedback.length}</strong></div>
|
|
1019
|
+
</div>
|
|
1020
|
+
</section>`,
|
|
1021
|
+
`<section class="feed-layout">
|
|
1022
|
+
<div class="feed-column">
|
|
1023
|
+
${feed}
|
|
1024
|
+
</div>
|
|
1025
|
+
<aside class="topology-rail">
|
|
1026
|
+
${panel(
|
|
1027
|
+
"Planner topology",
|
|
1028
|
+
renderPlannerLifecycle({
|
|
1029
|
+
status: session.status,
|
|
1030
|
+
lastError: session.lastError,
|
|
1031
|
+
mode: "session"
|
|
1032
|
+
})
|
|
1033
|
+
)}
|
|
1034
|
+
${panel(
|
|
1035
|
+
"Outputs",
|
|
1036
|
+
renderPlannerOutputs({
|
|
1037
|
+
mode: "session",
|
|
1038
|
+
playbookName: session.name,
|
|
1039
|
+
sessionId: session.id,
|
|
1040
|
+
draftDir: session.draftDir,
|
|
1041
|
+
finalDir: session.finalDir,
|
|
1042
|
+
status: session.status,
|
|
1043
|
+
reportUrl,
|
|
1044
|
+
published
|
|
1045
|
+
})
|
|
1046
|
+
)}
|
|
1047
|
+
${panel(
|
|
1048
|
+
"Feedback loop",
|
|
1049
|
+
`<form method="post" action="/sessions/${session.id}/feedback" class="stack">
|
|
1050
|
+
<label>
|
|
1051
|
+
<span>Reply to the thread</span>
|
|
1052
|
+
<textarea name="feedback" rows="6" placeholder="Comment on the latest post, call out loops, ask for a split, or flag unclear work."></textarea>
|
|
1053
|
+
</label>
|
|
1054
|
+
<button type="submit">Post reply</button>
|
|
1055
|
+
</form>`
|
|
1056
|
+
)}
|
|
1057
|
+
${panel(
|
|
1058
|
+
"Publish",
|
|
1059
|
+
`<form method="post" action="/sessions/${session.id}/accept">
|
|
1060
|
+
<button type="submit" ${session.status === "planning" ? "disabled" : ""}>Publish playbook</button>
|
|
1061
|
+
</form>
|
|
1062
|
+
<p class="hint">Publishing copies the approved draft into <code>.converge/playbooks/${escapeHtml(session.name)}</code>.</p>`
|
|
1063
|
+
)}
|
|
1064
|
+
</aside>
|
|
1065
|
+
</section>`,
|
|
1066
|
+
`<section class="footnote">
|
|
1067
|
+
<a href="/">Back to studio home</a>
|
|
1068
|
+
<span>Session:</span> <code>${escapeHtml(session.id)}</code>
|
|
1069
|
+
</section>`
|
|
1070
|
+
];
|
|
1071
|
+
}
|
|
1072
|
+
async function buildHelpView() {
|
|
1073
|
+
return [
|
|
1074
|
+
`<section class="compose-shell">
|
|
1075
|
+
<div class="compose-intro">
|
|
1076
|
+
<h1>How the planner works</h1>
|
|
1077
|
+
<p class="lede">Use the planner in two parts: the left side describes how the playbook should behave, and the right side describes what you want the playbook to produce.</p>
|
|
1078
|
+
</div>
|
|
1079
|
+
<section class="compose-card stack help-page">
|
|
1080
|
+
${panel(
|
|
1081
|
+
"A quick mental model",
|
|
1082
|
+
`<div class="help-flow">
|
|
1083
|
+
<div class="help-node">
|
|
1084
|
+
<span class="help-tag">How</span>
|
|
1085
|
+
<strong>Playbook workflow</strong>
|
|
1086
|
+
<span>Pick a template like research or app building.</span>
|
|
1087
|
+
</div>
|
|
1088
|
+
<div class="help-arrow">\u2192</div>
|
|
1089
|
+
<div class="help-node">
|
|
1090
|
+
<span class="help-tag">How</span>
|
|
1091
|
+
<strong>Playbook instruction</strong>
|
|
1092
|
+
<span>Add constraints, priorities, or preferred structure.</span>
|
|
1093
|
+
</div>
|
|
1094
|
+
<div class="help-arrow">\u2192</div>
|
|
1095
|
+
<div class="help-node highlight">
|
|
1096
|
+
<span class="help-tag">What</span>
|
|
1097
|
+
<strong>Goal / app description</strong>
|
|
1098
|
+
<span>Describe the app, outcome, or question to solve.</span>
|
|
1099
|
+
</div>
|
|
1100
|
+
</div>
|
|
1101
|
+
<p class="help-copy help-note">The planner turns those three pieces into a draft playbook you can revise in the browser.</p>`
|
|
1102
|
+
)}
|
|
1103
|
+
${panel(
|
|
1104
|
+
"What is a playbook?",
|
|
1105
|
+
`<div class="stack">
|
|
1106
|
+
<p class="help-copy">A playbook is the workflow the planner should follow. It defines the structure, tasks, and priorities before the goal is turned into work.</p>
|
|
1107
|
+
<div class="help-example">
|
|
1108
|
+
<strong>Think of it as the plan for planning</strong>
|
|
1109
|
+
<span>The playbook tells the planner whether it should research first, build first, split work into catalogs, or spawn dynamic tasks.</span>
|
|
1110
|
+
</div>
|
|
1111
|
+
</div>`
|
|
1112
|
+
)}
|
|
1113
|
+
${panel(
|
|
1114
|
+
"How the fields map",
|
|
1115
|
+
`<div class="stack">
|
|
1116
|
+
<div class="help-row"><strong>Template select</strong><span>Choose the playbook shape. This is the starting scaffold.</span></div>
|
|
1117
|
+
<div class="help-row"><strong>Playbook instruction</strong><span>Tell the planner how to interpret the shape, what to prioritize, and what to avoid.</span></div>
|
|
1118
|
+
<div class="help-row"><strong>Goal / app description</strong><span>Describe the thing you want the playbook to produce, build, or investigate.</span></div>
|
|
1119
|
+
</div>`
|
|
1120
|
+
)}
|
|
1121
|
+
${panel(
|
|
1122
|
+
"Examples",
|
|
1123
|
+
`<div class="help-cases">
|
|
1124
|
+
<div class="help-case">
|
|
1125
|
+
<span class="help-tag">Research</span>
|
|
1126
|
+
<strong>Workflow</strong>
|
|
1127
|
+
<span>Deep research</span>
|
|
1128
|
+
<strong>Instruction</strong>
|
|
1129
|
+
<span>Track sources, compare claims, and keep the structure focused on evidence.</span>
|
|
1130
|
+
<strong>Goal</strong>
|
|
1131
|
+
<span>\u201CInvestigate the best workflow for remote-first product teams.\u201D</span>
|
|
1132
|
+
</div>
|
|
1133
|
+
<div class="help-case">
|
|
1134
|
+
<span class="help-tag">Build app</span>
|
|
1135
|
+
<strong>Workflow</strong>
|
|
1136
|
+
<span>Build Flutter app</span>
|
|
1137
|
+
<strong>Instruction</strong>
|
|
1138
|
+
<span>Prefer mobile-first screens, reusable widgets, and tests around key flows.</span>
|
|
1139
|
+
<strong>Goal</strong>
|
|
1140
|
+
<span>\u201CBuild a family expense tracker with categories, recurring items, and simple reports.\u201D</span>
|
|
1141
|
+
</div>
|
|
1142
|
+
</div>`
|
|
1143
|
+
)}
|
|
1144
|
+
${panel(
|
|
1145
|
+
"What happens next?",
|
|
1146
|
+
`<div class="stack">
|
|
1147
|
+
<div class="help-row"><strong>1. Combine</strong><span>The planner merges the selected workflow, your instruction, and your goal into one prompt.</span></div>
|
|
1148
|
+
<div class="help-row"><strong>2. Structure</strong><span>It uses that prompt to draft tasks, templates, catalogs, spawn rules, and helper scripts.</span></div>
|
|
1149
|
+
<div class="help-row"><strong>3. Review</strong><span>You can revise the plan in the browser before publishing the playbook.</span></div>
|
|
1150
|
+
</div>`
|
|
1151
|
+
)}
|
|
1152
|
+
<p class="help-back"><a href="/">Back to planner</a></p>
|
|
1153
|
+
</section>
|
|
1154
|
+
</section>`
|
|
1155
|
+
];
|
|
1156
|
+
}
|
|
1157
|
+
async function loadDraftPlaybook(session) {
|
|
1158
|
+
if (!existsSync(session.draftDir)) return null;
|
|
1159
|
+
return await loadPlaybookFromFolder(session.draftDir);
|
|
1160
|
+
}
|
|
1161
|
+
function buildSessionFeed(session, planMarkdown, playbook, args) {
|
|
1162
|
+
const template = session.templateLabel || session.templateId || "Blank";
|
|
1163
|
+
const instruction = session.playbookInstruction?.trim() || "No playbook instruction supplied.";
|
|
1164
|
+
const feedbackItems = session.feedback.length ? session.feedback.map(
|
|
1165
|
+
(entry, index) => `<article class="reply-card">
|
|
1166
|
+
<div class="reply-meta">
|
|
1167
|
+
<span class="reply-index">Reply ${index + 1}</span>
|
|
1168
|
+
<span class="reply-time">${escapeHtml(entry.ts)}</span>
|
|
1169
|
+
</div>
|
|
1170
|
+
<p>${escapeHtml(entry.message)}</p>
|
|
1171
|
+
</article>`
|
|
1172
|
+
).join("") : `<div class="empty">No replies yet. Use the sidebar to post the next planning comment.</div>`;
|
|
1173
|
+
const taskThread = playbook?.def.tasks?.length ? `<div class="thread">
|
|
1174
|
+
${playbook.def.tasks.map((task, index) => {
|
|
1175
|
+
const deps = task.depends_on?.length ? task.depends_on.join(", ") : "none";
|
|
1176
|
+
return `<div class="reply-card nested">
|
|
1177
|
+
<div class="reply-meta">
|
|
1178
|
+
<span class="reply-index">Task ${index + 1}</span>
|
|
1179
|
+
<span class="reply-time">${escapeHtml(deps)}</span>
|
|
1180
|
+
</div>
|
|
1181
|
+
<strong>${escapeHtml(task.id || task.path || "task")}</strong>
|
|
1182
|
+
<p>${escapeHtml(task.title || "Planner task")}</p>
|
|
1183
|
+
</div>`;
|
|
1184
|
+
}).join("")}
|
|
1185
|
+
</div>` : `<div class="empty">Draft tasks will appear here once the planner generates a playbook.</div>`;
|
|
1186
|
+
return `
|
|
1187
|
+
<article class="feed-post">
|
|
1188
|
+
<div class="feed-post-head">
|
|
1189
|
+
<div>
|
|
1190
|
+
<div class="feed-kicker">Planner prompt</div>
|
|
1191
|
+
<h2>${escapeHtml(session.name)}</h2>
|
|
1192
|
+
</div>
|
|
1193
|
+
<div class="feed-chip-row">
|
|
1194
|
+
<span class="feed-chip">${escapeHtml(template)}</span>
|
|
1195
|
+
<span class="feed-chip">Revision ${session.revision}</span>
|
|
1196
|
+
<span class="feed-chip">${escapeHtml(session.status)}</span>
|
|
1197
|
+
</div>
|
|
1198
|
+
</div>
|
|
1199
|
+
<div class="feed-body">
|
|
1200
|
+
<p>${escapeHtml(shorten(session.goal, 240))}</p>
|
|
1201
|
+
<div class="feed-block">
|
|
1202
|
+
<div class="feed-label">Playbook instruction</div>
|
|
1203
|
+
<pre>${escapeHtml(instruction)}</pre>
|
|
1204
|
+
</div>
|
|
1205
|
+
</div>
|
|
1206
|
+
<div class="feed-footer">
|
|
1207
|
+
<a href="${escapeHtml(args.reportUrl)}">${args.published ? "Open review artifact" : "Review artifact after publish"}</a>
|
|
1208
|
+
<a href="/sessions/${session.id}">Permalink</a>
|
|
1209
|
+
</div>
|
|
1210
|
+
</article>
|
|
1211
|
+
|
|
1212
|
+
<article class="feed-post">
|
|
1213
|
+
<div class="feed-post-head">
|
|
1214
|
+
<div>
|
|
1215
|
+
<div class="feed-kicker">Draft playbook</div>
|
|
1216
|
+
<h2>Task topology and output</h2>
|
|
1217
|
+
</div>
|
|
1218
|
+
<div class="feed-chip-row">
|
|
1219
|
+
<span class="feed-chip">${playbook ? `${playbook.def.tasks.length} tasks` : "No draft yet"}</span>
|
|
1220
|
+
</div>
|
|
1221
|
+
</div>
|
|
1222
|
+
<div class="feed-body">
|
|
1223
|
+
<div class="feed-block">
|
|
1224
|
+
<div class="feed-label">Plan artifact</div>
|
|
1225
|
+
<pre class="plan-md">${escapeHtml(planMarkdown || "Planner output will appear here.")}</pre>
|
|
1226
|
+
</div>
|
|
1227
|
+
<div class="feed-block">
|
|
1228
|
+
<div class="feed-label">Subtasks</div>
|
|
1229
|
+
${taskThread}
|
|
1230
|
+
</div>
|
|
1231
|
+
</div>
|
|
1232
|
+
</article>
|
|
1233
|
+
|
|
1234
|
+
<article class="feed-post">
|
|
1235
|
+
<div class="feed-post-head">
|
|
1236
|
+
<div>
|
|
1237
|
+
<div class="feed-kicker">Feedback thread</div>
|
|
1238
|
+
<h2>Human replies and loops</h2>
|
|
1239
|
+
</div>
|
|
1240
|
+
<div class="feed-chip-row">
|
|
1241
|
+
<span class="feed-chip">${session.feedback.length} replies</span>
|
|
1242
|
+
<span class="feed-chip">Loop ${session.revision}</span>
|
|
1243
|
+
</div>
|
|
1244
|
+
</div>
|
|
1245
|
+
<div class="feed-body">
|
|
1246
|
+
<div class="thread">${feedbackItems}</div>
|
|
1247
|
+
</div>
|
|
1248
|
+
</article>
|
|
1249
|
+
`;
|
|
1250
|
+
}
|
|
1251
|
+
function buildPlaybookFeed(playbook, name, session, args) {
|
|
1252
|
+
const taskThread = playbook.def.tasks.length ? `<div class="thread">
|
|
1253
|
+
${playbook.def.tasks.map((task, index) => {
|
|
1254
|
+
const deps = task.depends_on?.length ? task.depends_on.join(", ") : "none";
|
|
1255
|
+
return `<div class="reply-card nested">
|
|
1256
|
+
<div class="reply-meta">
|
|
1257
|
+
<span class="reply-index">Task ${index + 1}</span>
|
|
1258
|
+
<span class="reply-time">${escapeHtml(deps)}</span>
|
|
1259
|
+
</div>
|
|
1260
|
+
<strong>${escapeHtml(task.id || task.path || "task")}</strong>
|
|
1261
|
+
<p>${escapeHtml(task.title || "Planner task")}</p>
|
|
1262
|
+
</div>`;
|
|
1263
|
+
}).join("")}
|
|
1264
|
+
</div>` : `<div class="empty">No tasks are defined yet.</div>`;
|
|
1265
|
+
const latestStatus = session?.status || "published";
|
|
1266
|
+
const latestRevision = session?.revision || 0;
|
|
1267
|
+
const latestFeedback = session?.feedback.length || 0;
|
|
1268
|
+
return `
|
|
1269
|
+
<article class="feed-post">
|
|
1270
|
+
<div class="feed-post-head">
|
|
1271
|
+
<div>
|
|
1272
|
+
<div class="feed-kicker">Published plan</div>
|
|
1273
|
+
<h2>${escapeHtml(name)}</h2>
|
|
1274
|
+
</div>
|
|
1275
|
+
<div class="feed-chip-row">
|
|
1276
|
+
<span class="feed-chip">${escapeHtml(latestStatus)}</span>
|
|
1277
|
+
<span class="feed-chip">Revision ${latestRevision}</span>
|
|
1278
|
+
<span class="feed-chip">${playbook.def.tasks.length} tasks</span>
|
|
1279
|
+
</div>
|
|
1280
|
+
</div>
|
|
1281
|
+
<div class="feed-body">
|
|
1282
|
+
<p>${escapeHtml(playbook.def.description || "No description provided.")}</p>
|
|
1283
|
+
<div class="feed-block">
|
|
1284
|
+
<div class="feed-label">Topology</div>
|
|
1285
|
+
${taskThread}
|
|
1286
|
+
</div>
|
|
1287
|
+
</div>
|
|
1288
|
+
<div class="feed-footer">
|
|
1289
|
+
<a href="${escapeHtml(args.reportUrl)}">Open review artifact</a>
|
|
1290
|
+
<a href="${escapeHtml(args.runUrl)}">Open run dashboard</a>
|
|
1291
|
+
</div>
|
|
1292
|
+
</article>
|
|
1293
|
+
|
|
1294
|
+
<article class="feed-post">
|
|
1295
|
+
<div class="feed-post-head">
|
|
1296
|
+
<div>
|
|
1297
|
+
<div class="feed-kicker">Review thread</div>
|
|
1298
|
+
<h2>Human-in-the-loop artifact</h2>
|
|
1299
|
+
</div>
|
|
1300
|
+
<div class="feed-chip-row">
|
|
1301
|
+
<span class="feed-chip">${latestFeedback} replies</span>
|
|
1302
|
+
<span class="feed-chip">${args.journalExists ? "journal present" : "journal empty"}</span>
|
|
1303
|
+
</div>
|
|
1304
|
+
</div>
|
|
1305
|
+
<div class="feed-body">
|
|
1306
|
+
<div class="feed-block">
|
|
1307
|
+
<div class="feed-label">Artifact</div>
|
|
1308
|
+
<p>Open the persisted HTML report to preview the infographic review and leave feedback.</p>
|
|
1309
|
+
</div>
|
|
1310
|
+
<div class="feed-block">
|
|
1311
|
+
<div class="feed-label">Loop summary</div>
|
|
1312
|
+
<p>Finalized from revision ${latestRevision} after ${latestFeedback} feedback ${latestFeedback === 1 ? "reply" : "replies"}.</p>
|
|
1313
|
+
</div>
|
|
1314
|
+
</div>
|
|
1315
|
+
</article>
|
|
1316
|
+
`;
|
|
1317
|
+
}
|
|
1318
|
+
async function buildPlaybookView(projectDir, name) {
|
|
1319
|
+
const playbookDir = join(projectDir, ".converge", "playbooks", name);
|
|
1320
|
+
if (!existsSync(playbookDir)) return null;
|
|
1321
|
+
const pb = await loadPlaybookFromFolder(playbookDir);
|
|
1322
|
+
const session = await loadPlannerSessionSnapshot(projectDir, name);
|
|
1323
|
+
const taskRows = pb.def.tasks.map((task, index) => {
|
|
1324
|
+
const deps = task.depends_on?.length ? task.depends_on.join(", ") : "none";
|
|
1325
|
+
return `<div class="reply-card nested">
|
|
1326
|
+
<div class="reply-meta">
|
|
1327
|
+
<span class="reply-index">Task ${index + 1}</span>
|
|
1328
|
+
<span class="reply-time">${escapeHtml(deps)}</span>
|
|
1329
|
+
</div>
|
|
1330
|
+
<strong>${escapeHtml(task.id || task.path || "task")}</strong>
|
|
1331
|
+
<p>${escapeHtml(task.title || "Planner task")}</p>
|
|
1332
|
+
</div>`;
|
|
1333
|
+
}).join("");
|
|
1334
|
+
const journalDir = join(projectDir, ".converge", "journal", name);
|
|
1335
|
+
const journalExists = existsSync(journalDir);
|
|
1336
|
+
const reportUrl = `/playbooks/${encodeURIComponent(name)}/tasks/manager-report/report`;
|
|
1337
|
+
const feed = buildPlaybookFeed(pb, name, session, {
|
|
1338
|
+
reportUrl,
|
|
1339
|
+
runUrl: `/playbooks/${encodeURIComponent(name)}/run`,
|
|
1340
|
+
journalExists
|
|
1341
|
+
});
|
|
1342
|
+
return [
|
|
1343
|
+
`<section class="hero compact">
|
|
1344
|
+
<div>
|
|
1345
|
+
<div class="eyebrow">Published playbook</div>
|
|
1346
|
+
<h1>${escapeHtml(name)}</h1>
|
|
1347
|
+
<p class="lede">${escapeHtml(pb.def.description || "No description provided.")}</p>
|
|
1348
|
+
</div>
|
|
1349
|
+
<div class="status-stack">
|
|
1350
|
+
<div class="badge published">published</div>
|
|
1351
|
+
<div class="metric">Tasks <strong>${pb.def.tasks.length}</strong></div>
|
|
1352
|
+
<div class="metric">Journal <strong>${journalExists ? "present" : "none yet"}</strong></div>
|
|
1353
|
+
</div>
|
|
1354
|
+
</section>`,
|
|
1355
|
+
`<section class="feed-layout">
|
|
1356
|
+
<div class="feed-column">
|
|
1357
|
+
${feed}
|
|
1358
|
+
</div>
|
|
1359
|
+
<aside class="topology-rail">
|
|
1360
|
+
${panel(
|
|
1361
|
+
"Playbook topology",
|
|
1362
|
+
renderPlannerLifecycle({
|
|
1363
|
+
status: session?.status ?? "published",
|
|
1364
|
+
lastError: session?.lastError,
|
|
1365
|
+
mode: "playbook"
|
|
1366
|
+
})
|
|
1367
|
+
)}
|
|
1368
|
+
${panel(
|
|
1369
|
+
"Outputs",
|
|
1370
|
+
renderPlannerOutputs({
|
|
1371
|
+
mode: "playbook",
|
|
1372
|
+
playbookName: name,
|
|
1373
|
+
sessionId: session?.id,
|
|
1374
|
+
draftDir: session?.draftDir,
|
|
1375
|
+
finalDir: session?.finalDir ?? playbookDir,
|
|
1376
|
+
status: session?.status ?? "published",
|
|
1377
|
+
reportUrl,
|
|
1378
|
+
runUrl: `/playbooks/${encodeURIComponent(name)}/run`,
|
|
1379
|
+
published: true
|
|
1380
|
+
})
|
|
1381
|
+
)}
|
|
1382
|
+
${panel(
|
|
1383
|
+
"Task threads",
|
|
1384
|
+
`<div class="thread">${taskRows || `<div class="empty">No tasks found.</div>`}</div>`
|
|
1385
|
+
)}
|
|
1386
|
+
${panel(
|
|
1387
|
+
"Runtime",
|
|
1388
|
+
`<div class="stack">
|
|
1389
|
+
<div class="empty">Run the playbook from the CLI: <code>converge run --playbook=${escapeHtml(name)}</code></div>
|
|
1390
|
+
<div class="empty"><a href="/playbooks/${encodeURIComponent(name)}/run">Open run dashboard</a></div>
|
|
1391
|
+
</div>`
|
|
1392
|
+
)}
|
|
1393
|
+
</aside>
|
|
1394
|
+
</section>`,
|
|
1395
|
+
`<section class="footnote"><a href="/">Back to studio home</a></section>`
|
|
1396
|
+
];
|
|
1397
|
+
}
|
|
1398
|
+
async function buildRunView(projectDir, name) {
|
|
1399
|
+
const runstate = await loadRunstate(projectDir, name);
|
|
1400
|
+
if (!runstate) return null;
|
|
1401
|
+
const nodes = Object.values(runstate.dag?.nodes ?? {});
|
|
1402
|
+
const totals = {
|
|
1403
|
+
total: nodes.length,
|
|
1404
|
+
pending: nodes.filter((n) => n.status === "pending").length,
|
|
1405
|
+
running: nodes.filter((n) => n.status === "running").length,
|
|
1406
|
+
passed: nodes.filter((n) => n.status === "pass").length,
|
|
1407
|
+
failed: nodes.filter((n) => n.status === "error").length
|
|
1408
|
+
};
|
|
1409
|
+
const nodeRows = nodes.sort((a, b) => String(a.id).localeCompare(String(b.id))).map(
|
|
1410
|
+
(node) => `<div class="task-row">
|
|
1411
|
+
<div>
|
|
1412
|
+
<div class="task-id">${escapeHtml(String(node.id))}</div>
|
|
1413
|
+
<div class="task-meta">${escapeHtml(
|
|
1414
|
+
(node.depends_on ?? []).length ? `depends on ${node.depends_on.join(", ")}` : "root task"
|
|
1415
|
+
)}</div>
|
|
1416
|
+
</div>
|
|
1417
|
+
<div class="badge ${escapeHtml(String(node.status || "pending"))}">${escapeHtml(String(node.status || "pending"))}</div>
|
|
1418
|
+
</div>`
|
|
1419
|
+
).join("");
|
|
1420
|
+
return [
|
|
1421
|
+
`<section class="hero compact">
|
|
1422
|
+
<div>
|
|
1423
|
+
<div class="eyebrow">Realtime execution</div>
|
|
1424
|
+
<h1>${escapeHtml(name)}</h1>
|
|
1425
|
+
<p class="lede">Read-only run dashboard from <code>.converge/journal/${escapeHtml(name)}/runstate.json</code>.</p>
|
|
1426
|
+
</div>
|
|
1427
|
+
<div class="status-stack">
|
|
1428
|
+
<div class="metric">Total <strong>${totals.total}</strong></div>
|
|
1429
|
+
<div class="metric">Pending <strong>${totals.pending}</strong></div>
|
|
1430
|
+
<div class="metric">Running <strong>${totals.running}</strong></div>
|
|
1431
|
+
<div class="metric">Passed <strong>${totals.passed}</strong></div>
|
|
1432
|
+
<div class="metric">Failed <strong>${totals.failed}</strong></div>
|
|
1433
|
+
</div>
|
|
1434
|
+
</section>`,
|
|
1435
|
+
panel(
|
|
1436
|
+
"Run metadata",
|
|
1437
|
+
`<div class="stack">
|
|
1438
|
+
<div class="metric">Execution id <strong>${escapeHtml(runstate.metadata?.execution_id || "unknown")}</strong></div>
|
|
1439
|
+
<div class="metric">Generated <strong>${escapeHtml(runstate.metadata?.generated_at || "unknown")}</strong></div>
|
|
1440
|
+
<div class="metric">Status <strong>${escapeHtml(runstate.metadata?.status || "unknown")}</strong></div>
|
|
1441
|
+
</div>`
|
|
1442
|
+
),
|
|
1443
|
+
panel("Task list", `<div class="stack">${nodeRows || `<div class="empty">No task nodes found.</div>`}</div>`),
|
|
1444
|
+
`<section class="footnote">
|
|
1445
|
+
<a href="/playbooks/${encodeURIComponent(name)}">Back to playbook</a>
|
|
1446
|
+
</section>`
|
|
1447
|
+
];
|
|
1448
|
+
}
|
|
1449
|
+
async function loadOrCreateHumanReportArtifact(projectDir, playbook, taskId) {
|
|
1450
|
+
const playbookDir = join(projectDir, ".converge", "playbooks", playbook);
|
|
1451
|
+
if (!existsSync(playbookDir)) return null;
|
|
1452
|
+
const artifactPath = getHumanReportArtifactPath(projectDir, playbook, taskId);
|
|
1453
|
+
if (existsSync(artifactPath)) {
|
|
1454
|
+
await announceHumanReviewArtifact(projectDir, playbook, taskId);
|
|
1455
|
+
return await readFile(artifactPath, "utf8");
|
|
1456
|
+
}
|
|
1457
|
+
return await writeHumanReportArtifact(projectDir, playbook, taskId);
|
|
1458
|
+
}
|
|
1459
|
+
async function writeHumanReportArtifact(projectDir, playbook, taskId) {
|
|
1460
|
+
const reviews = await loadHumanReviews(projectDir, playbook, taskId);
|
|
1461
|
+
const html = renderHumanReportContentHtml({
|
|
1462
|
+
playbook,
|
|
1463
|
+
reviews
|
|
1464
|
+
});
|
|
1465
|
+
const artifactPath = getHumanReportArtifactPath(projectDir, playbook, taskId);
|
|
1466
|
+
await mkdir(join(projectDir, ".converge", "inventory", playbook, "reports"), {
|
|
1467
|
+
recursive: true
|
|
1468
|
+
});
|
|
1469
|
+
await writeFile(artifactPath, html, "utf8");
|
|
1470
|
+
await announceHumanReviewArtifact(projectDir, playbook, taskId);
|
|
1471
|
+
return html;
|
|
1472
|
+
}
|
|
1473
|
+
function getHumanReportArtifactPath(projectDir, playbook, taskId) {
|
|
1474
|
+
return join(projectDir, ".converge", "inventory", playbook, "reports", `${taskId}.html`);
|
|
1475
|
+
}
|
|
1476
|
+
function getHumanReportReviewsPath(projectDir, playbook, taskId) {
|
|
1477
|
+
return join(projectDir, ".converge", "inventory", playbook, "reports", `${taskId}.jsonl`);
|
|
1478
|
+
}
|
|
1479
|
+
async function announceHumanReviewArtifact(projectDir, playbook, taskId) {
|
|
1480
|
+
const key = `${resolve(projectDir)}::${playbook}::${taskId}`;
|
|
1481
|
+
if (announcedHumanReviewArtifacts.has(key)) return;
|
|
1482
|
+
announcedHumanReviewArtifacts.add(key);
|
|
1483
|
+
const artifactPath = getHumanReportArtifactPath(projectDir, playbook, taskId);
|
|
1484
|
+
const handoff = await ensureHumanReviewHandoff(projectDir, playbook, taskId);
|
|
1485
|
+
const reportPath = getHumanReviewHandoffRoute(handoff.id);
|
|
1486
|
+
const state = await readHtmlServerState(projectDir).catch(() => null);
|
|
1487
|
+
const reportUrl = state && state.token ? `${new URL(reportPath, `http://${state.host}:${state.port}`).toString()}?token=${encodeURIComponent(state.token)}` : reportPath;
|
|
1488
|
+
console.log(
|
|
1489
|
+
[
|
|
1490
|
+
`[human-review] review URL: ${reportUrl}`,
|
|
1491
|
+
`[human-review] artifact: ${artifactPath}`
|
|
1492
|
+
].join("\n")
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
function renderHumanReviewPageHtml(args) {
|
|
1496
|
+
const contentHtml = args.reportContentHtml;
|
|
1497
|
+
return `<!doctype html>
|
|
1498
|
+
<html lang="en">
|
|
1499
|
+
<head>
|
|
1500
|
+
<meta charset="utf-8" />
|
|
1501
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1502
|
+
<title>${escapeHtml(args.playbook)} / ${escapeHtml(args.taskId)} report</title>
|
|
1503
|
+
${authCleanupScript()}
|
|
1504
|
+
<style>
|
|
1505
|
+
${renderStudioReviewStyles()}
|
|
1506
|
+
</style>
|
|
1507
|
+
</head>
|
|
1508
|
+
<body>
|
|
1509
|
+
<main class="shell">
|
|
1510
|
+
<section class="hero">
|
|
1511
|
+
<div>
|
|
1512
|
+
<div class="eyebrow">Human review report</div>
|
|
1513
|
+
<h1>${escapeHtml(args.playbook)} / ${escapeHtml(args.taskId)}</h1>
|
|
1514
|
+
<p class="lede">Read the report. Leave one feedback note if needed, or accept it as-is.</p>
|
|
1515
|
+
</div>
|
|
1516
|
+
</section>
|
|
1517
|
+
|
|
1518
|
+
<section class="layout">
|
|
1519
|
+
<article class="report-shell">
|
|
1520
|
+
${contentHtml}
|
|
1521
|
+
</article>
|
|
1522
|
+
|
|
1523
|
+
<aside class="sidebar">
|
|
1524
|
+
<h2 class="section-title">Feedback</h2>
|
|
1525
|
+
<form method="post" action="${escapeHtml(args.submitPath)}" class="form">
|
|
1526
|
+
<label>
|
|
1527
|
+
<span>One feedback note</span>
|
|
1528
|
+
<textarea name="feedback" placeholder="What should be clarified, revised, or rejected before this moves forward?"></textarea>
|
|
1529
|
+
</label>
|
|
1530
|
+
<div class="feed-actions">
|
|
1531
|
+
<button type="submit" name="action" value="accept">Accept</button>
|
|
1532
|
+
<button type="submit" name="action" value="feedback">Feedback</button>
|
|
1533
|
+
</div>
|
|
1534
|
+
</form>
|
|
1535
|
+
</aside>
|
|
1536
|
+
</section>
|
|
1537
|
+
|
|
1538
|
+
</main>
|
|
1539
|
+
</body>
|
|
1540
|
+
</html>`;
|
|
1541
|
+
}
|
|
1542
|
+
function renderStudioReviewStyles() {
|
|
1543
|
+
return `
|
|
1544
|
+
:root {
|
|
1545
|
+
color-scheme: dark;
|
|
1546
|
+
--bg: #050816;
|
|
1547
|
+
--bg-2: #0b1120;
|
|
1548
|
+
--card: rgba(15, 23, 42, 0.78);
|
|
1549
|
+
--card-2: rgba(2, 6, 23, 0.82);
|
|
1550
|
+
--line: rgba(148, 163, 184, 0.18);
|
|
1551
|
+
--text: #e5eefb;
|
|
1552
|
+
--muted: #8ea2bf;
|
|
1553
|
+
--accent: #38bdf8;
|
|
1554
|
+
--accent-2: #a855f7;
|
|
1555
|
+
--good: #22c55e;
|
|
1556
|
+
--warn: #f59e0b;
|
|
1557
|
+
--bad: #fb7185;
|
|
1558
|
+
}
|
|
1559
|
+
* { box-sizing: border-box; }
|
|
1560
|
+
body {
|
|
1561
|
+
margin: 0;
|
|
1562
|
+
font-family:
|
|
1563
|
+
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
1564
|
+
sans-serif;
|
|
1565
|
+
color: var(--text);
|
|
1566
|
+
background:
|
|
1567
|
+
radial-gradient(circle at top left, rgba(56, 189, 248, 0.16), transparent 28%),
|
|
1568
|
+
radial-gradient(circle at top right, rgba(168, 85, 247, 0.14), transparent 24%),
|
|
1569
|
+
linear-gradient(180deg, var(--bg), var(--bg-2));
|
|
1570
|
+
min-height: 100vh;
|
|
1571
|
+
}
|
|
1572
|
+
a { color: #9bdaf7; text-decoration: none; }
|
|
1573
|
+
a:hover { text-decoration: underline; }
|
|
1574
|
+
.shell {
|
|
1575
|
+
width: min(1200px, calc(100% - 32px));
|
|
1576
|
+
margin: 0 auto;
|
|
1577
|
+
padding: 28px 0 40px;
|
|
1578
|
+
}
|
|
1579
|
+
.hero {
|
|
1580
|
+
display: grid;
|
|
1581
|
+
grid-template-columns: minmax(0, 1fr);
|
|
1582
|
+
gap: 10px;
|
|
1583
|
+
align-items: start;
|
|
1584
|
+
padding: 16px 18px;
|
|
1585
|
+
border-radius: 20px;
|
|
1586
|
+
background:
|
|
1587
|
+
linear-gradient(135deg, rgba(56, 189, 248, 0.09), rgba(168, 85, 247, 0.05)),
|
|
1588
|
+
rgba(7, 12, 24, 0.88);
|
|
1589
|
+
border: 1px solid var(--line);
|
|
1590
|
+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
|
|
1591
|
+
}
|
|
1592
|
+
.eyebrow {
|
|
1593
|
+
display: inline-flex;
|
|
1594
|
+
padding: 6px 10px;
|
|
1595
|
+
border-radius: 999px;
|
|
1596
|
+
border: 1px solid rgba(125, 211, 252, 0.22);
|
|
1597
|
+
color: #bfe8ff;
|
|
1598
|
+
background: rgba(56, 189, 248, 0.08);
|
|
1599
|
+
text-transform: uppercase;
|
|
1600
|
+
letter-spacing: 0.12em;
|
|
1601
|
+
font-size: 11px;
|
|
1602
|
+
font-weight: 700;
|
|
1603
|
+
}
|
|
1604
|
+
h1 {
|
|
1605
|
+
margin: 8px 0 6px;
|
|
1606
|
+
font-size: clamp(1.45rem, 3vw, 2.1rem);
|
|
1607
|
+
line-height: 1.05;
|
|
1608
|
+
letter-spacing: -0.06em;
|
|
1609
|
+
}
|
|
1610
|
+
.lede {
|
|
1611
|
+
margin: 0;
|
|
1612
|
+
max-width: 68ch;
|
|
1613
|
+
color: var(--muted);
|
|
1614
|
+
line-height: 1.55;
|
|
1615
|
+
font-size: 0.96rem;
|
|
1616
|
+
}
|
|
1617
|
+
.layout {
|
|
1618
|
+
display: grid;
|
|
1619
|
+
grid-template-columns: minmax(0, 1fr) 340px;
|
|
1620
|
+
gap: 18px;
|
|
1621
|
+
margin-top: 18px;
|
|
1622
|
+
align-items: start;
|
|
1623
|
+
}
|
|
1624
|
+
.report-shell,
|
|
1625
|
+
.sidebar {
|
|
1626
|
+
border: 1px solid var(--line);
|
|
1627
|
+
border-radius: 24px;
|
|
1628
|
+
background: var(--card);
|
|
1629
|
+
backdrop-filter: blur(14px);
|
|
1630
|
+
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.24);
|
|
1631
|
+
}
|
|
1632
|
+
.report-shell { padding: 22px; }
|
|
1633
|
+
.sidebar { padding: 18px; position: sticky; top: 18px; }
|
|
1634
|
+
.section-title {
|
|
1635
|
+
margin: 0 0 12px;
|
|
1636
|
+
font-size: 0.95rem;
|
|
1637
|
+
text-transform: uppercase;
|
|
1638
|
+
letter-spacing: 0.12em;
|
|
1639
|
+
color: #d8e6fb;
|
|
1640
|
+
}
|
|
1641
|
+
.summary {
|
|
1642
|
+
margin-top: 14px;
|
|
1643
|
+
display: grid;
|
|
1644
|
+
gap: 12px;
|
|
1645
|
+
}
|
|
1646
|
+
.summary-block {
|
|
1647
|
+
padding: 14px 16px;
|
|
1648
|
+
border-radius: 18px;
|
|
1649
|
+
background: rgba(2, 6, 23, 0.55);
|
|
1650
|
+
border: 1px solid rgba(148, 163, 184, 0.14);
|
|
1651
|
+
}
|
|
1652
|
+
.summary-block .label {
|
|
1653
|
+
margin-bottom: 8px;
|
|
1654
|
+
color: var(--muted);
|
|
1655
|
+
font-size: 0.88rem;
|
|
1656
|
+
text-transform: uppercase;
|
|
1657
|
+
letter-spacing: 0.1em;
|
|
1658
|
+
}
|
|
1659
|
+
.report-body {
|
|
1660
|
+
display: grid;
|
|
1661
|
+
gap: 10px;
|
|
1662
|
+
}
|
|
1663
|
+
.report-body h3 {
|
|
1664
|
+
margin: 0;
|
|
1665
|
+
font-size: 1.25rem;
|
|
1666
|
+
letter-spacing: -0.03em;
|
|
1667
|
+
color: #f5f9ff;
|
|
1668
|
+
}
|
|
1669
|
+
.report-body p {
|
|
1670
|
+
margin: 0;
|
|
1671
|
+
color: #d7e2f1;
|
|
1672
|
+
line-height: 1.7;
|
|
1673
|
+
}
|
|
1674
|
+
.report-body .lead {
|
|
1675
|
+
font-size: 1rem;
|
|
1676
|
+
color: #e5eefb;
|
|
1677
|
+
}
|
|
1678
|
+
.checklist {
|
|
1679
|
+
margin: 0;
|
|
1680
|
+
padding-left: 18px;
|
|
1681
|
+
display: grid;
|
|
1682
|
+
gap: 8px;
|
|
1683
|
+
color: #d6deea;
|
|
1684
|
+
line-height: 1.6;
|
|
1685
|
+
}
|
|
1686
|
+
.form {
|
|
1687
|
+
display: grid;
|
|
1688
|
+
gap: 12px;
|
|
1689
|
+
}
|
|
1690
|
+
label {
|
|
1691
|
+
display: grid;
|
|
1692
|
+
gap: 6px;
|
|
1693
|
+
color: #d8e6fb;
|
|
1694
|
+
font-size: 0.92rem;
|
|
1695
|
+
}
|
|
1696
|
+
input, textarea, select, button {
|
|
1697
|
+
font: inherit;
|
|
1698
|
+
border-radius: 16px;
|
|
1699
|
+
border: 1px solid rgba(148, 163, 184, 0.22);
|
|
1700
|
+
background: rgba(2, 6, 23, 0.92);
|
|
1701
|
+
color: var(--text);
|
|
1702
|
+
padding: 13px 14px;
|
|
1703
|
+
}
|
|
1704
|
+
textarea { min-height: 120px; resize: vertical; }
|
|
1705
|
+
.feed-actions {
|
|
1706
|
+
display: flex;
|
|
1707
|
+
gap: 10px;
|
|
1708
|
+
flex-wrap: wrap;
|
|
1709
|
+
}
|
|
1710
|
+
button {
|
|
1711
|
+
cursor: pointer;
|
|
1712
|
+
border: 0;
|
|
1713
|
+
font-weight: 700;
|
|
1714
|
+
padding: 12px 16px;
|
|
1715
|
+
}
|
|
1716
|
+
button[value="accept"] {
|
|
1717
|
+
background: linear-gradient(135deg, #38bdf8, #22c55e);
|
|
1718
|
+
color: #04111b;
|
|
1719
|
+
}
|
|
1720
|
+
button[value="feedback"] {
|
|
1721
|
+
background: rgba(148, 163, 184, 0.12);
|
|
1722
|
+
color: #e7eef8;
|
|
1723
|
+
border: 1px solid rgba(148, 163, 184, 0.22);
|
|
1724
|
+
}
|
|
1725
|
+
@media (max-width: 900px) {
|
|
1726
|
+
.hero,
|
|
1727
|
+
.layout { grid-template-columns: 1fr; }
|
|
1728
|
+
.sidebar { position: static; }
|
|
1729
|
+
}
|
|
1730
|
+
`;
|
|
1731
|
+
}
|
|
1732
|
+
function renderHumanReportContentHtml(args) {
|
|
1733
|
+
const latest = args.reviews[args.reviews.length - 1] ?? null;
|
|
1734
|
+
const latestTitle = latest?.reportTitle || "Decision memo";
|
|
1735
|
+
const latestDecision = latest ? humanDecisionLabel(latest.decision) : "Waiting for review";
|
|
1736
|
+
const latestSummary = latest?.summary || "This report frames the proposal, the reasoning behind it, and the decision the human should make.";
|
|
1737
|
+
const recommendationText = latest?.decision === "approve" ? "The latest decision approves the proposal. If you want to proceed, keep the scope intact and preserve the stated tradeoffs." : latest?.decision === "reject" ? "The latest decision rejects the proposal. Use the feedback to revise the report and tighten the evidence before resubmitting." : "The latest decision requests revisions. Clarify the proposal, reduce ambiguity, and make the requested decision easier to justify.";
|
|
1738
|
+
const proposalText = latest?.summary || "This report should contain the actual proposal content, not a task checklist. Write it as a memo with the decision, rationale, tradeoffs, and the concrete next step the human should review.";
|
|
1739
|
+
const decisionPrompt = latest?.decision === "approve" ? "Confirm that the proposal is ready to move forward and that the evidence is sufficient." : latest?.decision === "reject" ? "Decide whether the proposal should be reworked before any downstream execution happens." : "Decide whether the proposal is ready, needs changes, or should be rejected.";
|
|
1740
|
+
const tradeoffText = "A strong report makes the tradeoffs explicit: speed versus confidence, scope versus fidelity, and immediate delivery versus future rework.";
|
|
1741
|
+
return `
|
|
1742
|
+
<h2 class="section-title">Report</h2>
|
|
1743
|
+
<div class="summary-block report-body">
|
|
1744
|
+
<div class="label">Proposal narrative</div>
|
|
1745
|
+
<h3>${escapeHtml(latestTitle)}</h3>
|
|
1746
|
+
<p class="lead">${escapeHtml(proposalText)}</p>
|
|
1747
|
+
</div>
|
|
1748
|
+
<div class="meta-row" aria-label="report metadata">
|
|
1749
|
+
<span class="chip">Decision: ${escapeHtml(latestDecision)}</span>
|
|
1750
|
+
<span class="chip">Updated: ${escapeHtml(latest ? formatHumanTimestamp(latest.ts) : "Waiting for review")}</span>
|
|
1751
|
+
<span class="chip">Playbook: ${escapeHtml(args.playbook)}</span>
|
|
1752
|
+
</div>
|
|
1753
|
+
<div class="summary" style="margin-top: 18px;">
|
|
1754
|
+
<div class="summary-block">
|
|
1755
|
+
<div class="label">Executive summary</div>
|
|
1756
|
+
<div>${escapeHtml(latestSummary)}</div>
|
|
1757
|
+
</div>
|
|
1758
|
+
<div class="summary-block">
|
|
1759
|
+
<div class="label">Recommendation</div>
|
|
1760
|
+
<div>${escapeHtml(recommendationText)}</div>
|
|
1761
|
+
</div>
|
|
1762
|
+
<div class="summary-block">
|
|
1763
|
+
<div class="label">Decision request</div>
|
|
1764
|
+
<div>${escapeHtml(decisionPrompt)}</div>
|
|
1765
|
+
</div>
|
|
1766
|
+
<div class="summary-block">
|
|
1767
|
+
<div class="label">Tradeoffs</div>
|
|
1768
|
+
<div>${escapeHtml(tradeoffText)}</div>
|
|
1769
|
+
</div>
|
|
1770
|
+
<div class="summary-block">
|
|
1771
|
+
<div class="label">What to check</div>
|
|
1772
|
+
<ul class="checklist">
|
|
1773
|
+
<li>Does the proposal clearly describe what should happen next?</li>
|
|
1774
|
+
<li>Are the risks, tradeoffs, and unknowns stated plainly?</li>
|
|
1775
|
+
<li>Is the requested decision easy to make from the evidence provided?</li>
|
|
1776
|
+
</ul>
|
|
1777
|
+
</div>
|
|
1778
|
+
</div>`;
|
|
1779
|
+
}
|
|
1780
|
+
async function serializeSession(session, projectDir) {
|
|
1781
|
+
const planMarkdownPath = join(session.draftDir, "PLAN.md");
|
|
1782
|
+
let planMarkdown = "";
|
|
1783
|
+
if (existsSync(planMarkdownPath)) {
|
|
1784
|
+
planMarkdown = await readFile(planMarkdownPath, "utf8");
|
|
1785
|
+
}
|
|
1786
|
+
return {
|
|
1787
|
+
id: session.id,
|
|
1788
|
+
name: session.name,
|
|
1789
|
+
goal: session.goal,
|
|
1790
|
+
status: session.status,
|
|
1791
|
+
revision: session.revision,
|
|
1792
|
+
createdAt: session.createdAt,
|
|
1793
|
+
updatedAt: session.updatedAt,
|
|
1794
|
+
draftExists: existsSync(session.draftDir),
|
|
1795
|
+
planMarkdown,
|
|
1796
|
+
feedback: session.feedback,
|
|
1797
|
+
lastError: session.lastError,
|
|
1798
|
+
finalDir: session.finalDir
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
async function loadRunstate(projectDir, playbook) {
|
|
1802
|
+
const primary = join(projectDir, ".converge", "journal", playbook, "runstate.json");
|
|
1803
|
+
const fallback = join(projectDir, ".converge", "target", playbook, "runstate.json");
|
|
1804
|
+
const path = existsSync(primary) ? primary : existsSync(fallback) ? fallback : null;
|
|
1805
|
+
if (!path) return null;
|
|
1806
|
+
try {
|
|
1807
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
1808
|
+
} catch {
|
|
1809
|
+
return null;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
async function loadPlannerSessionSnapshot(projectDir, playbook) {
|
|
1813
|
+
const rootDir = join(projectDir, SESSIONS_DIR);
|
|
1814
|
+
if (!existsSync(rootDir)) return null;
|
|
1815
|
+
const entries = await readdir(rootDir, { withFileTypes: true });
|
|
1816
|
+
const matches = [];
|
|
1817
|
+
for (const entry of entries) {
|
|
1818
|
+
if (!entry.isDirectory()) continue;
|
|
1819
|
+
const sessionPath = join(rootDir, entry.name, "session.json");
|
|
1820
|
+
if (!existsSync(sessionPath)) continue;
|
|
1821
|
+
try {
|
|
1822
|
+
const raw = JSON.parse(await readFile(sessionPath, "utf8"));
|
|
1823
|
+
if (raw.name !== playbook || !raw.id) continue;
|
|
1824
|
+
matches.push({
|
|
1825
|
+
id: raw.id,
|
|
1826
|
+
name: raw.name || playbook,
|
|
1827
|
+
status: raw.status || "idle",
|
|
1828
|
+
revision: raw.revision || 0,
|
|
1829
|
+
updatedAt: raw.updatedAt || (/* @__PURE__ */ new Date()).toISOString(),
|
|
1830
|
+
draftDir: raw.draftDir || join(rootDir, entry.name, "draft"),
|
|
1831
|
+
finalDir: raw.finalDir || join(projectDir, ".converge", "playbooks", raw.name || playbook),
|
|
1832
|
+
lastError: raw.lastError,
|
|
1833
|
+
feedback: Array.isArray(raw.feedback) ? raw.feedback.filter((item) => !!item && typeof item === "object").map((item) => ({
|
|
1834
|
+
ts: typeof item.ts === "string" ? item.ts : (/* @__PURE__ */ new Date()).toISOString(),
|
|
1835
|
+
message: typeof item.message === "string" ? item.message : ""
|
|
1836
|
+
})) : []
|
|
1837
|
+
});
|
|
1838
|
+
} catch {
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
matches.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
1843
|
+
return matches[0] ?? null;
|
|
1844
|
+
}
|
|
1845
|
+
async function loadHumanReviews(projectDir, playbook, taskId) {
|
|
1846
|
+
const path = getHumanReportReviewsPath(projectDir, playbook, taskId);
|
|
1847
|
+
if (!existsSync(path)) return [];
|
|
1848
|
+
const raw = await readFile(path, "utf8");
|
|
1849
|
+
const entries = [];
|
|
1850
|
+
for (const line of raw.split("\n").filter(Boolean)) {
|
|
1851
|
+
if (!line.startsWith("{")) continue;
|
|
1852
|
+
let parsed = tryParseLine(line);
|
|
1853
|
+
if (parsed && parsed.playbook === playbook && parsed.taskId === taskId) {
|
|
1854
|
+
entries.push(parsed);
|
|
1855
|
+
continue;
|
|
1856
|
+
}
|
|
1857
|
+
if (line.includes("}{")) {
|
|
1858
|
+
const parts = line.split("}{");
|
|
1859
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1860
|
+
const chunk = (i === 0 ? "" : "{") + parts[i] + (i === parts.length - 1 ? "" : "}");
|
|
1861
|
+
const p = tryParseLine(chunk);
|
|
1862
|
+
if (p && p.playbook === playbook && p.taskId === taskId) {
|
|
1863
|
+
entries.push(p);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return entries;
|
|
1869
|
+
}
|
|
1870
|
+
function tryParseLine(line) {
|
|
1871
|
+
try {
|
|
1872
|
+
return JSON.parse(line);
|
|
1873
|
+
} catch {
|
|
1874
|
+
return null;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
async function appendHumanReview(projectDir, review) {
|
|
1878
|
+
const dir = join(projectDir, ".converge", "inventory", review.playbook, "reports");
|
|
1879
|
+
await mkdir(dir, { recursive: true });
|
|
1880
|
+
const path = join(dir, `${review.taskId}.jsonl`);
|
|
1881
|
+
await writeFile(path, JSON.stringify(review) + "\n", { flag: "a" });
|
|
1882
|
+
await mirrorHumanReviewToAttemptFeedback(projectDir, review);
|
|
1883
|
+
}
|
|
1884
|
+
async function mirrorHumanReviewToAttemptFeedback(projectDir, review) {
|
|
1885
|
+
const attemptDir = join(
|
|
1886
|
+
projectDir,
|
|
1887
|
+
".converge",
|
|
1888
|
+
"journal",
|
|
1889
|
+
review.playbook,
|
|
1890
|
+
"tasks",
|
|
1891
|
+
review.taskId,
|
|
1892
|
+
"attempts",
|
|
1893
|
+
"wip"
|
|
1894
|
+
);
|
|
1895
|
+
if (!existsSync(attemptDir)) return;
|
|
1896
|
+
const feedbackPath = join(attemptDir, "FEEDBACK.md");
|
|
1897
|
+
const existing = existsSync(feedbackPath) ? await readFile(feedbackPath, "utf8") : "";
|
|
1898
|
+
const reviewBlock = formatHumanReviewFeedback(review);
|
|
1899
|
+
const nextFeedback = existing.trim() ? `${existing.trimEnd()}
|
|
1900
|
+
|
|
1901
|
+
---
|
|
1902
|
+
|
|
1903
|
+
${reviewBlock}
|
|
1904
|
+
` : `${reviewBlock}
|
|
1905
|
+
`;
|
|
1906
|
+
await writeFile(feedbackPath, nextFeedback, "utf8");
|
|
1907
|
+
}
|
|
1908
|
+
function formatHumanReviewFeedback(review) {
|
|
1909
|
+
return [
|
|
1910
|
+
"# FEEDBACK.md \u2014 Human Review",
|
|
1911
|
+
"",
|
|
1912
|
+
`**Playbook**: ${review.playbook}`,
|
|
1913
|
+
`**Task**: ${review.taskId}`,
|
|
1914
|
+
`**Decision**: ${humanDecisionLabel(review.decision)}`,
|
|
1915
|
+
`**Report**: ${review.reportTitle}`,
|
|
1916
|
+
`**Recorded**: ${review.ts}`,
|
|
1917
|
+
"",
|
|
1918
|
+
"## Summary",
|
|
1919
|
+
"",
|
|
1920
|
+
review.summary || "No summary provided.",
|
|
1921
|
+
"",
|
|
1922
|
+
"## Feedback",
|
|
1923
|
+
"",
|
|
1924
|
+
review.feedback || "No feedback provided."
|
|
1925
|
+
].join("\n");
|
|
1926
|
+
}
|
|
1927
|
+
function normalizeHumanReview(body, context) {
|
|
1928
|
+
const action = body.action?.trim();
|
|
1929
|
+
return {
|
|
1930
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1931
|
+
playbook: context.playbook,
|
|
1932
|
+
taskId: context.taskId,
|
|
1933
|
+
template: "employee-report",
|
|
1934
|
+
reportTitle: body.reportTitle?.trim() || "Weekly employee report",
|
|
1935
|
+
summary: body.summary?.trim() || "",
|
|
1936
|
+
decision: action === "accept" ? "approve" : action === "feedback" ? "revise" : normalizeDecision(body.decision),
|
|
1937
|
+
feedback: body.feedback?.trim() || ""
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
function normalizeDecision(value) {
|
|
1941
|
+
if (value === "approve" || value === "revise" || value === "reject") {
|
|
1942
|
+
return value;
|
|
1943
|
+
}
|
|
1944
|
+
return "revise";
|
|
1945
|
+
}
|
|
1946
|
+
function humanDecisionLabel(value) {
|
|
1947
|
+
if (value === "approve") return "Approved";
|
|
1948
|
+
if (value === "reject") return "Rejected";
|
|
1949
|
+
if (value === "revise") return "Needs revision";
|
|
1950
|
+
return "Waiting for review";
|
|
1951
|
+
}
|
|
1952
|
+
function formatHumanTimestamp(ts) {
|
|
1953
|
+
const date = new Date(ts);
|
|
1954
|
+
if (Number.isNaN(date.getTime())) return ts;
|
|
1955
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
1956
|
+
dateStyle: "medium",
|
|
1957
|
+
timeStyle: "short"
|
|
1958
|
+
}).format(date);
|
|
1959
|
+
}
|
|
1960
|
+
async function persistSession(rootDir, session) {
|
|
1961
|
+
const sessionDir = join(rootDir, session.id);
|
|
1962
|
+
await mkdir(sessionDir, { recursive: true });
|
|
1963
|
+
const payload = {
|
|
1964
|
+
id: session.id,
|
|
1965
|
+
projectDir: session.projectDir,
|
|
1966
|
+
name: session.name,
|
|
1967
|
+
templateId: session.templateId,
|
|
1968
|
+
templateLabel: session.templateLabel,
|
|
1969
|
+
playbookInstruction: session.playbookInstruction,
|
|
1970
|
+
goal: session.goal,
|
|
1971
|
+
createdAt: session.createdAt,
|
|
1972
|
+
updatedAt: session.updatedAt,
|
|
1973
|
+
revision: session.revision,
|
|
1974
|
+
status: session.status,
|
|
1975
|
+
draftDir: session.draftDir,
|
|
1976
|
+
workDir: session.workDir,
|
|
1977
|
+
finalDir: session.finalDir,
|
|
1978
|
+
lastError: session.lastError,
|
|
1979
|
+
feedback: session.feedback
|
|
1980
|
+
};
|
|
1981
|
+
await writeFile(join(sessionDir, "session.json"), JSON.stringify(payload, null, 2), "utf8");
|
|
1982
|
+
}
|
|
1983
|
+
async function appendFeedbackEntry(rootDir, sessionId, feedback) {
|
|
1984
|
+
const sessionDir = join(rootDir, sessionId);
|
|
1985
|
+
await mkdir(sessionDir, { recursive: true });
|
|
1986
|
+
const line = JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), message: feedback }) + "\n";
|
|
1987
|
+
await writeFile(join(sessionDir, "feedback.jsonl"), line, { flag: "a" });
|
|
1988
|
+
}
|
|
1989
|
+
async function loadSessionsFromDisk(rootDir, sessions) {
|
|
1990
|
+
if (!existsSync(rootDir)) return;
|
|
1991
|
+
const entries = await readdir(rootDir, { withFileTypes: true });
|
|
1992
|
+
for (const entry of entries) {
|
|
1993
|
+
if (!entry.isDirectory()) continue;
|
|
1994
|
+
const sessionPath = join(rootDir, entry.name, "session.json");
|
|
1995
|
+
if (!existsSync(sessionPath)) continue;
|
|
1996
|
+
try {
|
|
1997
|
+
const raw = JSON.parse(await readFile(sessionPath, "utf8"));
|
|
1998
|
+
if (!raw.id || !raw.name || !raw.goal) continue;
|
|
1999
|
+
sessions.set(raw.id, {
|
|
2000
|
+
id: raw.id,
|
|
2001
|
+
projectDir: raw.projectDir || resolve(rootDir, "..", "..", ".."),
|
|
2002
|
+
name: raw.name,
|
|
2003
|
+
templateId: raw.templateId || void 0,
|
|
2004
|
+
templateLabel: raw.templateLabel || void 0,
|
|
2005
|
+
playbookInstruction: raw.playbookInstruction || void 0,
|
|
2006
|
+
goal: raw.goal,
|
|
2007
|
+
createdAt: raw.createdAt || (/* @__PURE__ */ new Date()).toISOString(),
|
|
2008
|
+
updatedAt: raw.updatedAt || (/* @__PURE__ */ new Date()).toISOString(),
|
|
2009
|
+
revision: raw.revision || 0,
|
|
2010
|
+
status: raw.status || "idle",
|
|
2011
|
+
draftDir: raw.draftDir || join(rootDir, entry.name, "draft"),
|
|
2012
|
+
workDir: raw.workDir || void 0,
|
|
2013
|
+
finalDir: raw.finalDir || join(resolve(rootDir, "..", "..", ".."), ".converge", "playbooks", raw.name),
|
|
2014
|
+
lastError: raw.lastError,
|
|
2015
|
+
feedback: Array.isArray(raw.feedback) ? raw.feedback.filter((item) => !!item && typeof item === "object").map((item) => ({
|
|
2016
|
+
ts: typeof item.ts === "string" ? item.ts : (/* @__PURE__ */ new Date()).toISOString(),
|
|
2017
|
+
message: typeof item.message === "string" ? item.message : ""
|
|
2018
|
+
})) : [],
|
|
2019
|
+
activeRun: null,
|
|
2020
|
+
rerunRequested: false
|
|
2021
|
+
});
|
|
2022
|
+
} catch {
|
|
2023
|
+
continue;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
async function sanitizePlaybookYml(playbookDir) {
|
|
2028
|
+
const path = join(playbookDir, "playbook.yml");
|
|
2029
|
+
if (!existsSync(path)) return;
|
|
2030
|
+
const raw = await readFile(path, "utf8");
|
|
2031
|
+
const parsed = parse(raw);
|
|
2032
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
2033
|
+
if (!("tasks" in parsed)) return;
|
|
2034
|
+
const next = { ...parsed };
|
|
2035
|
+
delete next.tasks;
|
|
2036
|
+
await writeFile(path, stringify(next), "utf8");
|
|
2037
|
+
}
|
|
2038
|
+
function panel(title, body) {
|
|
2039
|
+
return `<section class="panel"><h2>${escapeHtml(title)}</h2>${body}</section>`;
|
|
2040
|
+
}
|
|
2041
|
+
function authCleanupScript() {
|
|
2042
|
+
return `<script>
|
|
2043
|
+
(() => {
|
|
2044
|
+
try {
|
|
2045
|
+
const url = new URL(window.location.href);
|
|
2046
|
+
if (!url.searchParams.has("token")) return;
|
|
2047
|
+
url.searchParams.delete("token");
|
|
2048
|
+
const query = url.searchParams.toString();
|
|
2049
|
+
const next = url.pathname + (query ? "?" + query : "") + url.hash;
|
|
2050
|
+
window.history.replaceState({}, "", next);
|
|
2051
|
+
} catch {
|
|
2052
|
+
// Ignore malformed URLs in non-browser contexts.
|
|
2053
|
+
}
|
|
2054
|
+
})();
|
|
2055
|
+
</script>`;
|
|
2056
|
+
}
|
|
2057
|
+
function sessionFooter(session) {
|
|
2058
|
+
return `<section class="footnote">
|
|
2059
|
+
<a href="/sessions/${session.id}">Back to session</a>
|
|
2060
|
+
<span>Status:</span> <code>${escapeHtml(session.status)}</code>
|
|
2061
|
+
</section>`;
|
|
2062
|
+
}
|
|
2063
|
+
function plannerLifecycleIndex(status) {
|
|
2064
|
+
if (status === "idle") return 0;
|
|
2065
|
+
if (status === "planning") return 1;
|
|
2066
|
+
if (status === "awaiting-feedback") return 2;
|
|
2067
|
+
if (status === "publishing") return 3;
|
|
2068
|
+
if (status === "published") return 4;
|
|
2069
|
+
return 5;
|
|
2070
|
+
}
|
|
2071
|
+
function renderPlannerLifecycle(args) {
|
|
2072
|
+
const steps = [
|
|
2073
|
+
{
|
|
2074
|
+
status: "idle",
|
|
2075
|
+
label: "Idle",
|
|
2076
|
+
detail: "The planner exists but has not started shaping the playbook yet."
|
|
2077
|
+
},
|
|
2078
|
+
{
|
|
2079
|
+
status: "planning",
|
|
2080
|
+
label: "Planning",
|
|
2081
|
+
detail: "The planner is drafting tasks, templates, catalogs, and helper scripts."
|
|
2082
|
+
},
|
|
2083
|
+
{
|
|
2084
|
+
status: "awaiting-feedback",
|
|
2085
|
+
label: "Awaiting feedback",
|
|
2086
|
+
detail: "The draft is ready for review and can be revised in the browser."
|
|
2087
|
+
},
|
|
2088
|
+
{
|
|
2089
|
+
status: "publishing",
|
|
2090
|
+
label: "Publishing",
|
|
2091
|
+
detail: "The accepted draft is being copied into the playbook folder."
|
|
2092
|
+
},
|
|
2093
|
+
{
|
|
2094
|
+
status: "published",
|
|
2095
|
+
label: "Published",
|
|
2096
|
+
detail: "The playbook is available to run and review in the studio."
|
|
2097
|
+
},
|
|
2098
|
+
{
|
|
2099
|
+
status: "failed",
|
|
2100
|
+
label: "Failed",
|
|
2101
|
+
detail: "The planner hit an error and the last failure is shown below."
|
|
2102
|
+
}
|
|
2103
|
+
];
|
|
2104
|
+
const currentIndex = plannerLifecycleIndex(args.status);
|
|
2105
|
+
const stepRows = steps.map((step, index) => {
|
|
2106
|
+
const current = args.status === "failed" ? step.status === "failed" : index === currentIndex;
|
|
2107
|
+
const done = args.status !== "failed" && index < currentIndex;
|
|
2108
|
+
const stateClass = current ? "current" : done ? "done" : "upcoming";
|
|
2109
|
+
return `<article class="lifecycle-step ${stateClass}">
|
|
2110
|
+
<div class="lifecycle-top">
|
|
2111
|
+
<span class="lifecycle-index">${index + 1}</span>
|
|
2112
|
+
<span class="lifecycle-label">${escapeHtml(step.label)}</span>
|
|
2113
|
+
</div>
|
|
2114
|
+
<p>${escapeHtml(step.detail)}</p>
|
|
2115
|
+
</article>`;
|
|
2116
|
+
}).join("");
|
|
2117
|
+
return `<div class="stack">
|
|
2118
|
+
<div class="lifecycle-summary">
|
|
2119
|
+
<span class="badge ${args.status}">${escapeHtml(args.status)}</span>
|
|
2120
|
+
<span class="metric">Mode <strong>${escapeHtml(args.mode)}</strong></span>
|
|
2121
|
+
</div>
|
|
2122
|
+
<div class="lifecycle-grid">${stepRows}</div>
|
|
2123
|
+
${args.lastError ? `<div class="error-box"><strong>Last error</strong><pre>${escapeHtml(args.lastError)}</pre></div>` : `<div class="empty">The current step is highlighted above. The published playbook and review artifact are linked in the outputs panel.</div>`}
|
|
2124
|
+
</div>`;
|
|
2125
|
+
}
|
|
2126
|
+
function renderPlannerOutputs(args) {
|
|
2127
|
+
const reviewLabel = args.mode === "session" ? "Preview the HTML artifact" : "Review the HTML artifact";
|
|
2128
|
+
const outputCards = args.mode === "session" ? [
|
|
2129
|
+
`<article class="artifact-card">
|
|
2130
|
+
<div class="artifact-tag">Draft output</div>
|
|
2131
|
+
<strong>Planner playbook</strong>
|
|
2132
|
+
<p>The current draft lives in <code>${escapeHtml(args.draftDir || "")}</code>.</p>
|
|
2133
|
+
${args.sessionId ? `<a href="/sessions/${encodeURIComponent(args.sessionId)}">Open session</a>` : ""}
|
|
2134
|
+
</article>`,
|
|
2135
|
+
`<article class="artifact-card accent">
|
|
2136
|
+
<div class="artifact-tag">Human in the loop</div>
|
|
2137
|
+
<strong>${escapeHtml(reviewLabel)}</strong>
|
|
2138
|
+
<p>One review task serves the persisted infographic HTML for human feedback.</p>
|
|
2139
|
+
${args.published ? `<a href="${escapeHtml(args.reportUrl)}">Open review artifact</a>` : `<span>The review artifact appears after the playbook is published.</span>`}
|
|
2140
|
+
</article>`,
|
|
2141
|
+
`<article class="artifact-card">
|
|
2142
|
+
<div class="artifact-tag">Publish target</div>
|
|
2143
|
+
<strong>Playbook folder</strong>
|
|
2144
|
+
<p>The accepted draft is copied into <code>${escapeHtml(args.finalDir || "")}</code>.</p>
|
|
2145
|
+
${args.published ? `<a href="/playbooks/${encodeURIComponent(args.playbookName)}">Open playbook view</a>` : `<span>The playbook view appears after publishing.</span>`}
|
|
2146
|
+
</article>`
|
|
2147
|
+
] : [
|
|
2148
|
+
`<article class="artifact-card">
|
|
2149
|
+
<div class="artifact-tag">Published output</div>
|
|
2150
|
+
<strong>Playbook folder</strong>
|
|
2151
|
+
<p>The accepted playbook lives in <code>${escapeHtml(args.finalDir || "")}</code>.</p>
|
|
2152
|
+
<a href="/playbooks/${encodeURIComponent(args.playbookName)}">Open playbook</a>
|
|
2153
|
+
</article>`,
|
|
2154
|
+
`<article class="artifact-card accent">
|
|
2155
|
+
<div class="artifact-tag">Human in the loop</div>
|
|
2156
|
+
<strong>${escapeHtml(reviewLabel)}</strong>
|
|
2157
|
+
<p>Use the HTML artifact to preview the report and leave feedback.</p>
|
|
2158
|
+
<a href="${escapeHtml(args.reportUrl)}">Open review artifact</a>
|
|
2159
|
+
</article>`,
|
|
2160
|
+
`<article class="artifact-card">
|
|
2161
|
+
<div class="artifact-tag">Runtime</div>
|
|
2162
|
+
<strong>Run dashboard</strong>
|
|
2163
|
+
<p>Watch the execution state from the journal once the playbook is run.</p>
|
|
2164
|
+
<a href="${escapeHtml(args.runUrl || `/playbooks/${encodeURIComponent(args.playbookName)}/run`)}">Open run dashboard</a>
|
|
2165
|
+
</article>`
|
|
2166
|
+
];
|
|
2167
|
+
return `<div class="artifact-grid">${outputCards.join("")}</div>`;
|
|
2168
|
+
}
|
|
2169
|
+
function renderLayout(title, sections, refresh = false) {
|
|
2170
|
+
return `<!doctype html>
|
|
2171
|
+
<html lang="en">
|
|
2172
|
+
<head>
|
|
2173
|
+
<meta charset="utf-8" />
|
|
2174
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2175
|
+
${refresh ? '<meta http-equiv="refresh" content="2" />' : ""}
|
|
2176
|
+
<title>${escapeHtml(title)}</title>
|
|
2177
|
+
${authCleanupScript()}
|
|
2178
|
+
<style>
|
|
2179
|
+
:root {
|
|
2180
|
+
color-scheme: dark;
|
|
2181
|
+
--bg: #0c1220;
|
|
2182
|
+
--panel: rgba(14, 20, 34, 0.92);
|
|
2183
|
+
--panel-border: rgba(148, 163, 184, 0.16);
|
|
2184
|
+
--text: #e5eefb;
|
|
2185
|
+
--muted: #96a4bb;
|
|
2186
|
+
--accent: #7dd3fc;
|
|
2187
|
+
--accent-2: #f59e0b;
|
|
2188
|
+
--success: #34d399;
|
|
2189
|
+
--error: #fb7185;
|
|
2190
|
+
}
|
|
2191
|
+
* { box-sizing: border-box; }
|
|
2192
|
+
body {
|
|
2193
|
+
margin: 0;
|
|
2194
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
2195
|
+
background:
|
|
2196
|
+
radial-gradient(circle at 18% 14%, rgba(56, 189, 248, 0.16), transparent 24%),
|
|
2197
|
+
radial-gradient(circle at 82% 18%, rgba(168, 85, 247, 0.16), transparent 22%),
|
|
2198
|
+
radial-gradient(circle at 50% 92%, rgba(16, 185, 129, 0.08), transparent 28%),
|
|
2199
|
+
linear-gradient(180deg, #050816 0%, #07101e 38%, #09111f 100%);
|
|
2200
|
+
color: var(--text);
|
|
2201
|
+
min-height: 100vh;
|
|
2202
|
+
overflow-x: hidden;
|
|
2203
|
+
}
|
|
2204
|
+
body::before,
|
|
2205
|
+
body::after {
|
|
2206
|
+
content: "";
|
|
2207
|
+
position: fixed;
|
|
2208
|
+
inset: auto;
|
|
2209
|
+
pointer-events: none;
|
|
2210
|
+
z-index: 0;
|
|
2211
|
+
filter: blur(20px);
|
|
2212
|
+
opacity: 0.8;
|
|
2213
|
+
}
|
|
2214
|
+
body::before {
|
|
2215
|
+
width: 420px;
|
|
2216
|
+
height: 420px;
|
|
2217
|
+
left: -140px;
|
|
2218
|
+
top: -120px;
|
|
2219
|
+
border-radius: 50%;
|
|
2220
|
+
background: radial-gradient(circle, rgba(56, 189, 248, 0.16), transparent 68%);
|
|
2221
|
+
}
|
|
2222
|
+
body::after {
|
|
2223
|
+
width: 460px;
|
|
2224
|
+
height: 460px;
|
|
2225
|
+
right: -180px;
|
|
2226
|
+
bottom: -140px;
|
|
2227
|
+
border-radius: 50%;
|
|
2228
|
+
background: radial-gradient(circle, rgba(168, 85, 247, 0.14), transparent 68%);
|
|
2229
|
+
}
|
|
2230
|
+
a { color: var(--accent); text-decoration: none; }
|
|
2231
|
+
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
|
2232
|
+
.page {
|
|
2233
|
+
max-width: 1240px;
|
|
2234
|
+
margin: 0 auto;
|
|
2235
|
+
padding: 24px 20px 32px;
|
|
2236
|
+
position: relative;
|
|
2237
|
+
z-index: 1;
|
|
2238
|
+
}
|
|
2239
|
+
.compose-shell {
|
|
2240
|
+
display: grid;
|
|
2241
|
+
place-items: center;
|
|
2242
|
+
gap: 18px;
|
|
2243
|
+
min-height: calc(100vh - 56px);
|
|
2244
|
+
padding: 8px 0 12px;
|
|
2245
|
+
position: relative;
|
|
2246
|
+
}
|
|
2247
|
+
.compose-shell > div {
|
|
2248
|
+
width: min(1080px, 100%);
|
|
2249
|
+
text-align: center;
|
|
2250
|
+
}
|
|
2251
|
+
.compose-shell h1 {
|
|
2252
|
+
margin: 8px 0 10px;
|
|
2253
|
+
font-size: clamp(1.7rem, 3.4vw, 2.55rem);
|
|
2254
|
+
line-height: 1;
|
|
2255
|
+
letter-spacing: -0.05em;
|
|
2256
|
+
font-weight: 650;
|
|
2257
|
+
text-wrap: balance;
|
|
2258
|
+
}
|
|
2259
|
+
.help-link {
|
|
2260
|
+
margin: 10px 0 0;
|
|
2261
|
+
font-size: 0.95rem;
|
|
2262
|
+
}
|
|
2263
|
+
.help-link-bottom {
|
|
2264
|
+
margin-top: 14px;
|
|
2265
|
+
text-align: center;
|
|
2266
|
+
}
|
|
2267
|
+
.lede {
|
|
2268
|
+
color: var(--muted);
|
|
2269
|
+
max-width: 62ch;
|
|
2270
|
+
margin: 0 auto;
|
|
2271
|
+
font-size: 1rem;
|
|
2272
|
+
line-height: 1.62;
|
|
2273
|
+
}
|
|
2274
|
+
.eyebrow {
|
|
2275
|
+
display: inline-flex;
|
|
2276
|
+
padding: 7px 11px;
|
|
2277
|
+
border: 1px solid var(--panel-border);
|
|
2278
|
+
border-radius: 999px;
|
|
2279
|
+
color: #bfe8ff;
|
|
2280
|
+
background: rgba(56, 189, 248, 0.1);
|
|
2281
|
+
text-transform: uppercase;
|
|
2282
|
+
letter-spacing: 0.12em;
|
|
2283
|
+
font-size: 11px;
|
|
2284
|
+
}
|
|
2285
|
+
.compose-card, .panel, .card-link, .footnote {
|
|
2286
|
+
border: 1px solid var(--panel-border);
|
|
2287
|
+
background:
|
|
2288
|
+
linear-gradient(180deg, rgba(17, 24, 39, 0.94), rgba(10, 15, 26, 0.94)),
|
|
2289
|
+
rgba(14, 20, 34, 0.92);
|
|
2290
|
+
border-radius: 24px;
|
|
2291
|
+
box-shadow:
|
|
2292
|
+
0 24px 80px rgba(0, 0, 0, 0.36),
|
|
2293
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
|
2294
|
+
backdrop-filter: blur(16px);
|
|
2295
|
+
}
|
|
2296
|
+
.compose-card {
|
|
2297
|
+
width: min(1080px, 100%);
|
|
2298
|
+
padding: 28px;
|
|
2299
|
+
text-align: left;
|
|
2300
|
+
position: relative;
|
|
2301
|
+
overflow: hidden;
|
|
2302
|
+
}
|
|
2303
|
+
.compose-card::before {
|
|
2304
|
+
content: "";
|
|
2305
|
+
position: absolute;
|
|
2306
|
+
inset: 0;
|
|
2307
|
+
background:
|
|
2308
|
+
linear-gradient(135deg, rgba(56, 189, 248, 0.08), transparent 35%),
|
|
2309
|
+
linear-gradient(315deg, rgba(168, 85, 247, 0.06), transparent 32%);
|
|
2310
|
+
pointer-events: none;
|
|
2311
|
+
}
|
|
2312
|
+
.panel { padding: 18px; margin-bottom: 16px; }
|
|
2313
|
+
.panel h2 { margin: 0 0 14px; font-size: 1rem; letter-spacing: 0.04em; text-transform: uppercase; color: #d7e7fb; }
|
|
2314
|
+
.help-page .panel { margin-bottom: 0; }
|
|
2315
|
+
.help-copy { margin: 0; color: #d5e0f3; line-height: 1.6; }
|
|
2316
|
+
.help-note { margin-top: 10px; }
|
|
2317
|
+
.help-row, .help-example {
|
|
2318
|
+
display: grid;
|
|
2319
|
+
gap: 4px;
|
|
2320
|
+
padding: 12px 14px;
|
|
2321
|
+
border-radius: 14px;
|
|
2322
|
+
background: rgba(9, 14, 25, 0.48);
|
|
2323
|
+
border: 1px solid rgba(148, 163, 184, 0.12);
|
|
2324
|
+
}
|
|
2325
|
+
.help-row strong, .help-example strong { color: #eef5ff; }
|
|
2326
|
+
.help-row span, .help-example span { color: var(--muted); line-height: 1.55; }
|
|
2327
|
+
.help-back { margin: 4px 0 0; }
|
|
2328
|
+
.lifecycle-summary {
|
|
2329
|
+
display: flex;
|
|
2330
|
+
align-items: center;
|
|
2331
|
+
gap: 10px;
|
|
2332
|
+
flex-wrap: wrap;
|
|
2333
|
+
}
|
|
2334
|
+
.lifecycle-grid {
|
|
2335
|
+
display: grid;
|
|
2336
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
2337
|
+
gap: 10px;
|
|
2338
|
+
}
|
|
2339
|
+
.lifecycle-step {
|
|
2340
|
+
display: grid;
|
|
2341
|
+
gap: 8px;
|
|
2342
|
+
padding: 14px;
|
|
2343
|
+
border-radius: 16px;
|
|
2344
|
+
border: 1px solid rgba(148, 163, 184, 0.14);
|
|
2345
|
+
background: rgba(9, 14, 25, 0.52);
|
|
2346
|
+
}
|
|
2347
|
+
.lifecycle-step.current {
|
|
2348
|
+
border-color: rgba(125, 211, 252, 0.28);
|
|
2349
|
+
background:
|
|
2350
|
+
linear-gradient(135deg, rgba(56, 189, 248, 0.14), rgba(168, 85, 247, 0.08)),
|
|
2351
|
+
rgba(9, 14, 25, 0.58);
|
|
2352
|
+
}
|
|
2353
|
+
.lifecycle-step.done {
|
|
2354
|
+
opacity: 0.88;
|
|
2355
|
+
border-color: rgba(52, 211, 153, 0.18);
|
|
2356
|
+
}
|
|
2357
|
+
.lifecycle-step.upcoming {
|
|
2358
|
+
opacity: 0.76;
|
|
2359
|
+
}
|
|
2360
|
+
.lifecycle-top {
|
|
2361
|
+
display: flex;
|
|
2362
|
+
align-items: center;
|
|
2363
|
+
gap: 8px;
|
|
2364
|
+
}
|
|
2365
|
+
.lifecycle-index {
|
|
2366
|
+
display: inline-flex;
|
|
2367
|
+
width: 1.7rem;
|
|
2368
|
+
height: 1.7rem;
|
|
2369
|
+
align-items: center;
|
|
2370
|
+
justify-content: center;
|
|
2371
|
+
border-radius: 999px;
|
|
2372
|
+
background: rgba(148, 163, 184, 0.12);
|
|
2373
|
+
color: #dce7f7;
|
|
2374
|
+
font-size: 0.82rem;
|
|
2375
|
+
font-weight: 700;
|
|
2376
|
+
}
|
|
2377
|
+
.lifecycle-label {
|
|
2378
|
+
font-weight: 700;
|
|
2379
|
+
color: #eef5ff;
|
|
2380
|
+
}
|
|
2381
|
+
.lifecycle-step p {
|
|
2382
|
+
margin: 0;
|
|
2383
|
+
color: var(--muted);
|
|
2384
|
+
line-height: 1.55;
|
|
2385
|
+
}
|
|
2386
|
+
.artifact-grid {
|
|
2387
|
+
display: grid;
|
|
2388
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
2389
|
+
gap: 12px;
|
|
2390
|
+
}
|
|
2391
|
+
.artifact-card {
|
|
2392
|
+
display: grid;
|
|
2393
|
+
gap: 8px;
|
|
2394
|
+
padding: 14px;
|
|
2395
|
+
border-radius: 16px;
|
|
2396
|
+
border: 1px solid rgba(148, 163, 184, 0.14);
|
|
2397
|
+
background: rgba(9, 14, 25, 0.52);
|
|
2398
|
+
}
|
|
2399
|
+
.artifact-card.accent {
|
|
2400
|
+
border-color: rgba(125, 211, 252, 0.24);
|
|
2401
|
+
background:
|
|
2402
|
+
linear-gradient(135deg, rgba(56, 189, 248, 0.12), rgba(168, 85, 247, 0.06)),
|
|
2403
|
+
rgba(9, 14, 25, 0.58);
|
|
2404
|
+
}
|
|
2405
|
+
.artifact-card strong {
|
|
2406
|
+
color: #eef5ff;
|
|
2407
|
+
font-size: 1rem;
|
|
2408
|
+
}
|
|
2409
|
+
.artifact-card p {
|
|
2410
|
+
margin: 0;
|
|
2411
|
+
color: var(--muted);
|
|
2412
|
+
line-height: 1.55;
|
|
2413
|
+
}
|
|
2414
|
+
.artifact-tag {
|
|
2415
|
+
display: inline-flex;
|
|
2416
|
+
width: fit-content;
|
|
2417
|
+
padding: 4px 8px;
|
|
2418
|
+
border-radius: 999px;
|
|
2419
|
+
border: 1px solid rgba(148, 163, 184, 0.14);
|
|
2420
|
+
background: rgba(9, 14, 25, 0.7);
|
|
2421
|
+
color: #b7c5d9;
|
|
2422
|
+
text-transform: uppercase;
|
|
2423
|
+
letter-spacing: 0.12em;
|
|
2424
|
+
font-size: 10px;
|
|
2425
|
+
font-weight: 700;
|
|
2426
|
+
}
|
|
2427
|
+
.artifact-card a {
|
|
2428
|
+
width: fit-content;
|
|
2429
|
+
}
|
|
2430
|
+
.error-box {
|
|
2431
|
+
padding: 14px 16px;
|
|
2432
|
+
border-radius: 16px;
|
|
2433
|
+
border: 1px solid rgba(251, 113, 133, 0.2);
|
|
2434
|
+
background: rgba(251, 113, 133, 0.08);
|
|
2435
|
+
}
|
|
2436
|
+
.error-box strong {
|
|
2437
|
+
display: block;
|
|
2438
|
+
margin-bottom: 8px;
|
|
2439
|
+
color: #ffb0bd;
|
|
2440
|
+
text-transform: uppercase;
|
|
2441
|
+
letter-spacing: 0.08em;
|
|
2442
|
+
font-size: 0.78rem;
|
|
2443
|
+
}
|
|
2444
|
+
.error-box pre {
|
|
2445
|
+
margin: 0;
|
|
2446
|
+
white-space: pre-wrap;
|
|
2447
|
+
color: #f3d9df;
|
|
2448
|
+
}
|
|
2449
|
+
.help-flow {
|
|
2450
|
+
display: grid;
|
|
2451
|
+
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
|
|
2452
|
+
gap: 12px;
|
|
2453
|
+
align-items: stretch;
|
|
2454
|
+
}
|
|
2455
|
+
.help-node, .help-case {
|
|
2456
|
+
display: grid;
|
|
2457
|
+
gap: 6px;
|
|
2458
|
+
padding: 14px;
|
|
2459
|
+
border-radius: 16px;
|
|
2460
|
+
border: 1px solid rgba(148, 163, 184, 0.14);
|
|
2461
|
+
background: rgba(9, 14, 25, 0.52);
|
|
2462
|
+
}
|
|
2463
|
+
.help-node.highlight {
|
|
2464
|
+
border-color: rgba(125, 211, 252, 0.26);
|
|
2465
|
+
background:
|
|
2466
|
+
linear-gradient(135deg, rgba(56, 189, 248, 0.14), rgba(168, 85, 247, 0.08)),
|
|
2467
|
+
rgba(9, 14, 25, 0.58);
|
|
2468
|
+
}
|
|
2469
|
+
.help-node strong,
|
|
2470
|
+
.help-case strong {
|
|
2471
|
+
color: #eef5ff;
|
|
2472
|
+
}
|
|
2473
|
+
.help-node span,
|
|
2474
|
+
.help-case span {
|
|
2475
|
+
color: var(--muted);
|
|
2476
|
+
line-height: 1.5;
|
|
2477
|
+
}
|
|
2478
|
+
.help-arrow {
|
|
2479
|
+
display: grid;
|
|
2480
|
+
place-items: center;
|
|
2481
|
+
color: #8fbfe2;
|
|
2482
|
+
font-size: 1.4rem;
|
|
2483
|
+
font-weight: 700;
|
|
2484
|
+
user-select: none;
|
|
2485
|
+
}
|
|
2486
|
+
.help-tag {
|
|
2487
|
+
display: inline-flex;
|
|
2488
|
+
width: fit-content;
|
|
2489
|
+
padding: 4px 8px;
|
|
2490
|
+
border-radius: 999px;
|
|
2491
|
+
border: 1px solid rgba(148, 163, 184, 0.14);
|
|
2492
|
+
background: rgba(9, 14, 25, 0.7);
|
|
2493
|
+
color: #b7c5d9;
|
|
2494
|
+
text-transform: uppercase;
|
|
2495
|
+
letter-spacing: 0.12em;
|
|
2496
|
+
font-size: 10px;
|
|
2497
|
+
font-weight: 700;
|
|
2498
|
+
}
|
|
2499
|
+
.help-cases {
|
|
2500
|
+
display: grid;
|
|
2501
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
2502
|
+
gap: 12px;
|
|
2503
|
+
}
|
|
2504
|
+
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
|
2505
|
+
.grid.split { grid-template-columns: 1.7fr 1fr; }
|
|
2506
|
+
.feed-layout {
|
|
2507
|
+
display: grid;
|
|
2508
|
+
grid-template-columns: minmax(0, 1.6fr) minmax(290px, 0.75fr);
|
|
2509
|
+
gap: 18px;
|
|
2510
|
+
align-items: start;
|
|
2511
|
+
}
|
|
2512
|
+
.feed-column {
|
|
2513
|
+
display: grid;
|
|
2514
|
+
gap: 16px;
|
|
2515
|
+
min-width: 0;
|
|
2516
|
+
}
|
|
2517
|
+
.topology-rail {
|
|
2518
|
+
display: grid;
|
|
2519
|
+
gap: 16px;
|
|
2520
|
+
position: sticky;
|
|
2521
|
+
top: 18px;
|
|
2522
|
+
}
|
|
2523
|
+
.feed-post,
|
|
2524
|
+
.reply-card {
|
|
2525
|
+
border: 1px solid rgba(148, 163, 184, 0.16);
|
|
2526
|
+
background:
|
|
2527
|
+
linear-gradient(180deg, rgba(17, 24, 39, 0.94), rgba(10, 15, 26, 0.94)),
|
|
2528
|
+
rgba(14, 20, 34, 0.92);
|
|
2529
|
+
border-radius: 24px;
|
|
2530
|
+
box-shadow:
|
|
2531
|
+
0 18px 48px rgba(0, 0, 0, 0.24),
|
|
2532
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
|
2533
|
+
}
|
|
2534
|
+
.feed-post {
|
|
2535
|
+
padding: 20px;
|
|
2536
|
+
}
|
|
2537
|
+
.feed-post-head {
|
|
2538
|
+
display: flex;
|
|
2539
|
+
align-items: flex-start;
|
|
2540
|
+
justify-content: space-between;
|
|
2541
|
+
gap: 18px;
|
|
2542
|
+
margin-bottom: 14px;
|
|
2543
|
+
}
|
|
2544
|
+
.feed-kicker {
|
|
2545
|
+
text-transform: uppercase;
|
|
2546
|
+
letter-spacing: 0.12em;
|
|
2547
|
+
font-size: 10px;
|
|
2548
|
+
color: #95b6d3;
|
|
2549
|
+
font-weight: 700;
|
|
2550
|
+
margin-bottom: 6px;
|
|
2551
|
+
}
|
|
2552
|
+
.feed-post h2 {
|
|
2553
|
+
margin: 0;
|
|
2554
|
+
font-size: 1.1rem;
|
|
2555
|
+
color: #eef5ff;
|
|
2556
|
+
}
|
|
2557
|
+
.feed-chip-row {
|
|
2558
|
+
display: flex;
|
|
2559
|
+
flex-wrap: wrap;
|
|
2560
|
+
justify-content: flex-end;
|
|
2561
|
+
gap: 8px;
|
|
2562
|
+
}
|
|
2563
|
+
.feed-chip {
|
|
2564
|
+
display: inline-flex;
|
|
2565
|
+
align-items: center;
|
|
2566
|
+
padding: 6px 10px;
|
|
2567
|
+
border-radius: 999px;
|
|
2568
|
+
border: 1px solid rgba(148, 163, 184, 0.16);
|
|
2569
|
+
background: rgba(9, 14, 25, 0.68);
|
|
2570
|
+
color: #c6d5ea;
|
|
2571
|
+
font-size: 0.8rem;
|
|
2572
|
+
font-weight: 700;
|
|
2573
|
+
}
|
|
2574
|
+
.feed-body {
|
|
2575
|
+
display: grid;
|
|
2576
|
+
gap: 14px;
|
|
2577
|
+
}
|
|
2578
|
+
.feed-block {
|
|
2579
|
+
display: grid;
|
|
2580
|
+
gap: 10px;
|
|
2581
|
+
padding: 14px;
|
|
2582
|
+
border-radius: 18px;
|
|
2583
|
+
background: rgba(9, 14, 25, 0.52);
|
|
2584
|
+
border: 1px solid rgba(148, 163, 184, 0.12);
|
|
2585
|
+
}
|
|
2586
|
+
.feed-label {
|
|
2587
|
+
color: #9cb5cd;
|
|
2588
|
+
text-transform: uppercase;
|
|
2589
|
+
letter-spacing: 0.1em;
|
|
2590
|
+
font-size: 0.78rem;
|
|
2591
|
+
font-weight: 700;
|
|
2592
|
+
}
|
|
2593
|
+
.feed-block pre {
|
|
2594
|
+
margin: 0;
|
|
2595
|
+
white-space: pre-wrap;
|
|
2596
|
+
line-height: 1.65;
|
|
2597
|
+
color: #e9f2ff;
|
|
2598
|
+
}
|
|
2599
|
+
.feed-footer {
|
|
2600
|
+
display: flex;
|
|
2601
|
+
gap: 12px;
|
|
2602
|
+
flex-wrap: wrap;
|
|
2603
|
+
margin-top: 14px;
|
|
2604
|
+
}
|
|
2605
|
+
.thread {
|
|
2606
|
+
display: grid;
|
|
2607
|
+
gap: 10px;
|
|
2608
|
+
}
|
|
2609
|
+
.reply-card {
|
|
2610
|
+
padding: 14px;
|
|
2611
|
+
border-radius: 18px;
|
|
2612
|
+
background: rgba(9, 14, 25, 0.58);
|
|
2613
|
+
}
|
|
2614
|
+
.reply-card.nested {
|
|
2615
|
+
margin-left: 12px;
|
|
2616
|
+
border-style: dashed;
|
|
2617
|
+
}
|
|
2618
|
+
.reply-meta {
|
|
2619
|
+
display: flex;
|
|
2620
|
+
flex-wrap: wrap;
|
|
2621
|
+
gap: 8px;
|
|
2622
|
+
align-items: center;
|
|
2623
|
+
margin-bottom: 8px;
|
|
2624
|
+
}
|
|
2625
|
+
.reply-index {
|
|
2626
|
+
color: #dce7f7;
|
|
2627
|
+
font-weight: 700;
|
|
2628
|
+
}
|
|
2629
|
+
.reply-time {
|
|
2630
|
+
color: var(--muted);
|
|
2631
|
+
font-size: 0.86rem;
|
|
2632
|
+
}
|
|
2633
|
+
.reply-card p {
|
|
2634
|
+
margin: 0;
|
|
2635
|
+
line-height: 1.6;
|
|
2636
|
+
color: #d9e3f1;
|
|
2637
|
+
}
|
|
2638
|
+
.reply-card strong {
|
|
2639
|
+
display: block;
|
|
2640
|
+
margin-bottom: 6px;
|
|
2641
|
+
color: #eef5ff;
|
|
2642
|
+
}
|
|
2643
|
+
.feed-post .empty {
|
|
2644
|
+
margin: 0;
|
|
2645
|
+
}
|
|
2646
|
+
.column { min-width: 0; }
|
|
2647
|
+
.stack { display: grid; gap: 14px; position: relative; z-index: 1; }
|
|
2648
|
+
.compose-panel {
|
|
2649
|
+
min-width: 0;
|
|
2650
|
+
padding: 20px;
|
|
2651
|
+
border-radius: 22px;
|
|
2652
|
+
border: 1px solid rgba(148, 163, 184, 0.14);
|
|
2653
|
+
background: rgba(9, 14, 25, 0.36);
|
|
2654
|
+
}
|
|
2655
|
+
.compose-section {
|
|
2656
|
+
display: grid;
|
|
2657
|
+
gap: 16px;
|
|
2658
|
+
position: relative;
|
|
2659
|
+
}
|
|
2660
|
+
.section-kicker {
|
|
2661
|
+
position: absolute;
|
|
2662
|
+
top: 16px;
|
|
2663
|
+
right: 16px;
|
|
2664
|
+
display: inline-flex;
|
|
2665
|
+
width: fit-content;
|
|
2666
|
+
padding: 6px 10px;
|
|
2667
|
+
border-radius: 999px;
|
|
2668
|
+
border: 1px solid rgba(148, 163, 184, 0.14);
|
|
2669
|
+
background: rgba(9, 14, 25, 0.54);
|
|
2670
|
+
color: #b7c5d9;
|
|
2671
|
+
text-transform: uppercase;
|
|
2672
|
+
letter-spacing: 0.12em;
|
|
2673
|
+
font-size: 10px;
|
|
2674
|
+
font-weight: 700;
|
|
2675
|
+
}
|
|
2676
|
+
.compose-stack-left {
|
|
2677
|
+
display: grid;
|
|
2678
|
+
gap: 16px;
|
|
2679
|
+
padding-top: 6px;
|
|
2680
|
+
}
|
|
2681
|
+
label { display: grid; gap: 8px; font-size: 0.92rem; color: #d5e0f3; }
|
|
2682
|
+
input, textarea, button {
|
|
2683
|
+
box-sizing: border-box;
|
|
2684
|
+
font: inherit;
|
|
2685
|
+
border-radius: 16px;
|
|
2686
|
+
border: 1px solid rgba(148, 163, 184, 0.22);
|
|
2687
|
+
background: rgba(9, 14, 25, 0.92);
|
|
2688
|
+
color: var(--text);
|
|
2689
|
+
padding: 13px 14px;
|
|
2690
|
+
}
|
|
2691
|
+
.template-select-field {
|
|
2692
|
+
display: grid;
|
|
2693
|
+
gap: 8px;
|
|
2694
|
+
margin-bottom: 2px;
|
|
2695
|
+
}
|
|
2696
|
+
.template-select-field span {
|
|
2697
|
+
color: #edf5ff;
|
|
2698
|
+
font-weight: 600;
|
|
2699
|
+
letter-spacing: 0.01em;
|
|
2700
|
+
}
|
|
2701
|
+
.template-select-shell {
|
|
2702
|
+
position: relative;
|
|
2703
|
+
}
|
|
2704
|
+
.template-select-shell::after {
|
|
2705
|
+
content: "";
|
|
2706
|
+
position: absolute;
|
|
2707
|
+
right: 16px;
|
|
2708
|
+
top: 50%;
|
|
2709
|
+
width: 9px;
|
|
2710
|
+
height: 9px;
|
|
2711
|
+
border-right: 2px solid rgba(229, 238, 251, 0.82);
|
|
2712
|
+
border-bottom: 2px solid rgba(229, 238, 251, 0.82);
|
|
2713
|
+
transform: translateY(-72%) rotate(45deg);
|
|
2714
|
+
pointer-events: none;
|
|
2715
|
+
}
|
|
2716
|
+
.template-select-shell select {
|
|
2717
|
+
width: 100%;
|
|
2718
|
+
min-height: 58px;
|
|
2719
|
+
appearance: none;
|
|
2720
|
+
-webkit-appearance: none;
|
|
2721
|
+
-moz-appearance: none;
|
|
2722
|
+
padding: 16px 46px 16px 18px;
|
|
2723
|
+
background:
|
|
2724
|
+
linear-gradient(180deg, rgba(17, 24, 39, 0.98), rgba(10, 15, 26, 0.98)),
|
|
2725
|
+
linear-gradient(135deg, rgba(56, 189, 248, 0.09), rgba(168, 85, 247, 0.06));
|
|
2726
|
+
border: 1px solid rgba(125, 211, 252, 0.22);
|
|
2727
|
+
box-shadow:
|
|
2728
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
|
2729
|
+
0 10px 24px rgba(0, 0, 0, 0.18);
|
|
2730
|
+
border-radius: 16px;
|
|
2731
|
+
cursor: pointer;
|
|
2732
|
+
line-height: 1.2;
|
|
2733
|
+
font-weight: 600;
|
|
2734
|
+
color: #eef5ff;
|
|
2735
|
+
outline: none;
|
|
2736
|
+
transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
|
|
2737
|
+
}
|
|
2738
|
+
.template-select-shell select:hover {
|
|
2739
|
+
border-color: rgba(125, 211, 252, 0.42);
|
|
2740
|
+
transform: translateY(-1px);
|
|
2741
|
+
}
|
|
2742
|
+
.template-select-shell select:focus {
|
|
2743
|
+
border-color: rgba(125, 211, 252, 0.65);
|
|
2744
|
+
box-shadow:
|
|
2745
|
+
0 0 0 3px rgba(125, 211, 252, 0.14),
|
|
2746
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
|
2747
|
+
0 10px 24px rgba(0, 0, 0, 0.18);
|
|
2748
|
+
}
|
|
2749
|
+
.template-select-shell select option,
|
|
2750
|
+
.template-select-shell select optgroup {
|
|
2751
|
+
color: #e5eefb;
|
|
2752
|
+
background: #0b1220;
|
|
2753
|
+
}
|
|
2754
|
+
textarea {
|
|
2755
|
+
resize: none;
|
|
2756
|
+
overflow: hidden;
|
|
2757
|
+
min-height: 4.5rem;
|
|
2758
|
+
}
|
|
2759
|
+
.prompt-field textarea {
|
|
2760
|
+
min-height: 5rem;
|
|
2761
|
+
font-size: 1rem;
|
|
2762
|
+
line-height: 1.55;
|
|
2763
|
+
}
|
|
2764
|
+
.playbook-field textarea {
|
|
2765
|
+
min-height: 4.5rem;
|
|
2766
|
+
font-size: 0.97rem;
|
|
2767
|
+
line-height: 1.5;
|
|
2768
|
+
}
|
|
2769
|
+
.compose-actions {
|
|
2770
|
+
display: flex;
|
|
2771
|
+
align-items: end;
|
|
2772
|
+
justify-content: space-between;
|
|
2773
|
+
gap: 24px;
|
|
2774
|
+
margin-top: 6px;
|
|
2775
|
+
position: relative;
|
|
2776
|
+
z-index: 1;
|
|
2777
|
+
}
|
|
2778
|
+
.compose-actions button {
|
|
2779
|
+
min-width: 180px;
|
|
2780
|
+
padding-inline: 20px;
|
|
2781
|
+
justify-self: end;
|
|
2782
|
+
align-self: end;
|
|
2783
|
+
}
|
|
2784
|
+
.compose-actions .hint {
|
|
2785
|
+
margin: 0;
|
|
2786
|
+
max-width: 56ch;
|
|
2787
|
+
font-size: 0.96rem;
|
|
2788
|
+
}
|
|
2789
|
+
button {
|
|
2790
|
+
cursor: pointer;
|
|
2791
|
+
background: linear-gradient(135deg, rgba(56, 189, 248, 0.28), rgba(168, 85, 247, 0.18));
|
|
2792
|
+
border-color: rgba(125, 211, 252, 0.34);
|
|
2793
|
+
font-weight: 600;
|
|
2794
|
+
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18);
|
|
2795
|
+
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
|
|
2796
|
+
}
|
|
2797
|
+
button:hover {
|
|
2798
|
+
transform: translateY(-1px);
|
|
2799
|
+
border-color: rgba(125, 211, 252, 0.5);
|
|
2800
|
+
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.22);
|
|
2801
|
+
}
|
|
2802
|
+
button:focus-visible,
|
|
2803
|
+
.template-select-shell select:focus-visible,
|
|
2804
|
+
textarea:focus-visible {
|
|
2805
|
+
outline: none;
|
|
2806
|
+
box-shadow:
|
|
2807
|
+
0 0 0 3px rgba(56, 189, 248, 0.16),
|
|
2808
|
+
0 0 0 1px rgba(56, 189, 248, 0.2);
|
|
2809
|
+
}
|
|
2810
|
+
button[disabled] { opacity: 0.45; cursor: not-allowed; }
|
|
2811
|
+
.cards { display: grid; gap: 12px; }
|
|
2812
|
+
.card-link {
|
|
2813
|
+
display: block;
|
|
2814
|
+
padding: 16px;
|
|
2815
|
+
color: inherit;
|
|
2816
|
+
}
|
|
2817
|
+
.card-title { font-weight: 700; margin-bottom: 4px; }
|
|
2818
|
+
.card-meta, .task-meta, .hint, .metric { color: var(--muted); font-size: 0.92rem; }
|
|
2819
|
+
.card-body { margin-top: 8px; line-height: 1.5; color: #cfd8e6; }
|
|
2820
|
+
.task-grid, .stack { display: grid; gap: 10px; }
|
|
2821
|
+
.task-row {
|
|
2822
|
+
display: grid;
|
|
2823
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
2824
|
+
gap: 10px;
|
|
2825
|
+
align-items: center;
|
|
2826
|
+
padding: 10px 12px;
|
|
2827
|
+
border-radius: 14px;
|
|
2828
|
+
background: rgba(9, 14, 25, 0.7);
|
|
2829
|
+
border: 1px solid rgba(148, 163, 184, 0.12);
|
|
2830
|
+
}
|
|
2831
|
+
.task-id { font-weight: 600; }
|
|
2832
|
+
.feedback-item {
|
|
2833
|
+
padding: 12px 14px;
|
|
2834
|
+
border-radius: 14px;
|
|
2835
|
+
background: rgba(9, 14, 25, 0.7);
|
|
2836
|
+
border: 1px solid rgba(148, 163, 184, 0.12);
|
|
2837
|
+
}
|
|
2838
|
+
.badge {
|
|
2839
|
+
display: inline-flex;
|
|
2840
|
+
align-items: center;
|
|
2841
|
+
justify-content: center;
|
|
2842
|
+
padding: 8px 12px;
|
|
2843
|
+
border-radius: 999px;
|
|
2844
|
+
font-size: 0.82rem;
|
|
2845
|
+
font-weight: 700;
|
|
2846
|
+
text-transform: uppercase;
|
|
2847
|
+
letter-spacing: 0.08em;
|
|
2848
|
+
width: fit-content;
|
|
2849
|
+
}
|
|
2850
|
+
.badge.idle { background: rgba(148, 163, 184, 0.12); color: #d7e4f5; }
|
|
2851
|
+
.badge.planning { background: rgba(125, 211, 252, 0.14); color: #8ddfff; }
|
|
2852
|
+
.badge.awaiting-feedback { background: rgba(245, 158, 11, 0.14); color: #f9d58b; }
|
|
2853
|
+
.badge.published { background: rgba(52, 211, 153, 0.14); color: #84f1c0; }
|
|
2854
|
+
.badge.failed { background: rgba(251, 113, 133, 0.14); color: #ffb0bd; }
|
|
2855
|
+
.badge.publishing { background: rgba(168, 85, 247, 0.14); color: #d0b0ff; }
|
|
2856
|
+
.status-stack { display: grid; align-content: start; gap: 10px; }
|
|
2857
|
+
.metric {
|
|
2858
|
+
padding: 12px 14px;
|
|
2859
|
+
border-radius: 14px;
|
|
2860
|
+
border: 1px solid rgba(148, 163, 184, 0.12);
|
|
2861
|
+
background: rgba(9, 14, 25, 0.7);
|
|
2862
|
+
}
|
|
2863
|
+
.metric strong { color: var(--text); font-size: 1.1rem; }
|
|
2864
|
+
.empty {
|
|
2865
|
+
color: var(--muted);
|
|
2866
|
+
padding: 14px 16px;
|
|
2867
|
+
border-radius: 14px;
|
|
2868
|
+
background: rgba(9, 14, 25, 0.45);
|
|
2869
|
+
border: 1px dashed rgba(148, 163, 184, 0.18);
|
|
2870
|
+
}
|
|
2871
|
+
.footnote {
|
|
2872
|
+
display: flex;
|
|
2873
|
+
flex-wrap: wrap;
|
|
2874
|
+
gap: 12px;
|
|
2875
|
+
align-items: center;
|
|
2876
|
+
padding: 12px 16px;
|
|
2877
|
+
color: var(--muted);
|
|
2878
|
+
margin-top: 12px;
|
|
2879
|
+
}
|
|
2880
|
+
.plan-md {
|
|
2881
|
+
white-space: pre-wrap;
|
|
2882
|
+
line-height: 1.6;
|
|
2883
|
+
overflow: auto;
|
|
2884
|
+
max-height: 28rem;
|
|
2885
|
+
}
|
|
2886
|
+
.error pre { white-space: pre-wrap; }
|
|
2887
|
+
@media (max-width: 960px) {
|
|
2888
|
+
.grid, .grid.split { grid-template-columns: 1fr; }
|
|
2889
|
+
.feed-layout {
|
|
2890
|
+
grid-template-columns: 1fr;
|
|
2891
|
+
}
|
|
2892
|
+
.topology-rail {
|
|
2893
|
+
position: static;
|
|
2894
|
+
}
|
|
2895
|
+
.lifecycle-grid,
|
|
2896
|
+
.artifact-grid,
|
|
2897
|
+
.help-cases,
|
|
2898
|
+
.help-flow {
|
|
2899
|
+
grid-template-columns: 1fr;
|
|
2900
|
+
}
|
|
2901
|
+
.compose-shell {
|
|
2902
|
+
min-height: auto;
|
|
2903
|
+
padding-top: 0;
|
|
2904
|
+
}
|
|
2905
|
+
.compose-shell h1 {
|
|
2906
|
+
font-size: clamp(2rem, 10vw, 3.2rem);
|
|
2907
|
+
}
|
|
2908
|
+
.compose-card {
|
|
2909
|
+
padding: 18px;
|
|
2910
|
+
}
|
|
2911
|
+
.compose-actions {
|
|
2912
|
+
flex-direction: column;
|
|
2913
|
+
align-items: stretch;
|
|
2914
|
+
}
|
|
2915
|
+
.compose-actions button {
|
|
2916
|
+
width: 100%;
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
</style>
|
|
2920
|
+
</head>
|
|
2921
|
+
<body>
|
|
2922
|
+
<main class="page">
|
|
2923
|
+
${sections.join("\n")}
|
|
2924
|
+
</main>
|
|
2925
|
+
</body>
|
|
2926
|
+
</html>`;
|
|
2927
|
+
}
|
|
2928
|
+
async function readForm(req) {
|
|
2929
|
+
const chunks = [];
|
|
2930
|
+
for await (const chunk of req) {
|
|
2931
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2932
|
+
}
|
|
2933
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
2934
|
+
const params = new URLSearchParams(raw);
|
|
2935
|
+
const out = {};
|
|
2936
|
+
for (const [k, v] of params.entries()) out[k] = v;
|
|
2937
|
+
return out;
|
|
2938
|
+
}
|
|
2939
|
+
function sendJson(res, status, body) {
|
|
2940
|
+
const json = JSON.stringify(body, null, 2);
|
|
2941
|
+
res.writeHead(status, {
|
|
2942
|
+
"content-type": "application/json; charset=utf-8",
|
|
2943
|
+
"content-length": Buffer.byteLength(json),
|
|
2944
|
+
"cache-control": "no-store"
|
|
2945
|
+
});
|
|
2946
|
+
res.end(json);
|
|
2947
|
+
}
|
|
2948
|
+
function sendHtml(res, status, html) {
|
|
2949
|
+
res.writeHead(status, {
|
|
2950
|
+
"content-type": "text/html; charset=utf-8",
|
|
2951
|
+
"content-length": Buffer.byteLength(html),
|
|
2952
|
+
"cache-control": "no-store"
|
|
2953
|
+
});
|
|
2954
|
+
res.end(html);
|
|
2955
|
+
}
|
|
2956
|
+
function redirect(res, location) {
|
|
2957
|
+
res.writeHead(303, {
|
|
2958
|
+
location,
|
|
2959
|
+
"cache-control": "no-store"
|
|
2960
|
+
});
|
|
2961
|
+
res.end();
|
|
2962
|
+
}
|
|
2963
|
+
function escapeHtml(value) {
|
|
2964
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2965
|
+
}
|
|
2966
|
+
function shorten(value, max) {
|
|
2967
|
+
const s = value.replace(/\s+/g, " ").trim();
|
|
2968
|
+
return s.length > max ? `${s.slice(0, max - 1)}\u2026` : s;
|
|
2969
|
+
}
|
|
2970
|
+
function slugify(value) {
|
|
2971
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "plan";
|
|
2972
|
+
}
|
|
2973
|
+
async function tryOpenBrowser(url) {
|
|
2974
|
+
const platform = process.platform;
|
|
2975
|
+
if (platform === "darwin") {
|
|
2976
|
+
await execFileAsync("open", [url]);
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2979
|
+
if (platform === "win32") {
|
|
2980
|
+
await execFileAsync("cmd", ["/c", "start", "", url]);
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2983
|
+
await execFileAsync("xdg-open", [url]);
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
export { createAddStudioServer, isPortOpen, runAddStudio };
|