From e00be74c8b575c725829677aadeb755ee81454d0 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Fri, 8 May 2026 11:29:56 +0100
Subject: feat(directives): unified contracts surface — backbone (#128)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

This is the backbone PR for the unified directive workflow. A directive
holds a sequence of contracts; each contract is a spec body whose
execution drives tasks in the directive's shared worktree. Lifecycle
(Lock & Start, queue scheduler, drag-reorder) lands in follow-ups.

What's in this PR:
- Migration adds `position` (queue order) and `merge_mode`
  (shared|own_pr) columns to directive_documents. The actual table
  rename is deferred — the legacy `contracts` table from the old
  contracts system still exists, and the rename collision waits for
  Phase 5 to drop legacy contracts.
- Repository: list orders by position; create assigns next-position;
  update accepts merge_mode; new reorder_directive_document_position
  shifts siblings inside a transaction.
- HTTP: endpoints aliased under /api/v1/directives/{id}/contracts and
  /api/v1/contracts/{id}/... with a new /contracts/{id}/reorder.
- Frontend: api types renamed `DirectiveContract*` (avoiding the
  legacy `Contract` type collision); document-directives.tsx imports
  via aliases so the rest of the file is untouched.

Internal struct + table names stay `DirectiveDocument` /
`directive_documents` until the legacy contracts cleanup.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 makima/frontend/src/routes/document-directives.tsx | 56 +++++++++++-----------
 1 file changed, 28 insertions(+), 28 deletions(-)

(limited to 'makima/frontend/src/routes/document-directives.tsx')

diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index 1714aed..d589dac 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -7,16 +7,16 @@ import { DocumentEditor } from "../components/directives/DocumentEditor";
 import {
   type DirectiveSummary,
   type DirectiveStatus,
-  type DirectiveDocument,
-  type DirectiveDocumentStatus,
+  type DirectiveContract as Contract,
+  type DirectiveContractStatus as ContractStatus,
   type DirectiveStep,
   type Task,
-  type DocumentTasksResponse,
-  listDirectiveDocuments,
-  createDirectiveDocument,
-  getDirectiveDocument,
-  updateDirectiveDocument,
-  listDirectiveDocumentTasks,
+  type DirectiveContractTasksResponse as ContractTasksResponse,
+  listDirectiveContracts as listContracts,
+  createDirectiveContract as createContract,
+  getDirectiveContract as getContract,
+  updateDirectiveContract as updateContract,
+  listDirectiveContractTasks as listContractTasks,
   createDirectiveTask,
   startDirective,
   pauseDirective,
@@ -43,7 +43,7 @@ const STATUS_DOT: Record<DirectiveStatus, string> = {
 
 // Per-document status palette. Active/draft documents use the same bright
 // green-ish accent as a running directive; shipped/archived use a muted blue.
-const DOC_STATUS_DOT: Record<DirectiveDocumentStatus, string> = {
+const DOC_STATUS_DOT: Record<ContractStatus, string> = {
   draft: "bg-[#556677]",
   active: "bg-green-400",
   shipped: "bg-[#75aafc]",
@@ -66,7 +66,7 @@ const DOC_STATUS_DOT: Record<DirectiveDocumentStatus, string> = {
 // rather than just an id slice). Accepts either a DirectiveSummary or a full
 // DirectiveWithSteps — only `title` is read.
 function fileLabel(
-  doc: DirectiveDocument,
+  doc: Contract,
   directive: { title: string },
 ): string {
   const docTitle = doc.title.trim();
@@ -172,7 +172,7 @@ interface DirectiveFolderProps {
   /** Called when the user clicks the folder header itself (after toggle). */
   onHeaderClick: () => void;
   selection: SidebarSelection | null;
-  onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void;
+  onSelectDocument: (directiveId: string, doc: Contract) => void;
   onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
   /** Open the inline "+ New ephemeral task" form for this directive. */
   onCreateEphemeralTask: (directive: DirectiveSummary) => void;
@@ -210,7 +210,7 @@ function DirectiveFolder({
 
   // Documents fetched lazily on open. We deliberately scope the fetch to the
   // open-state so closed folders don't pay the network cost on initial render.
-  const [docs, setDocs] = useState<DirectiveDocument[] | null>(null);
+  const [docs, setDocs] = useState<Contract[] | null>(null);
   const [docsLoading, setDocsLoading] = useState(false);
   const [docsError, setDocsError] = useState<string | null>(null);
 
@@ -224,7 +224,7 @@ function DirectiveFolder({
     setDocsLoading(true);
     setDocsError(null);
     try {
-      const list = await listDirectiveDocuments(directive.id);
+      const list = await listContracts(directive.id);
       setDocs(list);
     } catch (e) {
       setDocsError(e instanceof Error ? e.message : "Failed to load documents");
@@ -242,8 +242,8 @@ function DirectiveFolder({
   // Split the documents into the two visual groups. Memoised so we don't
   // recompute on every render.
   const { activeDocs, shippedDocs } = useMemo(() => {
-    const active: DirectiveDocument[] = [];
-    const shipped: DirectiveDocument[] = [];
+    const active: Contract[] = [];
+    const shipped: Contract[] = [];
     for (const d of docs ?? []) {
       if (d.status === "shipped" || d.status === "archived") {
         shipped.push(d);
@@ -447,7 +447,7 @@ function DirectiveFolder({
 // =============================================================================
 
 interface DocumentRowProps {
-  doc: DirectiveDocument;
+  doc: Contract;
   directive: DirectiveSummary;
   selected: boolean;
   onSelect: () => void;
@@ -547,7 +547,7 @@ function DocumentTasksFolder({
   onSelectTask,
 }: DocumentTasksFolderProps) {
   const [open, setOpen] = useState(defaultOpen);
-  const [data, setData] = useState<DocumentTasksResponse | null>(null);
+  const [data, setData] = useState<ContractTasksResponse | null>(null);
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);
 
@@ -561,7 +561,7 @@ function DocumentTasksFolder({
     setLoading(true);
     setError(null);
     try {
-      const res = await listDirectiveDocumentTasks(documentId);
+      const res = await listContractTasks(documentId);
       setData(res);
     } catch (e) {
       setError(e instanceof Error ? e.message : "Failed to load tasks");
@@ -770,7 +770,7 @@ interface SidebarProps {
   directives: DirectiveSummary[];
   loading: boolean;
   selection: SidebarSelection | null;
-  onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void;
+  onSelectDocument: (directiveId: string, doc: Contract) => void;
   onSelectDirective: (directiveId: string) => void;
   onCreateDocument: (directive: DirectiveSummary) => Promise<void>;
   onCreateContract: () => void;
@@ -894,8 +894,8 @@ function DocumentSidebar({
 // =============================================================================
 // Editor shell — wraps DocumentEditor and handles the "no document selected"
 // and loading states. Two modes:
-//   1) documentId selected → fetch the DirectiveDocument and edit doc.body via
-//      updateDirectiveDocument (the call that auto-reactivates a shipped doc).
+//   1) documentId selected → fetch the Contract and edit doc.body via
+//      updateContract (the call that auto-reactivates a shipped doc).
 //   2) no documentId (legacy fallback, kept for the "select a directive but
 //      not a document" transitional case) → edit directive.goal as before.
 // =============================================================================
@@ -918,7 +918,7 @@ function EditorShell({
   const documentId = selection?.documentId ?? null;
 
   // We deliberately don't pull `updateGoal` here — in the multi-document
-  // world, edits flow through updateDirectiveDocument (which auto-reactivates
+  // world, edits flow through updateContract (which auto-reactivates
   // a shipped doc when its body changes). The legacy directive.goal is
   // unused on this surface.
   const {
@@ -932,7 +932,7 @@ function EditorShell({
   // Document fetch — only when documentId is selected. Refetched whenever the
   // id changes; not polled (the document stream is too low-traffic to warrant
   // background refresh in this iteration).
-  const [doc, setDoc] = useState<DirectiveDocument | null>(null);
+  const [doc, setDoc] = useState<Contract | null>(null);
   const [docLoading, setDocLoading] = useState(false);
   const [docError, setDocError] = useState<string | null>(null);
 
@@ -946,7 +946,7 @@ function EditorShell({
     let cancelled = false;
     setDocLoading(true);
     setDocError(null);
-    getDirectiveDocument(documentId)
+    getContract(documentId)
       .then((d) => {
         if (cancelled) return;
         setDoc(d);
@@ -970,7 +970,7 @@ function EditorShell({
   const onUpdateDocumentBody = useCallback(
     async (body: string) => {
       if (!documentId) return;
-      const updated = await updateDirectiveDocument(documentId, { body });
+      const updated = await updateContract(documentId, { body });
       setDoc(updated);
       // Tell the sidebar to refetch the directive's document list so the
       // status chip flips from `shipped` back to `active` (and any title
@@ -1230,7 +1230,7 @@ export default function DocumentDirectivesPage() {
     lastResolvedRef.current = routeDirectiveId;
 
     let cancelled = false;
-    listDirectiveDocuments(routeDirectiveId)
+    listContracts(routeDirectiveId)
       .then((list) => {
         if (cancelled) return;
         // Prefer the first 'active' doc; fall back to the first 'draft'.
@@ -1259,7 +1259,7 @@ export default function DocumentDirectivesPage() {
   }, [routeDirectiveId, selection?.documentId, selection?.taskId, setSearchParams]);
 
   const handleSelectDocument = useCallback(
-    (directiveId: string, doc: DirectiveDocument) => {
+    (directiveId: string, doc: Contract) => {
       navigate(`/directives/${directiveId}?document=${doc.id}`);
     },
     [navigate],
@@ -1278,7 +1278,7 @@ export default function DocumentDirectivesPage() {
 
   const handleCreateDocument = useCallback(
     async (directive: DirectiveSummary) => {
-      const created = await createDirectiveDocument(directive.id, {
+      const created = await createContract(directive.id, {
         title: "",
         body: "",
       });
-- 
cgit v1.2.3

