@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 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.28",
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",
@@ -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
+ }
@@ -30,7 +30,9 @@
30
30
  </div>
31
31
  <div class="header-right">
32
32
  <a class="cp-nav-link" href="/">&larr; Update Sets</a>
33
- <a class="cp-nav-link" href="/prompt-lints">Prompt Lints &rarr;</a>
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
+ &rarr;</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" open>
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&amp;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>
@@ -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
- if (stored === "1") els.topics.open = false;
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 &rarr; " + 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, "&amp;")
889
+ .replace(/</g, "&lt;")
890
+ .replace(/>/g, "&gt;");
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 pathMatch = window.location.pathname.match(/^\/claude-plans\/([a-z0-9][a-z0-9-]{0,63})$/);
867
- var pathSlug = pathMatch && pathMatch[1];
1189
+ var paramSlug = new URLSearchParams(window.location.search).get("plan");
868
1190
  var sorted = sortedPlans();
869
- var target = pathSlug && state.plans.has(pathSlug)
870
- ? pathSlug
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(["/claude-plans", "/claude-plans/:slug"], function (req, res) {
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;