@tenonhq/dovetail-dashboard 0.0.28 → 0.0.30
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/CHANGELOG.md +15 -0
- package/package.json +2 -1
- package/public/claude-plans.css +187 -0
- package/public/claude-plans.html +11 -2
- package/public/claude-plans.js +328 -5
- package/server.js +102 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog — @tenonhq/dovetail-dashboard
|
|
2
|
+
|
|
3
|
+
## Unreleased — Phase E (claude-plans v2 bidirectional surfaces)
|
|
4
|
+
|
|
5
|
+
- **New dependency** `@tenonhq/dovetail-claude-plans` (workspace) — write routes call directly into the package's storage layer so the state machine, conflict rule, and dispatch-token lifecycle are enforced in exactly one place.
|
|
6
|
+
- **Three new POST routes** under `/api/claude-plans/:slug/`:
|
|
7
|
+
- `answers` → `recordAnswer()`. Forces `answered_by: "dashboard"` by default.
|
|
8
|
+
- `stage` → `setStage()`. Forces `source: "dashboard"` so the conflict rule (design doc §4) treats operator moves as authoritative.
|
|
9
|
+
- `dispatch` → `dispatchStage()`. Pass-through; dashboard sends dry-run first, then confirm+token.
|
|
10
|
+
- **Typed error envelope** — every error response carries `{ error: <code>, name: <ClassName>, message }`. HTTP status reflects the failure mode: 400 (validation / no-token), 404 (plan-not-found), 409 (illegal transition / conflict), 410 (stale token), 424 (missing agent), 500 (spawn / internal).
|
|
11
|
+
- **Stage Map strip** on the plan detail page — 10 stage pills with current-stage highlight, click-to-set_stage, per-stage Dispatch (▶) button, and a ⚠ marker for stages whose agent hasn't shipped yet (test-first, test-reality).
|
|
12
|
+
- **Dispatch dialog** — opens on Dispatch click, POSTs dry-run first, renders the resolved command + cwd, then the operator confirms to fire a live dispatch (which consumes the cached `set_stage` token).
|
|
13
|
+
- **Questions & Answers tab** — lists `plan.questions`, lets the operator answer each one (free-form input + clickable suggested options); SSE plan upsert re-renders the tab automatically.
|
|
14
|
+
- **Per-plan token cache** — the most recent `set_stage` response is held client-side per plan so the Dispatch button has a token to consume. Cache is cleared on plan switch.
|
|
15
|
+
- **Rate limited** — all three new routes share `claudePlansLimiter` (60 req / 15 min / IP), same as the existing destructive delete route.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tenonhq/dovetail-dashboard",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.30",
|
|
4
4
|
"description": "Update Set Dashboard for Dovetail",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"scripts": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"postpublish": "npm run version:bump"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
+
"@tenonhq/dovetail-claude-plans": "*",
|
|
12
13
|
"axios": "^1.5.1",
|
|
13
14
|
"axios-cookiejar-support": "^4.0.7",
|
|
14
15
|
"chokidar": "^3.6.0",
|
package/public/claude-plans.css
CHANGED
|
@@ -23,6 +23,21 @@
|
|
|
23
23
|
border-color: var(--brand);
|
|
24
24
|
color: var(--brand);
|
|
25
25
|
}
|
|
26
|
+
.cp-lints-badge {
|
|
27
|
+
display: inline-flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
min-width: 18px;
|
|
31
|
+
height: 18px;
|
|
32
|
+
margin: 0 2px;
|
|
33
|
+
padding: 0 5px;
|
|
34
|
+
background: var(--danger);
|
|
35
|
+
color: var(--fg-on-dark);
|
|
36
|
+
border-radius: var(--radius-pill);
|
|
37
|
+
font-size: 11px;
|
|
38
|
+
font-weight: var(--weight-bold);
|
|
39
|
+
line-height: 1;
|
|
40
|
+
}
|
|
26
41
|
.cp-storage {
|
|
27
42
|
font-size: 11px;
|
|
28
43
|
font-family: var(--font-mono);
|
|
@@ -1296,3 +1311,175 @@
|
|
|
1296
1311
|
.cp-topic-chip--active .cp-topic-chip-count {
|
|
1297
1312
|
background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
|
|
1298
1313
|
}
|
|
1314
|
+
|
|
1315
|
+
/* ===========================================================================
|
|
1316
|
+
* v2 — Stage Map, Questions tab, Dispatch dialog (Phase E)
|
|
1317
|
+
* Lightweight additions on top of the existing token system.
|
|
1318
|
+
* ========================================================================= */
|
|
1319
|
+
|
|
1320
|
+
.cp-stage-map {
|
|
1321
|
+
display: flex;
|
|
1322
|
+
flex-wrap: wrap;
|
|
1323
|
+
gap: 6px;
|
|
1324
|
+
padding: 12px 0;
|
|
1325
|
+
margin-bottom: 12px;
|
|
1326
|
+
border-bottom: 1px solid var(--border, #e5e7eb);
|
|
1327
|
+
}
|
|
1328
|
+
.cp-stage {
|
|
1329
|
+
position: relative;
|
|
1330
|
+
display: inline-flex;
|
|
1331
|
+
align-items: center;
|
|
1332
|
+
gap: 6px;
|
|
1333
|
+
padding: 6px 10px;
|
|
1334
|
+
border: 1px solid var(--border, #d1d5db);
|
|
1335
|
+
border-radius: 999px;
|
|
1336
|
+
background: var(--bg-elevated, #fff);
|
|
1337
|
+
font-size: 12px;
|
|
1338
|
+
font-weight: 500;
|
|
1339
|
+
cursor: pointer;
|
|
1340
|
+
user-select: none;
|
|
1341
|
+
}
|
|
1342
|
+
.cp-stage:hover { background: var(--bg-hover, #f3f4f6); }
|
|
1343
|
+
.cp-stage--current {
|
|
1344
|
+
background: var(--accent-bg, #2563eb);
|
|
1345
|
+
color: var(--accent-fg, #fff);
|
|
1346
|
+
border-color: var(--accent-bg, #2563eb);
|
|
1347
|
+
}
|
|
1348
|
+
.cp-stage--illegal {
|
|
1349
|
+
opacity: 0.4;
|
|
1350
|
+
cursor: not-allowed;
|
|
1351
|
+
}
|
|
1352
|
+
.cp-stage--missing-agent::after {
|
|
1353
|
+
content: "⚠";
|
|
1354
|
+
font-size: 11px;
|
|
1355
|
+
margin-left: 4px;
|
|
1356
|
+
}
|
|
1357
|
+
.cp-stage-dispatch {
|
|
1358
|
+
margin-left: 4px;
|
|
1359
|
+
padding: 0 6px;
|
|
1360
|
+
font-size: 11px;
|
|
1361
|
+
background: transparent;
|
|
1362
|
+
border: 1px solid currentColor;
|
|
1363
|
+
border-radius: 4px;
|
|
1364
|
+
cursor: pointer;
|
|
1365
|
+
color: inherit;
|
|
1366
|
+
}
|
|
1367
|
+
.cp-stage-dispatch:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
|
|
1368
|
+
|
|
1369
|
+
.cp-question {
|
|
1370
|
+
padding: 12px;
|
|
1371
|
+
border: 1px solid var(--border, #e5e7eb);
|
|
1372
|
+
border-radius: 8px;
|
|
1373
|
+
margin-bottom: 12px;
|
|
1374
|
+
background: var(--bg-elevated, #fff);
|
|
1375
|
+
}
|
|
1376
|
+
.cp-question--answered { opacity: 0.78; }
|
|
1377
|
+
.cp-question-header {
|
|
1378
|
+
display: flex;
|
|
1379
|
+
gap: 8px;
|
|
1380
|
+
align-items: center;
|
|
1381
|
+
margin-bottom: 6px;
|
|
1382
|
+
font-size: 11px;
|
|
1383
|
+
text-transform: uppercase;
|
|
1384
|
+
letter-spacing: 0.06em;
|
|
1385
|
+
color: var(--text-muted, #6b7280);
|
|
1386
|
+
}
|
|
1387
|
+
.cp-question-stage {
|
|
1388
|
+
padding: 2px 6px;
|
|
1389
|
+
border-radius: 4px;
|
|
1390
|
+
background: color-mix(in srgb, currentColor 10%, transparent);
|
|
1391
|
+
}
|
|
1392
|
+
.cp-question-q { font-size: 14px; margin-bottom: 10px; font-weight: 500; }
|
|
1393
|
+
.cp-question-options {
|
|
1394
|
+
display: flex; flex-wrap: wrap; gap: 6px;
|
|
1395
|
+
margin-bottom: 10px;
|
|
1396
|
+
}
|
|
1397
|
+
.cp-question-option {
|
|
1398
|
+
padding: 4px 8px;
|
|
1399
|
+
border: 1px solid var(--border, #d1d5db);
|
|
1400
|
+
border-radius: 4px;
|
|
1401
|
+
background: var(--bg-elevated, #fff);
|
|
1402
|
+
font-size: 12px;
|
|
1403
|
+
cursor: pointer;
|
|
1404
|
+
}
|
|
1405
|
+
.cp-question-option:hover { background: var(--bg-hover, #f3f4f6); }
|
|
1406
|
+
.cp-question-answer-form { display: flex; gap: 6px; }
|
|
1407
|
+
.cp-question-answer-input {
|
|
1408
|
+
flex: 1;
|
|
1409
|
+
padding: 6px 8px;
|
|
1410
|
+
border: 1px solid var(--border, #d1d5db);
|
|
1411
|
+
border-radius: 4px;
|
|
1412
|
+
font: inherit;
|
|
1413
|
+
}
|
|
1414
|
+
.cp-question-answer-submit {
|
|
1415
|
+
padding: 6px 12px;
|
|
1416
|
+
border: 1px solid var(--accent-bg, #2563eb);
|
|
1417
|
+
background: var(--accent-bg, #2563eb);
|
|
1418
|
+
color: var(--accent-fg, #fff);
|
|
1419
|
+
border-radius: 4px;
|
|
1420
|
+
cursor: pointer;
|
|
1421
|
+
}
|
|
1422
|
+
.cp-question-recorded {
|
|
1423
|
+
font-size: 13px;
|
|
1424
|
+
padding: 8px 10px;
|
|
1425
|
+
background: color-mix(in srgb, var(--accent-bg, #2563eb) 10%, transparent);
|
|
1426
|
+
border-radius: 4px;
|
|
1427
|
+
}
|
|
1428
|
+
.cp-question-recorded-meta {
|
|
1429
|
+
font-size: 11px;
|
|
1430
|
+
color: var(--text-muted, #6b7280);
|
|
1431
|
+
margin-top: 4px;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
.cp-dispatch-dialog {
|
|
1435
|
+
position: fixed;
|
|
1436
|
+
inset: 0;
|
|
1437
|
+
background: rgba(0,0,0,0.4);
|
|
1438
|
+
display: flex;
|
|
1439
|
+
align-items: center;
|
|
1440
|
+
justify-content: center;
|
|
1441
|
+
z-index: 1000;
|
|
1442
|
+
}
|
|
1443
|
+
.cp-dispatch-dialog-inner {
|
|
1444
|
+
background: var(--bg-elevated, #fff);
|
|
1445
|
+
border-radius: 8px;
|
|
1446
|
+
padding: 20px;
|
|
1447
|
+
max-width: 640px;
|
|
1448
|
+
width: 90%;
|
|
1449
|
+
font-size: 14px;
|
|
1450
|
+
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
|
1451
|
+
}
|
|
1452
|
+
.cp-dispatch-dialog h3 { margin-top: 0; }
|
|
1453
|
+
.cp-dispatch-dialog pre {
|
|
1454
|
+
background: var(--bg, #f9fafb);
|
|
1455
|
+
border-radius: 4px;
|
|
1456
|
+
padding: 10px;
|
|
1457
|
+
overflow-x: auto;
|
|
1458
|
+
font-size: 12px;
|
|
1459
|
+
}
|
|
1460
|
+
.cp-dispatch-dialog-actions {
|
|
1461
|
+
display: flex;
|
|
1462
|
+
gap: 8px;
|
|
1463
|
+
margin-top: 14px;
|
|
1464
|
+
justify-content: flex-end;
|
|
1465
|
+
}
|
|
1466
|
+
.cp-dispatch-dialog-actions button {
|
|
1467
|
+
padding: 6px 14px;
|
|
1468
|
+
border: 1px solid var(--border, #d1d5db);
|
|
1469
|
+
border-radius: 4px;
|
|
1470
|
+
background: var(--bg-elevated, #fff);
|
|
1471
|
+
cursor: pointer;
|
|
1472
|
+
}
|
|
1473
|
+
.cp-dispatch-dialog-actions button.cp-dispatch-confirm {
|
|
1474
|
+
background: var(--accent-bg, #2563eb);
|
|
1475
|
+
color: var(--accent-fg, #fff);
|
|
1476
|
+
border-color: var(--accent-bg, #2563eb);
|
|
1477
|
+
}
|
|
1478
|
+
.cp-dispatch-error {
|
|
1479
|
+
color: #b91c1c;
|
|
1480
|
+
background: #fee2e2;
|
|
1481
|
+
padding: 8px 10px;
|
|
1482
|
+
border-radius: 4px;
|
|
1483
|
+
margin-top: 10px;
|
|
1484
|
+
font-size: 13px;
|
|
1485
|
+
}
|
package/public/claude-plans.html
CHANGED
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
</div>
|
|
31
31
|
<div class="header-right">
|
|
32
32
|
<a class="cp-nav-link" href="/">← Update Sets</a>
|
|
33
|
-
<a class="cp-nav-link" href="/prompt-lints">Prompt Lints
|
|
33
|
+
<a class="cp-nav-link" href="/prompt-lints">Prompt Lints
|
|
34
|
+
<span class="cp-lints-badge" id="cp-lints-badge" title="Prompts needing review" hidden>0</span>
|
|
35
|
+
→</a>
|
|
34
36
|
<button class="cp-theme-toggle" id="cp-theme-toggle" type="button"
|
|
35
37
|
aria-label="Toggle dark mode" title="Toggle dark mode"></button>
|
|
36
38
|
<span class="cp-storage" id="cp-storage" title="storage root"></span>
|
|
@@ -43,7 +45,7 @@
|
|
|
43
45
|
<span class="cp-rail-title">Plans</span>
|
|
44
46
|
<span class="cp-count" id="cp-count">0</span>
|
|
45
47
|
</div>
|
|
46
|
-
<details class="cp-topics" id="cp-topics"
|
|
48
|
+
<details class="cp-topics" id="cp-topics">
|
|
47
49
|
<summary class="cp-topics-summary">
|
|
48
50
|
<span class="cp-topics-caret" aria-hidden="true">▾</span>
|
|
49
51
|
<span class="cp-topics-title">Topics</span>
|
|
@@ -102,6 +104,9 @@
|
|
|
102
104
|
<span class="cp-status-pill" id="cp-detail-status"></span>
|
|
103
105
|
<span class="cp-stamp" id="cp-detail-stamp"></span>
|
|
104
106
|
</header>
|
|
107
|
+
<!-- v2 Stage Map: 10-stage pipeline strip; clicking a stage triggers
|
|
108
|
+
POST /stage (subject to state-machine + conflict rules). -->
|
|
109
|
+
<div class="cp-stage-map" id="cp-stage-map" hidden></div>
|
|
105
110
|
<nav class="cp-tabs">
|
|
106
111
|
<button class="cp-tab active" data-tab="plan">Plan</button>
|
|
107
112
|
<button class="cp-tab" data-tab="prompts">
|
|
@@ -110,10 +115,14 @@
|
|
|
110
115
|
<button class="cp-tab" data-tab="artifacts">
|
|
111
116
|
Artifacts <span class="cp-tab-count" id="cp-artifact-count">0</span>
|
|
112
117
|
</button>
|
|
118
|
+
<button class="cp-tab" data-tab="questions">
|
|
119
|
+
Q&A <span class="cp-tab-count" id="cp-question-count">0</span>
|
|
120
|
+
</button>
|
|
113
121
|
</nav>
|
|
114
122
|
<div class="cp-tab-panel" id="cp-tab-plan"></div>
|
|
115
123
|
<div class="cp-tab-panel" id="cp-tab-prompts" hidden></div>
|
|
116
124
|
<div class="cp-tab-panel" id="cp-tab-artifacts" hidden></div>
|
|
125
|
+
<div class="cp-tab-panel" id="cp-tab-questions" hidden></div>
|
|
117
126
|
</article>
|
|
118
127
|
</section>
|
|
119
128
|
</main>
|
package/public/claude-plans.js
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
var els = {
|
|
44
44
|
storage: document.getElementById("cp-storage"),
|
|
45
|
+
lintsBadge: document.getElementById("cp-lints-badge"),
|
|
45
46
|
list: document.getElementById("cp-list"),
|
|
46
47
|
count: document.getElementById("cp-count"),
|
|
47
48
|
railEmpty: document.getElementById("cp-rail-empty"),
|
|
@@ -59,6 +60,9 @@
|
|
|
59
60
|
planPanel: document.getElementById("cp-tab-plan"),
|
|
60
61
|
artifactsPanel: document.getElementById("cp-tab-artifacts"),
|
|
61
62
|
promptsPanel: document.getElementById("cp-tab-prompts"),
|
|
63
|
+
questionsPanel: document.getElementById("cp-tab-questions"),
|
|
64
|
+
questionCount: document.getElementById("cp-question-count"),
|
|
65
|
+
stageMap: document.getElementById("cp-stage-map"),
|
|
62
66
|
tabs: document.querySelectorAll(".cp-tab"),
|
|
63
67
|
topics: document.getElementById("cp-topics"),
|
|
64
68
|
topicsCloud: document.getElementById("cp-topics-cloud"),
|
|
@@ -219,7 +223,7 @@
|
|
|
219
223
|
if (els.topics) {
|
|
220
224
|
try {
|
|
221
225
|
var stored = localStorage.getItem("cp-topics-collapsed");
|
|
222
|
-
|
|
226
|
+
els.topics.open = stored === "0";
|
|
223
227
|
} catch (_) {}
|
|
224
228
|
els.topics.addEventListener("toggle", function () {
|
|
225
229
|
try {
|
|
@@ -591,6 +595,10 @@
|
|
|
591
595
|
renderMarkdown(plan.content_md, els.planPanel);
|
|
592
596
|
}
|
|
593
597
|
|
|
598
|
+
// v2 surfaces — Stage Map strip + Questions tab.
|
|
599
|
+
renderStageMap(plan);
|
|
600
|
+
renderQuestions(plan);
|
|
601
|
+
|
|
594
602
|
addPlanSectionCopyBtns();
|
|
595
603
|
addTabsCopyAll(plan, artifacts, prompts);
|
|
596
604
|
|
|
@@ -705,10 +713,289 @@
|
|
|
705
713
|
els.planPanel.hidden = tab !== "plan";
|
|
706
714
|
els.artifactsPanel.hidden = tab !== "artifacts";
|
|
707
715
|
if (els.promptsPanel) els.promptsPanel.hidden = tab !== "prompts";
|
|
716
|
+
if (els.questionsPanel) els.questionsPanel.hidden = tab !== "questions";
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/* ─── v2 bidirectional pipeline (Phase E) ──────────────────────────────────
|
|
720
|
+
* Stage Map, Questions tab, Dispatch dialog. Server enforces every safety
|
|
721
|
+
* rule (state machine, conflict resolution, dispatch token); the client
|
|
722
|
+
* just renders state and POSTs intents. The latest set_stage token is
|
|
723
|
+
* cached per-plan so the Dispatch button can hand it back to the live call
|
|
724
|
+
* without a round-trip.
|
|
725
|
+
* ─────────────────────────────────────────────────────────────────────── */
|
|
726
|
+
|
|
727
|
+
var PIPELINE_STAGES = [
|
|
728
|
+
"research",
|
|
729
|
+
"pre-stage-improve",
|
|
730
|
+
"planning",
|
|
731
|
+
"post-plan-improve",
|
|
732
|
+
"test-first",
|
|
733
|
+
"code",
|
|
734
|
+
"per-step-review",
|
|
735
|
+
"architectural-review",
|
|
736
|
+
"test-reality",
|
|
737
|
+
"documentation"
|
|
738
|
+
];
|
|
739
|
+
|
|
740
|
+
// Stages whose driving agent isn't shipped yet (matches dispatch.ts).
|
|
741
|
+
// dispatch_stage will raise MissingAgentError for these; the UI greys
|
|
742
|
+
// them out and adds a ⚠ marker.
|
|
743
|
+
var STAGES_MISSING_AGENT = { "test-first": true, "test-reality": true };
|
|
744
|
+
|
|
745
|
+
// Per-plan cache of the most recent setStage response. The dispatch
|
|
746
|
+
// button reads token from here. Cleared on plan switch.
|
|
747
|
+
var stageTokenCache = {};
|
|
748
|
+
|
|
749
|
+
function v2ApiPath(slug, leaf) { return "/api/claude-plans/" + slug + "/" + leaf; }
|
|
750
|
+
|
|
751
|
+
function v2Post(slug, leaf, body) {
|
|
752
|
+
return fetch(v2ApiPath(slug, leaf), {
|
|
753
|
+
method: "POST",
|
|
754
|
+
headers: { "Content-Type": "application/json" },
|
|
755
|
+
body: JSON.stringify(body || {})
|
|
756
|
+
}).then(function (r) {
|
|
757
|
+
return r.json().then(function (json) {
|
|
758
|
+
if (!r.ok) {
|
|
759
|
+
var err = new Error(json.message || json.error || ("HTTP " + r.status));
|
|
760
|
+
err.code = json.error;
|
|
761
|
+
err.name = json.name || "ServerError";
|
|
762
|
+
err.status = r.status;
|
|
763
|
+
err.payload = json;
|
|
764
|
+
throw err;
|
|
765
|
+
}
|
|
766
|
+
return json;
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function renderStageMap(plan) {
|
|
772
|
+
var map = els.stageMap;
|
|
773
|
+
if (!map) return;
|
|
774
|
+
map.hidden = false;
|
|
775
|
+
map.innerHTML = "";
|
|
776
|
+
var current = plan.stage || null;
|
|
777
|
+
PIPELINE_STAGES.forEach(function (stage) {
|
|
778
|
+
var pill = document.createElement("button");
|
|
779
|
+
pill.className = "cp-stage";
|
|
780
|
+
if (stage === current) pill.classList.add("cp-stage--current");
|
|
781
|
+
if (STAGES_MISSING_AGENT[stage]) pill.classList.add("cp-stage--missing-agent");
|
|
782
|
+
pill.textContent = stage;
|
|
783
|
+
pill.title = STAGES_MISSING_AGENT[stage]
|
|
784
|
+
? stage + " — agent not shipped yet (PR #160); dispatch will raise MissingAgentError"
|
|
785
|
+
: stage + " — click to move plan here";
|
|
786
|
+
pill.addEventListener("click", function () {
|
|
787
|
+
v2Post(plan.slug, "stage", { to: stage }).then(function (res) {
|
|
788
|
+
stageTokenCache[plan.slug] = res.token;
|
|
789
|
+
showToast("Stage → " + res.stage + " (token issued)");
|
|
790
|
+
}).catch(function (err) {
|
|
791
|
+
showToast(err.code === "ILLEGAL_TRANSITION"
|
|
792
|
+
? "Illegal move: " + err.message
|
|
793
|
+
: "Stage move failed: " + err.message);
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
// Per-stage Dispatch button (Phase E §3).
|
|
797
|
+
var dispatchBtn = document.createElement("button");
|
|
798
|
+
dispatchBtn.className = "cp-stage-dispatch";
|
|
799
|
+
dispatchBtn.textContent = "▶";
|
|
800
|
+
dispatchBtn.title = "Dispatch a Claude Code session at " + stage;
|
|
801
|
+
dispatchBtn.addEventListener("click", function (e) {
|
|
802
|
+
e.stopPropagation();
|
|
803
|
+
openDispatchDialog(plan, stage);
|
|
804
|
+
});
|
|
805
|
+
pill.appendChild(dispatchBtn);
|
|
806
|
+
map.appendChild(pill);
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function openDispatchDialog(plan, stage) {
|
|
811
|
+
// Step 1 — dry-run dispatch. Render the resolved command, then offer
|
|
812
|
+
// a "Confirm live" button that re-POSTs with confirm:true + the
|
|
813
|
+
// cached token for this plan.
|
|
814
|
+
v2Post(plan.slug, "dispatch", { target_stage: stage }).then(function (dryRun) {
|
|
815
|
+
var backdrop = document.createElement("div");
|
|
816
|
+
backdrop.className = "cp-dispatch-dialog";
|
|
817
|
+
var inner = document.createElement("div");
|
|
818
|
+
inner.className = "cp-dispatch-dialog-inner";
|
|
819
|
+
inner.innerHTML =
|
|
820
|
+
"<h3>Dispatch → " + stage + "</h3>" +
|
|
821
|
+
"<p>Dry-run resolved the following spawn. Confirm to launch a real Claude Code session.</p>" +
|
|
822
|
+
"<pre>" +
|
|
823
|
+
"<strong>command:</strong> " + escapeHtml(dryRun.command) + "\n" +
|
|
824
|
+
"<strong>cwd:</strong> " + escapeHtml(dryRun.cwd) + "\n" +
|
|
825
|
+
"<strong>plan:</strong> " + escapeHtml(dryRun.plan_slug) + "\n" +
|
|
826
|
+
"<strong>stage:</strong> " + escapeHtml(dryRun.target_stage) +
|
|
827
|
+
"</pre>";
|
|
828
|
+
var errorEl = document.createElement("div");
|
|
829
|
+
errorEl.className = "cp-dispatch-error";
|
|
830
|
+
errorEl.hidden = true;
|
|
831
|
+
inner.appendChild(errorEl);
|
|
832
|
+
|
|
833
|
+
var actions = document.createElement("div");
|
|
834
|
+
actions.className = "cp-dispatch-dialog-actions";
|
|
835
|
+
var cancel = document.createElement("button");
|
|
836
|
+
cancel.textContent = "Cancel";
|
|
837
|
+
cancel.addEventListener("click", function () { document.body.removeChild(backdrop); });
|
|
838
|
+
var confirm = document.createElement("button");
|
|
839
|
+
confirm.className = "cp-dispatch-confirm";
|
|
840
|
+
confirm.textContent = "Confirm live dispatch";
|
|
841
|
+
confirm.addEventListener("click", function () {
|
|
842
|
+
var token = stageTokenCache[plan.slug];
|
|
843
|
+
if (!token || !token.token) {
|
|
844
|
+
errorEl.hidden = false;
|
|
845
|
+
errorEl.textContent =
|
|
846
|
+
"No fresh dispatch token cached for this plan. Click the stage on the Stage Map first " +
|
|
847
|
+
"to mint one (tokens are 5-min, single-use, stage-bound).";
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (token.issued_for_stage !== stage) {
|
|
851
|
+
errorEl.hidden = false;
|
|
852
|
+
errorEl.textContent =
|
|
853
|
+
"Cached token was issued for " + token.issued_for_stage +
|
|
854
|
+
", not " + stage + ". Click " + stage + " on the Stage Map to issue a matching token.";
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
confirm.disabled = true;
|
|
858
|
+
v2Post(plan.slug, "dispatch", {
|
|
859
|
+
target_stage: stage,
|
|
860
|
+
confirm: true,
|
|
861
|
+
token: token.token
|
|
862
|
+
}).then(function (live) {
|
|
863
|
+
document.body.removeChild(backdrop);
|
|
864
|
+
showToast("Live dispatch launched (pid=" + live.pid + ")");
|
|
865
|
+
delete stageTokenCache[plan.slug];
|
|
866
|
+
}).catch(function (err) {
|
|
867
|
+
confirm.disabled = false;
|
|
868
|
+
errorEl.hidden = false;
|
|
869
|
+
errorEl.textContent = (err.code || "Error") + ": " + err.message;
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
actions.appendChild(cancel);
|
|
873
|
+
actions.appendChild(confirm);
|
|
874
|
+
inner.appendChild(actions);
|
|
875
|
+
backdrop.appendChild(inner);
|
|
876
|
+
document.body.appendChild(backdrop);
|
|
877
|
+
}).catch(function (err) {
|
|
878
|
+
if (err.code === "MISSING_AGENT") {
|
|
879
|
+
showToast(stage + ": " + err.message);
|
|
880
|
+
} else {
|
|
881
|
+
showToast("Dispatch dry-run failed: " + err.message);
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function escapeHtml(s) {
|
|
887
|
+
return String(s == null ? "" : s)
|
|
888
|
+
.replace(/&/g, "&")
|
|
889
|
+
.replace(/</g, "<")
|
|
890
|
+
.replace(/>/g, ">");
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function renderQuestions(plan) {
|
|
894
|
+
if (!els.questionsPanel) return;
|
|
895
|
+
var questions = plan.questions || [];
|
|
896
|
+
els.questionsPanel.innerHTML = "";
|
|
897
|
+
if (els.questionCount) els.questionCount.textContent = String(questions.length);
|
|
898
|
+
if (questions.length === 0) {
|
|
899
|
+
var empty = document.createElement("div");
|
|
900
|
+
empty.className = "cp-detail-empty";
|
|
901
|
+
empty.style.padding = "40px 0";
|
|
902
|
+
empty.textContent = "No questions yet. Call push_question from Claude (or /qa park).";
|
|
903
|
+
els.questionsPanel.appendChild(empty);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
questions.forEach(function (q) {
|
|
907
|
+
var card = document.createElement("div");
|
|
908
|
+
card.className = "cp-question" + (q.answer ? " cp-question--answered" : "");
|
|
909
|
+
|
|
910
|
+
var header = document.createElement("div");
|
|
911
|
+
header.className = "cp-question-header";
|
|
912
|
+
if (q.stage) {
|
|
913
|
+
var stageTag = document.createElement("span");
|
|
914
|
+
stageTag.className = "cp-question-stage";
|
|
915
|
+
stageTag.textContent = q.stage;
|
|
916
|
+
header.appendChild(stageTag);
|
|
917
|
+
}
|
|
918
|
+
if (q.asked_by) {
|
|
919
|
+
var by = document.createElement("span");
|
|
920
|
+
by.textContent = "asked by " + q.asked_by;
|
|
921
|
+
header.appendChild(by);
|
|
922
|
+
}
|
|
923
|
+
card.appendChild(header);
|
|
924
|
+
|
|
925
|
+
var qText = document.createElement("div");
|
|
926
|
+
qText.className = "cp-question-q";
|
|
927
|
+
qText.textContent = q.question;
|
|
928
|
+
card.appendChild(qText);
|
|
929
|
+
|
|
930
|
+
if (q.answer) {
|
|
931
|
+
var recorded = document.createElement("div");
|
|
932
|
+
recorded.className = "cp-question-recorded";
|
|
933
|
+
recorded.textContent = q.answer;
|
|
934
|
+
var meta = document.createElement("div");
|
|
935
|
+
meta.className = "cp-question-recorded-meta";
|
|
936
|
+
meta.textContent = "answered " +
|
|
937
|
+
(q.answered_by ? "by " + q.answered_by + " " : "") +
|
|
938
|
+
(q.answered_at ? "at " + fmtTime(q.answered_at) : "");
|
|
939
|
+
recorded.appendChild(meta);
|
|
940
|
+
card.appendChild(recorded);
|
|
941
|
+
} else {
|
|
942
|
+
if (q.options && q.options.length) {
|
|
943
|
+
var optsRow = document.createElement("div");
|
|
944
|
+
optsRow.className = "cp-question-options";
|
|
945
|
+
q.options.forEach(function (opt) {
|
|
946
|
+
var b = document.createElement("button");
|
|
947
|
+
b.className = "cp-question-option";
|
|
948
|
+
b.textContent = opt;
|
|
949
|
+
b.addEventListener("click", function () { submitAnswer(plan.slug, q.id, opt); });
|
|
950
|
+
optsRow.appendChild(b);
|
|
951
|
+
});
|
|
952
|
+
card.appendChild(optsRow);
|
|
953
|
+
}
|
|
954
|
+
var form = document.createElement("form");
|
|
955
|
+
form.className = "cp-question-answer-form";
|
|
956
|
+
var input = document.createElement("input");
|
|
957
|
+
input.className = "cp-question-answer-input";
|
|
958
|
+
input.placeholder = "Type an answer…";
|
|
959
|
+
input.required = true;
|
|
960
|
+
var submit = document.createElement("button");
|
|
961
|
+
submit.type = "submit";
|
|
962
|
+
submit.className = "cp-question-answer-submit";
|
|
963
|
+
submit.textContent = "Answer";
|
|
964
|
+
form.appendChild(input);
|
|
965
|
+
form.appendChild(submit);
|
|
966
|
+
form.addEventListener("submit", function (e) {
|
|
967
|
+
e.preventDefault();
|
|
968
|
+
if (!input.value.trim()) return;
|
|
969
|
+
submitAnswer(plan.slug, q.id, input.value.trim());
|
|
970
|
+
});
|
|
971
|
+
card.appendChild(form);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
els.questionsPanel.appendChild(card);
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function submitAnswer(slug, questionId, answer) {
|
|
979
|
+
v2Post(slug, "answers", { question_id: questionId, answer: answer }).then(function () {
|
|
980
|
+
showToast("Answer recorded.");
|
|
981
|
+
// SSE will fan out the plan upsert and re-render naturally.
|
|
982
|
+
}).catch(function (err) {
|
|
983
|
+
showToast("record_answer failed: " + err.message);
|
|
984
|
+
});
|
|
708
985
|
}
|
|
709
986
|
|
|
710
987
|
function selectPlan(slug) {
|
|
711
988
|
state.selectedSlug = slug;
|
|
989
|
+
if (window.history && window.history.replaceState) {
|
|
990
|
+
var url = window.location.pathname + "?plan=" + encodeURIComponent(slug);
|
|
991
|
+
window.history.replaceState(null, "", url);
|
|
992
|
+
}
|
|
993
|
+
// Tokens are stage+plan bound — switching plans should not retain
|
|
994
|
+
// tokens from elsewhere (they'd be rejected on the server anyway,
|
|
995
|
+
// but better not to surface a stale token to the operator).
|
|
996
|
+
Object.keys(stageTokenCache).forEach(function (k) {
|
|
997
|
+
if (k !== slug) delete stageTokenCache[k];
|
|
998
|
+
});
|
|
712
999
|
renderRail();
|
|
713
1000
|
renderDetail();
|
|
714
1001
|
}
|
|
@@ -831,6 +1118,42 @@
|
|
|
831
1118
|
if (state.selectedSlug === planSlug) renderDetail();
|
|
832
1119
|
}
|
|
833
1120
|
|
|
1121
|
+
// A lint event "needs attention" if its score fell below the recording
|
|
1122
|
+
// threshold (default 50 when no threshold is supplied) or it flagged any
|
|
1123
|
+
// missing tags, antipatterns, or ceremony. The badge surfaces that count.
|
|
1124
|
+
function lintNeedsAttention(e) {
|
|
1125
|
+
if (!e) return false;
|
|
1126
|
+
var threshold = typeof e.threshold === "number" ? e.threshold : 50;
|
|
1127
|
+
if (typeof e.score === "number" && e.score < threshold) return true;
|
|
1128
|
+
if (Array.isArray(e.missing) && e.missing.length) return true;
|
|
1129
|
+
if (Array.isArray(e.antipatterns) && e.antipatterns.length) return true;
|
|
1130
|
+
if (Array.isArray(e.ceremony) && e.ceremony.length) return true;
|
|
1131
|
+
return false;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function setLintsBadge(count) {
|
|
1135
|
+
if (!els.lintsBadge) return;
|
|
1136
|
+
if (count > 0) {
|
|
1137
|
+
els.lintsBadge.textContent = String(count);
|
|
1138
|
+
els.lintsBadge.hidden = false;
|
|
1139
|
+
} else {
|
|
1140
|
+
els.lintsBadge.hidden = true;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function loadLintsBadge() {
|
|
1145
|
+
return fetch("/api/prompt-lints")
|
|
1146
|
+
.then(function (r) { return r.json(); })
|
|
1147
|
+
.then(function (data) {
|
|
1148
|
+
var events = data && Array.isArray(data.events) ? data.events : [];
|
|
1149
|
+
setLintsBadge(events.filter(lintNeedsAttention).length);
|
|
1150
|
+
})
|
|
1151
|
+
.catch(function (err) {
|
|
1152
|
+
console.warn("[claude-plans] failed to load prompt lints:", err);
|
|
1153
|
+
setLintsBadge(0);
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
|
|
834
1157
|
async function loadInitial() {
|
|
835
1158
|
try {
|
|
836
1159
|
var res = await fetch("/api/claude-plans");
|
|
@@ -863,11 +1186,10 @@
|
|
|
863
1186
|
}));
|
|
864
1187
|
renderRail();
|
|
865
1188
|
if (!state.selectedSlug) {
|
|
866
|
-
var
|
|
867
|
-
var pathSlug = pathMatch && pathMatch[1];
|
|
1189
|
+
var paramSlug = new URLSearchParams(window.location.search).get("plan");
|
|
868
1190
|
var sorted = sortedPlans();
|
|
869
|
-
var target =
|
|
870
|
-
?
|
|
1191
|
+
var target = paramSlug && state.plans.has(paramSlug)
|
|
1192
|
+
? paramSlug
|
|
871
1193
|
: (sorted.length > 0 ? sorted[0].slug : null);
|
|
872
1194
|
if (target) selectPlan(target);
|
|
873
1195
|
}
|
|
@@ -941,6 +1263,7 @@
|
|
|
941
1263
|
setupSearch();
|
|
942
1264
|
setupTopics();
|
|
943
1265
|
setActiveTab("plan");
|
|
1266
|
+
loadLintsBadge();
|
|
944
1267
|
loadInitial().then(startStream);
|
|
945
1268
|
});
|
|
946
1269
|
})();
|
package/server.js
CHANGED
|
@@ -1038,10 +1038,16 @@ function startClaudePlanWatcher() {
|
|
|
1038
1038
|
claudePlanWatcher.on("unlink", function (p) { handleWatcherChange("unlink", p); });
|
|
1039
1039
|
}
|
|
1040
1040
|
|
|
1041
|
-
app.get(
|
|
1041
|
+
app.get("/claude-plans", function (req, res) {
|
|
1042
1042
|
res.sendFile(path.join(__dirname, "public", "claude-plans.html"));
|
|
1043
1043
|
});
|
|
1044
1044
|
|
|
1045
|
+
// Legacy path-segment deep links (/claude-plans/:slug) redirect to the
|
|
1046
|
+
// query-param form so relative assets resolve against /claude-plans.
|
|
1047
|
+
app.get("/claude-plans/:slug", function (req, res) {
|
|
1048
|
+
res.redirect(301, "/claude-plans?plan=" + encodeURIComponent(req.params.slug));
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1045
1051
|
app.get("/prompt-lints", function (req, res) {
|
|
1046
1052
|
res.sendFile(path.join(__dirname, "public", "prompt-lints.html"));
|
|
1047
1053
|
});
|
|
@@ -1112,6 +1118,101 @@ app.get("/api/claude-plans/:slug", function (req, res) {
|
|
|
1112
1118
|
}
|
|
1113
1119
|
});
|
|
1114
1120
|
|
|
1121
|
+
// --- v2 bidirectional pipeline write routes (Phase E) ---
|
|
1122
|
+
// All three call into @tenonhq/dovetail-claude-plans' storage layer so
|
|
1123
|
+
// the state machine, conflict rule, and token lifecycle are enforced in
|
|
1124
|
+
// exactly one place. Errors surface their typed `code` and `name` to the
|
|
1125
|
+
// client so the dashboard can branch on them.
|
|
1126
|
+
const claudePlansLib = require("@tenonhq/dovetail-claude-plans/dist/storage");
|
|
1127
|
+
|
|
1128
|
+
function sendTypedError(res, err) {
|
|
1129
|
+
// ZodError (input validation) is a 400. Storage's typed errors expose
|
|
1130
|
+
// a `code` (e.g. ILLEGAL_TRANSITION, MISSING_AGENT). Anything else is
|
|
1131
|
+
// a 500 with the message.
|
|
1132
|
+
if (err && err.name === "ZodError") {
|
|
1133
|
+
return res.status(400).json({ error: "validation_failed", details: err.issues });
|
|
1134
|
+
}
|
|
1135
|
+
if (err && typeof err.code === "string") {
|
|
1136
|
+
var status = 409;
|
|
1137
|
+
if (err.code === "MISSING_AGENT") status = 424;
|
|
1138
|
+
if (err.code === "NO_TOKEN") status = 400;
|
|
1139
|
+
if (err.code === "STALE_TOKEN") status = 410;
|
|
1140
|
+
if (err.code === "SPAWN_ERROR") status = 500;
|
|
1141
|
+
return res.status(status).json({
|
|
1142
|
+
error: err.code,
|
|
1143
|
+
name: err.name,
|
|
1144
|
+
message: err.message
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
if (err && /plan not found/.test(err.message || "")) {
|
|
1148
|
+
return res.status(404).json({ error: "plan_not_found", message: err.message });
|
|
1149
|
+
}
|
|
1150
|
+
return res.status(500).json({ error: "internal", message: (err && err.message) || String(err) });
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// POST /api/claude-plans/:slug/answers — record an answer to a question.
|
|
1154
|
+
// Body: { question_id, answer, answered_by? }
|
|
1155
|
+
app.post("/api/claude-plans/:slug/answers", claudePlansLimiter, express.json(), function (req, res) {
|
|
1156
|
+
try {
|
|
1157
|
+
var slug = req.params.slug;
|
|
1158
|
+
if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
|
|
1159
|
+
var body = req.body || {};
|
|
1160
|
+
var result = claudePlansLib.recordAnswer({
|
|
1161
|
+
plan_slug: slug,
|
|
1162
|
+
question_id: body.question_id,
|
|
1163
|
+
answer: body.answer,
|
|
1164
|
+
answered_by: body.answered_by || "dashboard"
|
|
1165
|
+
});
|
|
1166
|
+
res.json(result);
|
|
1167
|
+
} catch (e) {
|
|
1168
|
+
sendTypedError(res, e);
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
// POST /api/claude-plans/:slug/stage — move the plan to a new stage.
|
|
1173
|
+
// Body: { to: PipelineStage, by? }
|
|
1174
|
+
// Source is forced to 'dashboard' so the conflict-resolution rule
|
|
1175
|
+
// (docs/v2-design.md §4) treats dashboard moves as authoritative.
|
|
1176
|
+
app.post("/api/claude-plans/:slug/stage", claudePlansLimiter, express.json(), function (req, res) {
|
|
1177
|
+
try {
|
|
1178
|
+
var slug = req.params.slug;
|
|
1179
|
+
if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
|
|
1180
|
+
var body = req.body || {};
|
|
1181
|
+
var result = claudePlansLib.setStage({
|
|
1182
|
+
plan_slug: slug,
|
|
1183
|
+
to: body.to,
|
|
1184
|
+
by: body.by || "dashboard",
|
|
1185
|
+
source: "dashboard"
|
|
1186
|
+
});
|
|
1187
|
+
res.json(result);
|
|
1188
|
+
} catch (e) {
|
|
1189
|
+
sendTypedError(res, e);
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
// POST /api/claude-plans/:slug/dispatch — dry-run or live dispatch.
|
|
1194
|
+
// Body: { target_stage, confirm?, token?, by? }
|
|
1195
|
+
// The dashboard's flow is: POST without confirm (dry-run) → show
|
|
1196
|
+
// resolved command in the UI → POST with confirm:true + token from
|
|
1197
|
+
// set_stage response → live spawn.
|
|
1198
|
+
app.post("/api/claude-plans/:slug/dispatch", claudePlansLimiter, express.json(), function (req, res) {
|
|
1199
|
+
try {
|
|
1200
|
+
var slug = req.params.slug;
|
|
1201
|
+
if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
|
|
1202
|
+
var body = req.body || {};
|
|
1203
|
+
var result = claudePlansLib.dispatchStage({
|
|
1204
|
+
plan_slug: slug,
|
|
1205
|
+
target_stage: body.target_stage,
|
|
1206
|
+
confirm: body.confirm === true,
|
|
1207
|
+
token: body.token,
|
|
1208
|
+
by: body.by || "dashboard"
|
|
1209
|
+
});
|
|
1210
|
+
res.json(result);
|
|
1211
|
+
} catch (e) {
|
|
1212
|
+
sendTypedError(res, e);
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1115
1216
|
app.delete("/api/claude-plans/:slug", claudePlansLimiter, function (req, res) {
|
|
1116
1217
|
try {
|
|
1117
1218
|
var slug = req.params.slug;
|