@tenonhq/dovetail-dashboard 0.0.29 → 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.29",
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",
@@ -1311,3 +1311,175 @@
1311
1311
  .cp-topic-chip--active .cp-topic-chip-count {
1312
1312
  background: color-mix(in srgb, var(--accent-fg) 20%, transparent);
1313
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
+ }
@@ -104,6 +104,9 @@
104
104
  <span class="cp-status-pill" id="cp-detail-status"></span>
105
105
  <span class="cp-stamp" id="cp-detail-stamp"></span>
106
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>
107
110
  <nav class="cp-tabs">
108
111
  <button class="cp-tab active" data-tab="plan">Plan</button>
109
112
  <button class="cp-tab" data-tab="prompts">
@@ -112,10 +115,14 @@
112
115
  <button class="cp-tab" data-tab="artifacts">
113
116
  Artifacts <span class="cp-tab-count" id="cp-artifact-count">0</span>
114
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>
115
121
  </nav>
116
122
  <div class="cp-tab-panel" id="cp-tab-plan"></div>
117
123
  <div class="cp-tab-panel" id="cp-tab-prompts" hidden></div>
118
124
  <div class="cp-tab-panel" id="cp-tab-artifacts" hidden></div>
125
+ <div class="cp-tab-panel" id="cp-tab-questions" hidden></div>
119
126
  </article>
120
127
  </section>
121
128
  </main>
@@ -60,6 +60,9 @@
60
60
  planPanel: document.getElementById("cp-tab-plan"),
61
61
  artifactsPanel: document.getElementById("cp-tab-artifacts"),
62
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"),
63
66
  tabs: document.querySelectorAll(".cp-tab"),
64
67
  topics: document.getElementById("cp-topics"),
65
68
  topicsCloud: document.getElementById("cp-topics-cloud"),
@@ -592,6 +595,10 @@
592
595
  renderMarkdown(plan.content_md, els.planPanel);
593
596
  }
594
597
 
598
+ // v2 surfaces — Stage Map strip + Questions tab.
599
+ renderStageMap(plan);
600
+ renderQuestions(plan);
601
+
595
602
  addPlanSectionCopyBtns();
596
603
  addTabsCopyAll(plan, artifacts, prompts);
597
604
 
@@ -706,6 +713,275 @@
706
713
  els.planPanel.hidden = tab !== "plan";
707
714
  els.artifactsPanel.hidden = tab !== "artifacts";
708
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
+ });
709
985
  }
710
986
 
711
987
  function selectPlan(slug) {
@@ -714,6 +990,12 @@
714
990
  var url = window.location.pathname + "?plan=" + encodeURIComponent(slug);
715
991
  window.history.replaceState(null, "", url);
716
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
+ });
717
999
  renderRail();
718
1000
  renderDetail();
719
1001
  }
package/server.js CHANGED
@@ -1118,6 +1118,101 @@ app.get("/api/claude-plans/:slug", function (req, res) {
1118
1118
  }
1119
1119
  });
1120
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
+
1121
1216
  app.delete("/api/claude-plans/:slug", claudePlansLimiter, function (req, res) {
1122
1217
  try {
1123
1218
  var slug = req.params.slug;