@promptowl/contextnest-community 1.14.0 → 1.16.1

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/dist/index.js CHANGED
@@ -4,36 +4,42 @@ import {
4
4
  approve,
5
5
  canReadNode,
6
6
  cancelReview,
7
+ declineDeletion,
8
+ deletionTargetTitle,
7
9
  dispatchEvent,
10
+ dropDeletionRequest,
8
11
  edgeTypeRoutes,
9
12
  edgeTypeToResponse,
10
13
  envRoutes,
11
14
  envValues,
12
15
  filterAccessible,
16
+ getDeletionRequest,
17
+ getLatestDeletionRequest,
13
18
  getPendingReview,
14
19
  getReviewHistory,
15
20
  getReviewQueue,
16
21
  isSafeConnectorUrl,
22
+ listDeletionRequests,
17
23
  listNotifications,
18
24
  listWatchers,
19
25
  markNotificationsRead,
20
26
  nodeWriteKey,
27
+ notifyDeletionResolved,
21
28
  notifyNestEvent,
22
29
  notifyNestInvite,
23
30
  parseConditionSchema,
24
31
  reject,
25
32
  removeWatcher,
26
33
  repointPendingReview,
34
+ requestDeletion,
27
35
  requireWorkflowPlane,
28
36
  resolveCallerEmail,
29
37
  safeJson,
30
- safePublishDocument,
31
38
  seedDefaultEdgeTypes,
32
39
  sendTestNotification,
33
- stripUndefinedDeep,
34
40
  submitForReview,
35
41
  withNodeWriteLock
36
- } from "./chunk-MZ4IR5KH.js";
42
+ } from "./chunk-QJVRZT6M.js";
37
43
  import {
38
44
  createGrant,
39
45
  deleteGrant,
@@ -42,7 +48,7 @@ import {
42
48
  listGrants,
43
49
  listUserGrants,
44
50
  resolveNodeGrant
45
- } from "./chunk-IHGPZ6NY.js";
51
+ } from "./chunk-OTGZQZKK.js";
46
52
  import {
47
53
  generateApiKey,
48
54
  getKeyPrefix,
@@ -55,39 +61,56 @@ import {
55
61
  checkConflict,
56
62
  createVersion,
57
63
  getApprovedVersion,
64
+ getApprovedVersions,
58
65
  getCurrentVersion,
59
66
  getDisplayStatus,
67
+ getTrackedNodeIds,
60
68
  getVersions,
69
+ listMyDrafts,
61
70
  setApprovedVersion,
62
71
  upsertVersion
63
- } from "./chunk-JP67WXLA.js";
72
+ } from "./chunk-D6HICFVW.js";
64
73
  import {
65
- addMember,
66
- buildTitleMap,
67
74
  canCreateInNest,
68
75
  canManageStewards,
69
76
  canUserAccess,
70
77
  canUserApprove,
71
78
  canUserEdit,
72
- createNest,
73
79
  createStewardRecord,
80
+ getCollaboratorRole,
81
+ getStewardRolesForUser,
82
+ getStewardsForNest,
83
+ getStewardsForUser,
84
+ listStewards,
85
+ removeSteward,
86
+ resolveGrantRoles,
87
+ resolveStewardsForNode,
88
+ resolveStewardsWithFallback,
89
+ resolveUserRoles,
90
+ syncFromConfig,
91
+ updateSteward
92
+ } from "./chunk-CLKZZZS2.js";
93
+ import {
94
+ addMember,
95
+ buildTitleMap,
96
+ canApproveWith,
97
+ createNest,
74
98
  createTeam,
75
99
  deleteNest,
76
100
  deleteTeam,
77
101
  disableStewardshipAndWipeGovernance,
78
102
  docLink,
103
+ engineApi,
79
104
  engineCache,
80
105
  findTeamByExternalId,
106
+ folderForNode,
81
107
  getAccessConfig,
82
- getCollaboratorRole,
83
108
  getCurrentLicense,
84
109
  getNest,
85
- getStewardRolesForUser,
86
- getStewardsForNest,
87
- getStewardsForUser,
88
110
  getSuspensionReason,
89
111
  getTeam,
90
112
  importNest,
113
+ insertOrIgnore,
91
114
  installLicenseKey,
92
115
  isConfigSuperAdmin,
93
116
  isLicenseAdminEmail,
@@ -101,7 +124,6 @@ import {
101
124
  listNests,
102
125
  listPublicNests,
103
126
  listSharedNests,
104
- listStewards,
105
127
  listTeamsForUser,
106
128
  listVisibleNests,
107
129
  loadAccessConfig,
@@ -109,36 +131,38 @@ import {
109
131
  loadServerSettings,
110
132
  nestAllowsSelfApprove,
111
133
  nestName,
134
+ nestPrimeTags,
135
+ nestReviewsPrimeOnly,
112
136
  nestStorageRoot,
137
+ normalizeTag,
138
+ nowExpr,
139
+ opContext,
113
140
  permissionLevel,
114
141
  persistSetting,
115
142
  removeMember,
116
- removeSteward,
143
+ removeNodeFromTagIndex,
117
144
  renameNest,
118
145
  renameTeam,
119
146
  replaceTeamRoster,
120
- resolveGrantRoles,
121
147
  resolveMemberIdentity,
122
148
  resolveNestPath,
123
149
  resolveNestPermission,
124
- resolveStewardsForNode,
125
- resolveStewardsWithFallback,
126
- resolveUserRoles,
127
150
  sendEmailToRecipient,
128
151
  setAllowSelfApprove,
152
+ setPrimeOnlyReview,
153
+ setPrimeTags,
129
154
  setStewardshipEnabled,
130
155
  shareTeamWithNest,
131
156
  startLicenseSafetyPoll,
132
157
  startTelemetryLoop,
133
- syncFromConfig,
158
+ syncNodeTags,
134
159
  trackEvent,
135
160
  uniqueNestName,
136
161
  unshareTeamFromNest,
137
162
  updateMemberRole,
138
- updateSteward,
139
163
  validateLicense,
140
164
  verifySmtp
141
- } from "./chunk-KPK656BH.js";
165
+ } from "./chunk-L7UCMGWZ.js";
142
166
  import {
143
167
  AppError,
144
168
  ConflictError,
@@ -148,24 +172,20 @@ import {
148
172
  UnauthorizedError,
149
173
  ValidationError
150
174
  } from "./chunk-GUNJTORH.js";
151
- import {
152
- normalizeEmail
153
- } from "./chunk-FRQJWGN3.js";
154
- import {
155
- insertOrIgnore,
156
- nowExpr
157
- } from "./chunk-XQ46F76G.js";
158
175
  import {
159
176
  config,
160
177
  getDb,
161
178
  initDb,
162
179
  isEmailListish,
163
180
  isEmailish
164
- } from "./chunk-I5KYGMIT.js";
181
+ } from "./chunk-JMJLNEXE.js";
165
182
  import {
166
183
  ANON_EMAIL,
167
184
  ANON_USER_ID
168
185
  } from "./chunk-SLTQACJW.js";
186
+ import {
187
+ normalizeEmail
188
+ } from "./chunk-FRQJWGN3.js";
169
189
 
170
190
  // src/index.ts
171
191
  import { serve } from "@hono/node-server";
@@ -176,6 +196,29 @@ import { LinearRouter } from "hono/router/linear-router";
176
196
  import { createMiddleware as createMiddleware2 } from "hono/factory";
177
197
  import { cors } from "hono/cors";
178
198
 
199
+ // src/shared/artifact-sandbox.ts
200
+ var ARTIFACT_SANDBOX_CSP = [
201
+ "sandbox allow-scripts allow-popups allow-modals",
202
+ "default-src 'none'",
203
+ "script-src 'unsafe-inline'",
204
+ "style-src 'unsafe-inline'",
205
+ "img-src data: blob:",
206
+ "font-src data:",
207
+ "connect-src 'none'",
208
+ "base-uri 'none'",
209
+ "form-action 'none'"
210
+ ].join("; ");
211
+ function mergeFrameAncestors(existing, directive) {
212
+ if (!existing) return directive;
213
+ if (/(^|;)\s*frame-ancestors\b/i.test(existing)) return existing;
214
+ return `${existing}; ${directive}`;
215
+ }
216
+ function applyArtifactSandboxHeaders(c) {
217
+ c.header("Content-Security-Policy", ARTIFACT_SANDBOX_CSP);
218
+ c.header("X-Content-Type-Options", "nosniff");
219
+ c.header("Referrer-Policy", "no-referrer");
220
+ }
221
+
179
222
  // src/auth/routes.ts
180
223
  import { Hono as Hono2 } from "hono";
181
224
  import { v4 as uuid2 } from "uuid";
@@ -393,6 +436,9 @@ function sweepStale(maxAgeMs = 10 * 6e4) {
393
436
 
394
437
  // src/auth/sso-token.ts
395
438
  import { createHmac, timingSafeEqual } from "crypto";
439
+ function isValidTicketSub(sub) {
440
+ return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(sub) && sub.length <= 254;
441
+ }
396
442
  function b64urlToBuffer(input) {
397
443
  const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - input.length % 4);
398
444
  return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
@@ -1215,6 +1261,7 @@ authRoutes.get("/sso", async (c) => {
1215
1261
  } catch {
1216
1262
  return ssoError("invalid_ticket");
1217
1263
  }
1264
+ if ((claims.scope || "") === "mcp") return ssoError("invalid_ticket");
1218
1265
  const expectedAud = config.PUBLIC_BASE_URL;
1219
1266
  if (!expectedAud && !warnedMissingSsoAudience) {
1220
1267
  warnedMissingSsoAudience = true;
@@ -1227,7 +1274,7 @@ authRoutes.get("/sso", async (c) => {
1227
1274
  if (aud !== expectedAud) return ssoError("bad_audience");
1228
1275
  }
1229
1276
  const sub = (claims.sub || "").trim();
1230
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(sub) || sub.length > 254) {
1277
+ if (!isValidTicketSub(sub)) {
1231
1278
  return ssoError("invalid_ticket");
1232
1279
  }
1233
1280
  if (!claims.jti) return ssoError("invalid_ticket");
@@ -1557,48 +1604,58 @@ authRoutes.get("/teammates", async (c) => {
1557
1604
 
1558
1605
  // src/nests/routes.ts
1559
1606
  import { Hono as Hono3 } from "hono";
1560
- import { streamText } from "hono/streaming";
1561
1607
 
1562
1608
  // src/nodes/service.ts
1563
1609
  import {
1564
- serializeDocument,
1565
1610
  parseDocument as parseDocument2,
1566
- normalizeStatus
1611
+ normalizeStatus,
1612
+ isRejected,
1613
+ DocumentNotFoundError
1567
1614
  } from "@promptowl/contextnest-engine";
1568
- import { rename, mkdir, rm } from "fs/promises";
1569
- import { dirname, join as join2, basename } from "path";
1615
+ import { rename, mkdir } from "fs/promises";
1616
+ import { dirname, join as join2 } from "path";
1570
1617
 
1571
- // src/governance/tag-index-service.ts
1572
- function normalizeTag(raw) {
1573
- return raw.trim().replace(/^#+/, "").toLowerCase();
1618
+ // src/governance/safe-publish.ts
1619
+ import {
1620
+ publishDocument,
1621
+ serializeDocument
1622
+ } from "@promptowl/contextnest-engine";
1623
+ function serializeNodeSafe(node) {
1624
+ return serializeDocument({
1625
+ ...node,
1626
+ frontmatter: stripUndefinedDeep(node.frontmatter)
1627
+ });
1574
1628
  }
1575
- async function syncNodeTags(nestId, nodeId, tags) {
1576
- const db = getDb();
1577
- const normalized = Array.from(
1578
- new Set(
1579
- tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
1580
- )
1581
- );
1582
- const insertSql = insertOrIgnore(
1583
- db,
1584
- "INSERT INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
1585
- );
1586
- await db.transaction(async (tx) => {
1587
- await tx.run(
1588
- "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
1589
- [nestId, nodeId]
1590
- );
1591
- for (const tag of normalized) {
1592
- await tx.run(insertSql, [nestId, nodeId, tag]);
1629
+ function stripUndefinedDeep(value) {
1630
+ if (Array.isArray(value)) {
1631
+ return value.filter((v) => v !== void 0).map((v) => stripUndefinedDeep(v));
1632
+ }
1633
+ if (value && typeof value === "object") {
1634
+ const out = {};
1635
+ for (const [k, v] of Object.entries(value)) {
1636
+ if (v === void 0) continue;
1637
+ out[k] = stripUndefinedDeep(v);
1593
1638
  }
1594
- });
1639
+ return out;
1640
+ }
1641
+ return value;
1595
1642
  }
1596
- async function removeNodeFromTagIndex(nestId, nodeId) {
1597
- const db = getDb();
1598
- await db.run(
1599
- "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
1600
- [nestId, nodeId]
1601
- );
1643
+
1644
+ // src/governance/prime-service.ts
1645
+ async function isPrimeDocument(nestId, tags, metadata) {
1646
+ const flag = documentPrimeFlag(metadata);
1647
+ if (flag !== void 0) return flag;
1648
+ const primeTags = await nestPrimeTags(nestId);
1649
+ if (!primeTags.length) return false;
1650
+ const wanted = new Set(primeTags);
1651
+ return (tags || []).some((t) => wanted.has(normalizeTag(String(t))));
1652
+ }
1653
+ function documentPrimeFlag(metadata) {
1654
+ const raw = metadata?.prime;
1655
+ if (typeof raw === "boolean") return raw;
1656
+ if (raw === "true") return true;
1657
+ if (raw === "false") return false;
1658
+ return void 0;
1602
1659
  }
1603
1660
 
1604
1661
  // src/governance/external-edit-service.ts
@@ -1607,6 +1664,7 @@ import { join } from "path";
1607
1664
  import {
1608
1665
  detectDrift,
1609
1666
  stageSuggestion,
1667
+ ChainEventLog,
1610
1668
  approveSuggestion,
1611
1669
  rejectSuggestion,
1612
1670
  listSuggestions,
@@ -1689,7 +1747,8 @@ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
1689
1747
  return { meta: result.meta, created: true };
1690
1748
  }
1691
1749
  async function scanNestForDrift(nestId, actor = "system:scanner") {
1692
- const { storage } = await engineCache.get(nestId);
1750
+ const { storage, invalidateDiscovery } = await engineCache.get(nestId);
1751
+ invalidateDiscovery();
1693
1752
  const docs = await storage.discoverDocuments();
1694
1753
  const results = await Promise.all(
1695
1754
  docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
@@ -1760,6 +1819,68 @@ async function getExternalEditDetail(nestId, documentId, suggestionId) {
1760
1819
  patch: found.patch
1761
1820
  };
1762
1821
  }
1822
+ async function recordVerdict(nestId, event) {
1823
+ try {
1824
+ const { storage } = await engineCache.get(nestId);
1825
+ await new ChainEventLog(storage).append(event);
1826
+ return true;
1827
+ } catch (err) {
1828
+ console.error(
1829
+ `[external-edit] failed to record verdict ${event.event_id} for nest ${nestId}:`,
1830
+ err
1831
+ );
1832
+ return false;
1833
+ }
1834
+ }
1835
+ async function listExternalEditVerdicts(nestId, documentId) {
1836
+ try {
1837
+ const { storage } = await engineCache.get(nestId);
1838
+ const events = await new ChainEventLog(storage).readByDocument(documentId);
1839
+ return events.flatMap((e) => {
1840
+ const meta = e.action_metadata || {};
1841
+ if (typeof meta.suggestion_id !== "string") return [];
1842
+ const reason = meta.rejection_reason;
1843
+ const comment = meta.approval_comment;
1844
+ return [
1845
+ {
1846
+ suggestion_id: meta.suggestion_id,
1847
+ status: typeof reason === "string" ? "rejected" : "approved",
1848
+ actor: e.actor,
1849
+ at: e.timestamp,
1850
+ note: typeof reason === "string" ? reason : typeof comment === "string" ? comment : void 0,
1851
+ source: typeof meta.source === "string" ? meta.source : void 0
1852
+ }
1853
+ ];
1854
+ });
1855
+ } catch (err) {
1856
+ console.error(
1857
+ `[external-edit] failed to read verdicts for ${nestId}/${documentId}:`,
1858
+ err
1859
+ );
1860
+ return [];
1861
+ }
1862
+ }
1863
+ async function mirrorVersion(input) {
1864
+ const { storage } = await engineCache.get(input.nestId);
1865
+ const node = await storage.readDocument(input.documentId);
1866
+ const { upsertVersion: upsertVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-V6K5SZ3P.js");
1867
+ await upsertVersion2({
1868
+ nestId: input.nestId,
1869
+ nodeId: input.documentId,
1870
+ version: input.version,
1871
+ content: node.body || "",
1872
+ author: input.actor,
1873
+ status: "published",
1874
+ tags: node.frontmatter.tags || [],
1875
+ changeNote: input.note
1876
+ });
1877
+ await setApprovedVersion2(
1878
+ input.nestId,
1879
+ input.documentId,
1880
+ input.version,
1881
+ input.actor
1882
+ );
1883
+ }
1763
1884
  async function approveExternalEdit(input) {
1764
1885
  const { storage } = await engineCache.get(input.nestId);
1765
1886
  let result;
@@ -1781,33 +1902,21 @@ async function approveExternalEdit(input) {
1781
1902
  throw err;
1782
1903
  }
1783
1904
  try {
1784
- const node = await storage.readDocument(input.documentId);
1785
- const versionNum = result.versionEntry.version;
1786
- const tags = node.frontmatter.tags || [];
1787
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-FGL47APL.js");
1788
- await createVersion2({
1905
+ await mirrorVersion({
1789
1906
  nestId: input.nestId,
1790
- nodeId: input.documentId,
1791
- version: versionNum,
1792
- content: node.body || "",
1793
- author: input.actor,
1794
- status: "published",
1795
- tags,
1796
- changeNote: input.comment || "External edit approved"
1907
+ documentId: input.documentId,
1908
+ version: result.versionEntry.version,
1909
+ actor: input.actor,
1910
+ note: input.comment ? `External edit approved: ${input.comment}` : "External edit approved"
1797
1911
  });
1798
- await setApprovedVersion2(
1799
- input.nestId,
1800
- input.documentId,
1801
- versionNum,
1802
- input.actor
1803
- );
1804
1912
  } catch (err) {
1805
1913
  console.error(
1806
1914
  `[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
1807
1915
  err
1808
1916
  );
1809
1917
  }
1810
- return result;
1918
+ const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
1919
+ return { ...result, historyRecorded };
1811
1920
  }
1812
1921
  async function rejectExternalEdit(input) {
1813
1922
  const { storage } = await engineCache.get(input.nestId);
@@ -1820,6 +1929,7 @@ async function rejectExternalEdit(input) {
1820
1929
  zone: "default",
1821
1930
  reason: input.reason
1822
1931
  });
1932
+ const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
1823
1933
  try {
1824
1934
  const approved = await loadChainHead(storage, input.documentId);
1825
1935
  if (approved) {
@@ -1831,7 +1941,7 @@ async function rejectExternalEdit(input) {
1831
1941
  err
1832
1942
  );
1833
1943
  }
1834
- return result;
1944
+ return { ...result, historyRecorded };
1835
1945
  }
1836
1946
  var scannerTimer = null;
1837
1947
  async function scanAllNests() {
@@ -1865,9 +1975,33 @@ async function userIdFromEmail(email) {
1865
1975
  return row?.id ?? ANON_USER_ID;
1866
1976
  }
1867
1977
  var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
1978
+ var MAX_TITLE_LENGTH = 120;
1979
+ function assertTitleLength(title) {
1980
+ if (title.length > MAX_TITLE_LENGTH) {
1981
+ throw new ValidationError(
1982
+ `title must be at most ${MAX_TITLE_LENGTH} characters`
1983
+ );
1984
+ }
1985
+ }
1986
+ function assertTitleSluggable(title) {
1987
+ if (!slugify(title)) {
1988
+ throw new ValidationError(
1989
+ "the document id is built from the title using a-z and 0-9, so the title needs at least one of those"
1990
+ );
1991
+ }
1992
+ }
1868
1993
  var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1994
+ function isIdUnderFolder(id, folder) {
1995
+ return id.startsWith(`nodes/${folder}/`) || id.startsWith(`${folder}/`);
1996
+ }
1869
1997
  function folderToSegments(folder) {
1870
1998
  const segments = folder.split("/").map(slugify).filter(Boolean);
1999
+ if (folder.trim() && segments.length === 0) {
2000
+ throw new ValidationError(
2001
+ "the folder path is built from the name using a-z and 0-9, so the name needs at least one of those"
2002
+ );
2003
+ }
2004
+ if (segments[0] === "nodes") segments.shift();
1871
2005
  if (segments.length > 8) {
1872
2006
  throw new ValidationError("folder may nest at most 8 levels deep");
1873
2007
  }
@@ -1878,7 +2012,6 @@ function folderToSegments(folder) {
1878
2012
  }
1879
2013
  return segments;
1880
2014
  }
1881
- var stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
1882
2015
  async function departmentTagFor(userEmail) {
1883
2016
  if (!config.OIDC_DEPARTMENT_TAGGING) return null;
1884
2017
  try {
@@ -1895,6 +2028,12 @@ async function departmentTagFor(userEmail) {
1895
2028
  }
1896
2029
  function toSafeError(err, context, message) {
1897
2030
  if (err instanceof AppError) return err;
2031
+ if (err?.code === "REJECTED_DOCUMENT") {
2032
+ console.error(`${context}:`, err);
2033
+ return new ConflictError(
2034
+ "This document is rejected. Change its status before saving."
2035
+ );
2036
+ }
1898
2037
  console.error(`${context}:`, err);
1899
2038
  return new AppError(500, message);
1900
2039
  }
@@ -1928,7 +2067,8 @@ function toNodeResponse(node) {
1928
2067
  pendingChange: node.pendingChange ?? void 0,
1929
2068
  // Same YAML-coercion rule as title above: `schedule: 123` parses as a
1930
2069
  // number — normalize to string at the API boundary.
1931
- schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0
2070
+ schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0,
2071
+ prime: documentPrimeFlag(fm.metadata)
1932
2072
  };
1933
2073
  }
1934
2074
  async function resolveAuthorship(nestId, nodes) {
@@ -1984,28 +2124,89 @@ async function resolveAuthorship(nestId, nodes) {
1984
2124
  }
1985
2125
  return result;
1986
2126
  }
1987
- async function listNodesForCaller(nestId, userId, filters = {}) {
1988
- const { storage, versions: versionManager } = await engineCache.get(nestId);
1989
- let documents = await storage.discoverDocuments();
1990
- if (filters.type) {
1991
- const wanted = new Set(
1992
- Array.isArray(filters.type) ? filters.type : [filters.type]
2127
+ async function getNodeForCaller(nestId, selector, userId, userEmail, opts = {}) {
2128
+ if (selector.id && !await canReadNode(nestId, selector.id, userId, userEmail)) {
2129
+ throw new ForbiddenError(
2130
+ "Access denied \u2014 no steward assignment for this node"
2131
+ );
2132
+ }
2133
+ let got;
2134
+ try {
2135
+ got = await engineApi.run(
2136
+ "context_get",
2137
+ {
2138
+ ...selector,
2139
+ include_raw: true,
2140
+ // A retired node stays readable here: its stewards decide whether to
2141
+ // revive it, and they cannot do that if the read refuses.
2142
+ allow_rejected: true,
2143
+ ...opts.verifyChecksum ? { verify_checksum: true } : {}
2144
+ },
2145
+ await opContext(nestId, userEmail)
1993
2146
  );
1994
- documents = documents.filter((n) => wanted.has(n.frontmatter.type));
2147
+ } catch (err) {
2148
+ if (err?.code !== "DOCUMENT_NOT_FOUND") {
2149
+ console.error(`getNodeForCaller failed (${nestId}/${selector.id ?? selector.title})`, err);
2150
+ }
2151
+ throw new NotFoundError(`Node not found: ${selector.id ?? selector.title}`);
1995
2152
  }
1996
- if (filters.tag) {
1997
- const tag = normalizeTag2(filters.tag);
1998
- documents = documents.filter(
1999
- (n) => (n.frontmatter.tags || []).includes(tag)
2153
+ if (!selector.id && !await canReadNode(nestId, got.id, userId, userEmail)) {
2154
+ throw new ForbiddenError(
2155
+ "Access denied \u2014 no steward assignment for this node"
2000
2156
  );
2001
2157
  }
2158
+ return {
2159
+ id: got.id,
2160
+ filePath: "",
2161
+ rawContent: got.raw ?? "",
2162
+ frontmatter: got.frontmatter,
2163
+ body: got.body,
2164
+ ...got.pendingChange ? { pendingChange: got.pendingChange } : {}
2165
+ };
2166
+ }
2167
+ async function listNodesForCaller(nestId, userId, filters = {}) {
2168
+ const { versions: versionManager } = await engineCache.get(nestId);
2002
2169
  const userEmail = await resolveCallerEmail(userId);
2003
- let accessible = await filterAccessible(nestId, userId, userEmail, documents);
2170
+ const { documents } = await engineApi.run(
2171
+ "context_list",
2172
+ {
2173
+ ...filters.type ? { type: filters.type } : {},
2174
+ ...filters.tag ? { tag: filters.tag } : {},
2175
+ ...filters.status ? { status: filters.status } : {},
2176
+ // Unlike the CLI and mcp-server, a retired node stays in this listing:
2177
+ // here it is an unpublished document its stewards may still need to act
2178
+ // on, not one removed from retrieval. An imported vault carrying
2179
+ // `status: rejected` registers exactly that way.
2180
+ include_retired: true,
2181
+ full: true
2182
+ },
2183
+ await opContext(nestId, userEmail)
2184
+ );
2185
+ const nodes = documents.map((d) => ({
2186
+ id: d.id,
2187
+ filePath: "",
2188
+ rawContent: "",
2189
+ frontmatter: d.frontmatter,
2190
+ body: d.body
2191
+ }));
2192
+ const publicReader = await isPublicReader(nestId, userId);
2193
+ const approvedOnly = publicReader || filters.approvedOnly === true;
2194
+ const approvedVersions = approvedOnly ? await getApprovedVersions(nestId) : null;
2195
+ let accessible = await filterAccessible(
2196
+ nestId,
2197
+ userId,
2198
+ userEmail,
2199
+ nodes,
2200
+ approvedVersions ?? void 0
2201
+ );
2004
2202
  if (await resolveNestPermission(nestId, userId) === "none" && !await isStewardshipEnabled(nestId)) {
2005
2203
  const grants = await listUserGrants(nestId, userId);
2006
- accessible = grants.length ? documents.filter((d) => grantCoversNode(grants, d.id)) : accessible;
2204
+ accessible = grants.length ? nodes.filter((d) => grantCoversNode(grants, d.id)) : accessible;
2007
2205
  }
2008
- const publicReader = await isPublicReader(nestId, userId);
2206
+ if (approvedVersions && !publicReader) {
2207
+ accessible = accessible.filter((d) => approvedVersions.has(d.id));
2208
+ }
2209
+ if (filters.limit) accessible = accessible.slice(0, filters.limit);
2009
2210
  const authorship = publicReader ? null : await resolveAuthorship(
2010
2211
  nestId,
2011
2212
  accessible.map((d) => ({
@@ -2021,8 +2222,8 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
2021
2222
  r.author = people.author;
2022
2223
  r.collaborators = people.collaborators;
2023
2224
  }
2024
- if (publicReader) {
2025
- const approved = await getApprovedVersion(nestId, doc.id);
2225
+ if (approvedVersions) {
2226
+ const approved = approvedVersions.get(doc.id);
2026
2227
  if (approved != null) {
2027
2228
  try {
2028
2229
  const raw = await versionManager.reconstructVersion(
@@ -2059,7 +2260,7 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
2059
2260
  return r;
2060
2261
  })
2061
2262
  );
2062
- return filters.limit ? enriched.slice(0, filters.limit) : enriched;
2263
+ return enriched;
2063
2264
  }
2064
2265
  async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
2065
2266
  return listNodesForCaller(nestId, await userIdFromEmail(userEmail), filters);
@@ -2069,20 +2270,32 @@ async function actorAutoPublishes(nestId, userEmail) {
2069
2270
  const roles = await resolveUserRoles(nestId, userEmail);
2070
2271
  return roles.includes("owner") || roles.includes("admin");
2071
2272
  }
2273
+ async function assertMayFlagPrime(nestId, userEmail) {
2274
+ const roles = await resolveUserRoles(nestId, userEmail);
2275
+ if (!roles.includes("owner") && !roles.includes("admin")) {
2276
+ throw new ForbiddenError(
2277
+ "Only the nest owner or an admin can change a document's prime flag."
2278
+ );
2279
+ }
2280
+ }
2281
+ async function autoPublishReason(nestId, userEmail, doc) {
2282
+ if (!await isStewardshipEnabled(nestId)) return "no stewards configured";
2283
+ if (await isPrimeDocument(nestId, doc.tags, doc.metadata)) return null;
2284
+ if (await nestReviewsPrimeOnly(nestId)) {
2285
+ return "prime-only review, document is not prime";
2286
+ }
2287
+ return await actorAutoPublishes(nestId, userEmail) ? "owner/admin self-approve" : null;
2288
+ }
2072
2289
  async function createNode(nestId, input, userEmail) {
2073
2290
  const { storage, versions: versionManager } = await engineCache.get(nestId);
2074
- const slug = slugify(input.title);
2291
+ assertTitleLength(input.title);
2292
+ if (!input.id) assertTitleSluggable(input.title);
2293
+ const slug = slugify(input.title).slice(0, 100).replace(/-+$/, "");
2075
2294
  let id = input.id;
2076
2295
  if (!id) {
2077
2296
  const folderSegments = folderToSegments(input.folder ?? "");
2078
- if ([...folderSegments, slug].some((seg) => seg.length > 100)) {
2079
- throw new ValidationError(
2080
- "each folder or title segment must be at most 100 characters"
2081
- );
2082
- }
2083
2297
  id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
2084
2298
  }
2085
- const now = (/* @__PURE__ */ new Date()).toISOString();
2086
2299
  const tags = (input.tags || []).map(normalizeTag2);
2087
2300
  const requestedType = input.type || "document";
2088
2301
  if (requestedType === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
@@ -2098,72 +2311,104 @@ async function createNode(nestId, input, userEmail) {
2098
2311
  )})`
2099
2312
  );
2100
2313
  }
2101
- const deptTag = await departmentTagFor(userEmail);
2102
- if (deptTag && !tags.some((t) => t.toLowerCase() === deptTag)) {
2103
- tags.push(deptTag);
2104
- }
2105
- const hasStewards = await isStewardshipEnabled(nestId);
2106
- const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
2107
- const initialStatus = autoPublish ? "published" : "draft";
2108
- const initialVersion = autoPublish ? 0 : 1;
2109
- let node = {
2110
- id,
2111
- filePath: "",
2112
- frontmatter: {
2113
- title: input.title,
2114
- type: requestedType,
2115
- tags,
2116
- status: input.status || initialStatus,
2117
- version: initialVersion,
2118
- created_at: now,
2119
- updated_at: now,
2120
- metadata: {
2121
- owners: ["*"],
2122
- scope: input.scope || "team",
2123
- ...input.schedule ? { schedule: input.schedule } : {}
2124
- }
2125
- },
2126
- body: input.content,
2127
- rawContent: ""
2128
- };
2129
- await storage.writeDocument(id, serializeDocument(node));
2130
- await syncNodeTags(nestId, id, tags);
2131
- let savedVersion = 1;
2132
- if (!autoPublish) {
2314
+ return withNodeWriteLock(nodeWriteKey(nestId, id), async () => {
2315
+ let occupied = false;
2133
2316
  try {
2134
- await versionManager.createVersion(node, userEmail);
2317
+ await storage.readDocument(id);
2318
+ occupied = true;
2135
2319
  } catch (err) {
2136
- console.error("VersionManager.createVersion failed (node create)", err);
2320
+ if (!(err instanceof DocumentNotFoundError)) throw err;
2137
2321
  }
2138
- await createVersion({
2139
- nestId,
2140
- nodeId: id,
2141
- version: 1,
2142
- content: input.content,
2143
- author: userEmail,
2144
- status: "draft",
2145
- tags
2322
+ if (occupied) {
2323
+ throw new ConflictError(
2324
+ `A document already exists at ${id}. Update it, or create this one under a different folder or title.`
2325
+ );
2326
+ }
2327
+ const db = getDb();
2328
+ const orphan = await db.get(
2329
+ "SELECT 1 AS present FROM node_versions WHERE nest_id = ? AND node_id = ? LIMIT 1",
2330
+ [nestId, id]
2331
+ );
2332
+ if (orphan) {
2333
+ console.warn(
2334
+ `[nodes] clearing orphaned rows for ${id} \u2014 version history exists but no document does`
2335
+ );
2336
+ await db.transaction(async (tx) => {
2337
+ await purgeNodeKeyedRows(tx, nestId, id);
2338
+ });
2339
+ }
2340
+ const deptTag = await departmentTagFor(userEmail);
2341
+ if (deptTag && !tags.some((t) => t.toLowerCase() === deptTag)) {
2342
+ tags.push(deptTag);
2343
+ }
2344
+ if (input.prime !== void 0) await assertMayFlagPrime(nestId, userEmail);
2345
+ const metadata = {
2346
+ owners: ["*"],
2347
+ scope: input.scope || "team",
2348
+ ...input.schedule ? { schedule: input.schedule } : {},
2349
+ ...input.prime === void 0 ? {} : { prime: input.prime }
2350
+ };
2351
+ const publishReason = await autoPublishReason(nestId, userEmail, {
2352
+ tags,
2353
+ metadata
2146
2354
  });
2147
- } else {
2355
+ const publishNote = publishReason === null ? void 0 : `Auto-published on create (${publishReason})`;
2356
+ let published = publishReason !== null;
2148
2357
  try {
2149
- const result = await safePublishDocument(storage, id, {
2150
- editedBy: userEmail,
2151
- note: hasStewards ? "Auto-published on create (owner/admin self-approve)" : "Auto-published on create (no stewards configured)"
2152
- });
2153
- savedVersion = result.node.frontmatter.version || 1;
2358
+ await engineApi.run(
2359
+ "context_create",
2360
+ {
2361
+ id,
2362
+ title: input.title,
2363
+ content: input.content,
2364
+ type: requestedType,
2365
+ // A skill node is invalid without a skill block, and this surface has
2366
+ // no field for one — default the trigger from the title, same as the
2367
+ // CLI and mcp-server do.
2368
+ ...requestedType === "skill" ? { trigger: `when asked to ${input.title.toLowerCase()}` } : {},
2369
+ ...tags.length ? { tags } : {},
2370
+ ...input.status ? { status: input.status } : {},
2371
+ metadata,
2372
+ publish: published,
2373
+ ...publishNote ? { note: publishNote } : {}
2374
+ },
2375
+ await opContext(nestId, userEmail)
2376
+ );
2377
+ } catch (err) {
2378
+ let onDisk = true;
2379
+ try {
2380
+ await storage.readDocument(id);
2381
+ } catch {
2382
+ onDisk = false;
2383
+ }
2384
+ if (!onDisk) throw err;
2385
+ console.error("publishDocument failed (node create auto-publish)", err);
2386
+ published = false;
2387
+ }
2388
+ const node = await storage.readDocument(id);
2389
+ await syncNodeTags(nestId, id, tags);
2390
+ let savedVersion = 1;
2391
+ if (published) {
2392
+ savedVersion = node.frontmatter.version || 1;
2154
2393
  await createVersion({
2155
2394
  nestId,
2156
2395
  nodeId: id,
2157
2396
  version: savedVersion,
2158
- content: result.node.body || "",
2397
+ content: node.body || "",
2159
2398
  author: userEmail,
2160
2399
  status: "published",
2161
- tags
2400
+ tags,
2401
+ changeNote: publishNote
2162
2402
  });
2163
2403
  await setApprovedVersion(nestId, id, savedVersion, userEmail);
2164
- node = result.node;
2165
- } catch (err) {
2166
- console.error("publishDocument failed (node create auto-publish)", err);
2404
+ } else {
2405
+ if (publishReason === null) {
2406
+ try {
2407
+ await versionManager.createVersion(node, userEmail);
2408
+ } catch (err) {
2409
+ console.error("VersionManager.createVersion failed (node create)", err);
2410
+ }
2411
+ }
2167
2412
  await createVersion({
2168
2413
  nestId,
2169
2414
  nodeId: id,
@@ -2174,140 +2419,102 @@ async function createNode(nestId, input, userEmail) {
2174
2419
  tags
2175
2420
  });
2176
2421
  }
2177
- }
2178
- await trackEvent("node.create", { nestId, nodeId: id });
2179
- return { node, version: savedVersion };
2180
- }
2181
- function explicitFrontmatterStatus(raw) {
2182
- const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2183
- if (!fm) return null;
2184
- const m = fm[1].match(/^status:[ \t]*["']?([A-Za-z_-]+)["']?[ \t]*$/m);
2185
- return m ? normalizeStatus(m[1]) : null;
2422
+ await trackEvent("node.create", { nestId, nodeId: id });
2423
+ return { node, version: savedVersion };
2424
+ });
2186
2425
  }
2187
- async function resetImportedDocForPublish(storage, nestId, nodeId) {
2188
- const versionsDir = join2(
2189
- resolveNestPath(nestId),
2190
- dirname(nodeId),
2191
- ".versions",
2192
- basename(nodeId)
2426
+ async function stageImportedFiles(nestId, files, userEmail) {
2427
+ const carried = files.filter(
2428
+ (f) => !String(f?.path || "").split(/[\\/]/).includes("_suggestions")
2429
+ );
2430
+ if (!carried.length) return { written: 0, rejected: [] };
2431
+ const result = await engineApi.run(
2432
+ "context_import",
2433
+ { files: carried, publish: false },
2434
+ await opContext(nestId, userEmail)
2193
2435
  );
2194
- await rm(versionsDir, { recursive: true, force: true });
2195
- const node = await storage.readDocument(nodeId);
2196
- const fm = { ...node.frontmatter };
2197
- delete fm.version;
2198
- delete fm.checksum;
2199
- await storage.writeDocument(nodeId, serializeDocument({ ...node, frontmatter: fm }));
2436
+ const rejected = (result.failed ?? []).map((f) => f.id ?? "(unnamed)");
2437
+ if (rejected.length) {
2438
+ console.warn(
2439
+ `[import] ${rejected.length} file(s) rejected:`,
2440
+ (result.failed ?? []).map((f) => `${f.id}: ${f.error}`).join(", ")
2441
+ );
2442
+ }
2443
+ return { written: result.written ?? 0, rejected };
2200
2444
  }
2201
- async function registerImportedDocuments(nestId, userEmail, onProgress) {
2202
- const { storage } = await engineCache.get(nestId);
2203
- let docs;
2445
+ async function alignOnDiskStatus(nestId, documentId, status) {
2204
2446
  try {
2205
- docs = await storage.discoverDocuments();
2447
+ const { storage } = await engineCache.get(nestId);
2448
+ const node = await storage.readDocument(documentId);
2449
+ if (node.frontmatter.status === status) return;
2450
+ await storage.writeDocument(
2451
+ documentId,
2452
+ serializeNodeSafe({
2453
+ ...node,
2454
+ frontmatter: { ...node.frontmatter, status }
2455
+ })
2456
+ );
2206
2457
  } catch (err) {
2207
- console.error("registerImportedDocuments: discovery failed", nestId, err);
2208
- return 0;
2458
+ console.warn(
2459
+ `[import] could not align on-disk status for ${nestId}/${documentId}:`,
2460
+ err
2461
+ );
2209
2462
  }
2463
+ }
2464
+ async function registerImportedDocuments(nestId, userEmail) {
2210
2465
  const startedAt = Date.now();
2211
- console.log(`[import] register: discovered=${docs.length} nest=${nestId}`);
2212
- await onProgress?.(0, docs.length);
2213
- let registered = 0;
2214
- let alreadyTracked = 0;
2215
- let publishFailed = 0;
2216
- let publishRecovered = 0;
2217
- let keptUnpublished = 0;
2218
- let processed = 0;
2219
- for (let doc of docs) {
2220
- const nodeId = doc.id;
2221
- await onProgress?.(++processed, docs.length);
2222
- if (await getCurrentVersion(nestId, nodeId) > 0) {
2223
- alreadyTracked++;
2224
- continue;
2466
+ const tracked = await getTrackedNodeIds(nestId);
2467
+ (await engineCache.get(nestId)).invalidateDiscovery();
2468
+ let result;
2469
+ try {
2470
+ result = await engineApi.run(
2471
+ "context_import",
2472
+ { discover: true, author: userEmail, exclude_ids: [...tracked] },
2473
+ await opContext(nestId, userEmail)
2474
+ );
2475
+ } catch (err) {
2476
+ console.error("[import] register failed", nestId, err);
2477
+ throw new Error(
2478
+ `Failed to import documents: ${err?.message ?? String(err)}`
2479
+ );
2480
+ }
2481
+ const docs = result.documents ?? [];
2482
+ const engineMs = Date.now() - startedAt;
2483
+ const dbStartedAt = Date.now();
2484
+ for (const d of docs) {
2485
+ const tags = d.tags.map((t) => normalizeTag2(String(t)));
2486
+ await createVersion({
2487
+ nestId,
2488
+ nodeId: d.id,
2489
+ version: d.version,
2490
+ content: d.content,
2491
+ author: userEmail,
2492
+ status: d.status,
2493
+ changeNote: d.status === "published" ? "Imported from existing folder" : "Imported from existing folder \u2014 could not be published, kept as a draft",
2494
+ tags
2495
+ });
2496
+ if (d.status === "published") {
2497
+ await setApprovedVersion(nestId, d.id, d.version, userEmail);
2498
+ } else {
2499
+ await alignOnDiskStatus(nestId, d.id, d.status);
2225
2500
  }
2226
- const title = doc.frontmatter?.title ?? (nodeId.split("/").pop() || nodeId);
2227
- doc = { ...doc, frontmatter: { ...doc.frontmatter, title, author: userEmail } };
2228
- try {
2229
- await storage.writeDocument(nodeId, serializeDocument(doc));
2230
- } catch (err) {
2231
- console.error("import: failed to persist import metadata", nodeId, err);
2232
- }
2233
- const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
2234
- const tags = rawTags.map((t) => normalizeTag2(String(t)));
2235
- const fmVersion = Number(doc.frontmatter?.version);
2236
- let version = Number.isInteger(fmVersion) && fmVersion > 0 ? fmVersion : 1;
2237
- let content = doc.body || "";
2238
- const status = explicitFrontmatterStatus(doc.rawContent);
2239
- if (status && status !== "published" && status !== "approved") {
2240
- await createVersion({
2241
- nestId,
2242
- nodeId,
2243
- version,
2244
- content,
2245
- author: userEmail,
2246
- status: "draft",
2247
- changeNote: "Imported from existing folder (kept unpublished)",
2248
- tags
2249
- });
2250
- await syncNodeTags(nestId, nodeId, tags);
2251
- keptUnpublished++;
2252
- registered++;
2253
- continue;
2254
- }
2255
- let published = false;
2256
- try {
2257
- const result = await safePublishDocument(storage, nodeId, {
2258
- editedBy: userEmail,
2259
- note: "Imported from existing folder"
2260
- });
2261
- version = result.node.frontmatter.version || version;
2262
- content = result.node.body || content;
2263
- published = true;
2264
- } catch (err) {
2265
- console.warn(
2266
- `[import] publish retry needed for ${nodeId}: ${err?.message ?? err}`
2267
- );
2268
- try {
2269
- await resetImportedDocForPublish(storage, nestId, nodeId);
2270
- const result = await safePublishDocument(storage, nodeId, {
2271
- editedBy: userEmail,
2272
- note: "Imported from existing folder (reset chain)"
2273
- });
2274
- version = result.node.frontmatter.version || 1;
2275
- content = result.node.body || content;
2276
- published = true;
2277
- publishRecovered++;
2278
- } catch (err2) {
2279
- publishFailed++;
2280
- console.error(
2281
- "reset+republish failed (import register)",
2282
- nodeId,
2283
- err2
2284
- );
2285
- }
2286
- }
2287
- await createVersion({
2288
- nestId,
2289
- nodeId,
2290
- version,
2291
- content,
2292
- author: userEmail,
2293
- status: published ? "published" : "draft",
2294
- changeNote: "Imported from existing folder",
2295
- tags
2296
- });
2297
- if (published) {
2298
- await setApprovedVersion(nestId, nodeId, version, userEmail);
2299
- }
2300
- await syncNodeTags(nestId, nodeId, tags);
2301
- registered++;
2302
- }
2303
- console.log(
2304
- `[import] register done: registered=${registered} alreadyTracked=${alreadyTracked} keptUnpublished=${keptUnpublished} publishRecovered=${publishRecovered} publishFailed=${publishFailed} tookMs=${Date.now() - startedAt}`
2305
- );
2306
- await trackEvent("nest.import.documents", { nestId, registered });
2307
- return registered;
2308
- }
2309
- async function updateNode(nestId, nodeId, patch, userEmail) {
2310
- return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
2501
+ await syncNodeTags(nestId, d.id, tags);
2502
+ }
2503
+ const published = docs.filter((d) => d.status === "published").length;
2504
+ console.log(
2505
+ `[import] register done: registered=${docs.length} published=${published} kept=${docs.length - published} failed=${result.failed.length} alreadyTracked=${tracked.size} engineMs=${engineMs} dbMs=${Date.now() - dbStartedAt}`
2506
+ );
2507
+ if (result.failed.length) {
2508
+ console.warn(
2509
+ "[import] documents that would not publish:",
2510
+ result.failed.map((f) => `${f.id}: ${f.error}`).join(", ")
2511
+ );
2512
+ }
2513
+ await trackEvent("nest.import.documents", { nestId, registered: docs.length });
2514
+ return docs.length;
2515
+ }
2516
+ async function updateNode(nestId, nodeId, patch, userEmail) {
2517
+ return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
2311
2518
  try {
2312
2519
  const { storage, versions: versionManager } = await engineCache.get(
2313
2520
  nestId
@@ -2318,30 +2525,43 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2318
2525
  } catch {
2319
2526
  throw new NotFoundError(`Node not found: ${nodeId}`);
2320
2527
  }
2528
+ const opInput = {};
2321
2529
  if (patch.content !== void 0) {
2322
2530
  node = { ...node, body: patch.content };
2531
+ opInput.content = patch.content;
2323
2532
  }
2324
2533
  if (patch.append) {
2325
2534
  node = { ...node, body: (node.body || "") + "\n\n" + patch.append };
2535
+ opInput.append = "\n" + patch.append;
2326
2536
  }
2327
2537
  if (patch.tags) {
2328
- const newTags = patch.tags.map(normalizeTag2);
2329
- const merged = [
2330
- .../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])
2331
- ];
2332
- node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
2538
+ const nextTags = [...new Set(patch.tags.map(normalizeTag2))];
2539
+ node = { ...node, frontmatter: { ...node.frontmatter, tags: nextTags } };
2540
+ opInput.tags = nextTags;
2333
2541
  }
2334
2542
  if (patch.status) {
2335
2543
  node = {
2336
2544
  ...node,
2337
2545
  frontmatter: { ...node.frontmatter, status: patch.status }
2338
2546
  };
2547
+ opInput.status = normalizeStatus(patch.status);
2548
+ } else if (isRejected(node)) {
2549
+ node = {
2550
+ ...node,
2551
+ frontmatter: { ...node.frontmatter, status: "draft" }
2552
+ };
2553
+ opInput.status = "draft";
2339
2554
  }
2340
2555
  if (patch.title) {
2556
+ if (patch.title !== node.frontmatter.title) {
2557
+ assertTitleLength(patch.title);
2558
+ assertTitleSluggable(patch.title);
2559
+ }
2341
2560
  node = {
2342
2561
  ...node,
2343
2562
  frontmatter: { ...node.frontmatter, title: patch.title }
2344
2563
  };
2564
+ opInput.title = patch.title;
2345
2565
  }
2346
2566
  if (patch.schedule !== void 0) {
2347
2567
  if (!isRunnableType(node.frontmatter.type)) {
@@ -2355,10 +2575,25 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2355
2575
  if (patch.schedule === "") delete metadata.schedule;
2356
2576
  else metadata.schedule = patch.schedule;
2357
2577
  node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
2578
+ opInput.metadata = {
2579
+ ...opInput.metadata,
2580
+ schedule: patch.schedule === "" ? null : patch.schedule
2581
+ };
2582
+ }
2583
+ if (patch.prime !== void 0) {
2584
+ await assertMayFlagPrime(nestId, userEmail);
2585
+ const metadata = { ...node.frontmatter.metadata };
2586
+ if (patch.prime === null) delete metadata.prime;
2587
+ else metadata.prime = patch.prime;
2588
+ node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
2589
+ opInput.metadata = { ...opInput.metadata, prime: patch.prime };
2358
2590
  }
2359
2591
  const hasStewards = await isStewardshipEnabled(nestId);
2360
- const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
2361
2592
  const currentTags = node.frontmatter.tags || [];
2593
+ const publishReason = await autoPublishReason(nestId, userEmail, {
2594
+ tags: currentTags,
2595
+ metadata: node.frontmatter.metadata
2596
+ });
2362
2597
  const pendingReview = hasStewards ? await getPendingReview(nestId, nodeId) : null;
2363
2598
  const isAuthorEditingOwnReview = !!pendingReview && pendingReview.requestedBy.toLowerCase() === userEmail.toLowerCase();
2364
2599
  if (pendingReview && !isAuthorEditingOwnReview) {
@@ -2367,22 +2602,15 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2367
2602
  );
2368
2603
  }
2369
2604
  let responseVersion;
2370
- if (!autoPublish || isAuthorEditingOwnReview) {
2605
+ if (publishReason === null || isAuthorEditingOwnReview) {
2371
2606
  const currentVersion = await getCurrentVersion(nestId, nodeId);
2372
- const newVersion = currentVersion + 1;
2373
- node = {
2374
- ...node,
2375
- frontmatter: {
2376
- ...node.frontmatter,
2377
- version: newVersion,
2378
- updated_at: (/* @__PURE__ */ new Date()).toISOString(),
2379
- // Drop stale published-state checksum so the next verified read
2380
- // doesn't flag this write as external drift.
2381
- checksum: void 0
2382
- }
2383
- };
2384
- node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
2385
- await storage.writeDocument(nodeId, serializeDocument(node));
2607
+ const newVersion = await versionManager.nextVersion(nodeId, currentVersion);
2608
+ await engineApi.run(
2609
+ "context_update",
2610
+ { id: nodeId, ...opInput, publish: false, version: newVersion },
2611
+ await opContext(nestId, userEmail)
2612
+ );
2613
+ node = await storage.readDocument(nodeId);
2386
2614
  await syncNodeTags(nestId, nodeId, currentTags);
2387
2615
  try {
2388
2616
  await versionManager.createVersion(node, userEmail, {
@@ -2410,25 +2638,15 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2410
2638
  }
2411
2639
  responseVersion = newVersion;
2412
2640
  } else {
2413
- node = {
2414
- ...node,
2415
- frontmatter: {
2416
- ...node.frontmatter,
2417
- updated_at: (/* @__PURE__ */ new Date()).toISOString(),
2418
- checksum: void 0
2419
- }
2420
- };
2421
- node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
2422
- await storage.writeDocument(nodeId, serializeDocument(node));
2423
- await syncNodeTags(nestId, nodeId, currentTags);
2424
- let publishedVersion = (node.frontmatter.version || 0) + 1;
2641
+ const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
2642
+ let publishedVersion;
2425
2643
  try {
2426
- const result = await safePublishDocument(storage, nodeId, {
2427
- editedBy: userEmail,
2428
- note: patch.changeNote || (hasStewards ? "Auto-published on edit (owner/admin self-approve)" : "Auto-published on edit (no stewards)")
2429
- });
2430
- publishedVersion = result.node.frontmatter.version || publishedVersion;
2431
- node = result.node;
2644
+ const result = await engineApi.run(
2645
+ "context_update",
2646
+ { id: nodeId, ...opInput, publish: true, note: publishNote },
2647
+ await opContext(nestId, userEmail)
2648
+ );
2649
+ publishedVersion = result.version;
2432
2650
  } catch (err) {
2433
2651
  console.error(
2434
2652
  "publishDocument failed (node patch auto-publish)",
@@ -2436,6 +2654,8 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2436
2654
  );
2437
2655
  throw err;
2438
2656
  }
2657
+ node = await storage.readDocument(nodeId);
2658
+ await syncNodeTags(nestId, nodeId, currentTags);
2439
2659
  await upsertVersion({
2440
2660
  nestId,
2441
2661
  nodeId,
@@ -2444,7 +2664,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2444
2664
  author: userEmail,
2445
2665
  status: "published",
2446
2666
  tags: currentTags,
2447
- changeNote: patch.changeNote
2667
+ changeNote: publishNote
2448
2668
  });
2449
2669
  await setApprovedVersion(
2450
2670
  nestId,
@@ -2464,9 +2684,69 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2464
2684
  }
2465
2685
  });
2466
2686
  }
2687
+ async function assertNotReviewLocked(nestId, nodeId, userEmail, action) {
2688
+ if (!await isStewardshipEnabled(nestId)) return;
2689
+ if (!await getPendingReview(nestId, nodeId)) return;
2690
+ if (canApproveWith(await resolveUserRoles(nestId, userEmail, { nodeId }))) return;
2691
+ throw new LockedError(
2692
+ `This document is awaiting steward review and is locked. Approve or reject the pending review before ${action}, or ask a reviewer to.`
2693
+ );
2694
+ }
2467
2695
  function derivedIdOf(sourceNodeId) {
2468
2696
  return `nodes/${sourceNodeId.replace(/^nodes\//, "")}--annotations`;
2469
2697
  }
2698
+ var NODE_KEYED_TABLES = [
2699
+ ["node_versions", "node_id"],
2700
+ ["review_requests", "node_id"],
2701
+ ["deletion_requests", "node_id"],
2702
+ ["approved_versions", "node_id"],
2703
+ ["annotation_threads", "node_id"],
2704
+ ["comments", "node_id"],
2705
+ ["node_tag_index", "node_id"],
2706
+ ["watchers", "node_id"],
2707
+ ["schedules", "agent_node"],
2708
+ // A live capability token bound to one agent node — deleting the agent must
2709
+ // revoke it, or a later node at that id answers the old webhook.
2710
+ ["trigger_hooks", "agent_node"],
2711
+ ["edges", "from_node"],
2712
+ ["edges", "to_node"]
2713
+ ];
2714
+ async function purgeNodeKeyedRows(tx, nestId, nodeId) {
2715
+ for (const [table, column] of NODE_KEYED_TABLES) {
2716
+ await tx.run(`DELETE FROM ${table} WHERE nest_id = ? AND ${column} = ?`, [
2717
+ nestId,
2718
+ nodeId
2719
+ ]);
2720
+ }
2721
+ await tx.run(
2722
+ `DELETE FROM stewards
2723
+ WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
2724
+ [nestId, nodeId]
2725
+ );
2726
+ await tx.run(
2727
+ `DELETE FROM grants
2728
+ WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
2729
+ [nestId, nodeId]
2730
+ );
2731
+ }
2732
+ async function rewriteNodeKeyedRows(tx, nestId, oldId, newId) {
2733
+ for (const [table, column] of NODE_KEYED_TABLES) {
2734
+ await tx.run(
2735
+ `UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
2736
+ [newId, nestId, oldId]
2737
+ );
2738
+ }
2739
+ await tx.run(
2740
+ `UPDATE stewards SET node_pattern = ?
2741
+ WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
2742
+ [newId, nestId, oldId]
2743
+ );
2744
+ await tx.run(
2745
+ `UPDATE grants SET target = ?
2746
+ WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
2747
+ [newId, nestId, oldId]
2748
+ );
2749
+ }
2470
2750
  async function moveNode(nestId, nodeId, folder, userEmail) {
2471
2751
  const { storage } = await engineCache.get(nestId);
2472
2752
  let node;
@@ -2475,108 +2755,138 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
2475
2755
  } catch {
2476
2756
  throw new NotFoundError(`Node not found: ${nodeId}`);
2477
2757
  }
2478
- if (await isStewardshipEnabled(nestId) && await getPendingReview(nestId, nodeId)) {
2479
- throw new LockedError(
2480
- "This document is awaiting steward review and is locked. Approve or reject the pending review before moving."
2481
- );
2482
- }
2758
+ await assertNotReviewLocked(nestId, nodeId, userEmail, "moving");
2483
2759
  const leaf = nodeId.split("/").pop();
2484
2760
  const segments = folderToSegments(folder);
2485
2761
  const newId = segments.length > 0 ? `nodes/${segments.join("/")}/${leaf}` : `nodes/${leaf}`;
2486
2762
  if (newId === nodeId) return { node, previousId: nodeId };
2487
- try {
2488
- await storage.readDocument(newId);
2489
- throw new ConflictError(
2490
- `A document already exists at that location: ${newId}`
2491
- );
2492
- } catch (err) {
2493
- if (err instanceof ConflictError) throw err;
2494
- }
2495
- const moved = {
2496
- ...node,
2497
- id: newId,
2498
- frontmatter: {
2499
- ...node.frontmatter,
2500
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
2763
+ return withNodeWriteLock(nodeWriteKey(nestId, newId), async () => {
2764
+ try {
2765
+ await storage.readDocument(newId);
2766
+ throw new ConflictError(
2767
+ `A document already exists at that location: ${newId}`
2768
+ );
2769
+ } catch (err) {
2770
+ if (err instanceof ConflictError) throw err;
2771
+ if (!(err instanceof DocumentNotFoundError)) throw err;
2501
2772
  }
2502
- };
2503
- await storage.writeDocument(newId, serializeDocument(moved));
2504
- try {
2505
- const nestPath = resolveNestPath(nestId);
2506
- const oldChain = join2(nestPath, dirname(nodeId), ".versions", leaf);
2507
- const newChain = join2(nestPath, dirname(newId), ".versions", leaf);
2508
- if (oldChain !== newChain) {
2509
- await mkdir(dirname(newChain), { recursive: true });
2510
- await rename(oldChain, newChain);
2773
+ const moved = {
2774
+ ...node,
2775
+ id: newId,
2776
+ frontmatter: {
2777
+ ...node.frontmatter,
2778
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
2779
+ }
2780
+ };
2781
+ await storage.writeDocument(newId, serializeNodeSafe(moved));
2782
+ try {
2783
+ const nestPath = resolveNestPath(nestId);
2784
+ const oldChain = join2(nestPath, dirname(nodeId), ".versions", leaf);
2785
+ const newChain = join2(nestPath, dirname(newId), ".versions", leaf);
2786
+ if (oldChain !== newChain) {
2787
+ await mkdir(dirname(newChain), { recursive: true });
2788
+ await rename(oldChain, newChain);
2789
+ }
2790
+ } catch {
2511
2791
  }
2512
- } catch {
2513
- }
2514
- await storage.deleteDocument(nodeId);
2515
- const oldDerived = derivedIdOf(nodeId);
2516
- const newDerived = derivedIdOf(newId);
2517
- const db = getDb();
2518
- await db.transaction(async (tx) => {
2519
- for (const [table, column] of [
2520
- ["node_versions", "node_id"],
2521
- ["review_requests", "node_id"],
2522
- ["approved_versions", "node_id"],
2523
- ["annotation_threads", "node_id"],
2524
- ["comments", "node_id"],
2525
- ["node_tag_index", "node_id"]
2526
- ]) {
2792
+ await storage.deleteDocument(nodeId);
2793
+ const oldDerived = derivedIdOf(nodeId);
2794
+ const newDerived = derivedIdOf(newId);
2795
+ const db = getDb();
2796
+ await db.transaction(async (tx) => {
2797
+ await rewriteNodeKeyedRows(tx, nestId, nodeId, newId);
2798
+ await rewriteNodeKeyedRows(tx, nestId, oldDerived, newDerived);
2527
2799
  await tx.run(
2528
- `UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
2800
+ "UPDATE runs SET agent_node = ? WHERE nest_id = ? AND agent_node = ?",
2529
2801
  [newId, nestId, nodeId]
2530
2802
  );
2531
2803
  await tx.run(
2532
- `UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
2533
- [newDerived, nestId, oldDerived]
2804
+ `UPDATE run_steps SET node_id = ?
2805
+ WHERE node_id = ? AND run_id IN (SELECT id FROM runs WHERE nest_id = ?)`,
2806
+ [newId, nodeId, nestId]
2807
+ );
2808
+ });
2809
+ try {
2810
+ const derivedDoc = await storage.readDocument(oldDerived);
2811
+ await storage.writeDocument(
2812
+ newDerived,
2813
+ serializeNodeSafe({ ...derivedDoc, id: newDerived })
2534
2814
  );
2815
+ await storage.deleteDocument(oldDerived);
2816
+ } catch {
2535
2817
  }
2536
- await tx.run(
2537
- `UPDATE stewards SET node_pattern = ?
2538
- WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
2539
- [newId, nestId, nodeId]
2540
- );
2541
- await tx.run(
2542
- `UPDATE grants SET target = ?
2543
- WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
2544
- [newId, nestId, nodeId]
2545
- );
2546
- await tx.run(
2547
- "UPDATE edges SET from_node = ? WHERE nest_id = ? AND from_node = ?",
2548
- [newId, nestId, nodeId]
2549
- );
2550
- await tx.run(
2551
- "UPDATE edges SET to_node = ? WHERE nest_id = ? AND to_node = ?",
2552
- [newId, nestId, nodeId]
2818
+ await trackEvent("node.move", { nestId, nodeId, newId });
2819
+ return { node: moved, previousId: nodeId };
2820
+ });
2821
+ }
2822
+ async function deleteNode(nestId, nodeId, userEmail) {
2823
+ const { storage } = await engineCache.get(nestId);
2824
+ await assertNotReviewLocked(nestId, nodeId, userEmail, "deleting");
2825
+ try {
2826
+ await engineApi.run(
2827
+ "context_delete",
2828
+ { id: nodeId },
2829
+ await opContext(nestId, userEmail)
2553
2830
  );
2554
- await tx.run(
2555
- "UPDATE runs SET agent_node = ? WHERE nest_id = ? AND agent_node = ?",
2556
- [newId, nestId, nodeId]
2831
+ } catch {
2832
+ throw new NotFoundError(`Node not found: ${nodeId}`);
2833
+ }
2834
+ await removeNodeFromTagIndex(nestId, nodeId);
2835
+ const derivedId = derivedIdOf(nodeId);
2836
+ try {
2837
+ await engineApi.run(
2838
+ "context_delete",
2839
+ { id: derivedId },
2840
+ await opContext(nestId, userEmail)
2557
2841
  );
2842
+ await removeNodeFromTagIndex(nestId, derivedId);
2843
+ } catch {
2844
+ }
2845
+ const db = getDb();
2846
+ await db.transaction(async (tx) => {
2847
+ await purgeNodeKeyedRows(tx, nestId, nodeId);
2848
+ await purgeNodeKeyedRows(tx, nestId, derivedId);
2558
2849
  await tx.run(
2559
- `UPDATE run_steps SET node_id = ?
2560
- WHERE node_id = ? AND run_id IN (SELECT id FROM runs WHERE nest_id = ?)`,
2561
- [newId, nodeId, nestId]
2850
+ `INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
2851
+ VALUES (?, ?, ?, ?)
2852
+ ON CONFLICT(nest_id, node_id) DO UPDATE
2853
+ SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
2854
+ [nestId, nodeId, userEmail, (/* @__PURE__ */ new Date()).toISOString()]
2562
2855
  );
2563
2856
  });
2564
- try {
2565
- const derivedDoc = await storage.readDocument(oldDerived);
2566
- await storage.writeDocument(
2567
- newDerived,
2568
- serializeDocument({ ...derivedDoc, id: newDerived })
2569
- );
2570
- await storage.deleteDocument(oldDerived);
2571
- } catch {
2857
+ await trackEvent("node.delete", { nestId, nodeId });
2858
+ }
2859
+ async function deleteFolder(nestId, folder, userEmail) {
2860
+ const segments = folderToSegments(folder);
2861
+ if (segments.length === 0) {
2862
+ throw new ValidationError("folder is required");
2863
+ }
2864
+ const rel = segments.join("/");
2865
+ const { storage } = await engineCache.get(nestId);
2866
+ const docs = await storage.discoverDocuments();
2867
+ const targets = docs.map((d) => d.id).filter((id) => isIdUnderFolder(id, rel));
2868
+ if (targets.length === 0) {
2869
+ throw new NotFoundError(`No documents found under folder: ${folder}`);
2870
+ }
2871
+ const deleted = [];
2872
+ for (const id of targets) {
2873
+ try {
2874
+ await deleteNode(nestId, id, userEmail);
2875
+ deleted.push(id);
2876
+ } catch (err) {
2877
+ const detail = deleted.length > 0 ? ` (${deleted.length} of ${targets.length} already deleted)` : "";
2878
+ throw new Error(
2879
+ `Failed to delete "${id}"${detail}: ${err instanceof Error ? err.message : String(err)}`
2880
+ );
2881
+ }
2572
2882
  }
2573
- await trackEvent("node.move", { nestId, nodeId, newId });
2574
- return { node: moved, previousId: nodeId };
2883
+ return deleted;
2575
2884
  }
2576
2885
 
2577
2886
  // src/nests/unsynced-service.ts
2578
- import { readdirSync, readFileSync, rmSync, statSync } from "fs";
2887
+ import { readdirSync, readFileSync, statSync } from "fs";
2579
2888
  import { join as join3, relative, resolve } from "path";
2889
+ import { rm } from "fs/promises";
2580
2890
  var RESERVED = /* @__PURE__ */ new Set(["nests"]);
2581
2891
  function isNestStorageRoot(absPath) {
2582
2892
  return resolve(absPath) === resolve(nestStorageRoot());
@@ -2744,23 +3054,31 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
2744
3054
  `[unsynced] name "${baseName}" already in use, using "${nestName2}" instead`
2745
3055
  );
2746
3056
  }
2747
- const nest = await importNest(userId, nestName2, files);
3057
+ const nest = await importNest(userId, nestName2);
2748
3058
  console.log(
2749
3059
  `[unsynced] nest created id=${nest.id} name="${nest.name}" from folder="${folderName}"`
2750
3060
  );
2751
- const documents = await registerImportedDocuments(nest.id, callerEmail);
3061
+ let documents;
3062
+ try {
3063
+ await stageImportedFiles(nest.id, files, callerEmail);
3064
+ documents = await registerImportedDocuments(nest.id, callerEmail);
3065
+ } catch (err) {
3066
+ await deleteNest(nest.id).catch(() => {
3067
+ });
3068
+ throw err;
3069
+ }
2752
3070
  console.log(
2753
3071
  `[unsynced] registered ${documents} document(s) for nest ${nest.id}`
2754
3072
  );
2755
3073
  try {
2756
- rmSync(src, { recursive: true, force: true });
3074
+ await rm(src, { recursive: true, force: true });
2757
3075
  console.log(`[unsynced] removed source folder ${src}`);
2758
3076
  } catch (err) {
2759
3077
  console.error("[unsynced] failed to remove source folder", src, err);
2760
3078
  }
2761
3079
  return { nest, documents };
2762
3080
  }
2763
- function deleteUnsyncedFolder(folderName) {
3081
+ async function deleteUnsyncedFolder(folderName) {
2764
3082
  console.log(`[unsynced] delete requested folder="${folderName}"`);
2765
3083
  assertSafeFolderName(folderName);
2766
3084
  const src = join3(config.DATA_ROOT, folderName);
@@ -2776,7 +3094,7 @@ function deleteUnsyncedFolder(folderName) {
2776
3094
  if (!stat.isDirectory()) {
2777
3095
  throw new ValidationError(`Not a directory: ${folderName}`);
2778
3096
  }
2779
- rmSync(src, { recursive: true, force: true });
3097
+ await rm(src, { recursive: true, force: true });
2780
3098
  console.log(`[unsynced] deleted folder ${src}`);
2781
3099
  }
2782
3100
 
@@ -2883,42 +3201,50 @@ nestRoutes.post("/:nestId", async (c) => {
2883
3201
  throw new NotFoundError("Not found");
2884
3202
  }
2885
3203
  const body = await c.req.json();
3204
+ const files = Array.isArray(body.files) ? body.files : [];
3205
+ const userId = c.get("userId");
3206
+ if (body.nestId) {
3207
+ const target = body.nestId;
3208
+ const nest2 = await getNest(target);
3209
+ if (!nest2 || !nest2.is_imported || await effectivePermission(target, userId) !== "owner") {
3210
+ throw new NotFoundError("Nest not found");
3211
+ }
3212
+ const email2 = await resolveCallerEmail(userId);
3213
+ if (body.finalize) {
3214
+ return c.json({ nest: nest2, documents: await registerImportedDocuments(target, email2) });
3215
+ }
3216
+ const registered = await getDb().get(
3217
+ "SELECT COUNT(*) AS c FROM node_versions WHERE nest_id = ?",
3218
+ [target]
3219
+ );
3220
+ if (Number(registered?.c ?? 0) > 0) {
3221
+ throw new ValidationError(
3222
+ "This nest has already been imported. Start a new import instead of adding files to a finished one."
3223
+ );
3224
+ }
3225
+ console.log(`[import] chunk: nest=${target} files=${files.length}`);
3226
+ return c.json(await stageImportedFiles(target, files, email2));
3227
+ }
2886
3228
  if (!body.name) {
2887
3229
  throw new ValidationError("name is required");
2888
3230
  }
2889
- const files = Array.isArray(body.files) ? body.files : [];
2890
- const userId = c.get("userId");
2891
3231
  console.log(
2892
- `[import] request: name=${JSON.stringify(body.name)} files=${files.length}`
3232
+ `[import] request: name=${JSON.stringify(body.name)} files=${files.length}` + (body.partial ? " (first chunk)" : "")
2893
3233
  );
2894
- if (c.req.query("stream") === "1") {
2895
- const name = body.name;
2896
- return streamText(c, async (stream) => {
2897
- try {
2898
- await stream.writeln(JSON.stringify({ phase: "write" }));
2899
- const nest2 = await importNest(userId, name, files);
2900
- const email = await resolveCallerEmail(userId);
2901
- const documents2 = await registerImportedDocuments(
2902
- nest2.id,
2903
- email,
2904
- async (done, total) => {
2905
- await stream.writeln(JSON.stringify({ phase: "register", done, total }));
2906
- }
2907
- );
2908
- await stream.writeln(JSON.stringify({ complete: true, nest: nest2, documents: documents2 }));
2909
- } catch (err) {
2910
- await stream.writeln(
2911
- JSON.stringify({ error: err?.message || "Import failed" })
2912
- );
2913
- }
3234
+ const email = await resolveCallerEmail(userId);
3235
+ const nest = await importNest(userId, body.name);
3236
+ try {
3237
+ const { rejected } = await stageImportedFiles(nest.id, files, email);
3238
+ if (body.partial) {
3239
+ return c.json({ nest, rejected }, 201);
3240
+ }
3241
+ const documents = await registerImportedDocuments(nest.id, email);
3242
+ return c.json({ nest, documents, rejected }, 201);
3243
+ } catch (err) {
3244
+ await deleteNest(nest.id).catch(() => {
2914
3245
  });
3246
+ throw err;
2915
3247
  }
2916
- const nest = await importNest(userId, body.name, files);
2917
- const documents = await registerImportedDocuments(
2918
- nest.id,
2919
- await resolveCallerEmail(userId)
2920
- );
2921
- return c.json({ nest, documents }, 201);
2922
3248
  });
2923
3249
  async function handleUnsyncedList(c) {
2924
3250
  const userId = c.get("userId");
@@ -2958,7 +3284,7 @@ nestRoutes.delete("/:nestId/sync", async (c) => {
2958
3284
  if (!body?.name) {
2959
3285
  throw new ValidationError("name is required");
2960
3286
  }
2961
- deleteUnsyncedFolder(body.name);
3287
+ await deleteUnsyncedFolder(body.name);
2962
3288
  return c.json({ ok: true });
2963
3289
  });
2964
3290
  nestRoutes.get("/:nestId", async (c) => {
@@ -3023,7 +3349,9 @@ nestRoutes.get("/:nestId/settings", async (c) => {
3023
3349
  }
3024
3350
  return c.json({
3025
3351
  stewardship_enabled: await isStewardshipEnabled(nestId),
3026
- allow_self_approve: await nestAllowsSelfApprove(nestId)
3352
+ allow_self_approve: await nestAllowsSelfApprove(nestId),
3353
+ prime_only_review: await nestReviewsPrimeOnly(nestId),
3354
+ prime_tags: await nestPrimeTags(nestId)
3027
3355
  });
3028
3356
  });
3029
3357
  nestRoutes.patch("/:nestId/settings", async (c) => {
@@ -3059,9 +3387,20 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
3059
3387
  if (typeof body.allow_self_approve === "boolean") {
3060
3388
  await setAllowSelfApprove(nestId, body.allow_self_approve);
3061
3389
  }
3390
+ if (typeof body.prime_only_review === "boolean") {
3391
+ await setPrimeOnlyReview(nestId, body.prime_only_review);
3392
+ }
3393
+ if (body.prime_tags !== void 0) {
3394
+ await setPrimeTags(
3395
+ nestId,
3396
+ Array.isArray(body.prime_tags) ? body.prime_tags : [body.prime_tags]
3397
+ );
3398
+ }
3062
3399
  return c.json({
3063
3400
  stewardship_enabled: await isStewardshipEnabled(nestId),
3064
3401
  allow_self_approve: await nestAllowsSelfApprove(nestId),
3402
+ prime_only_review: await nestReviewsPrimeOnly(nestId),
3403
+ prime_tags: await nestPrimeTags(nestId),
3065
3404
  wiped
3066
3405
  });
3067
3406
  });
@@ -3662,27 +4001,33 @@ teamRoutes.delete("/:teamId/members/:userId", async (c) => {
3662
4001
  return c.json({ team });
3663
4002
  });
3664
4003
 
4004
+ // src/nodes/search.ts
4005
+ function matchDocuments(documents, query) {
4006
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
4007
+ if (terms.length === 0) return [];
4008
+ return documents.filter((node) => {
4009
+ const haystack = [
4010
+ node.frontmatter.title,
4011
+ node.body || "",
4012
+ node.frontmatter.type || "",
4013
+ ...node.frontmatter.tags || []
4014
+ ].join(" ").toLowerCase();
4015
+ return terms.every((term) => haystack.includes(term));
4016
+ });
4017
+ }
4018
+ function toSearchHit(node) {
4019
+ return {
4020
+ id: node.id,
4021
+ title: node.frontmatter.title,
4022
+ type: node.frontmatter.type || "document",
4023
+ tags: node.frontmatter.tags || [],
4024
+ snippet: (node.body || "").slice(0, 200).replace(/\n/g, " ")
4025
+ };
4026
+ }
4027
+
3665
4028
  // src/nodes/routes.ts
3666
4029
  import { Hono as Hono8 } from "hono";
3667
4030
 
3668
- // src/shared/artifact-sandbox.ts
3669
- var ARTIFACT_SANDBOX_CSP = [
3670
- "sandbox allow-scripts allow-popups allow-modals",
3671
- "default-src 'none'",
3672
- "script-src 'unsafe-inline'",
3673
- "style-src 'unsafe-inline'",
3674
- "img-src data: blob:",
3675
- "font-src data:",
3676
- "connect-src 'none'",
3677
- "base-uri 'none'",
3678
- "form-action 'none'"
3679
- ].join("; ");
3680
- function applyArtifactSandboxHeaders(c) {
3681
- c.header("Content-Security-Policy", ARTIFACT_SANDBOX_CSP);
3682
- c.header("X-Content-Type-Options", "nosniff");
3683
- c.header("Referrer-Policy", "no-referrer");
3684
- }
3685
-
3686
4031
  // src/nodes/markdown-export.ts
3687
4032
  import { parseDocument as parseDocument3 } from "@promptowl/contextnest-engine";
3688
4033
  function nodeToMarkdown(node) {
@@ -3732,311 +4077,23 @@ ${m[1]}
3732
4077
  ` : "";
3733
4078
  }
3734
4079
 
3735
- // src/annotations/service.ts
3736
- import { v4 as uuid5 } from "uuid";
3737
-
3738
- // src/annotations/projection.ts
3739
- var MAX_CONTEXT_CHARS = 250;
3740
- var MAX_QUOTE_CHARS = 1e3;
3741
- function clampAnchor(raw) {
3742
- if (!raw) return null;
3743
- const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
3744
- if (!quote) return null;
3745
- const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
3746
- const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
3747
- const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
3748
- return { quote, before, after, ...line !== void 0 ? { line } : {} };
3749
- }
3750
- function oneLine(s) {
3751
- return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
4080
+ // src/nodes/routes.ts
4081
+ var nodeRoutes = new Hono8();
4082
+ function nodeAsMarkdown(response, nodeId) {
4083
+ return nodeToMarkdown({
4084
+ id: nodeId,
4085
+ title: response.title,
4086
+ tags: response.tags,
4087
+ status: response.status,
4088
+ body: response.content
4089
+ });
3752
4090
  }
3753
- function renderAnchor(anchor) {
3754
- if (!anchor) {
3755
- return "<!-- anchor: whole-artifact -->";
3756
- }
3757
- const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
3758
- const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
3759
- anchor.after
3760
- )}\u2026`;
3761
- return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
3762
- }
3763
- function statusHeading(thread) {
3764
- if (thread.status === "resolved") {
3765
- const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
3766
- const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
3767
- const meta = who || when ? ` (${who}${when})` : "";
3768
- return `\u2705 RESOLVED${meta}`;
3769
- }
3770
- return "\u{1F7E0} OPEN";
3771
- }
3772
- function snapshotLabel(v) {
3773
- return v == null ? "Unpinned" : `Snapshot v${v}`;
3774
- }
3775
- function orderThreads(threads) {
3776
- return [...threads].sort((a, b) => {
3777
- if (a.status !== b.status) return a.status === "open" ? -1 : 1;
3778
- return a.createdAt.localeCompare(b.createdAt);
3779
- });
3780
- }
3781
- function projectThreadsToMarkdown(artifactTitle, threads) {
3782
- const lines = [];
3783
- lines.push(`# ${artifactTitle} \u2014 Annotations`);
3784
- lines.push("");
3785
- lines.push(
3786
- "> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
3787
- );
3788
- lines.push("");
3789
- if (threads.length === 0) {
3790
- lines.push("_No annotations yet._");
3791
- lines.push("");
3792
- return lines.join("\n");
3793
- }
3794
- const buckets2 = /* @__PURE__ */ new Map();
3795
- for (const t of threads) {
3796
- const key = t.snapshotVersion ?? null;
3797
- (buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
3798
- }
3799
- const keys = [...buckets2.keys()].sort((a, b) => {
3800
- if (a == null) return 1;
3801
- if (b == null) return -1;
3802
- return b - a;
3803
- });
3804
- for (const key of keys) {
3805
- lines.push(`## ${snapshotLabel(key)}`);
3806
- lines.push("");
3807
- for (const thread of orderThreads(buckets2.get(key))) {
3808
- lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
3809
- lines.push(renderAnchor(thread.anchor));
3810
- for (const comment of thread.comments) {
3811
- lines.push(
3812
- `- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
3813
- );
3814
- }
3815
- lines.push("");
3816
- }
3817
- }
3818
- return lines.join("\n");
3819
- }
3820
-
3821
- // src/annotations/service.ts
3822
- async function loadComments(threadId) {
3823
- const db = getDb();
3824
- const rows = await db.all(
3825
- "SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
3826
- [threadId]
3827
- );
3828
- return rows.map((r) => ({
3829
- id: r.id,
3830
- author: r.author,
3831
- body: r.body,
3832
- createdAt: r.created_at
3833
- }));
3834
- }
3835
- async function rowToThread(row) {
3836
- let anchor = null;
3837
- if (row.anchor_json) {
3838
- try {
3839
- anchor = JSON.parse(row.anchor_json);
3840
- } catch {
3841
- anchor = null;
3842
- }
3843
- }
3844
- return {
3845
- id: row.id,
3846
- nestId: row.nest_id,
3847
- nodeId: row.node_id,
3848
- snapshotVersion: row.snapshot_version,
3849
- anchor,
3850
- status: row.status,
3851
- createdBy: row.created_by,
3852
- createdAt: row.created_at,
3853
- resolvedBy: row.resolved_by,
3854
- resolvedAt: row.resolved_at,
3855
- comments: await loadComments(row.id)
3856
- };
3857
- }
3858
- async function getThreadRow(threadId) {
3859
- return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
3860
- }
3861
- async function countThreadsByNode(nestId) {
3862
- const rows = await getDb().all(
3863
- `SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
3864
- FROM annotation_threads
3865
- WHERE nest_id = ?
3866
- GROUP BY node_id`,
3867
- [nestId]
3868
- );
3869
- const out = {};
3870
- for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
3871
- return out;
3872
- }
3873
- async function listThreads(nestId, nodeId) {
3874
- const rows = await getDb().all(
3875
- "SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
3876
- [nestId, nodeId]
3877
- );
3878
- return Promise.all(rows.map(rowToThread));
3879
- }
3880
- async function createThread(nestId, nodeId, input, authorEmail) {
3881
- const body = (input.body ?? "").trim();
3882
- if (!body) {
3883
- throw new Error("comment body is required");
3884
- }
3885
- const db = getDb();
3886
- const id = uuid5();
3887
- const anchor = clampAnchor(input.anchor);
3888
- const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
3889
- await db.transaction(async (tx) => {
3890
- await tx.run(
3891
- `INSERT INTO annotation_threads
3892
- (id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
3893
- VALUES (?, ?, ?, ?, ?, 'open', ?)`,
3894
- [
3895
- id,
3896
- nestId,
3897
- nodeId,
3898
- snapshot,
3899
- anchor ? JSON.stringify(anchor) : null,
3900
- authorEmail
3901
- ]
3902
- );
3903
- await tx.run(
3904
- "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
3905
- [uuid5(), id, authorEmail, body]
3906
- );
3907
- });
3908
- return rowToThread(await getThreadRow(id));
3909
- }
3910
- async function getScopedThreadRow(threadId, nestId, nodeId) {
3911
- const row = await getThreadRow(threadId);
3912
- if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
3913
- throw new NotFoundError(`Thread not found: ${threadId}`);
3914
- }
3915
- return row;
3916
- }
3917
- async function addComment(nestId, nodeId, threadId, authorEmail, body) {
3918
- const trimmed = (body ?? "").trim();
3919
- if (!trimmed) {
3920
- throw new Error("comment body is required");
3921
- }
3922
- await getScopedThreadRow(threadId, nestId, nodeId);
3923
- await getDb().run(
3924
- "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
3925
- [uuid5(), threadId, authorEmail, trimmed]
3926
- );
3927
- return rowToThread(await getThreadRow(threadId));
3928
- }
3929
- async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
3930
- await getScopedThreadRow(threadId, nestId, nodeId);
3931
- const db = getDb();
3932
- if (status === "resolved") {
3933
- await db.run(
3934
- `UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
3935
- [byEmail, threadId]
3936
- );
3937
- } else {
3938
- await db.run(
3939
- "UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
3940
- [threadId]
3941
- );
3942
- }
3943
- return rowToThread(await getThreadRow(threadId));
3944
- }
3945
- async function readArtifact(nestId, nodeId, version) {
3946
- const { storage, versions: versionManager } = await engineCache.get(nestId);
3947
- const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
3948
- let title = nodeId;
3949
- let type = null;
3950
- let liveHtml = "";
3951
- let liveExists = true;
3952
- try {
3953
- const node = await storage.readDocument(nodeId);
3954
- title = node.frontmatter?.title || nodeId;
3955
- type = node.frontmatter?.type || null;
3956
- liveHtml = node.body || "";
3957
- } catch {
3958
- liveExists = false;
3959
- }
3960
- if (target != null) {
3961
- try {
3962
- const raw = await versionManager.reconstructVersion(nodeId, target);
3963
- return { title, html: bodyOnly(nodeId, raw), version: target, type };
3964
- } catch (err) {
3965
- console.warn(
3966
- `[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
3967
- err
3968
- );
3969
- }
3970
- }
3971
- if (!liveExists) {
3972
- throw new NotFoundError(`Artifact not found: ${nodeId}`);
3973
- }
3974
- return { title, html: liveHtml, version: target, type };
3975
- }
3976
- function derivedAnnotationsId(sourceNodeId) {
3977
- const base = sourceNodeId.replace(/^nodes\//, "");
3978
- return `nodes/${base}--annotations`;
3979
- }
3980
- async function syncAnnotationsNode(nestId, nodeId, userEmail) {
3981
- try {
3982
- let title = nodeId;
3983
- let type = null;
3984
- try {
3985
- const { storage } = await engineCache.get(nestId);
3986
- const node = await storage.readDocument(nodeId);
3987
- title = node.frontmatter?.title || nodeId;
3988
- type = node.frontmatter?.type || "document";
3989
- } catch {
3990
- }
3991
- if (type !== null && type !== "artifact") return;
3992
- const threads = await listThreads(nestId, nodeId);
3993
- const derivedTitle = `${title} \u2014 Annotations`;
3994
- const markdown = projectThreadsToMarkdown(title, threads);
3995
- const derivedId = derivedAnnotationsId(nodeId);
3996
- try {
3997
- await updateNode(nestId, derivedId, { content: markdown }, userEmail);
3998
- } catch (err) {
3999
- if (err instanceof NotFoundError) {
4000
- await createNode(
4001
- nestId,
4002
- {
4003
- id: derivedId,
4004
- title: derivedTitle,
4005
- content: markdown,
4006
- type: "document",
4007
- tags: ["annotations"]
4008
- },
4009
- userEmail
4010
- );
4011
- } else {
4012
- throw err;
4013
- }
4014
- }
4015
- } catch (err) {
4016
- console.warn(
4017
- `[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
4018
- err
4019
- );
4020
- }
4021
- }
4022
-
4023
- // src/nodes/routes.ts
4024
- var nodeRoutes = new Hono8();
4025
- function nodeAsMarkdown(response, nodeId) {
4026
- return nodeToMarkdown({
4027
- id: nodeId,
4028
- title: response.title,
4029
- tags: response.tags,
4030
- status: response.status,
4031
- body: response.content
4032
- });
4033
- }
4034
- function rawNodeBody(c, response, storedRaw) {
4035
- const body = response.content ?? "";
4036
- const payload = c.req.query("frontmatter") === "1" ? frontmatterBlockOf(storedRaw) + body : body;
4037
- c.header("X-Content-Type-Options", "nosniff");
4038
- if ((response.type || "document") === "artifact") {
4039
- applyArtifactSandboxHeaders(c);
4091
+ function rawNodeBody(c, response, storedRaw) {
4092
+ const body = response.content ?? "";
4093
+ const payload = c.req.query("frontmatter") === "1" ? frontmatterBlockOf(storedRaw) + body : body;
4094
+ c.header("X-Content-Type-Options", "nosniff");
4095
+ if ((response.type || "document") === "artifact") {
4096
+ applyArtifactSandboxHeaders(c);
4040
4097
  }
4041
4098
  return c.body(payload, 200, {
4042
4099
  "Content-Type": rawContentType(response.type)
@@ -4045,7 +4102,17 @@ function rawNodeBody(c, response, storedRaw) {
4045
4102
  nodeRoutes.get("/", async (c) => {
4046
4103
  const nestId = c.req.param("nestId");
4047
4104
  const userId = c.get("userId");
4048
- const nodes = await listNodesForCaller(nestId, userId);
4105
+ const q = c.req.query();
4106
+ const limit = q.limit ? parseInt(q.limit, 10) : void 0;
4107
+ const nodes = await listNodesForCaller(nestId, userId, {
4108
+ ...q.type ? { type: q.type } : {},
4109
+ ...q.tag ? { tag: q.tag } : {},
4110
+ ...q.status ? { status: q.status } : {},
4111
+ // Lets an owner preview what making the nest public would expose.
4112
+ ...q.approved_only === "1" || q.approved_only === "true" ? { approvedOnly: true } : {},
4113
+ // Ignore junk rather than 400 — a bad ?limit should not fail a listing.
4114
+ ...limit !== void 0 && Number.isFinite(limit) && limit > 0 ? { limit } : {}
4115
+ });
4049
4116
  return c.json({ count: nodes.length, nodes });
4050
4117
  });
4051
4118
  nodeRoutes.post("/", async (c) => {
@@ -4068,7 +4135,8 @@ nodeRoutes.post("/", async (c) => {
4068
4135
  scope: body.scope,
4069
4136
  status: body.status,
4070
4137
  schedule: body.schedule,
4071
- folder: body.folder
4138
+ folder: body.folder,
4139
+ prime: body.prime
4072
4140
  },
4073
4141
  authorEmail
4074
4142
  );
@@ -4085,7 +4153,7 @@ nodeRoutes.post("/", async (c) => {
4085
4153
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4086
4154
  const nestId = c.req.param("nestId");
4087
4155
  const nodeId = c.req.param("nodeId");
4088
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-G7IF7ZNP.js");
4156
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-LTREGVSJ.js");
4089
4157
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
4090
4158
  nestId,
4091
4159
  nodeId
@@ -4103,42 +4171,10 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4103
4171
  ownerEmail
4104
4172
  });
4105
4173
  });
4106
- nodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
4107
- const nestId = c.req.param("nestId");
4108
- const nodeId = c.req.param("nodeId");
4109
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-FGL47APL.js");
4110
- const allVersions = await getVersions2(nestId, nodeId);
4111
- const approved = await getApprovedVersion2(nestId, nodeId);
4112
- const db = getDb();
4113
- const resolutions = await db.all(
4114
- `SELECT version, status, resolved_by, resolved_at
4115
- FROM review_requests
4116
- WHERE nest_id = ? AND node_id = ?
4117
- AND status IN ('approved', 'rejected')
4118
- AND resolved_by IS NOT NULL
4119
- ORDER BY resolved_at DESC`,
4120
- [nestId, nodeId]
4121
- );
4122
- const byVersion = /* @__PURE__ */ new Map();
4123
- for (const r of resolutions) {
4124
- if (!byVersion.has(r.version)) {
4125
- byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
4126
- }
4127
- }
4128
- const enriched = allVersions.map((v) => {
4129
- const r = byVersion.get(v.version);
4130
- return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
4131
- });
4132
- return c.json({
4133
- versions: enriched,
4134
- approvedVersion: approved,
4135
- currentVersion: allVersions[0]?.version || 0
4136
- });
4137
- });
4138
4174
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
4139
4175
  const nestId = c.req.param("nestId");
4140
4176
  const nodeId = c.req.param("nodeId");
4141
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-6G7MVTY6.js");
4177
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-V2GR6VM3.js");
4142
4178
  const history = await getReviewHistory2(nestId, nodeId);
4143
4179
  return c.json({ reviews: history });
4144
4180
  });
@@ -4227,18 +4263,13 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
4227
4263
  const { storage, versions: versionManager } = await engineCache.get(nestId);
4228
4264
  const userId = c.get("userId");
4229
4265
  const userEmail = await resolveCallerEmail(userId);
4230
- if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
4231
- return c.json(
4232
- { error: "Access denied \u2014 no steward assignment for this node" },
4233
- 403
4234
- );
4235
- }
4236
- let node;
4237
- try {
4238
- node = await storage.readDocument(nodeId, { verifyChecksum: true });
4239
- } catch {
4240
- throw new NotFoundError(`Node not found: ${nodeId}`);
4241
- }
4266
+ let node = await getNodeForCaller(
4267
+ nestId,
4268
+ { id: nodeId },
4269
+ userId,
4270
+ userEmail,
4271
+ { verifyChecksum: true }
4272
+ );
4242
4273
  if (node.pendingChange) {
4243
4274
  try {
4244
4275
  await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
@@ -4304,6 +4335,10 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
4304
4335
  const pending = await getPendingReview(nestId, nodeId);
4305
4336
  response.pendingReviewBy = pending?.requestedBy ?? null;
4306
4337
  }
4338
+ response.deletionRequest = await getLatestDeletionRequest(
4339
+ nestId,
4340
+ nodeId
4341
+ );
4307
4342
  if (isRawFormat(c)) {
4308
4343
  return rawNodeBody(c, response, node.rawContent);
4309
4344
  }
@@ -4347,93 +4382,317 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
4347
4382
  title: body.title,
4348
4383
  status: body.status,
4349
4384
  changeNote: body.changeNote,
4350
- schedule: body.schedule
4385
+ schedule: body.schedule,
4386
+ prime: body.prime
4351
4387
  },
4352
4388
  authorEmail
4353
4389
  );
4354
- return c.json({ node: toNodeResponse(node), version: responseVersion });
4355
- });
4356
- nodeRoutes.delete("/:nodeId{.+}", async (c) => {
4357
- const nestId = c.req.param("nestId");
4358
- const nodeId = c.req.param("nodeId");
4359
- const { storage } = await engineCache.get(nestId);
4360
- if (await isStewardshipEnabled(nestId) && await getPendingReview(nestId, nodeId)) {
4361
- throw new LockedError(
4362
- "This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
4363
- );
4364
- }
4365
- try {
4366
- await storage.deleteDocument(nodeId);
4367
- } catch {
4368
- throw new NotFoundError(`Node not found: ${nodeId}`);
4369
- }
4370
- await removeNodeFromTagIndex(nestId, nodeId);
4371
- const derivedId = derivedAnnotationsId(nodeId);
4372
- try {
4373
- await storage.deleteDocument(derivedId);
4374
- await removeNodeFromTagIndex(nestId, derivedId);
4375
- } catch {
4390
+ return c.json({ node: toNodeResponse(node), version: responseVersion });
4391
+ });
4392
+ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
4393
+ const nestId = c.req.param("nestId");
4394
+ const nodeId = c.req.param("nodeId");
4395
+ await deleteNode(nestId, nodeId, await getUserEmail(c));
4396
+ return c.json({ deleted: true });
4397
+ });
4398
+ async function getUserEmail(c) {
4399
+ const userId = c.get("userId");
4400
+ const db = getDb();
4401
+ const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
4402
+ return user?.email || "anonymous@localhost";
4403
+ }
4404
+
4405
+ // src/annotations/routes.ts
4406
+ import { Hono as Hono9 } from "hono";
4407
+
4408
+ // src/annotations/service.ts
4409
+ import { v4 as uuid5 } from "uuid";
4410
+
4411
+ // src/annotations/projection.ts
4412
+ var MAX_CONTEXT_CHARS = 250;
4413
+ var MAX_QUOTE_CHARS = 1e3;
4414
+ function clampAnchor(raw) {
4415
+ if (!raw) return null;
4416
+ const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
4417
+ if (!quote) return null;
4418
+ const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
4419
+ const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
4420
+ const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
4421
+ return { quote, before, after, ...line !== void 0 ? { line } : {} };
4422
+ }
4423
+ function oneLine(s) {
4424
+ return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
4425
+ }
4426
+ function renderAnchor(anchor) {
4427
+ if (!anchor) {
4428
+ return "<!-- anchor: whole-artifact -->";
4429
+ }
4430
+ const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
4431
+ const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
4432
+ anchor.after
4433
+ )}\u2026`;
4434
+ return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
4435
+ }
4436
+ function statusHeading(thread) {
4437
+ if (thread.status === "resolved") {
4438
+ const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
4439
+ const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
4440
+ const meta = who || when ? ` (${who}${when})` : "";
4441
+ return `\u2705 RESOLVED${meta}`;
4442
+ }
4443
+ return "\u{1F7E0} OPEN";
4444
+ }
4445
+ function snapshotLabel(v) {
4446
+ return v == null ? "Unpinned" : `Snapshot v${v}`;
4447
+ }
4448
+ function orderThreads(threads) {
4449
+ return [...threads].sort((a, b) => {
4450
+ if (a.status !== b.status) return a.status === "open" ? -1 : 1;
4451
+ return a.createdAt.localeCompare(b.createdAt);
4452
+ });
4453
+ }
4454
+ function projectThreadsToMarkdown(artifactTitle, threads) {
4455
+ const lines = [];
4456
+ lines.push(`# ${artifactTitle} \u2014 Annotations`);
4457
+ lines.push("");
4458
+ lines.push(
4459
+ "> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
4460
+ );
4461
+ lines.push("");
4462
+ if (threads.length === 0) {
4463
+ lines.push("_No annotations yet._");
4464
+ lines.push("");
4465
+ return lines.join("\n");
4466
+ }
4467
+ const buckets2 = /* @__PURE__ */ new Map();
4468
+ for (const t of threads) {
4469
+ const key = t.snapshotVersion ?? null;
4470
+ (buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
4471
+ }
4472
+ const keys = [...buckets2.keys()].sort((a, b) => {
4473
+ if (a == null) return 1;
4474
+ if (b == null) return -1;
4475
+ return b - a;
4476
+ });
4477
+ for (const key of keys) {
4478
+ lines.push(`## ${snapshotLabel(key)}`);
4479
+ lines.push("");
4480
+ for (const thread of orderThreads(buckets2.get(key))) {
4481
+ lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
4482
+ lines.push(renderAnchor(thread.anchor));
4483
+ for (const comment of thread.comments) {
4484
+ lines.push(
4485
+ `- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
4486
+ );
4487
+ }
4488
+ lines.push("");
4489
+ }
4490
+ }
4491
+ return lines.join("\n");
4492
+ }
4493
+
4494
+ // src/annotations/service.ts
4495
+ async function loadComments(threadId) {
4496
+ const db = getDb();
4497
+ const rows = await db.all(
4498
+ "SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
4499
+ [threadId]
4500
+ );
4501
+ return rows.map((r) => ({
4502
+ id: r.id,
4503
+ author: r.author,
4504
+ body: r.body,
4505
+ createdAt: r.created_at
4506
+ }));
4507
+ }
4508
+ async function rowToThread(row) {
4509
+ let anchor = null;
4510
+ if (row.anchor_json) {
4511
+ try {
4512
+ anchor = JSON.parse(row.anchor_json);
4513
+ } catch {
4514
+ anchor = null;
4515
+ }
4516
+ }
4517
+ return {
4518
+ id: row.id,
4519
+ nestId: row.nest_id,
4520
+ nodeId: row.node_id,
4521
+ snapshotVersion: row.snapshot_version,
4522
+ anchor,
4523
+ status: row.status,
4524
+ createdBy: row.created_by,
4525
+ createdAt: row.created_at,
4526
+ resolvedBy: row.resolved_by,
4527
+ resolvedAt: row.resolved_at,
4528
+ comments: await loadComments(row.id)
4529
+ };
4530
+ }
4531
+ async function getThreadRow(threadId) {
4532
+ return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
4533
+ }
4534
+ async function countThreadsByNode(nestId) {
4535
+ const rows = await getDb().all(
4536
+ `SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
4537
+ FROM annotation_threads
4538
+ WHERE nest_id = ?
4539
+ GROUP BY node_id`,
4540
+ [nestId]
4541
+ );
4542
+ const out = {};
4543
+ for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
4544
+ return out;
4545
+ }
4546
+ async function listThreads(nestId, nodeId) {
4547
+ const rows = await getDb().all(
4548
+ "SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
4549
+ [nestId, nodeId]
4550
+ );
4551
+ return Promise.all(rows.map(rowToThread));
4552
+ }
4553
+ async function createThread(nestId, nodeId, input, authorEmail) {
4554
+ const body = (input.body ?? "").trim();
4555
+ if (!body) {
4556
+ throw new Error("comment body is required");
4376
4557
  }
4377
4558
  const db = getDb();
4559
+ const id = uuid5();
4560
+ const anchor = clampAnchor(input.anchor);
4561
+ const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
4378
4562
  await db.transaction(async (tx) => {
4379
4563
  await tx.run(
4380
- "DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
4381
- [nestId, nodeId]
4382
- );
4383
- await tx.run(
4384
- "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
4385
- [nestId, nodeId]
4386
- );
4387
- await tx.run(
4388
- "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
4389
- [nestId, nodeId]
4390
- );
4391
- await tx.run(
4392
- `DELETE FROM stewards
4393
- WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
4394
- [nestId, nodeId]
4395
- );
4396
- await tx.run(
4397
- "DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
4398
- [nestId, nodeId]
4399
- );
4400
- await tx.run(
4401
- "DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
4402
- [nestId, derivedId]
4564
+ `INSERT INTO annotation_threads
4565
+ (id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
4566
+ VALUES (?, ?, ?, ?, ?, 'open', ?)`,
4567
+ [
4568
+ id,
4569
+ nestId,
4570
+ nodeId,
4571
+ snapshot,
4572
+ anchor ? JSON.stringify(anchor) : null,
4573
+ authorEmail
4574
+ ]
4403
4575
  );
4404
4576
  await tx.run(
4405
- "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
4406
- [nestId, derivedId]
4577
+ "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4578
+ [uuid5(), id, authorEmail, body]
4407
4579
  );
4408
- await tx.run(
4409
- "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
4410
- [nestId, derivedId]
4580
+ });
4581
+ return rowToThread(await getThreadRow(id));
4582
+ }
4583
+ async function getScopedThreadRow(threadId, nestId, nodeId) {
4584
+ const row = await getThreadRow(threadId);
4585
+ if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
4586
+ throw new NotFoundError(`Thread not found: ${threadId}`);
4587
+ }
4588
+ return row;
4589
+ }
4590
+ async function addComment(nestId, nodeId, threadId, authorEmail, body) {
4591
+ const trimmed = (body ?? "").trim();
4592
+ if (!trimmed) {
4593
+ throw new Error("comment body is required");
4594
+ }
4595
+ await getScopedThreadRow(threadId, nestId, nodeId);
4596
+ await getDb().run(
4597
+ "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4598
+ [uuid5(), threadId, authorEmail, trimmed]
4599
+ );
4600
+ return rowToThread(await getThreadRow(threadId));
4601
+ }
4602
+ async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
4603
+ await getScopedThreadRow(threadId, nestId, nodeId);
4604
+ const db = getDb();
4605
+ if (status === "resolved") {
4606
+ await db.run(
4607
+ `UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
4608
+ [byEmail, threadId]
4411
4609
  );
4412
- await tx.run(
4413
- "DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
4414
- [nestId, derivedId]
4610
+ } else {
4611
+ await db.run(
4612
+ "UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
4613
+ [threadId]
4415
4614
  );
4416
- await tx.run(
4417
- `INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
4418
- VALUES (?, ?, ?, ?)
4419
- ON CONFLICT(nest_id, node_id) DO UPDATE
4420
- SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
4421
- [nestId, nodeId, await getUserEmail(c), (/* @__PURE__ */ new Date()).toISOString()]
4615
+ }
4616
+ return rowToThread(await getThreadRow(threadId));
4617
+ }
4618
+ async function readArtifact(nestId, nodeId, version) {
4619
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
4620
+ const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
4621
+ let title = nodeId;
4622
+ let type = null;
4623
+ let liveHtml = "";
4624
+ let liveExists = true;
4625
+ try {
4626
+ const node = await storage.readDocument(nodeId);
4627
+ title = node.frontmatter?.title || nodeId;
4628
+ type = node.frontmatter?.type || null;
4629
+ liveHtml = node.body || "";
4630
+ } catch {
4631
+ liveExists = false;
4632
+ }
4633
+ if (target != null) {
4634
+ try {
4635
+ const raw = await versionManager.reconstructVersion(nodeId, target);
4636
+ return { title, html: bodyOnly(nodeId, raw), version: target, type };
4637
+ } catch (err) {
4638
+ console.warn(
4639
+ `[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
4640
+ err
4641
+ );
4642
+ }
4643
+ }
4644
+ if (!liveExists) {
4645
+ throw new NotFoundError(`Artifact not found: ${nodeId}`);
4646
+ }
4647
+ return { title, html: liveHtml, version: target, type };
4648
+ }
4649
+ function derivedAnnotationsId(sourceNodeId) {
4650
+ const base = sourceNodeId.replace(/^nodes\//, "");
4651
+ return `nodes/${base}--annotations`;
4652
+ }
4653
+ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
4654
+ try {
4655
+ let title = nodeId;
4656
+ let type = null;
4657
+ try {
4658
+ const { storage } = await engineCache.get(nestId);
4659
+ const node = await storage.readDocument(nodeId);
4660
+ title = node.frontmatter?.title || nodeId;
4661
+ type = node.frontmatter?.type || "document";
4662
+ } catch {
4663
+ }
4664
+ if (type !== null && type !== "artifact") return;
4665
+ const threads = await listThreads(nestId, nodeId);
4666
+ const derivedTitle = `${title.slice(0, 100)} \u2014 Annotations`;
4667
+ const markdown = projectThreadsToMarkdown(title, threads);
4668
+ const derivedId = derivedAnnotationsId(nodeId);
4669
+ try {
4670
+ await updateNode(nestId, derivedId, { content: markdown }, userEmail);
4671
+ } catch (err) {
4672
+ if (err instanceof NotFoundError) {
4673
+ await createNode(
4674
+ nestId,
4675
+ {
4676
+ id: derivedId,
4677
+ title: derivedTitle,
4678
+ content: markdown,
4679
+ type: "document",
4680
+ tags: ["annotations"]
4681
+ },
4682
+ userEmail
4683
+ );
4684
+ } else {
4685
+ throw err;
4686
+ }
4687
+ }
4688
+ } catch (err) {
4689
+ console.warn(
4690
+ `[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
4691
+ err
4422
4692
  );
4423
- });
4424
- await trackEvent("node.delete", { nestId, nodeId });
4425
- return c.json({ deleted: true });
4426
- });
4427
- async function getUserEmail(c) {
4428
- const userId = c.get("userId");
4429
- const db = getDb();
4430
- const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
4431
- return user?.email || "anonymous@localhost";
4693
+ }
4432
4694
  }
4433
4695
 
4434
- // src/annotations/routes.ts
4435
- import { Hono as Hono9 } from "hono";
4436
-
4437
4696
  // src/annotations/types.ts
4438
4697
  var ARTIFACT_NODE_TYPE = "artifact";
4439
4698
 
@@ -5527,29 +5786,14 @@ queryRoutes.get("/search", async (c) => {
5527
5786
  const nestId = c.req.param("nestId");
5528
5787
  const { storage } = await engineCache.get(nestId);
5529
5788
  const documents = await storage.discoverDocuments();
5530
- const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
5531
- const matches = documents.filter((node) => {
5532
- const haystack = [
5533
- node.frontmatter.title,
5534
- node.body || "",
5535
- node.frontmatter.type || "",
5536
- ...node.frontmatter.tags || []
5537
- ].join(" ").toLowerCase();
5538
- return terms.every((term) => haystack.includes(term));
5539
- });
5789
+ const matches = matchDocuments(documents, q);
5540
5790
  const userId = c.get("userId");
5541
5791
  const userEmail = await resolveCallerEmail(userId);
5542
5792
  const accessible = await filterAccessible(nestId, userId, userEmail, matches);
5543
5793
  return c.json({
5544
5794
  query: q,
5545
5795
  count: accessible.length,
5546
- nodes: accessible.map((n) => ({
5547
- id: n.id,
5548
- title: n.frontmatter.title,
5549
- type: n.frontmatter.type || "document",
5550
- tags: n.frontmatter.tags || [],
5551
- snippet: (n.body || "").slice(0, 200).replace(/\n/g, " ")
5552
- }))
5796
+ nodes: accessible.map(toSearchHit)
5553
5797
  });
5554
5798
  });
5555
5799
  queryRoutes.get("/overview", async (c) => {
@@ -5708,6 +5952,7 @@ queryRoutes.post("/publish", async (c) => {
5708
5952
  await syncNodeTags(nestId, id, tags);
5709
5953
  created.push(id);
5710
5954
  }
5955
+ if (created.length > 0 || body.context_md) await storage.regenerateIndex();
5711
5956
  trackEvent("nest.publish", { nestId, count: created.length });
5712
5957
  return c.json(
5713
5958
  {
@@ -5724,6 +5969,30 @@ import { Hono as Hono12 } from "hono";
5724
5969
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5725
5970
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5726
5971
 
5972
+ // src/shared/node-id.ts
5973
+ function assertSafeNodeId(rawId) {
5974
+ let id = rawId;
5975
+ try {
5976
+ id = decodeURIComponent(rawId);
5977
+ } catch {
5978
+ }
5979
+ if (!id || id.length > 512) {
5980
+ throw new ValidationError("invalid node id");
5981
+ }
5982
+ if (id.includes("\0") || id.includes("\\")) {
5983
+ throw new ValidationError("invalid node id");
5984
+ }
5985
+ if (id.startsWith("/")) {
5986
+ throw new ValidationError("invalid node id (absolute path)");
5987
+ }
5988
+ for (const seg of id.split("/")) {
5989
+ if (seg === "" || seg === "." || seg === "..") {
5990
+ throw new ValidationError("invalid node id (path traversal)");
5991
+ }
5992
+ }
5993
+ return id;
5994
+ }
5995
+
5727
5996
  // src/telemetry/trace-log.ts
5728
5997
  var PRUNE_EVERY = 500;
5729
5998
  var insertsSincePrune = 0;
@@ -5794,38 +6063,14 @@ async function listTraceEvents(filters = {}) {
5794
6063
  [...args, limit, offset]
5795
6064
  );
5796
6065
  return { events, total };
5797
- } catch {
5798
- return { events: [], total: 0 };
5799
- }
5800
- }
5801
-
5802
- // src/mcp/workflow-tools.ts
5803
- import { v4 as uuid8 } from "uuid";
5804
-
5805
- // src/shared/node-id.ts
5806
- function assertSafeNodeId(rawId) {
5807
- let id = rawId;
5808
- try {
5809
- id = decodeURIComponent(rawId);
5810
- } catch {
5811
- }
5812
- if (!id || id.length > 512) {
5813
- throw new ValidationError("invalid node id");
5814
- }
5815
- if (id.includes("\0") || id.includes("\\")) {
5816
- throw new ValidationError("invalid node id");
5817
- }
5818
- if (id.startsWith("/")) {
5819
- throw new ValidationError("invalid node id (absolute path)");
5820
- }
5821
- for (const seg of id.split("/")) {
5822
- if (seg === "" || seg === "." || seg === "..") {
5823
- throw new ValidationError("invalid node id (path traversal)");
5824
- }
6066
+ } catch {
6067
+ return { events: [], total: 0 };
5825
6068
  }
5826
- return id;
5827
6069
  }
5828
6070
 
6071
+ // src/mcp/workflow-tools.ts
6072
+ import { v4 as uuid8 } from "uuid";
6073
+
5829
6074
  // src/workflow/run-service.ts
5830
6075
  import { v4 as uuid6 } from "uuid";
5831
6076
  var RUNNABLE_TYPES = ["agent", "skill"];
@@ -6221,6 +6466,18 @@ async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
6221
6466
  }
6222
6467
  }
6223
6468
  }
6469
+ async function assertNoDuplicateEdge(db, nestId, fromNode, toNode, type, excludeId) {
6470
+ const row = await db.get(
6471
+ `SELECT id FROM edges
6472
+ WHERE nest_id = ? AND from_node = ? AND to_node = ? AND type_id = ?` + (excludeId ? " AND id != ?" : ""),
6473
+ excludeId ? [nestId, fromNode, toNode, type.id, excludeId] : [nestId, fromNode, toNode, type.id]
6474
+ );
6475
+ if (row) {
6476
+ throw new ConflictError(
6477
+ `a "${type.name}" connection from "${fromNode}" to "${toNode}" already exists`
6478
+ );
6479
+ }
6480
+ }
6224
6481
  var edgeRoutes = new Hono11();
6225
6482
  edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
6226
6483
  const nestId = c.req.param("nestId");
@@ -6291,6 +6548,7 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
6291
6548
  const now = (/* @__PURE__ */ new Date()).toISOString();
6292
6549
  const id = uuid7();
6293
6550
  await db.transaction(async (tx) => {
6551
+ await assertNoDuplicateEdge(tx, nestId, fromNode, toNode, type);
6294
6552
  if (type.is_flow) {
6295
6553
  await assertNoFlowCycle(tx, nestId, fromNode, toNode);
6296
6554
  }
@@ -6318,6 +6576,8 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
6318
6576
  "SELECT * FROM edge_types WHERE id = ?",
6319
6577
  [row.type_id]
6320
6578
  );
6579
+ const wasFlow = !!type.is_flow;
6580
+ let retype = null;
6321
6581
  if (typeof body.type === "string" && body.type.trim()) {
6322
6582
  const next = await db.get(
6323
6583
  "SELECT * FROM edge_types WHERE nest_id = ? AND (id = ? OR LOWER(name) = LOWER(?))",
@@ -6328,9 +6588,7 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
6328
6588
  `unknown edge type "${body.type}" \u2014 define it in the registry first`
6329
6589
  );
6330
6590
  }
6331
- if (next.is_flow && !type.is_flow) {
6332
- await assertNoFlowCycle(db, nestId, row.from_node, row.to_node);
6333
- }
6591
+ if (next.id !== type.id) retype = next;
6334
6592
  type = next;
6335
6593
  }
6336
6594
  let mode = row.condition_mode;
@@ -6344,11 +6602,19 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
6344
6602
  mode = v.mode;
6345
6603
  stored = v.stored;
6346
6604
  }
6347
- await db.run(
6348
- `UPDATE edges SET type_id = ?, condition_mode = ?, condition = ?, updated_at = ?
6349
- WHERE id = ?`,
6350
- [type.id, mode, stored, (/* @__PURE__ */ new Date()).toISOString(), id]
6351
- );
6605
+ await db.transaction(async (tx) => {
6606
+ if (retype) {
6607
+ await assertNoDuplicateEdge(tx, nestId, row.from_node, row.to_node, retype, id);
6608
+ if (retype.is_flow && !wasFlow) {
6609
+ await assertNoFlowCycle(tx, nestId, row.from_node, row.to_node);
6610
+ }
6611
+ }
6612
+ await tx.run(
6613
+ `UPDATE edges SET type_id = ?, condition_mode = ?, condition = ?, updated_at = ?
6614
+ WHERE id = ?`,
6615
+ [type.id, mode, stored, (/* @__PURE__ */ new Date()).toISOString(), id]
6616
+ );
6617
+ });
6352
6618
  const updated = await db.get("SELECT * FROM edges WHERE id = ?", [id]);
6353
6619
  return c.json({ edge: toResponse(updated, type) });
6354
6620
  });
@@ -6683,6 +6949,7 @@ async function createEdge(ctx, args) {
6683
6949
  const now = (/* @__PURE__ */ new Date()).toISOString();
6684
6950
  const id = uuid8();
6685
6951
  await db.transaction(async (tx) => {
6952
+ await assertNoDuplicateEdge(tx, ctx.nestId, fromNode, toNode, type);
6686
6953
  if (type.is_flow) {
6687
6954
  await assertNoFlowCycle(tx, ctx.nestId, fromNode, toNode);
6688
6955
  }
@@ -6783,6 +7050,30 @@ function normalizeHops(raw) {
6783
7050
  if (!Number.isFinite(n)) return 2;
6784
7051
  return Math.max(0, Math.min(MAX_HOPS, Math.floor(n)));
6785
7052
  }
7053
+ function toolError(text, code) {
7054
+ return { text, isError: true, data: { code, message: text } };
7055
+ }
7056
+ function toCallToolResult(out) {
7057
+ if (typeof out === "string") {
7058
+ return { content: [{ type: "text", text: out }] };
7059
+ }
7060
+ return {
7061
+ content: [{ type: "text", text: out.text }],
7062
+ structuredContent: out.data,
7063
+ ...out.isError ? { isError: true } : {}
7064
+ };
7065
+ }
7066
+ function summarizeNode(node, body) {
7067
+ return {
7068
+ id: node.id,
7069
+ title: node.frontmatter.title,
7070
+ description: node.frontmatter.description,
7071
+ type: node.frontmatter.type || "document",
7072
+ status: node.frontmatter.status || "draft",
7073
+ tags: node.frontmatter.tags,
7074
+ body: body ?? void 0
7075
+ };
7076
+ }
6786
7077
  var TOOL_DEFINITIONS = [
6787
7078
  {
6788
7079
  name: "context_init",
@@ -6851,12 +7142,23 @@ var TOOL_DEFINITIONS = [
6851
7142
  },
6852
7143
  {
6853
7144
  name: "context_list",
6854
- description: "Browse vault contents with optional type, tag, or limit filters.",
7145
+ description: "Browse vault contents with optional type, tag, status, or limit filters.",
6855
7146
  inputSchema: {
6856
7147
  type: "object",
6857
7148
  properties: {
6858
7149
  type: { type: "string", description: "Filter by node type" },
6859
- tag: { type: "string", description: "Filter by tag" },
7150
+ tag: {
7151
+ type: "string",
7152
+ description: "Filter by tag (leading # optional, case-insensitive)"
7153
+ },
7154
+ status: {
7155
+ type: "string",
7156
+ description: "Filter by status: draft | pending_review | approved | published | rejected. Aliases normalized. Unlike the CLI and the OSS server, retired nodes stay in this listing whether or not you ask \u2014 here they are unpublished documents their stewards may still act on."
7157
+ },
7158
+ approved_only: {
7159
+ type: "boolean",
7160
+ description: "Return only nodes with an approved version, each served at that version rather than the live file \u2014 the view a public nest gives a stranger."
7161
+ },
6860
7162
  limit: { type: "number", description: "Max nodes to return" }
6861
7163
  }
6862
7164
  }
@@ -6909,7 +7211,7 @@ var TOOL_DEFINITIONS = [
6909
7211
  },
6910
7212
  {
6911
7213
  name: "context_create",
6912
- description: "Create a new knowledge node in the vault.",
7214
+ description: "Create a new knowledge node in the vault. The title must not already be in use \u2014 these tools resolve documents by title, so a duplicate is refused rather than filed as an unreachable twin. To change an existing document use context_update; to refile it use context_move.",
6913
7215
  inputSchema: {
6914
7216
  type: "object",
6915
7217
  properties: {
@@ -6930,7 +7232,7 @@ var TOOL_DEFINITIONS = [
6930
7232
  scope: { type: "string", description: "Visibility scope" },
6931
7233
  folder: {
6932
7234
  type: "string",
6933
- description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
7235
+ description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified. A leading "nodes/" is accepted and not doubled, so "nodes/gtm/deals" and "gtm/deals" mean the same folder.'
6934
7236
  }
6935
7237
  },
6936
7238
  required: ["title", "content"]
@@ -6938,13 +7240,20 @@ var TOOL_DEFINITIONS = [
6938
7240
  },
6939
7241
  {
6940
7242
  name: "context_update",
6941
- description: "Update an existing node \u2014 append, replace content, add tags.",
7243
+ description: "Update an existing node \u2014 replace or append content, set tags, move it through its lifecycle. Whether the edit publishes is decided by this nest's stewardship rules, not by the caller.",
6942
7244
  inputSchema: {
6943
7245
  type: "object",
6944
7246
  properties: {
6945
7247
  title: {
7248
+ // Selects the node; it does NOT rename it. Titles are unique here
7249
+ // precisely so MCP callers can address a document this way. Renaming
7250
+ // is not reachable from this tool.
7251
+ type: "string",
7252
+ description: "Title of node to update (selects it; does not rename)"
7253
+ },
7254
+ id: {
6946
7255
  type: "string",
6947
- description: "Title of node to update"
7256
+ description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
6948
7257
  },
6949
7258
  content: {
6950
7259
  type: "string",
@@ -6956,9 +7265,62 @@ var TOOL_DEFINITIONS = [
6956
7265
  items: { type: "string" },
6957
7266
  description: "Tags to add"
6958
7267
  },
6959
- scope: { type: "string", description: "New scope" }
7268
+ status: {
7269
+ type: "string",
7270
+ description: "New lifecycle status: draft | pending_review | approved | published | rejected. Aliases are normalized."
7271
+ },
7272
+ changeNote: {
7273
+ type: "string",
7274
+ description: "Why this changed \u2014 recorded in version history"
7275
+ }
7276
+ }
7277
+ // Neither is required alone — one of them is (the engine catalog's
7278
+ // `.refine(id || title)`, which JSON Schema can't express here).
7279
+ // `title` was required, which made this tool unreachable for `ctx`:
7280
+ // it addresses nodes by id and never sends a title.
7281
+ }
7282
+ },
7283
+ {
7284
+ name: "context_delete",
7285
+ // Mirrors the engine's canonical `context_delete` operation (core
7286
+ // namespace, alias delete_document): same name, same {id, title} input,
7287
+ // same "deleted" semantics. The engine's `uri` field is omitted — this
7288
+ // repo has no contextnest:// resolver on any surface, so accepting one
7289
+ // would promise a resolution it can't do.
7290
+ description: "Delete a node from the vault by title or id. Removes everything keyed to it: version history, review state, annotations, comments, watchers, schedules, trigger hooks, workflow edges, document grants, and any steward assignments scoped to it. Irreversible \u2014 the node must be recreated to come back, and it comes back clean: no history, no stewards, no shares. A node under open review is frozen unless you can approve it (owner, admin, or its reviewer).",
7291
+ inputSchema: {
7292
+ type: "object",
7293
+ properties: {
7294
+ title: { type: "string", description: "Title of node to delete" },
7295
+ id: {
7296
+ type: "string",
7297
+ description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
7298
+ }
7299
+ }
7300
+ }
7301
+ },
7302
+ {
7303
+ name: "context_move",
7304
+ // NOT in the engine's operation catalog — `move` is a community-edition
7305
+ // concern (the id rewrite spans governance tables the engine doesn't own).
7306
+ // This mirrors the existing REST POST /nodes/:id/move rather than adding a
7307
+ // new concept, so the surfaces agree. If move is ever promoted to a core
7308
+ // operation upstream, adopt that descriptor and drop this schema.
7309
+ description: "Move a node to a different folder. The id changes; version history, review state, annotations, comments, watchers, schedules, links, and document grants all follow. The title and its [[wiki-link]] stay valid. A node under open review is frozen unless you can approve it (owner, admin, or its reviewer).",
7310
+ inputSchema: {
7311
+ type: "object",
7312
+ properties: {
7313
+ title: { type: "string", description: "Title of node to move" },
7314
+ id: {
7315
+ type: "string",
7316
+ description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
7317
+ },
7318
+ folder: {
7319
+ type: "string",
7320
+ description: 'Destination folder under nodes/ (e.g. "gtm/deals"); segments are slugified. Use "" to move to the nest root.'
7321
+ }
6960
7322
  },
6961
- required: ["title"]
7323
+ required: ["folder"]
6962
7324
  }
6963
7325
  },
6964
7326
  // ─── Governance Tools ───────────────────────────────────────────────
@@ -6975,6 +7337,73 @@ var TOOL_DEFINITIONS = [
6975
7337
  }
6976
7338
  }
6977
7339
  },
7340
+ {
7341
+ // Deletion requests, mirroring the REST + UI flow: anyone who can READ a
7342
+ // thing may ask for it to be deleted; only an owner/admin resolves it.
7343
+ // Not in the engine catalog (1.2.1) — deletion requests live in this
7344
+ // repo's governance tables, which the engine doesn't own. Adopt an engine
7345
+ // descriptor here if the operation is ever promoted upstream.
7346
+ name: "context_request_deletion",
7347
+ description: "Ask for something to be deleted when you can't delete it yourself. Flags a document, a folder (with everything in it), or the whole nest for a nest owner/admin to action. Nothing is removed by this call \u2014 it opens a request, and the person who resolves it either deletes the target or rejects it with a note. Use this instead of context_delete when that returns a permission error, or when the document belongs to someone else.",
7348
+ inputSchema: {
7349
+ type: "object",
7350
+ properties: {
7351
+ title: { type: "string", description: "Title of the document to flag" },
7352
+ id: {
7353
+ type: "string",
7354
+ description: 'Document id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two documents share a title.'
7355
+ },
7356
+ folder: {
7357
+ type: "string",
7358
+ description: 'Flag a whole folder instead of a document \u2014 the path, e.g. "gtm/deals". Every document under it goes if the request is honoured.'
7359
+ },
7360
+ nest: {
7361
+ type: "boolean",
7362
+ description: "Flag the ENTIRE nest for deletion. Only an owner/admin can honour it."
7363
+ },
7364
+ reason: {
7365
+ type: "string",
7366
+ description: "Why it should be deleted. Required \u2014 this is what the admin judges."
7367
+ }
7368
+ },
7369
+ required: ["reason"]
7370
+ }
7371
+ },
7372
+ {
7373
+ name: "context_deletion_queue",
7374
+ description: "Show deletion requests waiting on a nest owner/admin: what was flagged, who asked, and why. Use the request id with context_resolve_deletion to action one.",
7375
+ inputSchema: {
7376
+ type: "object",
7377
+ properties: {
7378
+ status: {
7379
+ type: "string",
7380
+ description: '"pending" (default) or "declined" \u2014 past rejections and their notes.'
7381
+ }
7382
+ }
7383
+ }
7384
+ },
7385
+ {
7386
+ name: "context_resolve_deletion",
7387
+ description: "Action a deletion request: delete the flagged document/folder/nest, or reject it with a note explaining why it stays. Owner/admin only. Rejecting tells the requester the verdict; deleting is irreversible.",
7388
+ inputSchema: {
7389
+ type: "object",
7390
+ properties: {
7391
+ request_id: {
7392
+ type: "string",
7393
+ description: "Request id from context_deletion_queue"
7394
+ },
7395
+ action: {
7396
+ type: "string",
7397
+ description: '"delete" to honour it, "decline" to reject it and keep the target'
7398
+ },
7399
+ note: {
7400
+ type: "string",
7401
+ description: 'Why you rejected it. Required when action is "decline".'
7402
+ }
7403
+ },
7404
+ required: ["request_id", "action"]
7405
+ }
7406
+ },
6978
7407
  {
6979
7408
  name: "context_review_queue",
6980
7409
  description: "Show documents pending review. These need a steward to approve or reject them before they become available to AI.",
@@ -6988,6 +7417,14 @@ var TOOL_DEFINITIONS = [
6988
7417
  }
6989
7418
  }
6990
7419
  },
7420
+ {
7421
+ name: "context_my_drafts",
7422
+ description: "List YOUR unsubmitted drafts in this nest \u2014 documents you wrote that were never submitted for review (plus any that came back rejected). These are invisible to the review queue and unreadable by AI queries until submitted and approved. Use context_submit_review to hand one to a steward.",
7423
+ inputSchema: {
7424
+ type: "object",
7425
+ properties: {}
7426
+ }
7427
+ },
6991
7428
  {
6992
7429
  name: "context_submit_review",
6993
7430
  description: "Submit a node for steward review. The node will be marked as pending review and stewards will be notified.",
@@ -7031,9 +7468,13 @@ var TOOL_DEFINITIONS = [
7031
7468
  inputSchema: {
7032
7469
  type: "object",
7033
7470
  properties: {
7034
- title: { type: "string", description: "Title of the node" }
7035
- },
7036
- required: ["title"]
7471
+ title: { type: "string", description: "Title of the node" },
7472
+ id: {
7473
+ type: "string",
7474
+ description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
7475
+ }
7476
+ }
7477
+ // One of id or title — see context_update.
7037
7478
  }
7038
7479
  },
7039
7480
  {
@@ -7140,40 +7581,69 @@ async function handleToolCall(toolName, args, ctx) {
7140
7581
  });
7141
7582
  }
7142
7583
  }
7584
+ async function resolveTargetNode(storage, args) {
7585
+ if (!args.id && !args.title) return "Provide a title or an id.";
7586
+ if (args.id) {
7587
+ try {
7588
+ return await storage.readDocument(assertSafeNodeId(args.id));
7589
+ } catch {
7590
+ return `Node not found: ${args.id}`;
7591
+ }
7592
+ }
7593
+ const docs = await storage.discoverDocuments();
7594
+ const matches = docs.filter(
7595
+ (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7596
+ );
7597
+ if (matches.length > 1) {
7598
+ return `${matches.length} nodes share the title "${args.title}". Re-run with one of these ids instead: ${matches.map((n) => n.id).join(", ")}`;
7599
+ }
7600
+ return matches[0] ?? `Node not found: ${args.title}`;
7601
+ }
7143
7602
  async function runTool(toolName, args, ctx) {
7144
7603
  const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
7145
7604
  switch (toolName) {
7146
7605
  case "context_init": {
7147
- const content = await storage.readContextMd();
7148
- if (!content)
7606
+ const { context_md } = await engineApi.run(
7607
+ "context_init",
7608
+ {},
7609
+ await opContext(nestId, userEmail)
7610
+ );
7611
+ if (!context_md)
7149
7612
  return "No CONTEXT.md found. Use context_overview to see what's available.";
7150
7613
  return `# Vault Instructions (CONTEXT.md)
7151
7614
 
7152
- ${content}`;
7615
+ ${context_md}`;
7153
7616
  }
7154
7617
  case "context_overview": {
7155
- const docs = await storage.discoverDocuments();
7618
+ const { total } = await engineApi.run(
7619
+ "context_init",
7620
+ {},
7621
+ await opContext(nestId, userEmail)
7622
+ );
7623
+ const visible = await listNodesForCallerByEmail(nestId, userEmail, {});
7156
7624
  const types = {};
7157
7625
  const tags = {};
7158
- for (const n of docs) {
7159
- const t = n.frontmatter.type || "document";
7160
- types[t] = (types[t] || 0) + 1;
7161
- for (const tag of n.frontmatter.tags || []) tags[tag] = (tags[tag] || 0) + 1;
7162
- }
7163
- const typeList = Object.entries(types).sort((a, b) => b[1] - a[1]).map(([t, c]) => ` ${t}: ${c}`).join("\n");
7164
- const tagList = Object.entries(tags).sort((a, b) => b[1] - a[1]).map(([t, c]) => ` ${t}: ${c}`).join("\n");
7165
- const nodeList = docs.map(
7166
- (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).slice(0, 4).join(" ")}
7167
- ${(n.body || "").slice(0, 120).replace(/\n/g, " ")}`
7626
+ for (const n of visible) {
7627
+ types[n.type] = (types[n.type] || 0) + 1;
7628
+ for (const tag of n.tags || []) tags[tag] = (tags[tag] || 0) + 1;
7629
+ }
7630
+ const fmt = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([k, c]) => ` ${k}: ${c}`).join("\n");
7631
+ const nodeList = visible.map(
7632
+ (n, i) => `${i + 1}. **${n.title}** [${n.type}] ${(n.tags || []).slice(0, 4).join(" ")}
7633
+ ${(n.content || "").slice(0, 120).replace(/\n/g, " ")}`
7168
7634
  ).join("\n\n");
7635
+ const withheld = total - visible.length;
7636
+ const note = withheld > 0 ? `
7637
+ _${withheld} node(s) not visible to you._
7638
+ ` : "";
7169
7639
  return `# Vault Overview
7170
- **Total nodes:** ${docs.length}
7171
-
7640
+ **Total nodes:** ${visible.length}
7641
+ ${note}
7172
7642
  ## Types
7173
- ${typeList}
7643
+ ${fmt(types)}
7174
7644
 
7175
7645
  ## Tags
7176
- ${tagList}
7646
+ ${fmt(tags)}
7177
7647
 
7178
7648
  ## All Nodes
7179
7649
 
@@ -7195,14 +7665,26 @@ ${nodeList}`;
7195
7665
  ].join(" ").toLowerCase();
7196
7666
  return terms.every((t) => hay.includes(t));
7197
7667
  });
7198
- if (!matches.length) return `No nodes matched search: "${args.query}"`;
7668
+ if (!matches.length)
7669
+ return {
7670
+ text: `No nodes matched search: "${args.query}"`,
7671
+ data: { results: [] }
7672
+ };
7199
7673
  const results = matches.map(
7200
7674
  ({ node: n, body }, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
7201
7675
  ${(body || "").slice(0, 200).replace(/\n/g, " ")}`
7202
7676
  ).join("\n\n");
7203
- return `Found ${matches.length} node(s) matching "${args.query}":
7204
-
7205
- ${results}`;
7677
+ return {
7678
+ text: `Found ${matches.length} node(s) matching "${args.query}":
7679
+
7680
+ ${results}`,
7681
+ // Same gated bodies the prose is built from — `resolveLlmBody`
7682
+ // already refused everything this caller may not read. No `score`:
7683
+ // this is a term-containment filter, not a ranked search.
7684
+ data: {
7685
+ results: matches.map(({ node: n, body }) => summarizeNode(n, body))
7686
+ }
7687
+ };
7206
7688
  }
7207
7689
  case "context_table_query": {
7208
7690
  const result = await queryTableNode(
@@ -7219,61 +7701,119 @@ ${results}`;
7219
7701
  ${lines.join("\n")}`;
7220
7702
  }
7221
7703
  case "context_query": {
7222
- const result = await queryEngine.query(args.query, {
7223
- hops: normalizeHops(args.hops)
7224
- });
7704
+ const { documents, traversal } = await engineApi.run(
7705
+ "context_query",
7706
+ { query: args.query, hops: normalizeHops(args.hops) },
7707
+ await opContext(nestId, userEmail)
7708
+ );
7225
7709
  const visibility = await Promise.all(
7226
- result.documents.map(async (n) => ({
7227
- node: n,
7228
- visible: await resolveLlmBody(ctx, n) !== null
7710
+ documents.map(async (d) => ({
7711
+ doc: d,
7712
+ body: await resolveLlmBody(ctx, {
7713
+ id: d.id,
7714
+ body: d.body ?? "",
7715
+ frontmatter: { title: d.title }
7716
+ })
7229
7717
  }))
7230
7718
  );
7231
- const nodes = visibility.filter((e) => e.visible).map((e) => e.node);
7232
- if (!nodes.length) return `No nodes matched: ${args.query}`;
7233
- const list = nodes.map(
7234
- (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}`
7235
- ).join("\n\n");
7236
- return `Found ${nodes.length} node(s) for \`${args.query}\`:
7237
-
7238
- ${list}`;
7719
+ const visible = visibility.filter((e) => e.body !== null);
7720
+ const nodes = visible.map((e) => e.doc);
7721
+ if (!nodes.length)
7722
+ return {
7723
+ text: `No nodes matched: ${args.query}`,
7724
+ data: { documents: [], traversal }
7725
+ };
7726
+ const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}] ${(n.tags || []).join(" ")}`).join("\n\n");
7727
+ return {
7728
+ text: `Found ${nodes.length} node(s) for \`${args.query}\`:
7729
+
7730
+ ${list}`,
7731
+ // The engine already hands back catalog `nodeSummary` shapes — only
7732
+ // `body` is swapped for the gated one resolveLlmBody returned.
7733
+ // `source_nodes` is deliberately absent: this handler never gates
7734
+ // them, so serving them would hand out content the prose refuses.
7735
+ data: {
7736
+ documents: visible.map((e) => ({ ...e.doc, body: e.body ?? void 0 })),
7737
+ traversal
7738
+ }
7739
+ };
7239
7740
  }
7240
7741
  case "context_get": {
7241
- const docs = await storage.discoverDocuments();
7242
7742
  let node;
7243
- if (args.title) {
7244
- node = docs.find(
7245
- (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7743
+ try {
7744
+ node = await getNodeForCaller(
7745
+ nestId,
7746
+ args.title ? { title: args.title } : { id: args.id },
7747
+ ctx.userId,
7748
+ userEmail
7749
+ );
7750
+ } catch {
7751
+ return toolError(
7752
+ `Node not found: ${args.title || args.id}`,
7753
+ "DOCUMENT_NOT_FOUND"
7246
7754
  );
7247
- } else if (args.id) {
7248
- node = docs.find((n) => n.id === args.id);
7249
7755
  }
7250
- if (!node) return `Node not found: ${args.title || args.id}`;
7251
7756
  const body = await resolveLlmBody(ctx, node);
7252
7757
  if (body === null) {
7253
- return `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`;
7758
+ return toolError(
7759
+ `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`,
7760
+ "UNAUTHORIZED_ACTION"
7761
+ );
7254
7762
  }
7255
7763
  const meta = [
7256
7764
  `**Title:** ${node.frontmatter.title}`,
7257
7765
  `**Type:** ${node.frontmatter.type || "document"}`,
7258
7766
  node.frontmatter.tags?.length ? `**Tags:** ${node.frontmatter.tags.join(" ")}` : null
7259
7767
  ].filter(Boolean).join("\n");
7260
- return `${meta}
7768
+ return {
7769
+ text: `${meta}
7261
7770
 
7262
7771
  ---
7263
7772
 
7264
- ${body || "(no content)"}`;
7773
+ ${body || "(no content)"}`,
7774
+ // `body` is the gated one, not node.body — under stewardship that is
7775
+ // the APPROVED snapshot, which can be older than the live draft this
7776
+ // frontmatter came from. So the payload carries only the fields the
7777
+ // prose above already reports, none of which claim to describe a
7778
+ // specific version. `version`, `status`, `checksum` and the
7779
+ // timestamps are deliberately absent: they describe the draft, and
7780
+ // pairing them with an approved body would misdescribe it (checksum
7781
+ // would also be a draft hash — see context_versions). `raw` is absent
7782
+ // too: no include_raw input here (Phase 2).
7783
+ data: {
7784
+ id: node.id,
7785
+ frontmatter: {
7786
+ title: node.frontmatter.title,
7787
+ description: node.frontmatter.description,
7788
+ type: node.frontmatter.type || "document",
7789
+ tags: node.frontmatter.tags
7790
+ },
7791
+ body
7792
+ }
7793
+ };
7265
7794
  }
7266
7795
  case "context_list": {
7267
7796
  const nodes = await listNodesForCallerByEmail(nestId, userEmail, {
7268
7797
  type: args.type,
7269
7798
  tag: args.tag,
7799
+ status: args.status,
7800
+ approvedOnly: args.approved_only === true,
7270
7801
  limit: args.limit || 50
7271
7802
  });
7272
- if (!nodes.length) return "No nodes found with the given filters.";
7803
+ const documents = nodes.map((n) => ({
7804
+ id: n.id,
7805
+ title: n.title,
7806
+ description: n.description,
7807
+ type: n.type || "document",
7808
+ status: n.status || "draft",
7809
+ tags: n.tags
7810
+ }));
7811
+ if (!nodes.length)
7812
+ return { text: "No nodes found with the given filters.", data: { documents } };
7273
7813
  const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}]`).join("\n");
7274
- return `${nodes.length} node(s):
7814
+ return { text: `${nodes.length} node(s):
7275
7815
 
7276
- ${list}`;
7816
+ ${list}`, data: { documents } };
7277
7817
  }
7278
7818
  case "context_resolve": {
7279
7819
  const result = await queryEngine.query(args.selector, {
@@ -7371,43 +7911,125 @@ ${sections}`;
7371
7911
  }
7372
7912
  case "context_create": {
7373
7913
  if (!await canCreateInNest(nestId, userEmail)) {
7374
- return "You don't have permission to create documents in this nest.";
7914
+ return toolError(
7915
+ "You don't have permission to create documents in this nest.",
7916
+ "UNAUTHORIZED_ACTION"
7917
+ );
7375
7918
  }
7376
- const { node } = await createNode(
7377
- nestId,
7378
- {
7379
- title: args.title,
7380
- content: args.content,
7381
- type: args.type,
7382
- tags: args.tags,
7383
- scope: args.scope,
7384
- folder: args.folder
7385
- },
7386
- userEmail
7387
- );
7388
- return `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`;
7919
+ const wanted = String(args.title ?? "").toLowerCase();
7920
+ return withNodeWriteLock(nodeWriteKey(nestId, "*create*"), async () => {
7921
+ const existingDocs = await storage.discoverDocuments();
7922
+ const twin = existingDocs.find(
7923
+ (n) => String(n.frontmatter.title ?? "").toLowerCase() === wanted
7924
+ );
7925
+ if (twin) {
7926
+ return toolError(
7927
+ `A node titled "${twin.frontmatter.title}" already exists (${twin.id}). Titles must be unique \u2014 MCP tools resolve documents by title, so a second one would be unreachable. Use context_update to change it, context_move to refile it, or create this one under a different title.`,
7928
+ "DOCUMENT_ALREADY_EXISTS"
7929
+ );
7930
+ }
7931
+ const { node, version } = await createNode(
7932
+ nestId,
7933
+ {
7934
+ title: args.title,
7935
+ content: args.content,
7936
+ type: args.type,
7937
+ tags: args.tags,
7938
+ scope: args.scope,
7939
+ folder: args.folder
7940
+ },
7941
+ userEmail
7942
+ );
7943
+ return {
7944
+ text: `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`,
7945
+ // checkpoint is null by construction: this server seals nothing —
7946
+ // a write becomes live through steward review, not a checkpoint.
7947
+ data: {
7948
+ id: node.id,
7949
+ version,
7950
+ status: node.frontmatter.status,
7951
+ checkpoint: null
7952
+ }
7953
+ };
7954
+ });
7389
7955
  }
7390
7956
  case "context_update": {
7391
- const docs = await storage.discoverDocuments();
7392
- const node = docs.find(
7393
- (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7394
- );
7395
- if (!node) return `Node not found: ${args.title}`;
7957
+ const node = await resolveTargetNode(storage, args);
7958
+ if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
7959
+ const label = args.id ? node.frontmatter.title : args.title;
7396
7960
  const editCheck = await canUserEdit(nestId, node.id, userEmail);
7397
7961
  if (!editCheck.allowed) {
7398
- return `You don't have permission to edit "${args.title}": ${editCheck.reason}`;
7962
+ return toolError(
7963
+ `You don't have permission to edit "${label}": ${editCheck.reason}`,
7964
+ "UNAUTHORIZED_ACTION"
7965
+ );
7399
7966
  }
7400
- const { node: updated } = await updateNode(
7967
+ const mergedTags = args.tags ? [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...args.tags])] : void 0;
7968
+ const { node: updated, version } = await updateNode(
7401
7969
  nestId,
7402
7970
  node.id,
7403
7971
  {
7404
7972
  content: args.content,
7405
7973
  append: args.append,
7406
- tags: args.tags
7974
+ tags: mergedTags,
7975
+ status: args.status,
7976
+ changeNote: args.changeNote
7407
7977
  },
7408
7978
  userEmail
7409
7979
  );
7410
- return `Updated node: **${updated.frontmatter.title}**`;
7980
+ return {
7981
+ text: `Updated node: **${updated.frontmatter.title}** \u2014 v${version}, status: ${updated.frontmatter.status}`,
7982
+ // checkpoint null — see context_create.
7983
+ data: {
7984
+ id: updated.id,
7985
+ version,
7986
+ status: updated.frontmatter.status,
7987
+ checkpoint: null
7988
+ }
7989
+ };
7990
+ }
7991
+ case "context_delete": {
7992
+ const node = await resolveTargetNode(storage, args);
7993
+ if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
7994
+ const deleteCheck = await canUserEdit(nestId, node.id, userEmail);
7995
+ if (!deleteCheck.allowed) {
7996
+ return toolError(
7997
+ `You don't have permission to delete "${node.frontmatter.title}": ${deleteCheck.reason}
7998
+
7999
+ Use context_request_deletion to ask a nest owner/admin to delete it, with a reason.`,
8000
+ "UNAUTHORIZED_ACTION"
8001
+ );
8002
+ }
8003
+ await deleteNode(nestId, node.id, userEmail);
8004
+ return {
8005
+ text: `Deleted node: **${node.frontmatter.title}** (${node.id})`,
8006
+ data: {
8007
+ id: node.id,
8008
+ title: node.frontmatter.title,
8009
+ deleted: true
8010
+ }
8011
+ };
8012
+ }
8013
+ case "context_move": {
8014
+ if (typeof args.folder !== "string") {
8015
+ return 'folder is required \u2014 a path like "gtm/deals", or "" for the nest root.';
8016
+ }
8017
+ const node = await resolveTargetNode(storage, args);
8018
+ if (typeof node === "string") return node;
8019
+ const moveCheck = await canUserEdit(nestId, node.id, userEmail);
8020
+ if (!moveCheck.allowed) {
8021
+ return `You don't have permission to move "${node.frontmatter.title}": ${moveCheck.reason}`;
8022
+ }
8023
+ const { node: moved, previousId } = await moveNode(
8024
+ nestId,
8025
+ node.id,
8026
+ args.folder,
8027
+ userEmail
8028
+ );
8029
+ if (previousId === moved.id) {
8030
+ return `**${moved.frontmatter.title}** is already at \`${moved.id}\` \u2014 nothing to move.`;
8031
+ }
8032
+ return `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}\``;
7411
8033
  }
7412
8034
  // ─── Governance Tool Handlers ──────────────────────────────────────
7413
8035
  case "context_stewards": {
@@ -7448,6 +8070,126 @@ ${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ?
7448
8070
 
7449
8071
  ${sections}`;
7450
8072
  }
8073
+ case "context_request_deletion": {
8074
+ const reason = typeof args.reason === "string" ? args.reason.trim() : "";
8075
+ if (!reason) {
8076
+ return "reason is required \u2014 say why it should be deleted; that is what the admin judges.";
8077
+ }
8078
+ if (args.nest === true) {
8079
+ const request2 = await requestDeletion({
8080
+ nestId,
8081
+ nodeId: nestId,
8082
+ targetType: "nest",
8083
+ requestedBy: userEmail,
8084
+ reason,
8085
+ baseUrl: ctx.baseUrl
8086
+ });
8087
+ return `Requested deletion of the **entire nest**. A nest owner or admin will delete it or reject the request.
8088
+ Request id: \`${request2.id}\``;
8089
+ }
8090
+ if (typeof args.folder === "string" && args.folder.trim()) {
8091
+ const folder = args.folder.trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
8092
+ const docs = await storage.discoverDocuments();
8093
+ const inside = docs.filter((d) => isIdUnderFolder(d.id, folder));
8094
+ if (inside.length === 0) {
8095
+ return `No folder "${folder}" \u2014 nothing is filed under it. Use context_list to see what exists.`;
8096
+ }
8097
+ const request2 = await requestDeletion({
8098
+ nestId,
8099
+ nodeId: folder,
8100
+ targetType: "folder",
8101
+ requestedBy: userEmail,
8102
+ reason,
8103
+ baseUrl: ctx.baseUrl
8104
+ });
8105
+ return `Requested deletion of folder **${folder}** (${inside.length} document(s) inside). A nest owner or admin will delete it or reject the request.
8106
+ Request id: \`${request2.id}\``;
8107
+ }
8108
+ const node = await resolveTargetNode(storage, args);
8109
+ if (typeof node === "string") return node;
8110
+ if (!await canReadNode(nestId, node.id, userId, userEmail)) {
8111
+ return `Node not found: ${node.id}`;
8112
+ }
8113
+ const request = await requestDeletion({
8114
+ nestId,
8115
+ nodeId: node.id,
8116
+ targetType: "document",
8117
+ requestedBy: userEmail,
8118
+ reason,
8119
+ baseUrl: ctx.baseUrl
8120
+ });
8121
+ return `Requested deletion of **${node.frontmatter.title}**. A nest owner or admin will delete it or reject the request \u2014 nothing is removed yet.
8122
+ Request id: \`${request.id}\``;
8123
+ }
8124
+ case "context_deletion_queue": {
8125
+ const status = args.status === "declined" ? "declined" : "pending";
8126
+ const requests = await listDeletionRequests({ nestId, status });
8127
+ if (requests.length === 0) {
8128
+ return status === "pending" ? "No deletion requests waiting. All caught up!" : "No rejected deletion requests.";
8129
+ }
8130
+ const list = requests.map((r, i) => {
8131
+ const kind = r.targetType === "document" ? "" : `${r.targetType}: `;
8132
+ const verdict = r.status === "declined" ? `
8133
+ Rejected by ${r.resolvedBy}: "${r.resolutionNote}"` : "";
8134
+ return `${i + 1}. **${kind}${r.title || r.nodeId}**
8135
+ Asked by ${r.requestedBy}: "${r.reason}"
8136
+ Request id: \`${r.id}\`${verdict}`;
8137
+ }).join("\n\n");
8138
+ return `# Deletion requests (${status})
8139
+ **${requests.length} item(s)**
8140
+
8141
+ ${list}`;
8142
+ }
8143
+ case "context_resolve_deletion": {
8144
+ const requestId = typeof args.request_id === "string" ? args.request_id : "";
8145
+ const action = args.action === "delete" ? "delete" : args.action === "decline" ? "decline" : null;
8146
+ if (!requestId || !action) {
8147
+ return 'request_id and action are required \u2014 action is "delete" or "decline". Use context_deletion_queue to list request ids.';
8148
+ }
8149
+ const request = await getDeletionRequest(requestId);
8150
+ if (!request || request.nestId !== nestId || request.status !== "pending") {
8151
+ return `No pending deletion request with id ${requestId}.`;
8152
+ }
8153
+ const roles = await resolveUserRoles(nestId, userEmail);
8154
+ if (!roles.includes("owner") && !roles.includes("admin")) {
8155
+ return `You don't have permission to resolve deletion requests in this nest \u2014 only a nest owner or admin can. You can add to the discussion, but the verdict is theirs.`;
8156
+ }
8157
+ const label = await deletionTargetTitle(request);
8158
+ if (action === "decline") {
8159
+ const note = typeof args.note === "string" ? args.note.trim() : "";
8160
+ if (!note) {
8161
+ return "note is required when rejecting \u2014 the requester is told why it stays.";
8162
+ }
8163
+ await declineDeletion({
8164
+ nestId,
8165
+ id: requestId,
8166
+ declinedBy: userEmail,
8167
+ note,
8168
+ baseUrl: ctx.baseUrl
8169
+ });
8170
+ return `Rejected the deletion request for **${label}**. It stays, and ${request.requestedBy} has been told why.`;
8171
+ }
8172
+ if (request.targetType === "nest") {
8173
+ await deleteNest(nestId);
8174
+ } else if (request.targetType === "folder") {
8175
+ await deleteFolder(nestId, request.nodeId, userEmail);
8176
+ await dropDeletionRequest(request.id);
8177
+ } else {
8178
+ await deleteNode(nestId, request.nodeId, userEmail);
8179
+ }
8180
+ await notifyDeletionResolved({
8181
+ nestId,
8182
+ nodeId: request.nodeId,
8183
+ docTitle: label,
8184
+ outcome: "deleted",
8185
+ requestedBy: request.requestedBy,
8186
+ resolvedBy: userEmail,
8187
+ requestId: request.id,
8188
+ targetType: request.targetType,
8189
+ baseUrl: ctx.baseUrl
8190
+ });
8191
+ return `Deleted **${label}** \u2014 ${request.requestedBy}'s request was honoured. Irreversible.`;
8192
+ }
7451
8193
  case "context_review_queue": {
7452
8194
  const status = args.status || "pending";
7453
8195
  const result = await getReviewQueue({
@@ -7466,19 +8208,47 @@ ${sections}`;
7466
8208
  **${result.total} item(s)**
7467
8209
 
7468
8210
  ${list}`;
8211
+ }
8212
+ case "context_my_drafts": {
8213
+ const { drafts, total } = await listMyDrafts({
8214
+ authorEmail: userEmail,
8215
+ nestIds: [ctx.nestId],
8216
+ limit: 100
8217
+ });
8218
+ if (total === 0) {
8219
+ return "No unsubmitted drafts \u2014 everything you've written here is in review or approved.";
8220
+ }
8221
+ const list = drafts.map(
8222
+ (d, i) => `${i + 1}. **${d.title}** v${d.version} \u2014 ${d.status}
8223
+ Path: ${d.nodeId}
8224
+ Last edited: ${d.updatedAt}`
8225
+ ).join("\n\n");
8226
+ const more = total > drafts.length ? `
8227
+
8228
+ _Showing the ${drafts.length} most recent of ${total}._` : "";
8229
+ return `# My Drafts
8230
+ **${total} unsubmitted document(s)**
8231
+
8232
+ ${list}${more}
8233
+
8234
+ Submit one with \`context_submit_review\`.`;
7469
8235
  }
7470
8236
  case "context_submit_review": {
7471
8237
  const docs = await storage.discoverDocuments();
7472
8238
  const node = docs.find(
7473
8239
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7474
8240
  );
7475
- if (!node) return `Node not found: ${args.title}`;
8241
+ if (!node) return toolError(`Node not found: ${args.title}`, "DOCUMENT_NOT_FOUND");
7476
8242
  const submitCheck = await canUserEdit(ctx.nestId, node.id, userEmail);
7477
8243
  if (!submitCheck.allowed) {
7478
- return `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`;
8244
+ return toolError(
8245
+ `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`,
8246
+ "UNAUTHORIZED_ACTION"
8247
+ );
7479
8248
  }
7480
8249
  const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
7481
- if (currentVersion === 0) return `No versions found for "${args.title}"`;
8250
+ if (currentVersion === 0)
8251
+ return toolError(`No versions found for "${args.title}"`, "VERSION_NOT_FOUND");
7482
8252
  try {
7483
8253
  const request = await submitForReview({
7484
8254
  nestId: ctx.nestId,
@@ -7497,9 +8267,33 @@ ${list}`;
7497
8267
 
7498
8268
  **Stewards who can review:**
7499
8269
  ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "";
7500
- return `Submitted "${args.title}" v${currentVersion} for review (${request.priority} priority).${stewardList}`;
8270
+ return {
8271
+ text: `Submitted "${args.title}" v${currentVersion} for review (${request.priority} priority).${stewardList}`,
8272
+ // No catalog shape to conform to — `context_submit_review` is a
8273
+ // governance-namespace operation and that namespace is declared but
8274
+ // unimplemented in the engine (`api/index.ts`, NAMESPACES). So this
8275
+ // is the review state the handler already computed, and nothing
8276
+ // more: no `version`/`checkpoint` at the top level, because this
8277
+ // operation writes no new version and seals no checkpoint — it
8278
+ // points a review request at the version that already existed.
8279
+ // Built from the same gated values the prose above is built from
8280
+ // (`request`, `resolved`), never re-read past the permission check.
8281
+ data: {
8282
+ id: node.id,
8283
+ submitted: true,
8284
+ review: {
8285
+ id: request.id,
8286
+ version: request.version,
8287
+ status: request.status,
8288
+ priority: request.priority,
8289
+ requested_by: request.requestedBy,
8290
+ requested_at: request.requestedAt
8291
+ },
8292
+ reviewers: resolved.map((r) => r.steward.userEmail)
8293
+ }
8294
+ };
7501
8295
  } catch (err) {
7502
- return `Failed to submit: ${err.message}`;
8296
+ return toolError(`Failed to submit: ${err.message}`, "INTERNAL");
7503
8297
  }
7504
8298
  }
7505
8299
  case "context_approve": {
@@ -7543,23 +8337,46 @@ Reason: ${args.note}`;
7543
8337
  }
7544
8338
  }
7545
8339
  case "context_versions": {
7546
- const docs = await storage.discoverDocuments();
7547
- const node = docs.find(
7548
- (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7549
- );
7550
- if (!node) return `Node not found: ${args.title}`;
8340
+ const node = await resolveTargetNode(storage, args);
8341
+ if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
8342
+ const label = args.id ? node.frontmatter.title : args.title;
7551
8343
  const allVersions = await getVersions(ctx.nestId, node.id);
7552
8344
  const approved = await getApprovedVersion(ctx.nestId, node.id);
7553
8345
  if (allVersions.length === 0) {
7554
- return `No version history for "${args.title}".`;
8346
+ return {
8347
+ text: `No version history for "${label}".`,
8348
+ data: { id: node.id, approved_version: approved ?? null, versions: [] }
8349
+ };
7555
8350
  }
7556
8351
  const list = allVersions.map(
7557
8352
  (v) => `- **v${v.version}** ${v.status}${v.version === approved ? " (AI-active)" : ""} \u2014 by ${v.editedBy} at ${v.editedAt}${v.changeNote ? ` "${v.changeNote}"` : ""}`
7558
8353
  ).join("\n");
7559
- return `# Version History: ${args.title}
8354
+ return {
8355
+ text: `# Version History: ${label}
7560
8356
  **Current:** v${allVersions[0]?.version || 0} | **AI-active:** ${approved ? `v${approved}` : "none"}
7561
8357
 
7562
- ${list}`;
8358
+ ${list}`,
8359
+ // Five catalog fields have no equivalent here and are omitted rather
8360
+ // than faked: `keyframe_interval` and `keyframe` (content lives on
8361
+ // disk — there is no keyframe+diff model), `chain_hash` (integrity is
8362
+ // server-side, not a per-version chain), `content_hash` (the column
8363
+ // exists but is conflict-detection state this tool has never served,
8364
+ // and it is ungated), and `published_at` (a version carries a
8365
+ // lifecycle status, not a publish timestamp). `status` and
8366
+ // `approved_version` are the community equivalent — the same two
8367
+ // facts the prose above reports.
8368
+ data: {
8369
+ id: node.id,
8370
+ approved_version: approved ?? null,
8371
+ versions: allVersions.map((v) => ({
8372
+ version: v.version,
8373
+ status: v.status,
8374
+ edited_by: v.editedBy,
8375
+ edited_at: v.editedAt,
8376
+ note: v.changeNote
8377
+ }))
8378
+ }
8379
+ };
7563
8380
  }
7564
8381
  case "context_assign_steward": {
7565
8382
  const scope = args.scope;
@@ -7617,8 +8434,8 @@ ${list}`;
7617
8434
  if (!target) return "target is required (a node id or folder prefix).";
7618
8435
  if (!["read", "write"].includes(role)) return "role must be read or write.";
7619
8436
  try {
7620
- const { createGrant: createGrant2 } = await import("./grants-service-5ZFISCBH.js");
7621
- const db = (await import("./client-3MGIP57D.js")).getDb();
8437
+ const { createGrant: createGrant2 } = await import("./grants-service-I535QVTW.js");
8438
+ const db = (await import("./client-OBS5HOTA.js")).getDb();
7622
8439
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
7623
8440
  const e = normalizeEmail2(String(args.email || ""));
7624
8441
  if (!e) return "email is required.";
@@ -7711,7 +8528,7 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
7711
8528
  }
7712
8529
  server.tool(tool.name, tool.description, shape, async (args) => {
7713
8530
  const engine = await engineCache.get(nestId);
7714
- const text = await handleToolCall(tool.name, args, {
8531
+ const out = await handleToolCall(tool.name, args, {
7715
8532
  storage: engine.storage,
7716
8533
  queryEngine: engine.query,
7717
8534
  versionManager: engine.versions,
@@ -7720,7 +8537,7 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
7720
8537
  userEmail,
7721
8538
  baseUrl
7722
8539
  });
7723
- return { content: [{ type: "text", text }] };
8540
+ return toCallToolResult(out);
7724
8541
  });
7725
8542
  }
7726
8543
  return server;
@@ -8797,6 +9614,7 @@ import { Hono as Hono19 } from "hono";
8797
9614
  import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
8798
9615
  import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
8799
9616
  import { z as z2 } from "zod";
9617
+ var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set(["nest_create", "context_create"]);
8800
9618
  async function getUserEmail3(userId) {
8801
9619
  const db = getDb();
8802
9620
  const user = await db.get("SELECT email FROM users WHERE id = ?", [
@@ -8978,6 +9796,38 @@ var SERVER_TOOL_DEFINITIONS = [
8978
9796
  },
8979
9797
  required: ["nest", "selector"]
8980
9798
  }
9799
+ },
9800
+ {
9801
+ name: "nest_create",
9802
+ description: "Create a new nest owned by the caller. Returns the new nest's id \u2014 target it with the other tools afterwards.",
9803
+ inputSchema: {
9804
+ type: "object",
9805
+ properties: {
9806
+ name: { type: "string", description: "Nest name" },
9807
+ description: { type: "string", description: "Optional description" }
9808
+ },
9809
+ required: ["name"]
9810
+ }
9811
+ },
9812
+ {
9813
+ name: "context_create",
9814
+ description: "Create a new knowledge node (document) in a nest. Needs write permission on that nest. A `folder` path creates the folder implicitly \u2014 folders exist through the nodes placed in them.",
9815
+ inputSchema: {
9816
+ type: "object",
9817
+ properties: {
9818
+ ...NEST_ARG,
9819
+ title: { type: "string", description: "Descriptive title" },
9820
+ content: { type: "string", description: "Markdown content body" },
9821
+ type: { type: "string", description: "Node type (default: document)" },
9822
+ tags: { type: "array", items: { type: "string" }, description: "Tags" },
9823
+ scope: { type: "string", description: "Visibility scope" },
9824
+ folder: {
9825
+ type: "string",
9826
+ description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
9827
+ }
9828
+ },
9829
+ required: ["nest", "title", "content"]
9830
+ }
8981
9831
  }
8982
9832
  ];
8983
9833
  async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
@@ -8986,6 +9836,31 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
8986
9836
  if (!nests.length) return "No nests accessible with these credentials.";
8987
9837
  return renderNestIndexMarkdown(nests, baseUrl);
8988
9838
  }
9839
+ if (SERVER_WRITE_TOOLS.has(toolName)) {
9840
+ if (isSuspended()) {
9841
+ const reason = getSuspensionReason();
9842
+ return `This server is suspended by PromptOwl; writes are blocked${reason ? ` \u2014 ${reason}` : ""}. Contact support@promptowl.ai.`;
9843
+ }
9844
+ if (!getCurrentLicense()?.valid) {
9845
+ return "This server has no valid PromptOwl license installed, so writes are disabled. Ask the server admin to install one (POST /license/install).";
9846
+ }
9847
+ }
9848
+ if (toolName === "nest_create") {
9849
+ if (nestScope) {
9850
+ return "This credential is scoped to a single nest and cannot create new ones.";
9851
+ }
9852
+ try {
9853
+ const nest2 = await createNest(
9854
+ userId,
9855
+ String(args.name ?? ""),
9856
+ args.description ? String(args.description) : void 0
9857
+ );
9858
+ return `Created nest "${nest2.name}" (id: ${nest2.id}). Add documents with context_create, retrieve with the other tools.`;
9859
+ } catch (err) {
9860
+ if (err instanceof ValidationError) return err.message;
9861
+ throw err;
9862
+ }
9863
+ }
8989
9864
  const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
8990
9865
  const nest = await resolveNestRef(String(args.nest ?? ""), userId);
8991
9866
  if (!nest) return inaccessible;
@@ -9021,7 +9896,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
9021
9896
  shape[key] = field;
9022
9897
  }
9023
9898
  server.tool(tool.name, tool.description, shape, async (args) => {
9024
- const text = await handleServerToolCall(
9899
+ const out = await handleServerToolCall(
9025
9900
  tool.name,
9026
9901
  args,
9027
9902
  userId,
@@ -9029,7 +9904,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
9029
9904
  nestScope,
9030
9905
  baseUrl
9031
9906
  );
9032
- return { content: [{ type: "text", text }] };
9907
+ return toCallToolResult(out);
9033
9908
  });
9034
9909
  }
9035
9910
  return server;
@@ -9144,7 +10019,9 @@ Streamable HTTP, stateless. Two endpoints:
9144
10019
  Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
9145
10020
  \`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
9146
10021
  each takes a \`nest\` argument (id or exact name) plus the same
9147
- selector/hops/max_tokens parameters as the REST API. Read-only.
10022
+ selector/hops/max_tokens parameters as the REST API. Two bootstrap writes:
10023
+ \`nest_create\` (new nest owned by the caller) and \`context_create\`
10024
+ (new document in a writable nest; a \`folder\` path creates the folder).
9148
10025
  - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
9149
10026
  governance (create/update, submit/approve/reject, stewards, sharing).
9150
10027
 
@@ -9737,6 +10614,123 @@ governanceRoutes.get("/review-queue", async (c) => {
9737
10614
  }
9738
10615
  return c.json({ ...result, requests });
9739
10616
  });
10617
+ function denyUnlessNestManager(c, what) {
10618
+ const permission = c.get("nestPermission");
10619
+ if (permission === "owner" || permission === "admin") return null;
10620
+ return c.json(
10621
+ {
10622
+ error: `Only the nest owner or an admin can delete ${what}. Request its deletion instead, or ask an admin to action it.`
10623
+ },
10624
+ 403
10625
+ );
10626
+ }
10627
+ governanceRoutes.post("/delete-folder", async (c) => {
10628
+ const nestId = c.req.param("nestId");
10629
+ const denied = denyUnlessNestManager(c, "a folder");
10630
+ if (denied) return denied;
10631
+ const body = await c.req.json().catch(() => ({}));
10632
+ if (!body.folder?.trim()) {
10633
+ throw new ValidationError("folder is required");
10634
+ }
10635
+ const deleted = await deleteFolder(
10636
+ nestId,
10637
+ body.folder.trim(),
10638
+ await getUserEmail4(c)
10639
+ );
10640
+ return c.json({ deleted: deleted.length, nodeIds: deleted });
10641
+ });
10642
+ governanceRoutes.post("/request-deletion", async (c) => {
10643
+ const nestId = c.req.param("nestId");
10644
+ const body = await c.req.json().catch(() => ({}));
10645
+ const reason = body.reason?.trim();
10646
+ if (!reason) {
10647
+ throw new ValidationError("A reason for the deletion request is required");
10648
+ }
10649
+ if (body.targetType !== "folder" && body.targetType !== "nest") {
10650
+ throw new ValidationError(
10651
+ 'targetType must be "folder" or "nest" \u2014 flag a single document via POST /nests/:nestId/nodes/:nodeId/request-deletion'
10652
+ );
10653
+ }
10654
+ const target = body.targetType === "nest" ? nestId : (body.target || "").trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
10655
+ if (body.targetType === "folder" && !target) {
10656
+ throw new ValidationError("target (the folder path) is required");
10657
+ }
10658
+ if (body.targetType === "folder") {
10659
+ const { storage } = await engineCache.get(nestId);
10660
+ const docs = await storage.discoverDocuments();
10661
+ if (!docs.some((d) => isIdUnderFolder(d.id, target))) {
10662
+ return c.json({ error: `Folder not found: ${target}` }, 404);
10663
+ }
10664
+ }
10665
+ const request = await requestDeletion({
10666
+ nestId,
10667
+ nodeId: target,
10668
+ targetType: body.targetType,
10669
+ requestedBy: await getUserEmail4(c),
10670
+ reason,
10671
+ baseUrl: requestBaseUrl(c.req.url)
10672
+ });
10673
+ return c.json({ request }, 201);
10674
+ });
10675
+ governanceRoutes.get("/deletion-requests", async (c) => {
10676
+ const nestId = c.req.param("nestId");
10677
+ const raw = c.req.query("status");
10678
+ const status = raw === "declined" || raw === "all" ? raw : "pending";
10679
+ const requests = await listDeletionRequests({ nestId, status });
10680
+ return c.json({ requests, total: requests.length });
10681
+ });
10682
+ governanceRoutes.post("/deletion-requests/:id/delete", async (c) => {
10683
+ const nestId = c.req.param("nestId");
10684
+ const request = await getDeletionRequest(c.req.param("id"));
10685
+ if (!request || request.nestId !== nestId || request.status !== "pending") {
10686
+ return c.json({ error: "Deletion request not found" }, 404);
10687
+ }
10688
+ const actor = await getUserEmail4(c);
10689
+ const docTitle = await deletionTargetTitle(request);
10690
+ if (request.targetType === "nest" || request.targetType === "folder") {
10691
+ const denied = denyUnlessNestManager(
10692
+ c,
10693
+ request.targetType === "nest" ? "a nest" : "a folder"
10694
+ );
10695
+ if (denied) return denied;
10696
+ }
10697
+ if (request.targetType === "nest") {
10698
+ await deleteNest(nestId);
10699
+ } else if (request.targetType === "folder") {
10700
+ await deleteFolder(nestId, request.nodeId, actor);
10701
+ await dropDeletionRequest(request.id);
10702
+ } else {
10703
+ await deleteNode(nestId, request.nodeId, actor);
10704
+ }
10705
+ await notifyDeletionResolved({
10706
+ nestId,
10707
+ nodeId: request.nodeId,
10708
+ docTitle,
10709
+ outcome: "deleted",
10710
+ requestedBy: request.requestedBy,
10711
+ resolvedBy: actor,
10712
+ requestId: request.id,
10713
+ targetType: request.targetType,
10714
+ baseUrl: requestBaseUrl(c.req.url)
10715
+ });
10716
+ return c.json({ deleted: true, nodeId: request.nodeId });
10717
+ });
10718
+ governanceRoutes.post("/deletion-requests/:id/decline", async (c) => {
10719
+ const nestId = c.req.param("nestId");
10720
+ const body = await c.req.json().catch(() => ({}));
10721
+ if (!body.note?.trim()) {
10722
+ throw new ValidationError("A note is required when declining");
10723
+ }
10724
+ const request = await declineDeletion({
10725
+ nestId,
10726
+ id: c.req.param("id"),
10727
+ declinedBy: await getUserEmail4(c),
10728
+ note: body.note.trim(),
10729
+ baseUrl: requestBaseUrl(c.req.url)
10730
+ });
10731
+ if (!request) return c.json({ error: "Deletion request not found" }, 404);
10732
+ return c.json({ request });
10733
+ });
9740
10734
  governanceRoutes.get("/feed", async (c) => {
9741
10735
  const nestId = c.req.param("nestId");
9742
10736
  const filterRaw = c.req.query("filter");
@@ -9798,25 +10792,78 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
9798
10792
  governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
9799
10793
  const nestId = c.req.param("nestId");
9800
10794
  const nodeId = c.req.param("nodeId");
10795
+ const userId = c.get("userId");
10796
+ const userEmail = await resolveCallerEmail(userId);
10797
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
10798
+ return c.json(
10799
+ { error: "Access denied \u2014 no steward assignment for this node" },
10800
+ 403
10801
+ );
10802
+ }
9801
10803
  const allVersions = await getVersions(nestId, nodeId);
9802
10804
  const approved = await getApprovedVersion(nestId, nodeId);
9803
10805
  const { versions: versionManager } = await engineCache.get(nestId);
9804
- const withContent = await Promise.all(
10806
+ const resolutions = await getDb().all(
10807
+ `SELECT version, status, resolved_by
10808
+ FROM review_requests
10809
+ WHERE nest_id = ? AND node_id = ?
10810
+ AND status IN ('approved', 'rejected')
10811
+ AND resolved_by IS NOT NULL
10812
+ ORDER BY resolved_at DESC`,
10813
+ [nestId, nodeId]
10814
+ );
10815
+ const byVersion = /* @__PURE__ */ new Map();
10816
+ for (const r of resolutions) {
10817
+ if (!byVersion.has(r.version)) {
10818
+ byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
10819
+ }
10820
+ }
10821
+ const enriched = await Promise.all(
9805
10822
  allVersions.map(async (v) => {
10823
+ const r = byVersion.get(v.version);
10824
+ const resolution = r ? { resolvedBy: r.resolvedBy, resolutionStatus: r.status } : {};
9806
10825
  try {
9807
- const raw = await versionManager.reconstructVersion(nodeId, v.version);
9808
- return { ...v, content: bodyOnly(nodeId, raw) };
10826
+ const diff = await versionManager.getDiff(nodeId, v.version);
10827
+ return { ...v, ...resolution, diff: diff ?? void 0 };
9809
10828
  } catch {
9810
- return v;
10829
+ return { ...v, ...resolution };
9811
10830
  }
9812
10831
  })
9813
10832
  );
9814
10833
  return c.json({
9815
- versions: withContent,
10834
+ versions: enriched,
9816
10835
  approvedVersion: approved,
9817
- currentVersion: allVersions[0]?.version || 0
10836
+ currentVersion: allVersions[0]?.version || 0,
10837
+ // Verdicts on out-of-band edits. A rejection commits no content, so it has
10838
+ // no version row to hang off — it lives in the engine's chain-event log and
10839
+ // rides alongside the version list rather than inside it.
10840
+ externalEditVerdicts: await listExternalEditVerdicts(
10841
+ nestId,
10842
+ nodeId
10843
+ )
9818
10844
  });
9819
10845
  });
10846
+ governanceNodeRoutes.get("/:nodeId{.+}/versions/:version{[0-9]+}", async (c) => {
10847
+ const nestId = c.req.param("nestId");
10848
+ const nodeId = c.req.param("nodeId");
10849
+ const version = Number(c.req.param("version"));
10850
+ const userId = c.get("userId");
10851
+ const userEmail = await resolveCallerEmail(userId);
10852
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
10853
+ return c.json(
10854
+ { error: "Access denied \u2014 no steward assignment for this node" },
10855
+ 403
10856
+ );
10857
+ }
10858
+ const { versions: versionManager } = await engineCache.get(nestId);
10859
+ try {
10860
+ const raw = await versionManager.reconstructVersion(nodeId, version);
10861
+ return c.json({ version, content: bodyOnly(nodeId, raw) });
10862
+ } catch (err) {
10863
+ console.error("reconstructVersion failed", nestId, nodeId, version, err);
10864
+ return c.json({ version, content: null }, 200);
10865
+ }
10866
+ });
9820
10867
  governanceNodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
9821
10868
  const nestId = c.req.param("nestId");
9822
10869
  const nodeId = c.req.param("nodeId");
@@ -9929,6 +10976,33 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
9929
10976
  201
9930
10977
  );
9931
10978
  });
10979
+ governanceNodeRoutes.post("/:nodeId{.+}/request-deletion", async (c) => {
10980
+ const nestId = c.req.param("nestId");
10981
+ const nodeId = c.req.param("nodeId");
10982
+ const body = await c.req.json().catch(() => ({}));
10983
+ const reason = body.reason?.trim();
10984
+ if (!reason) {
10985
+ throw new ValidationError("A reason for the deletion request is required");
10986
+ }
10987
+ const userEmail = await getUserEmail4(c);
10988
+ if (!await canReadNode(nestId, nodeId, c.get("userId"), userEmail)) {
10989
+ return c.json({ error: `Node not found: ${nodeId}` }, 404);
10990
+ }
10991
+ const { storage } = await engineCache.get(nestId);
10992
+ try {
10993
+ await storage.readDocument(nodeId);
10994
+ } catch {
10995
+ return c.json({ error: `Node not found: ${nodeId}` }, 404);
10996
+ }
10997
+ const request = await requestDeletion({
10998
+ nestId,
10999
+ nodeId,
11000
+ requestedBy: userEmail,
11001
+ reason,
11002
+ baseUrl: requestBaseUrl(c.req.url)
11003
+ });
11004
+ return c.json({ request }, 201);
11005
+ });
9932
11006
  governanceNodeRoutes.post(
9933
11007
  "/:nodeId{.+}/external-edits/:suggestionId/approve",
9934
11008
  async (c) => {
@@ -9948,7 +11022,9 @@ governanceNodeRoutes.post(
9948
11022
  return c.json({
9949
11023
  approved: true,
9950
11024
  version: result.versionEntry.version,
9951
- chainEvent: result.chainEvent.event_id
11025
+ chainEvent: result.chainEvent.event_id,
11026
+ // As on reject: false means the verdict never reached the audit log.
11027
+ historyRecorded: result.historyRecorded
9952
11028
  });
9953
11029
  } catch (err) {
9954
11030
  console.error(
@@ -9983,7 +11059,10 @@ governanceNodeRoutes.post(
9983
11059
  });
9984
11060
  return c.json({
9985
11061
  rejected: true,
9986
- chainEvent: result.chainEvent.event_id
11062
+ chainEvent: result.chainEvent.event_id,
11063
+ // False means the edit was rejected but the audit entry did not land —
11064
+ // the caller is not shown a clean success (server log carries why).
11065
+ historyRecorded: result.historyRecorded
9987
11066
  });
9988
11067
  } catch (err) {
9989
11068
  return c.json({ error: err.message }, 400);
@@ -10162,6 +11241,49 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
10162
11241
  }
10163
11242
  return c.json({ error: "Missing or invalid credentials" }, 401);
10164
11243
  });
11244
+ function nestPathParts(path) {
11245
+ return path.replace(/^\/nests\//, "").split("/").filter(Boolean);
11246
+ }
11247
+ var flexAuthWithPromptowlTicket = createMiddleware2(
11248
+ async (c, next) => {
11249
+ const header = c.req.header("Authorization") || "";
11250
+ const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
11251
+ const secret = config.OFFICIAL_COMMUNITY_SSO_SECRET;
11252
+ const looksLikeJwt = !!token && !token.startsWith("cnst_") && token.split(".").length === 3;
11253
+ if (!looksLikeJwt || !secret) return flexAuthMiddleware(c, next);
11254
+ let claims;
11255
+ try {
11256
+ claims = verifySsoTicket(token, secret);
11257
+ } catch {
11258
+ return c.json({ error: "Invalid service ticket" }, 401);
11259
+ }
11260
+ if (claims.scope !== "mcp" || claims.jti) {
11261
+ return c.json({ error: "Invalid service ticket" }, 401);
11262
+ }
11263
+ const expectedAud = config.PUBLIC_BASE_URL;
11264
+ if (expectedAud && (claims.aud || "").replace(/\/$/, "") !== expectedAud) {
11265
+ return c.json({ error: "Invalid service ticket" }, 401);
11266
+ }
11267
+ const sub = claims.sub.trim();
11268
+ if (!isValidTicketSub(sub)) {
11269
+ return c.json({ error: "Invalid service ticket" }, 401);
11270
+ }
11271
+ const db = getDb();
11272
+ const user = await db.get(
11273
+ "SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
11274
+ [sub]
11275
+ );
11276
+ if (!user) {
11277
+ return c.json(
11278
+ { error: "No ContextNest account exists for this PromptOwl user" },
11279
+ 404
11280
+ );
11281
+ }
11282
+ c.set("userId", user.id);
11283
+ c.set("nestScope", null);
11284
+ return next();
11285
+ }
11286
+ );
10165
11287
  var APPROVE_CACHE_TTL_MS = 3e4;
10166
11288
  var approveCache = /* @__PURE__ */ new Map();
10167
11289
  async function canApproveInNest(nestId, email) {
@@ -10185,6 +11307,13 @@ function createApp() {
10185
11307
  "*",
10186
11308
  corsOrigins === "*" ? cors({ origin: "*" }) : cors({ origin: corsOrigins, credentials: true })
10187
11309
  );
11310
+ const frameAncestors = `frame-ancestors ${config.FRAME_ANCESTORS}`;
11311
+ app.use("*", async (c, next) => {
11312
+ await next();
11313
+ const existing = c.res.headers.get("Content-Security-Policy");
11314
+ const merged = mergeFrameAncestors(existing, frameAncestors);
11315
+ if (merged !== existing) c.header("Content-Security-Policy", merged);
11316
+ });
10188
11317
  const ASSET_UPLOAD_PATH = /^\/nests\/[^/]+\/assets\/?$/;
10189
11318
  app.use("*", async (c, next) => {
10190
11319
  const lenStr = c.req.header("Content-Length");
@@ -10837,6 +11966,25 @@ function createApp() {
10837
11966
  requested_at: r.requestedAt,
10838
11967
  priority: r.priority
10839
11968
  });
11969
+ const deletions = await listDeletionRequests({
11970
+ nestIds: [...nestName2.keys()],
11971
+ status: "pending"
11972
+ });
11973
+ const toDeletionItem = (r) => ({
11974
+ type: "deletion",
11975
+ nest_id: r.nestId,
11976
+ nest_name: nestName2.get(r.nestId),
11977
+ node_id: r.nodeId,
11978
+ title: r.title || r.nodeId,
11979
+ requested_by: r.requestedBy,
11980
+ requested_at: r.createdAt,
11981
+ priority: "normal",
11982
+ reason: r.reason,
11983
+ request_id: r.id,
11984
+ // node_id holds a folder path / nest id for those targets, so a client
11985
+ // must not treat it as a document to open.
11986
+ target_type: r.targetType
11987
+ });
10840
11988
  const byNewest = (a, b) => a.requested_at < b.requested_at ? 1 : -1;
10841
11989
  const others = requests.filter((r) => r.requestedBy !== me);
10842
11990
  const nestApprove = new Map(
@@ -10849,8 +11997,27 @@ function createApp() {
10849
11997
  )
10850
11998
  )
10851
11999
  );
10852
- const items = others.filter((r) => nestApprove.get(r.nestId)).map(toItem).sort(byNewest);
10853
- const waiting = requests.filter((r) => r.requestedBy === me).map(toItem).sort(byNewest);
12000
+ const items = others.filter((r) => nestApprove.get(r.nestId)).map(toItem);
12001
+ const waiting = requests.filter((r) => r.requestedBy === me).map(toItem);
12002
+ const othersDeletions = deletions.filter((r) => r.requestedBy !== me);
12003
+ const nestManage = new Map(
12004
+ await Promise.all(
12005
+ [...new Set(othersDeletions.map((r) => r.nestId))].map(
12006
+ async (nestId) => {
12007
+ const roles = await resolveUserRoles(nestId, me);
12008
+ return [nestId, roles.includes("owner") || roles.includes("admin")];
12009
+ }
12010
+ )
12011
+ )
12012
+ );
12013
+ items.push(
12014
+ ...othersDeletions.filter((r) => nestManage.get(r.nestId)).map(toDeletionItem)
12015
+ );
12016
+ items.sort(byNewest);
12017
+ waiting.push(
12018
+ ...deletions.filter((r) => r.requestedBy === me).map(toDeletionItem)
12019
+ );
12020
+ waiting.sort(byNewest);
10854
12021
  const notifications = await listNotifications(me, { unreadOnly: true, limit: 50 });
10855
12022
  return c.json({
10856
12023
  items,
@@ -10861,6 +12028,37 @@ function createApp() {
10861
12028
  pagination: { total, limit, offset, has_more: offset + requests.length < total }
10862
12029
  });
10863
12030
  });
12031
+ app.get("/me/drafts", async (c) => {
12032
+ const userId = c.get("userId");
12033
+ const me = await resolveCallerEmail(userId);
12034
+ const nestName2 = /* @__PURE__ */ new Map();
12035
+ for (const n of await listVisibleNests(userId))
12036
+ nestName2.set(n.id, n.name ?? n.id);
12037
+ const limit = Math.min(Math.max(Number(c.req.query("limit")) || 25, 1), 100);
12038
+ const offset = Math.max(Number(c.req.query("offset")) || 0, 0);
12039
+ const { drafts, total } = await listMyDrafts({
12040
+ authorEmail: me,
12041
+ nestIds: [...nestName2.keys()],
12042
+ limit,
12043
+ offset
12044
+ });
12045
+ return c.json({
12046
+ drafts: drafts.map((d) => ({
12047
+ nest_id: d.nestId,
12048
+ nest_name: nestName2.get(d.nestId),
12049
+ node_id: d.nodeId,
12050
+ title: d.title,
12051
+ // Where it actually lives — the thing that was impossible to find.
12052
+ folder: folderForNode(d.nodeId),
12053
+ status: d.status,
12054
+ version: d.version,
12055
+ updated_at: d.updatedAt
12056
+ })),
12057
+ // has_more off the REQUESTED page size, not the returned row count: the
12058
+ // existence filter can shorten a page, and that must not read as "last".
12059
+ pagination: { total, limit, offset, has_more: offset + limit < total }
12060
+ });
12061
+ });
10864
12062
  app.get("/me/notifications", async (c) => {
10865
12063
  const me = await resolveCallerEmail(c.get("userId"));
10866
12064
  const unreadOnly = c.req.query("unread") === "true";
@@ -10896,19 +12094,54 @@ function createApp() {
10896
12094
  users: usersRow.c
10897
12095
  });
10898
12096
  });
12097
+ app.use("/search", flexAuthMiddleware);
12098
+ app.get("/search", async (c) => {
12099
+ const q = (c.req.query("q") || "").trim();
12100
+ if (!q) throw new ValidationError("q query parameter is required");
12101
+ const limit = Math.min(Math.max(Number(c.req.query("limit")) || 20, 1), 50);
12102
+ const userId = c.get("userId");
12103
+ const userEmail = await resolveCallerEmail(userId);
12104
+ const perNest = await Promise.all(
12105
+ (await listVisibleNests(userId)).map(async (nest) => {
12106
+ try {
12107
+ const { storage } = await engineCache.get(nest.id);
12108
+ const matches = matchDocuments(await storage.discoverDocuments(), q);
12109
+ if (matches.length === 0) return [];
12110
+ const accessible = await filterAccessible(nest.id, userId, userEmail, matches);
12111
+ return accessible.map((n) => ({
12112
+ ...toSearchHit(n),
12113
+ nest_id: nest.id,
12114
+ nest_name: nest.name ?? nest.id
12115
+ }));
12116
+ } catch (e) {
12117
+ console.error("[search] skipped nest", nest.id, e);
12118
+ return [];
12119
+ }
12120
+ })
12121
+ );
12122
+ const hits = perNest.flat();
12123
+ return c.json({
12124
+ query: q,
12125
+ count: hits.length,
12126
+ nodes: hits.slice(0, limit)
12127
+ });
12128
+ });
10899
12129
  app.use("/runs/*", flexAuthMiddleware);
10900
12130
  app.route("/runs", runDetailRoutes);
10901
12131
  app.route("/hooks", hookFireRoutes);
10902
12132
  app.route("/llms.txt", llmsTxtRoutes);
10903
- app.use("/index", flexAuthMiddleware);
12133
+ app.use("/index", flexAuthWithPromptowlTicket);
10904
12134
  app.route("/index", nestIndexRoutes);
10905
- app.use("/mcp", flexAuthMiddleware);
12135
+ app.use("/mcp", flexAuthWithPromptowlTicket);
10906
12136
  app.route("/mcp", serverMcpRoutes);
10907
12137
  const nestsApp = new Hono21();
10908
- nestsApp.use("*", flexAuthMiddleware);
10909
12138
  nestsApp.use("*", async (c, next) => {
10910
- const localPath = c.req.path.replace(/^\/nests\//, "");
10911
- const parts = localPath.split("/").filter(Boolean);
12139
+ const parts = nestPathParts(c.req.path);
12140
+ const ticketEligible = parts.length === 2 && (c.req.method === "POST" && parts[1] === "context" || c.req.method === "GET" && parts[1] === "nodes" || parts[1] === "mcp");
12141
+ return ticketEligible ? flexAuthWithPromptowlTicket(c, next) : flexAuthMiddleware(c, next);
12142
+ });
12143
+ nestsApp.use("*", async (c, next) => {
12144
+ const parts = nestPathParts(c.req.path);
10912
12145
  if (parts.length < 2) return next();
10913
12146
  if (parts[0] === "unsynced") return next();
10914
12147
  const nestId = parts[0];
@@ -10945,7 +12178,8 @@ function createApp() {
10945
12178
  const isCommentPath = /\/comments$/.test(path2) || /\/comments\/[^/]+\/resolve$/.test(path2);
10946
12179
  const isActivityPath = /\/activity$/.test(path2);
10947
12180
  const isFeedPath = parts.length === 2 && parts[1] === "feed";
10948
- const isGovernance = path2.includes("/stewards") || path2.includes("/review-queue") || path2.includes("/versions") || path2.includes("/reviews") || path2.includes("/submit-review") || path2.includes("/cancel-review") || path2.includes("/approve") || path2.includes("/reject") || path2.includes("/can-access") || path2.includes("/can-approve") || path2.includes("/can-edit") || isCommentPath || isActivityPath || isFeedPath;
12181
+ const isDeletionQueuePath = parts.length === 2 && parts[1] === "deletion-requests";
12182
+ const isGovernance = path2.includes("/stewards") || path2.includes("/review-queue") || path2.includes("/versions") || path2.includes("/reviews") || path2.includes("/submit-review") || path2.includes("/cancel-review") || path2.includes("/approve") || path2.includes("/reject") || path2.includes("/can-access") || path2.includes("/can-approve") || path2.includes("/can-edit") || isCommentPath || isActivityPath || isFeedPath || isDeletionQueuePath;
10949
12183
  const needsLicense = c.req.method !== "GET" || isGovernance;
10950
12184
  if (needsLicense) {
10951
12185
  const lic = getCurrentLicense();
@@ -10991,6 +12225,7 @@ function createApp() {
10991
12225
  const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
10992
12226
  const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
10993
12227
  const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
12228
+ const isDeletionRequestAction = /\/request-deletion$/.test(path);
10994
12229
  const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
10995
12230
  if (isStewardRoster && permission !== "owner" && permission !== "admin") {
10996
12231
  return c.json(
@@ -11009,7 +12244,7 @@ function createApp() {
11009
12244
  required = c.req.method === "GET" ? "write" : "admin";
11010
12245
  } else if (parts[1] === "hooks") {
11011
12246
  required = "write";
11012
- } else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
12247
+ } else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isDeletionRequestAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
11013
12248
  required = "write";
11014
12249
  }
11015
12250
  const isNodeAction = c.req.method === "POST" && parts.length >= 4 && (parts[parts.length - 1] === "revert" || parts[parts.length - 1] === "move");
@@ -11102,6 +12337,7 @@ function createApp() {
11102
12337
  nestsApp.route("/:nestId/assets", assetRoutes);
11103
12338
  nestsApp.route("/:nestId/mcp", mcpRoutes);
11104
12339
  app.route("/nests", nestsApp);
12340
+ app.all("/.well-known/*", (c) => c.json({ error: "Not found" }, 404));
11105
12341
  app.use(
11106
12342
  "/assets/*",
11107
12343
  serveStatic({
@@ -11109,14 +12345,20 @@ function createApp() {
11109
12345
  onFound: (_p, c) => c.header("Cache-Control", "public, max-age=31536000, immutable")
11110
12346
  })
11111
12347
  );
12348
+ const isDocumentNavigation = (c) => c.req.header("Sec-Fetch-Mode") === "navigate" || (c.req.header("Accept") || "").includes("text/html");
11112
12349
  app.get(
11113
12350
  "*",
12351
+ async (c, next) => {
12352
+ if (isDocumentNavigation(c)) return next();
12353
+ return c.json({ error: "Not found" }, 404);
12354
+ },
11114
12355
  serveStatic({
11115
12356
  root: UI_DIR_REL,
11116
12357
  path: "index.html",
11117
12358
  onFound: (_p, c) => c.header("Cache-Control", "no-cache")
11118
12359
  })
11119
12360
  );
12361
+ app.notFound((c) => c.json({ error: "Not found" }, 404));
11120
12362
  app.onError((err, c) => {
11121
12363
  if (err instanceof AppError) {
11122
12364
  return c.json({ error: err.message }, err.statusCode);
@@ -11289,6 +12531,111 @@ async function backfillNodeVersionsFromHistory(db) {
11289
12531
  `[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
11290
12532
  );
11291
12533
  }
12534
+ var NOTIFICATION_TITLES_MIGRATION_ID = "006_backfill_notification_titles";
12535
+ async function backfillNotificationTitles(db) {
12536
+ const already = await db.get(
12537
+ "SELECT id FROM schema_migrations WHERE id = ?",
12538
+ [NOTIFICATION_TITLES_MIGRATION_ID]
12539
+ );
12540
+ if (already) return;
12541
+ const rows = await db.all(
12542
+ "SELECT id, nest_id, message FROM notifications WHERE message LIKE '%nodes/%'"
12543
+ );
12544
+ let rewritten = 0;
12545
+ if (rows.length > 0) {
12546
+ const titlesByNest = /* @__PURE__ */ new Map();
12547
+ for (const nestId of new Set(rows.map((r) => r.nest_id))) {
12548
+ titlesByNest.set(nestId, await buildTitleMap(nestId));
12549
+ }
12550
+ for (const row of rows) {
12551
+ const titles = titlesByNest.get(row.nest_id) ?? /* @__PURE__ */ new Map();
12552
+ const next = row.message.replace(
12553
+ /"([^"]*nodes\/[^"]*)"/g,
12554
+ (whole, id) => {
12555
+ const label = describeNodeWith(titles, id);
12556
+ return `"${label.replace(/"/g, "")}"`;
12557
+ }
12558
+ );
12559
+ if (next === row.message) continue;
12560
+ await db.run("UPDATE notifications SET message = ? WHERE id = ?", [
12561
+ next,
12562
+ row.id
12563
+ ]);
12564
+ rewritten++;
12565
+ }
12566
+ }
12567
+ await db.run(
12568
+ insertOrIgnore(db, "INSERT INTO schema_migrations (id) VALUES (?)"),
12569
+ [NOTIFICATION_TITLES_MIGRATION_ID]
12570
+ );
12571
+ console.log(
12572
+ `[backfill] notifications: rewrote ${rewritten} of ${rows.length} message(s) carrying a node id`
12573
+ );
12574
+ }
12575
+ function describeNodeWith(titles, nodeId) {
12576
+ const at = nodeId.indexOf("nodes/");
12577
+ const normalized = at > 0 ? nodeId.slice(at) : nodeId;
12578
+ const title = titles.get(nodeId) || titles.get(normalized) || normalized.split("/").pop() || nodeId;
12579
+ const folder = folderForNode(normalized);
12580
+ return folder ? `${title} (in ${folder})` : title;
12581
+ }
12582
+
12583
+ // src/db/repair-version-chains.ts
12584
+ var MIGRATION_ID2 = "010_repair_grafted_version_chains";
12585
+ async function repairGraftedVersionChains(db) {
12586
+ const already = await db.get("SELECT id FROM schema_migrations WHERE id = ?", [
12587
+ MIGRATION_ID2
12588
+ ]);
12589
+ if (already) return;
12590
+ const nests = await db.all("SELECT id FROM nests");
12591
+ let scanned = 0;
12592
+ let repaired = 0;
12593
+ let externalized = 0;
12594
+ for (const { id: nestId } of nests) {
12595
+ let storage;
12596
+ let versions;
12597
+ try {
12598
+ ({ storage, versions } = await engineCache.get(nestId));
12599
+ } catch (err) {
12600
+ console.warn(
12601
+ `[repair] engine unavailable for nest ${nestId}:`,
12602
+ err.message
12603
+ );
12604
+ continue;
12605
+ }
12606
+ let docs;
12607
+ try {
12608
+ docs = await storage.discoverDocuments();
12609
+ } catch (err) {
12610
+ console.warn(
12611
+ `[repair] discoverDocuments failed for nest ${nestId}:`,
12612
+ err.message
12613
+ );
12614
+ continue;
12615
+ }
12616
+ for (const doc of docs) {
12617
+ scanned += 1;
12618
+ try {
12619
+ if (await versions.repairLatestVersion(doc.id)) {
12620
+ repaired += 1;
12621
+ console.log(`[repair] re-anchored version chain: ${nestId}/${doc.id}`);
12622
+ }
12623
+ externalized += await versions.externalizeDiffs(doc.id);
12624
+ } catch (err) {
12625
+ console.warn(
12626
+ `[repair] could not repair ${nestId}/${doc.id}:`,
12627
+ err.message
12628
+ );
12629
+ }
12630
+ }
12631
+ }
12632
+ await db.run(insertOrIgnore(db, "INSERT INTO schema_migrations (id) VALUES (?)"), [
12633
+ MIGRATION_ID2
12634
+ ]);
12635
+ console.log(
12636
+ `[repair] version chains: scanned ${scanned} docs across ${nests.length} nests, re-anchored ${repaired}, externalized ${externalized} inline change log(s)`
12637
+ );
12638
+ }
11292
12639
 
11293
12640
  // src/workflow/scheduler.ts
11294
12641
  import { v4 as uuid15 } from "uuid";
@@ -11366,6 +12713,16 @@ async function main() {
11366
12713
  } catch (err) {
11367
12714
  console.error("[backfill] node_versions backfill failed:", err);
11368
12715
  }
12716
+ try {
12717
+ await repairGraftedVersionChains(db);
12718
+ } catch (err) {
12719
+ console.error("[repair] version chain repair failed:", err);
12720
+ }
12721
+ try {
12722
+ await backfillNotificationTitles(db);
12723
+ } catch (err) {
12724
+ console.error("[backfill] notification title backfill failed:", err);
12725
+ }
11369
12726
  const accessCfg = loadAccessConfig();
11370
12727
  if (accessCfg) {
11371
12728
  console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);
@@ -11437,6 +12794,7 @@ async function main() {
11437
12794
  POST /nests/:id/query Selector query
11438
12795
  POST /nests/:id/context One-call context retrieval
11439
12796
  GET /nests/:id/search?q= Full-text search
12797
+ GET /search?q= Search every nest you can see
11440
12798
  POST /nests/:id/publish Bulk publish
11441
12799
  POST /nests/:id/mcp MCP endpoint
11442
12800
  GET /health Health check