@openplaybooks/converge-studio 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.js +191 -18
  3. package/package.json +8 -8
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Converge Framework Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { mkdir, readdir, readFile, writeFile, rm, cp, rename } from 'fs/promises
5
5
  import { existsSync } from 'fs';
6
6
  import { join, resolve, dirname } from 'path';
7
7
  import { parse, stringify } from 'yaml';
8
- import { plan } from '@openplaybooks/converge-core';
8
+ import { plan, parseTaskMd } from '@openplaybooks/converge-core';
9
9
  import { loadHumanReviewHandoffById, ensureHumanReviewHandoff, getHumanReviewHandoffRoute } from '@openplaybooks/converge-core/task/review';
10
10
  import { loadPlaybookFromFolder } from '@openplaybooks/converge-core/playbook';
11
11
  import { createServer } from 'http';
@@ -644,7 +644,9 @@ async function handleRequest(args) {
644
644
  taskId: handoff.taskId
645
645
  });
646
646
  await appendHumanReview(projectDir, review);
647
- await writeHumanReportArtifact(projectDir, handoff.playbook, handoff.taskId);
647
+ await rm(getHumanReportArtifactPath(projectDir, handoff.playbook, handoff.taskId), { force: true }).catch(() => {
648
+ });
649
+ await loadOrCreateHumanReportArtifact(projectDir, handoff.playbook, handoff.taskId);
648
650
  redirect(res, `/studio/handoff/${encodeURIComponent(handoff.id)}`);
649
651
  return;
650
652
  }
@@ -655,7 +657,9 @@ async function handleRequest(args) {
655
657
  const body = await readForm(req);
656
658
  const review = normalizeHumanReview(body, { playbook, taskId });
657
659
  await appendHumanReview(projectDir, review);
658
- await writeHumanReportArtifact(projectDir, playbook, taskId);
660
+ await rm(getHumanReportArtifactPath(projectDir, playbook, taskId), { force: true }).catch(() => {
661
+ });
662
+ await loadOrCreateHumanReportArtifact(projectDir, playbook, taskId);
659
663
  redirect(
660
664
  res,
661
665
  `/studio/handoff/${encodeURIComponent((await ensureHumanReviewHandoff(projectDir, playbook, taskId)).id)}`
@@ -674,6 +678,8 @@ async function handleRequest(args) {
674
678
  sendHtml(res, 404, renderLayout("Not found", [panel("Not found", "Unknown human review page.")]));
675
679
  return;
676
680
  }
681
+ const reviews = await loadHumanReviews(projectDir, handoff.playbook, handoff.taskId);
682
+ const reviewPrompt = await loadTaskReviewPrompt(projectDir, handoff.playbook, handoff.taskId);
677
683
  sendHtml(
678
684
  res,
679
685
  200,
@@ -681,7 +687,9 @@ async function handleRequest(args) {
681
687
  playbook: handoff.playbook,
682
688
  taskId: handoff.taskId,
683
689
  reportContentHtml: report,
684
- submitPath: path
690
+ submitPath: path,
691
+ reviews,
692
+ reviewPrompt
685
693
  })
686
694
  );
687
695
  return;
@@ -705,6 +713,8 @@ async function handleRequest(args) {
705
713
  sendHtml(res, 404, renderLayout("Not found", [panel("Not found", "Unknown human review page.")]));
706
714
  return;
707
715
  }
716
+ const reviews = await loadHumanReviews(projectDir, name, taskId);
717
+ const reviewPrompt = await loadTaskReviewPrompt(projectDir, name, taskId);
708
718
  sendHtml(
709
719
  res,
710
720
  200,
@@ -712,7 +722,9 @@ async function handleRequest(args) {
712
722
  playbook: name,
713
723
  taskId,
714
724
  reportContentHtml: report,
715
- submitPath: path
725
+ submitPath: path,
726
+ reviews,
727
+ reviewPrompt
716
728
  })
717
729
  );
718
730
  return;
@@ -1449,10 +1461,28 @@ async function buildRunView(projectDir, name) {
1449
1461
  async function loadOrCreateHumanReportArtifact(projectDir, playbook, taskId) {
1450
1462
  const playbookDir = join(projectDir, ".converge", "playbooks", playbook);
1451
1463
  if (!existsSync(playbookDir)) return null;
1452
- const artifactPath = getHumanReportArtifactPath(projectDir, playbook, taskId);
1453
- if (existsSync(artifactPath)) {
1464
+ const cachedPath = getHumanReportArtifactPath(projectDir, playbook, taskId);
1465
+ if (existsSync(cachedPath)) {
1454
1466
  await announceHumanReviewArtifact(projectDir, playbook, taskId);
1455
- return await readFile(artifactPath, "utf8");
1467
+ return await readFile(cachedPath, "utf8");
1468
+ }
1469
+ const taskMdPath = resolveTaskMdPath(projectDir, playbook, taskId);
1470
+ try {
1471
+ const parsed = await parseTaskMd(taskMdPath);
1472
+ if (parsed?.def.review?.artifact) {
1473
+ const reviewArtifactFile = join(projectDir, parsed.def.review.artifact);
1474
+ if (existsSync(reviewArtifactFile)) {
1475
+ const raw = await readFile(reviewArtifactFile, "utf8");
1476
+ const format = parsed.def.review.format ?? (reviewArtifactFile.endsWith(".html") ? "html" : "md");
1477
+ const html = format === "html" ? raw : renderMarkdownArtifact(raw);
1478
+ await mkdir(join(projectDir, ".converge", "inventory", playbook, "reports"), { recursive: true });
1479
+ await writeFile(cachedPath, html, "utf8");
1480
+ await announceHumanReviewArtifact(projectDir, playbook, taskId);
1481
+ return html;
1482
+ }
1483
+ return renderWaitingForArtifact(parsed.def.review.artifact);
1484
+ }
1485
+ } catch {
1456
1486
  }
1457
1487
  return await writeHumanReportArtifact(projectDir, playbook, taskId);
1458
1488
  }
@@ -1470,6 +1500,31 @@ async function writeHumanReportArtifact(projectDir, playbook, taskId) {
1470
1500
  await announceHumanReviewArtifact(projectDir, playbook, taskId);
1471
1501
  return html;
1472
1502
  }
1503
+ function resolveTaskMdPath(projectDir, playbook, taskId) {
1504
+ return join(projectDir, ".converge", "playbooks", playbook, "tasks", taskId, "TASK.md");
1505
+ }
1506
+ async function loadTaskReviewPrompt(projectDir, playbook, taskId) {
1507
+ try {
1508
+ const parsed = await parseTaskMd(resolveTaskMdPath(projectDir, playbook, taskId));
1509
+ return parsed?.def.review?.prompt;
1510
+ } catch {
1511
+ return void 0;
1512
+ }
1513
+ }
1514
+ function renderMarkdownArtifact(markdown) {
1515
+ return `<div class="summary-block report-body">
1516
+ <div class="label">Artifact content</div>
1517
+ <pre style="white-space: pre-wrap; font-family: inherit; color: var(--text); line-height: 1.7; margin: 0;">${escapeHtml(markdown)}</pre>
1518
+ </div>`;
1519
+ }
1520
+ function renderWaitingForArtifact(artifactPath) {
1521
+ return `<div class="summary-block report-body">
1522
+ <div class="label">Artifact pending</div>
1523
+ <h3>Waiting for artifact</h3>
1524
+ <p class="lead">The review artifact at <code>${escapeHtml(artifactPath)}</code> has not been generated yet.</p>
1525
+ <p>The upstream task must complete and produce this file before the review can proceed.</p>
1526
+ </div>`;
1527
+ }
1473
1528
  function getHumanReportArtifactPath(projectDir, playbook, taskId) {
1474
1529
  return join(projectDir, ".converge", "inventory", playbook, "reports", `${taskId}.html`);
1475
1530
  }
@@ -1494,6 +1549,21 @@ async function announceHumanReviewArtifact(projectDir, playbook, taskId) {
1494
1549
  }
1495
1550
  function renderHumanReviewPageHtml(args) {
1496
1551
  const contentHtml = args.reportContentHtml;
1552
+ const reviews = args.reviews ?? [];
1553
+ const latestDecision = reviews.length > 0 ? reviews[reviews.length - 1].decision : void 0;
1554
+ const decisionBadge = latestDecision ? `<span class="decision-badge decision-${escapeHtml(latestDecision)}">${escapeHtml(humanDecisionLabel(latestDecision))}</span>` : `<span class="decision-badge decision-pending">Awaiting review</span>`;
1555
+ const promptHtml = args.reviewPrompt ? `<p class="lede">${escapeHtml(args.reviewPrompt)}</p>` : `<p class="lede">Review the report below. Accept to proceed, or leave feedback for revision.</p>`;
1556
+ const historyHtml = reviews.length > 0 ? `<div class="review-history">
1557
+ <h3 class="history-title">Review history</h3>
1558
+ ${reviews.map((r, i) => `<div class="history-entry">
1559
+ <div class="history-meta">
1560
+ <span class="history-index">#${i + 1}</span>
1561
+ <span class="history-decision decision-${escapeHtml(r.decision)}">${escapeHtml(humanDecisionLabel(r.decision))}</span>
1562
+ <span class="history-time">${escapeHtml(formatHumanTimestamp(r.ts))}</span>
1563
+ </div>
1564
+ ${r.feedback ? `<p class="history-feedback">${escapeHtml(r.feedback)}</p>` : ""}
1565
+ </div>`).join("")}
1566
+ </div>` : "";
1497
1567
  return `<!doctype html>
1498
1568
  <html lang="en">
1499
1569
  <head>
@@ -1511,7 +1581,11 @@ function renderHumanReviewPageHtml(args) {
1511
1581
  <div>
1512
1582
  <div class="eyebrow">Human review report</div>
1513
1583
  <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>
1584
+ ${promptHtml}
1585
+ </div>
1586
+ <div class="hero-status">
1587
+ ${decisionBadge}
1588
+ <span class="hero-meta">${reviews.length} review${reviews.length === 1 ? "" : "s"}</span>
1515
1589
  </div>
1516
1590
  </section>
1517
1591
 
@@ -1521,17 +1595,18 @@ function renderHumanReviewPageHtml(args) {
1521
1595
  </article>
1522
1596
 
1523
1597
  <aside class="sidebar">
1524
- <h2 class="section-title">Feedback</h2>
1598
+ <h2 class="section-title">Decision</h2>
1525
1599
  <form method="post" action="${escapeHtml(args.submitPath)}" class="form">
1526
1600
  <label>
1527
- <span>One feedback note</span>
1601
+ <span>Feedback note</span>
1528
1602
  <textarea name="feedback" placeholder="What should be clarified, revised, or rejected before this moves forward?"></textarea>
1529
1603
  </label>
1530
1604
  <div class="feed-actions">
1531
- <button type="submit" name="action" value="accept">Accept</button>
1532
- <button type="submit" name="action" value="feedback">Feedback</button>
1605
+ <button type="submit" name="action" value="accept" class="btn-accept">Accept &amp; continue</button>
1606
+ <button type="submit" name="action" value="feedback" class="btn-revise">Request revision</button>
1533
1607
  </div>
1534
1608
  </form>
1609
+ ${historyHtml}
1535
1610
  </aside>
1536
1611
  </section>
1537
1612
 
@@ -1713,17 +1788,115 @@ function renderStudioReviewStyles() {
1713
1788
  font-weight: 700;
1714
1789
  padding: 12px 16px;
1715
1790
  }
1716
- button[value="accept"] {
1791
+ .btn-accept {
1717
1792
  background: linear-gradient(135deg, #38bdf8, #22c55e);
1718
1793
  color: #04111b;
1719
1794
  }
1720
- button[value="feedback"] {
1795
+ .btn-revise {
1721
1796
  background: rgba(148, 163, 184, 0.12);
1722
1797
  color: #e7eef8;
1723
1798
  border: 1px solid rgba(148, 163, 184, 0.22);
1724
1799
  }
1800
+ .hero {
1801
+ display: grid;
1802
+ grid-template-columns: minmax(0, 1fr) auto;
1803
+ align-items: start;
1804
+ }
1805
+ .hero-status {
1806
+ display: grid;
1807
+ gap: 8px;
1808
+ align-content: start;
1809
+ text-align: right;
1810
+ }
1811
+ .hero-meta {
1812
+ color: var(--muted);
1813
+ font-size: 0.88rem;
1814
+ }
1815
+ .decision-badge {
1816
+ display: inline-flex;
1817
+ align-items: center;
1818
+ justify-content: center;
1819
+ padding: 7px 14px;
1820
+ border-radius: 999px;
1821
+ font-size: 0.82rem;
1822
+ font-weight: 700;
1823
+ text-transform: uppercase;
1824
+ letter-spacing: 0.08em;
1825
+ }
1826
+ .decision-approve {
1827
+ background: rgba(34, 197, 94, 0.16);
1828
+ color: #6ee7a0;
1829
+ border: 1px solid rgba(34, 197, 94, 0.24);
1830
+ }
1831
+ .decision-revise {
1832
+ background: rgba(245, 158, 11, 0.16);
1833
+ color: #fbbf44;
1834
+ border: 1px solid rgba(245, 158, 11, 0.24);
1835
+ }
1836
+ .decision-reject {
1837
+ background: rgba(251, 113, 133, 0.16);
1838
+ color: #ffb0bd;
1839
+ border: 1px solid rgba(251, 113, 133, 0.24);
1840
+ }
1841
+ .decision-pending {
1842
+ background: rgba(148, 163, 184, 0.12);
1843
+ color: #b7c5d9;
1844
+ border: 1px solid rgba(148, 163, 184, 0.2);
1845
+ }
1846
+ .review-history {
1847
+ margin-top: 18px;
1848
+ display: grid;
1849
+ gap: 10px;
1850
+ }
1851
+ .history-title {
1852
+ margin: 0;
1853
+ font-size: 0.88rem;
1854
+ text-transform: uppercase;
1855
+ letter-spacing: 0.1em;
1856
+ color: #9cb5cd;
1857
+ font-weight: 700;
1858
+ }
1859
+ .history-entry {
1860
+ padding: 12px 14px;
1861
+ border-radius: 14px;
1862
+ background: rgba(2, 6, 23, 0.55);
1863
+ border: 1px solid rgba(148, 163, 184, 0.14);
1864
+ display: grid;
1865
+ gap: 8px;
1866
+ }
1867
+ .history-meta {
1868
+ display: flex;
1869
+ flex-wrap: wrap;
1870
+ gap: 8px;
1871
+ align-items: center;
1872
+ }
1873
+ .history-index {
1874
+ color: #dce7f7;
1875
+ font-weight: 700;
1876
+ font-size: 0.86rem;
1877
+ }
1878
+ .history-decision {
1879
+ display: inline-flex;
1880
+ padding: 3px 8px;
1881
+ border-radius: 999px;
1882
+ font-size: 0.75rem;
1883
+ font-weight: 700;
1884
+ text-transform: uppercase;
1885
+ letter-spacing: 0.06em;
1886
+ }
1887
+ .history-time {
1888
+ color: var(--muted);
1889
+ font-size: 0.82rem;
1890
+ }
1891
+ .history-feedback {
1892
+ margin: 0;
1893
+ color: #d7e2f1;
1894
+ font-size: 0.92rem;
1895
+ line-height: 1.6;
1896
+ }
1725
1897
  @media (max-width: 900px) {
1726
- .hero,
1898
+ .hero { grid-template-columns: 1fr; }
1899
+ .hero-status { text-align: left; }
1727
1900
  .layout { grid-template-columns: 1fr; }
1728
1901
  .sidebar { position: static; }
1729
1902
  }
@@ -1930,8 +2103,8 @@ function normalizeHumanReview(body, context) {
1930
2103
  ts: (/* @__PURE__ */ new Date()).toISOString(),
1931
2104
  playbook: context.playbook,
1932
2105
  taskId: context.taskId,
1933
- template: "employee-report",
1934
- reportTitle: body.reportTitle?.trim() || "Weekly employee report",
2106
+ template: "",
2107
+ reportTitle: body.reportTitle?.trim() || context.taskId,
1935
2108
  summary: body.summary?.trim() || "",
1936
2109
  decision: action === "accept" ? "approve" : action === "feedback" ? "revise" : normalizeDecision(body.decision),
1937
2110
  feedback: body.feedback?.trim() || ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openplaybooks/converge-studio",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser studio for planning Converge playbooks",
@@ -27,13 +27,8 @@
27
27
  "README.md",
28
28
  "LICENSE"
29
29
  ],
30
- "scripts": {
31
- "build": "tsup",
32
- "test": "vitest run",
33
- "typecheck": "tsc --noEmit"
34
- },
35
30
  "dependencies": {
36
- "@openplaybooks/converge-core": "^0.4.2",
31
+ "@openplaybooks/converge-core": "^0.4.3",
37
32
  "yaml": "^2.8.3"
38
33
  },
39
34
  "devDependencies": {
@@ -41,5 +36,10 @@
41
36
  "tsup": "^8.5.1",
42
37
  "typescript": "^5.7.0",
43
38
  "vitest": "^4.0.18"
39
+ },
40
+ "scripts": {
41
+ "build": "tsup",
42
+ "test": "vitest run",
43
+ "typecheck": "tsc --noEmit"
44
44
  }
45
- }
45
+ }