From 6d6f4bcc7e075cf1c82f8c8b237b772d040c5abb Mon Sep 17 00:00:00 2001 From: Keysat Date: Fri, 5 Jun 2026 18:29:47 -0500 Subject: [PATCH] Thesis Workshop redesign: edit/choose/delete + approve-as-current (v0.1.0:56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Grant's feedback that the Workshop was confusing and underbuilt (no delete, no approve, redundant generate-vs-feedback panels, and a stray "0" on segment lines). Backend (architect_tools.py + server.py routes/handlers): - retire_node: soft-delete a node + its subtree (reversible). DELETE /api/thesis/nodes/{id}. - choose_variant: 'Use this' — keep this option, soft-delete the others in its group, mark it approved. POST /api/thesis/nodes/{id}/choose. - upsert_thesis_node gains actor_type so a manual human edit is recorded as 'human'. PUT /api/thesis/nodes/{id} edits a part's text directly. - handle_approve_line: one-click 'approve as current' — records this admin's approval on the line's in-review version (creating + submitting one from the live tree if none), promoting to canonical at the required distinct-approval count. POST /api/thesis/lines/{key}/approve. Frontend (ThesisWorkshop redesign): - Merged the redundant "Generate options" + "Give feedback" panels into one "Ask the Architect for options" box (revise was just generate-with-guidance). - Per option: Use this / Edit (inline) / Delete. Per part: edit + delete via the same. - "Approve as current" bar with dual-sign-off state + a "Current ✓" badge, and a one-line "how it works" hint. Refreshes the tree after every action. - Fixed the stray "0": `{line.is_core && }` rendered 0 for non-core lines (SQLite integer 0); now `{!!line.is_core && ...}`. Verified: backend test_thesis_actions.py (choose/edit/retire-subtree/dual-approval->canonical), and a live in-browser smoke test (JSX compiles, Workshop renders, options show Use/Edit/Delete, approve returns 1-of-2, no runtime errors). Co-Authored-By: Claude Opus 4.8 --- backend/mcp/architect_tools.py | 48 ++++- backend/server.py | 75 +++++++ backend/test_thesis_actions.py | 85 ++++++++ frontend/index.html | 261 ++++++++++++++--------- start9/0.4/startos/utils.ts | 5 +- start9/0.4/startos/versions/index.ts | 5 +- start9/0.4/startos/versions/v0.1.0.56.ts | 20 ++ 7 files changed, 389 insertions(+), 110 deletions(-) create mode 100644 backend/test_thesis_actions.py create mode 100644 start9/0.4/startos/versions/v0.1.0.56.ts diff --git a/backend/mcp/architect_tools.py b/backend/mcp/architect_tools.py index 949e0e8..b049dd6 100644 --- a/backend/mcp/architect_tools.py +++ b/backend/mcp/architect_tools.py @@ -192,7 +192,7 @@ def create_thesis_line(line_key, name, segment_key=None, is_core=False, descript def upsert_thesis_node(line_id, node_type, body, title=None, parent_id=None, ord=None, variant_group=None, node_id=None, change_reason=None, change_summary=None, - actor_id="architect", claude_session_id=None, meta=None, db=None): + actor_id="architect", actor_type="agent", claude_session_id=None, meta=None, db=None): """Create or edit a node. On edit, the prior state is written to thesis_node_revisions before the live row changes (full provenance).""" c = _conn(db) @@ -209,7 +209,7 @@ def upsert_thesis_node(line_id, node_type, body, title=None, parent_id=None, ord VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (str(uuid.uuid4()), node_id, prev["line_id"], rev_no, prev["node_type"], prev["title"], prev["body"], prev["status"], prev["ord"], prev["variant_group"], prev["meta"], change_summary, change_reason, - "agent", actor_id, claude_session_id, _now())) + actor_type, actor_id, claude_session_id, _now())) c.execute("""UPDATE thesis_nodes SET node_type=?, title=COALESCE(?,title), body=?, ord=COALESCE(?,ord), variant_group=?, meta=COALESCE(?,meta), updated_at=? WHERE id=?""", (node_type, title, body, ord, variant_group, json.dumps(meta) if meta else None, _now(), node_id)) @@ -280,6 +280,50 @@ def submit_version_for_review(version_id, db=None): return {"version_id": version_id, "status": "in_review"} +def retire_node(node_id, actor_id="human", db=None): + """Soft-delete a node and its whole subtree (deleted_at). Reversible (guardrail #3); + the node + descendants drop out of the tree but the rows + revision history remain.""" + c = _conn(db) + row = c.execute("SELECT id FROM thesis_nodes WHERE id=? AND deleted_at IS NULL", (node_id,)).fetchone() + if not row: + c.close() + return {"error": "not_found", "node_id": node_id} + ids, frontier = [node_id], [node_id] + while frontier: + nxt = [] + for pid in frontier: + for r in c.execute("SELECT id FROM thesis_nodes WHERE parent_id=? AND deleted_at IS NULL", (pid,)): + ids.append(r["id"]); nxt.append(r["id"]) + frontier = nxt + for nid in ids: + c.execute("UPDATE thesis_nodes SET deleted_at=?, updated_at=? WHERE id=?", (_now(), _now(), nid)) + _log(c, "thesis.node_retired", node_id, {"count": len(ids)}, actor_id=actor_id, actor_type="human") + c.commit() + c.close() + return {"retired": node_id, "count": len(ids)} + + +def choose_variant(node_id, actor_id="human", db=None): + """'Use this option': keep this variant, soft-delete the OTHER variants in its group, + and mark it approved. Collapses a node's competing options down to the chosen one.""" + c = _conn(db) + node = c.execute("SELECT id, variant_group FROM thesis_nodes WHERE id=? AND deleted_at IS NULL", (node_id,)).fetchone() + if not node: + c.close() + return {"error": "not_found", "node_id": node_id} + retired = [] + if node["variant_group"]: + for r in c.execute("SELECT id FROM thesis_nodes WHERE variant_group=? AND id<>? AND deleted_at IS NULL", + (node["variant_group"], node_id)): + c.execute("UPDATE thesis_nodes SET deleted_at=?, updated_at=? WHERE id=?", (_now(), _now(), r["id"])) + retired.append(r["id"]) + c.execute("UPDATE thesis_nodes SET status='approved', updated_at=? WHERE id=?", (_now(), node_id)) + _log(c, "thesis.variant_chosen", node_id, {"retired_siblings": len(retired)}, actor_id=actor_id, actor_type="human") + c.commit() + c.close() + return {"chosen": node_id, "retired_siblings": len(retired)} + + def upsert_segment(segment_key, name, definition=None, needs_to_hear=None, avoid=None, db=None): """Create/replace a segment's active definition (retire the prior active row).""" c = _conn(db) diff --git a/backend/server.py b/backend/server.py index 5ae83f3..9c0d6fe 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1898,6 +1898,10 @@ class CRMHandler(BaseHTTPRequestHandler): return self.handle_node_feedback(user, path.split('/')[-2], body) if path == '/api/architect/ground': return self.handle_architect_ground(user, body) + if re.match(r'^/api/thesis/nodes/[^/]+/choose$', path): + return self.handle_choose_variant(user, path.split('/')[-2]) + if re.match(r'^/api/thesis/lines/[^/]+/approve$', path): + return self.handle_approve_line(user, path.split('/')[-2]) if path == '/api/thesis/lines': return self.handle_create_thesis_line(user, body) if re.match(r'^/api/thesis/lines/[^/]+/nodes$', path): @@ -1938,6 +1942,8 @@ class CRMHandler(BaseHTTPRequestHandler): return self.handle_update_lp_profile(user, path.split('/')[-1], body) if path == '/api/fundraising/state': return self.handle_update_fundraising_state(user, body) + if re.match(r'^/api/thesis/nodes/[^/]+$', path): + return self.handle_edit_thesis_node(user, path.split('/')[-1], body) self.send_error_json("Not found", 404) @@ -1987,6 +1993,8 @@ class CRMHandler(BaseHTTPRequestHandler): return self.handle_delete_opportunity(user, path.split('/')[-1]) if re.match(r'^/api/communications/[^/]+$', path): return self.handle_delete_communication(user, path.split('/')[-1]) + if re.match(r'^/api/thesis/nodes/[^/]+$', path): + return self.handle_retire_thesis_node(user, path.split('/')[-1]) self.send_error_json("Not found", 404) # ═══════════════════════════════════════════════════════════════════════════ @@ -3669,6 +3677,73 @@ class CRMHandler(BaseHTTPRequestHandler): conn.close() return row['line_key'] if row else None + def handle_edit_thesis_node(self, user, node_id, body): + """Manual human edit of a node's title/body (no Architect).""" + if not require_admin(user): + return self.send_error_json("Admin required", 403) + if _architect_tools is None: + return self.send_error_json("Unavailable", 503) + node = _architect_tools.get_node(node_id, db=DB_PATH) + if node.get('error'): + return self.send_error_json("Node not found", 404) + body = body or {} + res = _architect_tools.upsert_thesis_node( + node['line_id'], node['node_type'], body.get('body', node.get('body') or ''), + title=body.get('title', node.get('title')), node_id=node_id, + change_reason='manual edit', actor_id=user['user_id'], actor_type='human', db=DB_PATH) + return self.send_json({"data": res}) + + def handle_retire_thesis_node(self, user, node_id): + """Soft-delete a node + its subtree.""" + if not require_admin(user): + return self.send_error_json("Admin required", 403) + if _architect_tools is None: + return self.send_error_json("Unavailable", 503) + res = _architect_tools.retire_node(node_id, actor_id=user['user_id'], db=DB_PATH) + if res.get('error'): + return self.send_error_json("Node not found", 404) + return self.send_json({"data": res}) + + def handle_choose_variant(self, user, node_id): + """'Use this option' — keep this variant, retire its siblings.""" + if not require_admin(user): + return self.send_error_json("Admin required", 403) + if _architect_tools is None: + return self.send_error_json("Unavailable", 503) + res = _architect_tools.choose_variant(node_id, actor_id=user['user_id'], db=DB_PATH) + if res.get('error'): + return self.send_error_json("Node not found", 404) + return self.send_json({"data": res}) + + def handle_approve_line(self, user, line_key): + """One-click 'approve as current': record this admin's approval on the line's + in-review version (creating + submitting one from the live tree if none exists); + promotes to canonical once the required distinct approvals are reached.""" + if not require_admin(user): + return self.send_error_json("Admin required", 403) + if _architect_tools is None or thesis_review is None: + return self.send_error_json("Unavailable", 503) + conn = get_db() + line = conn.execute("SELECT id FROM thesis_lines WHERE line_key=? AND deleted_at IS NULL", (line_key,)).fetchone() + v = None + if line: + v = conn.execute("SELECT id FROM thesis_versions WHERE line_id=? AND status='in_review' ORDER BY version_no DESC LIMIT 1", + (line['id'],)).fetchone() + conn.close() + if not line: + return self.send_error_json("Line not found", 404) + if v: + version_id = v['id'] + else: + ver = _architect_tools.create_thesis_version(line_key, rationale="Approved as current (Workshop)", + created_by=user['user_id'], db=DB_PATH) + if ver.get('error'): + return self.send_error_json(ver['error'], 400) + _architect_tools.submit_version_for_review(ver['id'], db=DB_PATH) + version_id = ver['id'] + res = thesis_review.record_review(DB_PATH, version_id, user['user_id'], 'approve') + return self.send_json({"data": res}) + def handle_generate_options(self, user, node_id, body): if not require_admin(user): return self.send_error_json("Admin required", 403) diff --git a/backend/test_thesis_actions.py b/backend/test_thesis_actions.py new file mode 100644 index 0000000..b123f2e --- /dev/null +++ b/backend/test_thesis_actions.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Backend test for the Workshop's new thesis actions: edit, retire-subtree, +choose-variant, and one-click dual-approval -> canonical. Runs init_db (which seeds the +v5 thesis) against a throwaway DB, then exercises the architect_tools + thesis_review +functions the new routes call. Offline + synthetic. + +Run: cd backend && python3 test_thesis_actions.py +""" +import os +import sqlite3 +import sys +import tempfile + +_tmp = tempfile.mkdtemp() +os.environ["CRM_DATA_DIR"] = _tmp +os.environ["CRM_DB_PATH"] = os.path.join(_tmp, "crm.db") +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import server # noqa: E402 (the CRM server; it imports architect_tools + thesis_review for us) +at = server._architect_tools +thesis_review = server.thesis_review + +FAILS = [] + + +def check(cond, msg): + print((" PASS " if cond else " FAIL ") + msg) + if not cond: + FAILS.append(msg) + + +def main(): + server.init_db() + db = server.DB_PATH + c = sqlite3.connect(db) + c.row_factory = sqlite3.Row + + # choose-variant: pick Option A in the positioning group -> Option B retired + opts = c.execute("SELECT id, title FROM thesis_nodes WHERE variant_group='positioning' AND deleted_at IS NULL").fetchall() + check(len(opts) == 2, f"positioning starts with 2 options (got {len(opts)})") + a = next(o for o in opts if "Option A" in (o["title"] or "")) + res = at.choose_variant(a["id"], db=db) + check(res.get("retired_siblings") == 1, "choose_variant retired the 1 sibling") + live = c.execute("SELECT COUNT(*) FROM thesis_nodes WHERE variant_group='positioning' AND deleted_at IS NULL").fetchone()[0] + check(live == 1, f"positioning now has 1 live option (got {live})") + check(c.execute("SELECT status FROM thesis_nodes WHERE id=?", (a["id"],)).fetchone()[0] == "approved", + "chosen option marked approved") + + # edit: manual human edit updates body + writes a revision + thr = c.execute("SELECT id FROM thesis_nodes WHERE node_type='throughline' AND deleted_at IS NULL").fetchone() + at.upsert_thesis_node(None, "throughline", "EDITED THROUGHLINE.", node_id=thr["id"], actor_type="human", + change_reason="manual edit", actor_id="u1", db=db) + check(c.execute("SELECT body FROM thesis_nodes WHERE id=?", (thr["id"],)).fetchone()[0] == "EDITED THROUGHLINE.", + "manual edit updated the node body") + rev = c.execute("SELECT actor_type FROM thesis_node_revisions WHERE node_id=? ORDER BY rev_no DESC LIMIT 1", (thr["id"],)).fetchone() + check(rev and rev[0] == "human", "edit recorded a 'human' revision") + + # retire-subtree: retiring the Pillars section drops it + its 3 pillar claims + sec = c.execute("SELECT id FROM thesis_nodes WHERE node_type='section' AND title='Pillars' AND deleted_at IS NULL").fetchone() + kids = c.execute("SELECT COUNT(*) FROM thesis_nodes WHERE parent_id=? AND deleted_at IS NULL", (sec["id"],)).fetchone()[0] + res = at.retire_node(sec["id"], db=db) + check(res.get("count") == kids + 1, f"retire_node soft-deleted the section + {kids} children (got {res.get('count')})") + check(c.execute("SELECT deleted_at FROM thesis_nodes WHERE id=?", (sec["id"],)).fetchone()[0] is not None, + "section is soft-deleted") + + # one-click dual approval -> canonical (two distinct admins) + ver = at.create_thesis_version("core", created_by="u1", db=db) + at.submit_version_for_review(ver["id"], db=db) + r1 = thesis_review.record_review(db, ver["id"], "u1", "approve") + check(r1["approvals"] == 1 and not r1["promoted_to_canonical"], "1st approval does not promote") + r2 = thesis_review.record_review(db, ver["id"], "u2", "approve") + check(r2["approvals"] == 2 and r2["promoted_to_canonical"], "2nd distinct approval promotes to canonical") + check(thesis_review.get_canonical(db, "core").get("status") == "ok", "core line now has a canonical version") + # a 2nd approval from the SAME admin would NOT have promoted (distinct approvers only) — covered by record_review + + c.close() + print() + if FAILS: + print(f"FAILED ({len(FAILS)})") + sys.exit(1) + print("ALL PASS (thesis workshop actions)") + + +if __name__ == "__main__": + main() diff --git a/frontend/index.html b/frontend/index.html index f963658..f2a2618 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9228,7 +9228,7 @@
{line.name} - {line.is_core && Core} + {!!line.is_core && Core}
{line.line_key}{line.segment_key ? ` · ${line.segment_key}` : ''}
@@ -9318,7 +9318,7 @@ // Renders a single node's competing options (its variant group). The // number of options is whatever the server returns — could be 0, 1, or // many — and is rendered as a dynamic list, never a fixed A/B layout. - const ThesisWorkshopOptions = ({ token, node, lineKey, isAdmin, architectReady, onShowToast }) => { + const ThesisWorkshopOptions = ({ token, node, lineKey, isAdmin, architectReady, onShowToast, onTreeChanged }) => { const nodeId = node.id; const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -9326,9 +9326,9 @@ const [genN, setGenN] = useState(3); const [guidance, setGuidance] = useState(''); const [generating, setGenerating] = useState(false); - const [feedbackN, setFeedbackN] = useState(2); - const [feedback, setFeedback] = useState(''); - const [sendingFeedback, setSendingFeedback] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editText, setEditText] = useState(''); + const [busyId, setBusyId] = useState(null); const load = useCallback(async () => { try { @@ -9356,9 +9356,9 @@ body: JSON.stringify({ n, guidance: guidance.trim() }) }, token); const count = result?.data?.generated ?? (result?.data?.options || []).length; - onShowToast(`Generated ${count} option${count === 1 ? '' : 's'}`, 'success'); + onShowToast(`The Architect drafted ${count} option${count === 1 ? '' : 's'}`, 'success'); setGuidance(''); - await load(); + await refresh(); } catch (err) { if (isArchitectMissingKeyError(err)) { onShowToast(ARCHITECT_CONNECT_HINT, 'error'); @@ -9370,72 +9370,98 @@ } }; - const handleFeedback = async (e) => { - e.preventDefault(); - if (!isAdmin || sendingFeedback) return; - if (!feedback.trim()) { - onShowToast('Add some feedback for the Architect to address', 'error'); - return; - } - const n = Math.max(1, Math.min(8, parseInt(feedbackN, 10) || 1)); + const refresh = async () => { await load(); if (onTreeChanged) onTreeChanged(); }; + + const chooseOption = async (optId) => { + if (busyId) return; try { - setSendingFeedback(true); - const result = await api(`/api/thesis/nodes/${nodeId}/feedback`, { - method: 'POST', - body: JSON.stringify({ feedback: feedback.trim(), n }) - }, token); - const count = result?.data?.generated ?? (result?.data?.options || []).length; - onShowToast(`Revised — ${count} new option${count === 1 ? '' : 's'}`, 'success'); - setFeedback(''); - await load(); - } catch (err) { - if (isArchitectMissingKeyError(err)) { - onShowToast(ARCHITECT_CONNECT_HINT, 'error'); - } else { - onShowToast(getErrorMessage(err, 'Failed to send feedback'), 'error'); - } - } finally { - setSendingFeedback(false); - } + setBusyId(optId); + await api(`/api/thesis/nodes/${optId}/choose`, { method: 'POST' }, token); + onShowToast('Set as the chosen wording', 'success'); + await refresh(); + } catch (err) { onShowToast(getErrorMessage(err, 'Could not choose this option'), 'error'); } + finally { setBusyId(null); } + }; + + const deleteOption = async (optId) => { + if (busyId) return; + if (!window.confirm('Delete this and anything under it? This can be undone by an admin.')) return; + try { + setBusyId(optId); + await api(`/api/thesis/nodes/${optId}`, { method: 'DELETE' }, token); + onShowToast('Deleted', 'success'); + await refresh(); + } catch (err) { onShowToast(getErrorMessage(err, 'Could not delete'), 'error'); } + finally { setBusyId(null); } + }; + + const saveEdit = async (optId) => { + if (busyId) return; + try { + setBusyId(optId); + await api(`/api/thesis/nodes/${optId}`, { method: 'PUT', body: JSON.stringify({ body: editText }) }, token); + setEditingId(null); + onShowToast('Saved', 'success'); + await refresh(); + } catch (err) { onShowToast(getErrorMessage(err, 'Could not save'), 'error'); } + finally { setBusyId(null); } }; if (loading) return
; if (error) return
{error}
; const options = Array.isArray(data?.variants) ? data.variants : []; - const busy = generating || sendingFeedback; + const multi = options.length > 1; + const busy = generating; return (
{options.length === 0 ? (
- No options yet for this part. - {architectReady ? ' Generate some below.' : ' Connect the Architect to generate drafts, or edit the thesis directly.'} + No wording yet for this part.{isAdmin && architectReady ? ' Ask the Architect below, or write your own.' : ''}
) : (
{options.map((opt, i) => { const meta = opt.meta || {}; const rationale = meta.rationale || meta.reason || meta.notes || ''; - const text = opt.body || opt.title || ''; + const editing = editingId === opt.id; + const oBusy = busyId === opt.id; return (
- Option {i + 1} of {options.length} + {multi ? `Option ${i + 1} of ${options.length}` : 'Current wording'} {opt.status && ( {formatThesisStatus(opt.status)} )}
- {opt.title && opt.body && opt.title !== opt.body && ( -
{opt.title}
+ {editing ? ( +