@promptowl/contextnest-community 1.14.0 → 1.15.0

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,45 @@ 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
38
  safePublishDocument,
31
39
  seedDefaultEdgeTypes,
32
40
  sendTestNotification,
41
+ serializeNodeSafe,
33
42
  stripUndefinedDeep,
34
43
  submitForReview,
35
44
  withNodeWriteLock
36
- } from "./chunk-MZ4IR5KH.js";
45
+ } from "./chunk-C4U75IUR.js";
37
46
  import {
38
47
  createGrant,
39
48
  deleteGrant,
@@ -42,7 +51,7 @@ import {
42
51
  listGrants,
43
52
  listUserGrants,
44
53
  resolveNodeGrant
45
- } from "./chunk-IHGPZ6NY.js";
54
+ } from "./chunk-5IAKUUZF.js";
46
55
  import {
47
56
  generateApiKey,
48
57
  getKeyPrefix,
@@ -58,19 +67,35 @@ import {
58
67
  getCurrentVersion,
59
68
  getDisplayStatus,
60
69
  getVersions,
70
+ listMyDrafts,
61
71
  setApprovedVersion,
62
72
  upsertVersion
63
- } from "./chunk-JP67WXLA.js";
73
+ } from "./chunk-JWVZ3T35.js";
64
74
  import {
65
- addMember,
66
- buildTitleMap,
67
75
  canCreateInNest,
68
76
  canManageStewards,
69
77
  canUserAccess,
70
78
  canUserApprove,
71
79
  canUserEdit,
72
- createNest,
73
80
  createStewardRecord,
81
+ getCollaboratorRole,
82
+ getStewardRolesForUser,
83
+ getStewardsForNest,
84
+ getStewardsForUser,
85
+ listStewards,
86
+ removeSteward,
87
+ resolveGrantRoles,
88
+ resolveStewardsForNode,
89
+ resolveStewardsWithFallback,
90
+ resolveUserRoles,
91
+ syncFromConfig,
92
+ updateSteward
93
+ } from "./chunk-GWDOLYFJ.js";
94
+ import {
95
+ addMember,
96
+ buildTitleMap,
97
+ canApproveWith,
98
+ createNest,
74
99
  createTeam,
75
100
  deleteNest,
76
101
  deleteTeam,
@@ -78,16 +103,14 @@ import {
78
103
  docLink,
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,37 @@ import {
109
131
  loadServerSettings,
110
132
  nestAllowsSelfApprove,
111
133
  nestName,
134
+ nestPrimeTags,
135
+ nestReviewsPrimeOnly,
112
136
  nestStorageRoot,
137
+ normalizeTag,
138
+ nowExpr,
113
139
  permissionLevel,
114
140
  persistSetting,
115
141
  removeMember,
116
- removeSteward,
142
+ removeNodeFromTagIndex,
117
143
  renameNest,
118
144
  renameTeam,
119
145
  replaceTeamRoster,
120
- resolveGrantRoles,
121
146
  resolveMemberIdentity,
122
147
  resolveNestPath,
123
148
  resolveNestPermission,
124
- resolveStewardsForNode,
125
- resolveStewardsWithFallback,
126
- resolveUserRoles,
127
149
  sendEmailToRecipient,
128
150
  setAllowSelfApprove,
151
+ setPrimeOnlyReview,
152
+ setPrimeTags,
129
153
  setStewardshipEnabled,
130
154
  shareTeamWithNest,
131
155
  startLicenseSafetyPoll,
132
156
  startTelemetryLoop,
133
- syncFromConfig,
157
+ syncNodeTags,
134
158
  trackEvent,
135
159
  uniqueNestName,
136
160
  unshareTeamFromNest,
137
161
  updateMemberRole,
138
- updateSteward,
139
162
  validateLicense,
140
163
  verifySmtp
141
- } from "./chunk-KPK656BH.js";
164
+ } from "./chunk-7BOG3S5H.js";
142
165
  import {
143
166
  AppError,
144
167
  ConflictError,
@@ -148,24 +171,20 @@ import {
148
171
  UnauthorizedError,
149
172
  ValidationError
150
173
  } 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
174
  import {
159
175
  config,
160
176
  getDb,
161
177
  initDb,
162
178
  isEmailListish,
163
179
  isEmailish
164
- } from "./chunk-I5KYGMIT.js";
180
+ } from "./chunk-BCFKLY4H.js";
165
181
  import {
166
182
  ANON_EMAIL,
167
183
  ANON_USER_ID
168
184
  } from "./chunk-SLTQACJW.js";
185
+ import {
186
+ normalizeEmail
187
+ } from "./chunk-FRQJWGN3.js";
169
188
 
170
189
  // src/index.ts
171
190
  import { serve } from "@hono/node-server";
@@ -393,6 +412,9 @@ function sweepStale(maxAgeMs = 10 * 6e4) {
393
412
 
394
413
  // src/auth/sso-token.ts
395
414
  import { createHmac, timingSafeEqual } from "crypto";
415
+ function isValidTicketSub(sub) {
416
+ return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(sub) && sub.length <= 254;
417
+ }
396
418
  function b64urlToBuffer(input) {
397
419
  const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - input.length % 4);
398
420
  return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
@@ -1215,6 +1237,7 @@ authRoutes.get("/sso", async (c) => {
1215
1237
  } catch {
1216
1238
  return ssoError("invalid_ticket");
1217
1239
  }
1240
+ if ((claims.scope || "") === "mcp") return ssoError("invalid_ticket");
1218
1241
  const expectedAud = config.PUBLIC_BASE_URL;
1219
1242
  if (!expectedAud && !warnedMissingSsoAudience) {
1220
1243
  warnedMissingSsoAudience = true;
@@ -1227,7 +1250,7 @@ authRoutes.get("/sso", async (c) => {
1227
1250
  if (aud !== expectedAud) return ssoError("bad_audience");
1228
1251
  }
1229
1252
  const sub = (claims.sub || "").trim();
1230
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(sub) || sub.length > 254) {
1253
+ if (!isValidTicketSub(sub)) {
1231
1254
  return ssoError("invalid_ticket");
1232
1255
  }
1233
1256
  if (!claims.jti) return ssoError("invalid_ticket");
@@ -1563,42 +1586,27 @@ import { streamText } from "hono/streaming";
1563
1586
  import {
1564
1587
  serializeDocument,
1565
1588
  parseDocument as parseDocument2,
1566
- normalizeStatus
1589
+ normalizeStatus,
1590
+ DocumentNotFoundError
1567
1591
  } from "@promptowl/contextnest-engine";
1568
1592
  import { rename, mkdir, rm } from "fs/promises";
1569
1593
  import { dirname, join as join2, basename } from "path";
1570
1594
 
1571
- // src/governance/tag-index-service.ts
1572
- function normalizeTag(raw) {
1573
- return raw.trim().replace(/^#+/, "").toLowerCase();
1574
- }
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]);
1593
- }
1594
- });
1595
+ // src/governance/prime-service.ts
1596
+ async function isPrimeDocument(nestId, tags, metadata) {
1597
+ const flag = documentPrimeFlag(metadata);
1598
+ if (flag !== void 0) return flag;
1599
+ const primeTags = await nestPrimeTags(nestId);
1600
+ if (!primeTags.length) return false;
1601
+ const wanted = new Set(primeTags);
1602
+ return (tags || []).some((t) => wanted.has(normalizeTag(String(t))));
1595
1603
  }
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
- );
1604
+ function documentPrimeFlag(metadata) {
1605
+ const raw = metadata?.prime;
1606
+ if (typeof raw === "boolean") return raw;
1607
+ if (raw === "true") return true;
1608
+ if (raw === "false") return false;
1609
+ return void 0;
1602
1610
  }
1603
1611
 
1604
1612
  // src/governance/external-edit-service.ts
@@ -1784,7 +1792,7 @@ async function approveExternalEdit(input) {
1784
1792
  const node = await storage.readDocument(input.documentId);
1785
1793
  const versionNum = result.versionEntry.version;
1786
1794
  const tags = node.frontmatter.tags || [];
1787
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-FGL47APL.js");
1795
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-ECYCBVSE.js");
1788
1796
  await createVersion2({
1789
1797
  nestId: input.nestId,
1790
1798
  nodeId: input.documentId,
@@ -1865,9 +1873,21 @@ async function userIdFromEmail(email) {
1865
1873
  return row?.id ?? ANON_USER_ID;
1866
1874
  }
1867
1875
  var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
1876
+ var MAX_TITLE_LENGTH = 120;
1877
+ function assertTitleLength(title) {
1878
+ if (title.length > MAX_TITLE_LENGTH) {
1879
+ throw new ValidationError(
1880
+ `title must be at most ${MAX_TITLE_LENGTH} characters`
1881
+ );
1882
+ }
1883
+ }
1868
1884
  var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1885
+ function isIdUnderFolder(id, folder) {
1886
+ return id.startsWith(`nodes/${folder}/`) || id.startsWith(`${folder}/`);
1887
+ }
1869
1888
  function folderToSegments(folder) {
1870
1889
  const segments = folder.split("/").map(slugify).filter(Boolean);
1890
+ if (segments[0] === "nodes") segments.shift();
1871
1891
  if (segments.length > 8) {
1872
1892
  throw new ValidationError("folder may nest at most 8 levels deep");
1873
1893
  }
@@ -1928,7 +1948,8 @@ function toNodeResponse(node) {
1928
1948
  pendingChange: node.pendingChange ?? void 0,
1929
1949
  // Same YAML-coercion rule as title above: `schedule: 123` parses as a
1930
1950
  // number — normalize to string at the API boundary.
1931
- schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0
1951
+ schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0,
1952
+ prime: documentPrimeFlag(fm.metadata)
1932
1953
  };
1933
1954
  }
1934
1955
  async function resolveAuthorship(nestId, nodes) {
@@ -2069,17 +2090,29 @@ async function actorAutoPublishes(nestId, userEmail) {
2069
2090
  const roles = await resolveUserRoles(nestId, userEmail);
2070
2091
  return roles.includes("owner") || roles.includes("admin");
2071
2092
  }
2093
+ async function assertMayFlagPrime(nestId, userEmail) {
2094
+ const roles = await resolveUserRoles(nestId, userEmail);
2095
+ if (!roles.includes("owner") && !roles.includes("admin")) {
2096
+ throw new ForbiddenError(
2097
+ "Only the nest owner or an admin can change a document's prime flag."
2098
+ );
2099
+ }
2100
+ }
2101
+ async function autoPublishReason(nestId, userEmail, doc) {
2102
+ if (!await isStewardshipEnabled(nestId)) return "no stewards configured";
2103
+ if (await isPrimeDocument(nestId, doc.tags, doc.metadata)) return null;
2104
+ if (await nestReviewsPrimeOnly(nestId)) {
2105
+ return "prime-only review, document is not prime";
2106
+ }
2107
+ return await actorAutoPublishes(nestId, userEmail) ? "owner/admin self-approve" : null;
2108
+ }
2072
2109
  async function createNode(nestId, input, userEmail) {
2073
2110
  const { storage, versions: versionManager } = await engineCache.get(nestId);
2074
- const slug = slugify(input.title);
2111
+ assertTitleLength(input.title);
2112
+ const slug = slugify(input.title).slice(0, 100).replace(/-+$/, "");
2075
2113
  let id = input.id;
2076
2114
  if (!id) {
2077
2115
  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
2116
  id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
2084
2117
  }
2085
2118
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -2098,72 +2131,75 @@ async function createNode(nestId, input, userEmail) {
2098
2131
  )})`
2099
2132
  );
2100
2133
  }
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) {
2134
+ return withNodeWriteLock(nodeWriteKey(nestId, id), async () => {
2135
+ let occupied = false;
2133
2136
  try {
2134
- await versionManager.createVersion(node, userEmail);
2137
+ await storage.readDocument(id);
2138
+ occupied = true;
2135
2139
  } catch (err) {
2136
- console.error("VersionManager.createVersion failed (node create)", err);
2140
+ if (!(err instanceof DocumentNotFoundError)) throw err;
2137
2141
  }
2138
- await createVersion({
2139
- nestId,
2140
- nodeId: id,
2141
- version: 1,
2142
- content: input.content,
2143
- author: userEmail,
2144
- status: "draft",
2145
- tags
2146
- });
2147
- } else {
2148
- 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;
2154
- await createVersion({
2155
- nestId,
2156
- nodeId: id,
2157
- version: savedVersion,
2158
- content: result.node.body || "",
2159
- author: userEmail,
2160
- status: "published",
2161
- tags
2142
+ if (occupied) {
2143
+ throw new ConflictError(
2144
+ `A document already exists at ${id}. Update it, or create this one under a different folder or title.`
2145
+ );
2146
+ }
2147
+ const db = getDb();
2148
+ const orphan = await db.get(
2149
+ "SELECT 1 AS present FROM node_versions WHERE nest_id = ? AND node_id = ? LIMIT 1",
2150
+ [nestId, id]
2151
+ );
2152
+ if (orphan) {
2153
+ console.warn(
2154
+ `[nodes] clearing orphaned rows for ${id} \u2014 version history exists but no document does`
2155
+ );
2156
+ await db.transaction(async (tx) => {
2157
+ await purgeNodeKeyedRows(tx, nestId, id);
2162
2158
  });
2163
- await setApprovedVersion(nestId, id, savedVersion, userEmail);
2164
- node = result.node;
2165
- } catch (err) {
2166
- console.error("publishDocument failed (node create auto-publish)", err);
2159
+ }
2160
+ const deptTag = await departmentTagFor(userEmail);
2161
+ if (deptTag && !tags.some((t) => t.toLowerCase() === deptTag)) {
2162
+ tags.push(deptTag);
2163
+ }
2164
+ if (input.prime !== void 0) await assertMayFlagPrime(nestId, userEmail);
2165
+ const metadata = {
2166
+ owners: ["*"],
2167
+ scope: input.scope || "team",
2168
+ ...input.schedule ? { schedule: input.schedule } : {},
2169
+ ...input.prime === void 0 ? {} : { prime: input.prime }
2170
+ };
2171
+ const publishReason = await autoPublishReason(nestId, userEmail, {
2172
+ tags,
2173
+ metadata
2174
+ });
2175
+ const autoPublish = publishReason !== null;
2176
+ const initialStatus = autoPublish ? "published" : "draft";
2177
+ const initialVersion = autoPublish ? 0 : 1;
2178
+ let node = {
2179
+ id,
2180
+ filePath: "",
2181
+ frontmatter: {
2182
+ title: input.title,
2183
+ type: requestedType,
2184
+ tags,
2185
+ status: input.status || initialStatus,
2186
+ version: initialVersion,
2187
+ created_at: now,
2188
+ updated_at: now,
2189
+ metadata
2190
+ },
2191
+ body: input.content,
2192
+ rawContent: ""
2193
+ };
2194
+ await storage.writeDocument(id, serializeDocument(node));
2195
+ await syncNodeTags(nestId, id, tags);
2196
+ let savedVersion = 1;
2197
+ if (publishReason === null) {
2198
+ try {
2199
+ await versionManager.createVersion(node, userEmail);
2200
+ } catch (err) {
2201
+ console.error("VersionManager.createVersion failed (node create)", err);
2202
+ }
2167
2203
  await createVersion({
2168
2204
  nestId,
2169
2205
  nodeId: id,
@@ -2173,10 +2209,42 @@ async function createNode(nestId, input, userEmail) {
2173
2209
  status: "draft",
2174
2210
  tags
2175
2211
  });
2212
+ } else {
2213
+ const publishNote = `Auto-published on create (${publishReason})`;
2214
+ try {
2215
+ const result = await safePublishDocument(storage, id, {
2216
+ editedBy: userEmail,
2217
+ note: publishNote
2218
+ });
2219
+ savedVersion = result.node.frontmatter.version || 1;
2220
+ await createVersion({
2221
+ nestId,
2222
+ nodeId: id,
2223
+ version: savedVersion,
2224
+ content: result.node.body || "",
2225
+ author: userEmail,
2226
+ status: "published",
2227
+ tags,
2228
+ changeNote: publishNote
2229
+ });
2230
+ await setApprovedVersion(nestId, id, savedVersion, userEmail);
2231
+ node = result.node;
2232
+ } catch (err) {
2233
+ console.error("publishDocument failed (node create auto-publish)", err);
2234
+ await createVersion({
2235
+ nestId,
2236
+ nodeId: id,
2237
+ version: 1,
2238
+ content: input.content,
2239
+ author: userEmail,
2240
+ status: "draft",
2241
+ tags
2242
+ });
2243
+ }
2176
2244
  }
2177
- }
2178
- await trackEvent("node.create", { nestId, nodeId: id });
2179
- return { node, version: savedVersion };
2245
+ await trackEvent("node.create", { nestId, nodeId: id });
2246
+ return { node, version: savedVersion };
2247
+ });
2180
2248
  }
2181
2249
  function explicitFrontmatterStatus(raw) {
2182
2250
  const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
@@ -2196,7 +2264,7 @@ async function resetImportedDocForPublish(storage, nestId, nodeId) {
2196
2264
  const fm = { ...node.frontmatter };
2197
2265
  delete fm.version;
2198
2266
  delete fm.checksum;
2199
- await storage.writeDocument(nodeId, serializeDocument({ ...node, frontmatter: fm }));
2267
+ await storage.writeDocument(nodeId, serializeNodeSafe({ ...node, frontmatter: fm }));
2200
2268
  }
2201
2269
  async function registerImportedDocuments(nestId, userEmail, onProgress) {
2202
2270
  const { storage } = await engineCache.get(nestId);
@@ -2226,7 +2294,7 @@ async function registerImportedDocuments(nestId, userEmail, onProgress) {
2226
2294
  const title = doc.frontmatter?.title ?? (nodeId.split("/").pop() || nodeId);
2227
2295
  doc = { ...doc, frontmatter: { ...doc.frontmatter, title, author: userEmail } };
2228
2296
  try {
2229
- await storage.writeDocument(nodeId, serializeDocument(doc));
2297
+ await storage.writeDocument(nodeId, serializeNodeSafe(doc));
2230
2298
  } catch (err) {
2231
2299
  console.error("import: failed to persist import metadata", nodeId, err);
2232
2300
  }
@@ -2338,6 +2406,9 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2338
2406
  };
2339
2407
  }
2340
2408
  if (patch.title) {
2409
+ if (patch.title !== node.frontmatter.title) {
2410
+ assertTitleLength(patch.title);
2411
+ }
2341
2412
  node = {
2342
2413
  ...node,
2343
2414
  frontmatter: { ...node.frontmatter, title: patch.title }
@@ -2356,9 +2427,19 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2356
2427
  else metadata.schedule = patch.schedule;
2357
2428
  node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
2358
2429
  }
2430
+ if (patch.prime !== void 0) {
2431
+ await assertMayFlagPrime(nestId, userEmail);
2432
+ const metadata = { ...node.frontmatter.metadata };
2433
+ if (patch.prime === null) delete metadata.prime;
2434
+ else metadata.prime = patch.prime;
2435
+ node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
2436
+ }
2359
2437
  const hasStewards = await isStewardshipEnabled(nestId);
2360
- const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
2361
2438
  const currentTags = node.frontmatter.tags || [];
2439
+ const publishReason = await autoPublishReason(nestId, userEmail, {
2440
+ tags: currentTags,
2441
+ metadata: node.frontmatter.metadata
2442
+ });
2362
2443
  const pendingReview = hasStewards ? await getPendingReview(nestId, nodeId) : null;
2363
2444
  const isAuthorEditingOwnReview = !!pendingReview && pendingReview.requestedBy.toLowerCase() === userEmail.toLowerCase();
2364
2445
  if (pendingReview && !isAuthorEditingOwnReview) {
@@ -2367,9 +2448,9 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2367
2448
  );
2368
2449
  }
2369
2450
  let responseVersion;
2370
- if (!autoPublish || isAuthorEditingOwnReview) {
2451
+ if (publishReason === null || isAuthorEditingOwnReview) {
2371
2452
  const currentVersion = await getCurrentVersion(nestId, nodeId);
2372
- const newVersion = currentVersion + 1;
2453
+ const newVersion = await versionManager.nextVersion(nodeId, currentVersion);
2373
2454
  node = {
2374
2455
  ...node,
2375
2456
  frontmatter: {
@@ -2422,10 +2503,11 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2422
2503
  await storage.writeDocument(nodeId, serializeDocument(node));
2423
2504
  await syncNodeTags(nestId, nodeId, currentTags);
2424
2505
  let publishedVersion = (node.frontmatter.version || 0) + 1;
2506
+ const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
2425
2507
  try {
2426
2508
  const result = await safePublishDocument(storage, nodeId, {
2427
2509
  editedBy: userEmail,
2428
- note: patch.changeNote || (hasStewards ? "Auto-published on edit (owner/admin self-approve)" : "Auto-published on edit (no stewards)")
2510
+ note: publishNote
2429
2511
  });
2430
2512
  publishedVersion = result.node.frontmatter.version || publishedVersion;
2431
2513
  node = result.node;
@@ -2444,7 +2526,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2444
2526
  author: userEmail,
2445
2527
  status: "published",
2446
2528
  tags: currentTags,
2447
- changeNote: patch.changeNote
2529
+ changeNote: publishNote
2448
2530
  });
2449
2531
  await setApprovedVersion(
2450
2532
  nestId,
@@ -2464,9 +2546,69 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2464
2546
  }
2465
2547
  });
2466
2548
  }
2549
+ async function assertNotReviewLocked(nestId, nodeId, userEmail, action) {
2550
+ if (!await isStewardshipEnabled(nestId)) return;
2551
+ if (!await getPendingReview(nestId, nodeId)) return;
2552
+ if (canApproveWith(await resolveUserRoles(nestId, userEmail, { nodeId }))) return;
2553
+ throw new LockedError(
2554
+ `This document is awaiting steward review and is locked. Approve or reject the pending review before ${action}, or ask a reviewer to.`
2555
+ );
2556
+ }
2467
2557
  function derivedIdOf(sourceNodeId) {
2468
2558
  return `nodes/${sourceNodeId.replace(/^nodes\//, "")}--annotations`;
2469
2559
  }
2560
+ var NODE_KEYED_TABLES = [
2561
+ ["node_versions", "node_id"],
2562
+ ["review_requests", "node_id"],
2563
+ ["deletion_requests", "node_id"],
2564
+ ["approved_versions", "node_id"],
2565
+ ["annotation_threads", "node_id"],
2566
+ ["comments", "node_id"],
2567
+ ["node_tag_index", "node_id"],
2568
+ ["watchers", "node_id"],
2569
+ ["schedules", "agent_node"],
2570
+ // A live capability token bound to one agent node — deleting the agent must
2571
+ // revoke it, or a later node at that id answers the old webhook.
2572
+ ["trigger_hooks", "agent_node"],
2573
+ ["edges", "from_node"],
2574
+ ["edges", "to_node"]
2575
+ ];
2576
+ async function purgeNodeKeyedRows(tx, nestId, nodeId) {
2577
+ for (const [table, column] of NODE_KEYED_TABLES) {
2578
+ await tx.run(`DELETE FROM ${table} WHERE nest_id = ? AND ${column} = ?`, [
2579
+ nestId,
2580
+ nodeId
2581
+ ]);
2582
+ }
2583
+ await tx.run(
2584
+ `DELETE FROM stewards
2585
+ WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
2586
+ [nestId, nodeId]
2587
+ );
2588
+ await tx.run(
2589
+ `DELETE FROM grants
2590
+ WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
2591
+ [nestId, nodeId]
2592
+ );
2593
+ }
2594
+ async function rewriteNodeKeyedRows(tx, nestId, oldId, newId) {
2595
+ for (const [table, column] of NODE_KEYED_TABLES) {
2596
+ await tx.run(
2597
+ `UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
2598
+ [newId, nestId, oldId]
2599
+ );
2600
+ }
2601
+ await tx.run(
2602
+ `UPDATE stewards SET node_pattern = ?
2603
+ WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
2604
+ [newId, nestId, oldId]
2605
+ );
2606
+ await tx.run(
2607
+ `UPDATE grants SET target = ?
2608
+ WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
2609
+ [newId, nestId, oldId]
2610
+ );
2611
+ }
2470
2612
  async function moveNode(nestId, nodeId, folder, userEmail) {
2471
2613
  const { storage } = await engineCache.get(nestId);
2472
2614
  let node;
@@ -2475,103 +2617,124 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
2475
2617
  } catch {
2476
2618
  throw new NotFoundError(`Node not found: ${nodeId}`);
2477
2619
  }
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
- }
2620
+ await assertNotReviewLocked(nestId, nodeId, userEmail, "moving");
2483
2621
  const leaf = nodeId.split("/").pop();
2484
2622
  const segments = folderToSegments(folder);
2485
2623
  const newId = segments.length > 0 ? `nodes/${segments.join("/")}/${leaf}` : `nodes/${leaf}`;
2486
2624
  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()
2625
+ return withNodeWriteLock(nodeWriteKey(nestId, newId), async () => {
2626
+ try {
2627
+ await storage.readDocument(newId);
2628
+ throw new ConflictError(
2629
+ `A document already exists at that location: ${newId}`
2630
+ );
2631
+ } catch (err) {
2632
+ if (err instanceof ConflictError) throw err;
2633
+ if (!(err instanceof DocumentNotFoundError)) throw err;
2501
2634
  }
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);
2635
+ const moved = {
2636
+ ...node,
2637
+ id: newId,
2638
+ frontmatter: {
2639
+ ...node.frontmatter,
2640
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
2641
+ }
2642
+ };
2643
+ await storage.writeDocument(newId, serializeNodeSafe(moved));
2644
+ try {
2645
+ const nestPath = resolveNestPath(nestId);
2646
+ const oldChain = join2(nestPath, dirname(nodeId), ".versions", leaf);
2647
+ const newChain = join2(nestPath, dirname(newId), ".versions", leaf);
2648
+ if (oldChain !== newChain) {
2649
+ await mkdir(dirname(newChain), { recursive: true });
2650
+ await rename(oldChain, newChain);
2651
+ }
2652
+ } catch {
2511
2653
  }
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
- ]) {
2654
+ await storage.deleteDocument(nodeId);
2655
+ const oldDerived = derivedIdOf(nodeId);
2656
+ const newDerived = derivedIdOf(newId);
2657
+ const db = getDb();
2658
+ await db.transaction(async (tx) => {
2659
+ await rewriteNodeKeyedRows(tx, nestId, nodeId, newId);
2660
+ await rewriteNodeKeyedRows(tx, nestId, oldDerived, newDerived);
2527
2661
  await tx.run(
2528
- `UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
2662
+ "UPDATE runs SET agent_node = ? WHERE nest_id = ? AND agent_node = ?",
2529
2663
  [newId, nestId, nodeId]
2530
2664
  );
2531
2665
  await tx.run(
2532
- `UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
2533
- [newDerived, nestId, oldDerived]
2666
+ `UPDATE run_steps SET node_id = ?
2667
+ WHERE node_id = ? AND run_id IN (SELECT id FROM runs WHERE nest_id = ?)`,
2668
+ [newId, nodeId, nestId]
2669
+ );
2670
+ });
2671
+ try {
2672
+ const derivedDoc = await storage.readDocument(oldDerived);
2673
+ await storage.writeDocument(
2674
+ newDerived,
2675
+ serializeNodeSafe({ ...derivedDoc, id: newDerived })
2534
2676
  );
2677
+ await storage.deleteDocument(oldDerived);
2678
+ } catch {
2535
2679
  }
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]
2553
- );
2554
- await tx.run(
2555
- "UPDATE runs SET agent_node = ? WHERE nest_id = ? AND agent_node = ?",
2556
- [newId, nestId, nodeId]
2557
- );
2558
- 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]
2562
- );
2680
+ await trackEvent("node.move", { nestId, nodeId, newId });
2681
+ return { node: moved, previousId: nodeId };
2563
2682
  });
2683
+ }
2684
+ async function deleteNode(nestId, nodeId, userEmail) {
2685
+ const { storage } = await engineCache.get(nestId);
2686
+ await assertNotReviewLocked(nestId, nodeId, userEmail, "deleting");
2564
2687
  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);
2688
+ await storage.deleteDocument(nodeId);
2689
+ } catch {
2690
+ throw new NotFoundError(`Node not found: ${nodeId}`);
2691
+ }
2692
+ await removeNodeFromTagIndex(nestId, nodeId);
2693
+ const derivedId = derivedIdOf(nodeId);
2694
+ try {
2695
+ await storage.deleteDocument(derivedId);
2696
+ await removeNodeFromTagIndex(nestId, derivedId);
2571
2697
  } catch {
2572
2698
  }
2573
- await trackEvent("node.move", { nestId, nodeId, newId });
2574
- return { node: moved, previousId: nodeId };
2699
+ const db = getDb();
2700
+ await db.transaction(async (tx) => {
2701
+ await purgeNodeKeyedRows(tx, nestId, nodeId);
2702
+ await purgeNodeKeyedRows(tx, nestId, derivedId);
2703
+ await tx.run(
2704
+ `INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
2705
+ VALUES (?, ?, ?, ?)
2706
+ ON CONFLICT(nest_id, node_id) DO UPDATE
2707
+ SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
2708
+ [nestId, nodeId, userEmail, (/* @__PURE__ */ new Date()).toISOString()]
2709
+ );
2710
+ });
2711
+ await trackEvent("node.delete", { nestId, nodeId });
2712
+ }
2713
+ async function deleteFolder(nestId, folder, userEmail) {
2714
+ const segments = folderToSegments(folder);
2715
+ if (segments.length === 0) {
2716
+ throw new ValidationError("folder is required");
2717
+ }
2718
+ const rel = segments.join("/");
2719
+ const { storage } = await engineCache.get(nestId);
2720
+ const docs = await storage.discoverDocuments();
2721
+ const targets = docs.map((d) => d.id).filter((id) => isIdUnderFolder(id, rel));
2722
+ if (targets.length === 0) {
2723
+ throw new NotFoundError(`No documents found under folder: ${folder}`);
2724
+ }
2725
+ const deleted = [];
2726
+ for (const id of targets) {
2727
+ try {
2728
+ await deleteNode(nestId, id, userEmail);
2729
+ deleted.push(id);
2730
+ } catch (err) {
2731
+ const detail = deleted.length > 0 ? ` (${deleted.length} of ${targets.length} already deleted)` : "";
2732
+ throw new Error(
2733
+ `Failed to delete "${id}"${detail}: ${err instanceof Error ? err.message : String(err)}`
2734
+ );
2735
+ }
2736
+ }
2737
+ return deleted;
2575
2738
  }
2576
2739
 
2577
2740
  // src/nests/unsynced-service.ts
@@ -3023,7 +3186,9 @@ nestRoutes.get("/:nestId/settings", async (c) => {
3023
3186
  }
3024
3187
  return c.json({
3025
3188
  stewardship_enabled: await isStewardshipEnabled(nestId),
3026
- allow_self_approve: await nestAllowsSelfApprove(nestId)
3189
+ allow_self_approve: await nestAllowsSelfApprove(nestId),
3190
+ prime_only_review: await nestReviewsPrimeOnly(nestId),
3191
+ prime_tags: await nestPrimeTags(nestId)
3027
3192
  });
3028
3193
  });
3029
3194
  nestRoutes.patch("/:nestId/settings", async (c) => {
@@ -3059,9 +3224,20 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
3059
3224
  if (typeof body.allow_self_approve === "boolean") {
3060
3225
  await setAllowSelfApprove(nestId, body.allow_self_approve);
3061
3226
  }
3227
+ if (typeof body.prime_only_review === "boolean") {
3228
+ await setPrimeOnlyReview(nestId, body.prime_only_review);
3229
+ }
3230
+ if (body.prime_tags !== void 0) {
3231
+ await setPrimeTags(
3232
+ nestId,
3233
+ Array.isArray(body.prime_tags) ? body.prime_tags : [body.prime_tags]
3234
+ );
3235
+ }
3062
3236
  return c.json({
3063
3237
  stewardship_enabled: await isStewardshipEnabled(nestId),
3064
3238
  allow_self_approve: await nestAllowsSelfApprove(nestId),
3239
+ prime_only_review: await nestReviewsPrimeOnly(nestId),
3240
+ prime_tags: await nestPrimeTags(nestId),
3065
3241
  wiped
3066
3242
  });
3067
3243
  });
@@ -3732,294 +3908,6 @@ ${m[1]}
3732
3908
  ` : "";
3733
3909
  }
3734
3910
 
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();
3752
- }
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
3911
  // src/nodes/routes.ts
4024
3912
  var nodeRoutes = new Hono8();
4025
3913
  function nodeAsMarkdown(response, nodeId) {
@@ -4068,7 +3956,8 @@ nodeRoutes.post("/", async (c) => {
4068
3956
  scope: body.scope,
4069
3957
  status: body.status,
4070
3958
  schedule: body.schedule,
4071
- folder: body.folder
3959
+ folder: body.folder,
3960
+ prime: body.prime
4072
3961
  },
4073
3962
  authorEmail
4074
3963
  );
@@ -4085,7 +3974,7 @@ nodeRoutes.post("/", async (c) => {
4085
3974
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4086
3975
  const nestId = c.req.param("nestId");
4087
3976
  const nodeId = c.req.param("nodeId");
4088
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-G7IF7ZNP.js");
3977
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-FHJ2RBKN.js");
4089
3978
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
4090
3979
  nestId,
4091
3980
  nodeId
@@ -4103,42 +3992,10 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4103
3992
  ownerEmail
4104
3993
  });
4105
3994
  });
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
3995
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
4139
3996
  const nestId = c.req.param("nestId");
4140
3997
  const nodeId = c.req.param("nodeId");
4141
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-6G7MVTY6.js");
3998
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-JUAFYB73.js");
4142
3999
  const history = await getReviewHistory2(nestId, nodeId);
4143
4000
  return c.json({ reviews: history });
4144
4001
  });
@@ -4304,6 +4161,10 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
4304
4161
  const pending = await getPendingReview(nestId, nodeId);
4305
4162
  response.pendingReviewBy = pending?.requestedBy ?? null;
4306
4163
  }
4164
+ response.deletionRequest = await getLatestDeletionRequest(
4165
+ nestId,
4166
+ nodeId
4167
+ );
4307
4168
  if (isRawFormat(c)) {
4308
4169
  return rawNodeBody(c, response, node.rawContent);
4309
4170
  }
@@ -4347,7 +4208,8 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
4347
4208
  title: body.title,
4348
4209
  status: body.status,
4349
4210
  changeNote: body.changeNote,
4350
- schedule: body.schedule
4211
+ schedule: body.schedule,
4212
+ prime: body.prime
4351
4213
  },
4352
4214
  authorEmail
4353
4215
  );
@@ -4356,84 +4218,307 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
4356
4218
  nodeRoutes.delete("/:nodeId{.+}", async (c) => {
4357
4219
  const nestId = c.req.param("nestId");
4358
4220
  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."
4221
+ await deleteNode(nestId, nodeId, await getUserEmail(c));
4222
+ return c.json({ deleted: true });
4223
+ });
4224
+ async function getUserEmail(c) {
4225
+ const userId = c.get("userId");
4226
+ const db = getDb();
4227
+ const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
4228
+ return user?.email || "anonymous@localhost";
4229
+ }
4230
+
4231
+ // src/annotations/routes.ts
4232
+ import { Hono as Hono9 } from "hono";
4233
+
4234
+ // src/annotations/service.ts
4235
+ import { v4 as uuid5 } from "uuid";
4236
+
4237
+ // src/annotations/projection.ts
4238
+ var MAX_CONTEXT_CHARS = 250;
4239
+ var MAX_QUOTE_CHARS = 1e3;
4240
+ function clampAnchor(raw) {
4241
+ if (!raw) return null;
4242
+ const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
4243
+ if (!quote) return null;
4244
+ const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
4245
+ const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
4246
+ const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
4247
+ return { quote, before, after, ...line !== void 0 ? { line } : {} };
4248
+ }
4249
+ function oneLine(s) {
4250
+ return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
4251
+ }
4252
+ function renderAnchor(anchor) {
4253
+ if (!anchor) {
4254
+ return "<!-- anchor: whole-artifact -->";
4255
+ }
4256
+ const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
4257
+ const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
4258
+ anchor.after
4259
+ )}\u2026`;
4260
+ return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
4261
+ }
4262
+ function statusHeading(thread) {
4263
+ if (thread.status === "resolved") {
4264
+ const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
4265
+ const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
4266
+ const meta = who || when ? ` (${who}${when})` : "";
4267
+ return `\u2705 RESOLVED${meta}`;
4268
+ }
4269
+ return "\u{1F7E0} OPEN";
4270
+ }
4271
+ function snapshotLabel(v) {
4272
+ return v == null ? "Unpinned" : `Snapshot v${v}`;
4273
+ }
4274
+ function orderThreads(threads) {
4275
+ return [...threads].sort((a, b) => {
4276
+ if (a.status !== b.status) return a.status === "open" ? -1 : 1;
4277
+ return a.createdAt.localeCompare(b.createdAt);
4278
+ });
4279
+ }
4280
+ function projectThreadsToMarkdown(artifactTitle, threads) {
4281
+ const lines = [];
4282
+ lines.push(`# ${artifactTitle} \u2014 Annotations`);
4283
+ lines.push("");
4284
+ lines.push(
4285
+ "> 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)."
4286
+ );
4287
+ lines.push("");
4288
+ if (threads.length === 0) {
4289
+ lines.push("_No annotations yet._");
4290
+ lines.push("");
4291
+ return lines.join("\n");
4292
+ }
4293
+ const buckets2 = /* @__PURE__ */ new Map();
4294
+ for (const t of threads) {
4295
+ const key = t.snapshotVersion ?? null;
4296
+ (buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
4297
+ }
4298
+ const keys = [...buckets2.keys()].sort((a, b) => {
4299
+ if (a == null) return 1;
4300
+ if (b == null) return -1;
4301
+ return b - a;
4302
+ });
4303
+ for (const key of keys) {
4304
+ lines.push(`## ${snapshotLabel(key)}`);
4305
+ lines.push("");
4306
+ for (const thread of orderThreads(buckets2.get(key))) {
4307
+ lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
4308
+ lines.push(renderAnchor(thread.anchor));
4309
+ for (const comment of thread.comments) {
4310
+ lines.push(
4311
+ `- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
4312
+ );
4313
+ }
4314
+ lines.push("");
4315
+ }
4316
+ }
4317
+ return lines.join("\n");
4318
+ }
4319
+
4320
+ // src/annotations/service.ts
4321
+ async function loadComments(threadId) {
4322
+ const db = getDb();
4323
+ const rows = await db.all(
4324
+ "SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
4325
+ [threadId]
4326
+ );
4327
+ return rows.map((r) => ({
4328
+ id: r.id,
4329
+ author: r.author,
4330
+ body: r.body,
4331
+ createdAt: r.created_at
4332
+ }));
4333
+ }
4334
+ async function rowToThread(row) {
4335
+ let anchor = null;
4336
+ if (row.anchor_json) {
4337
+ try {
4338
+ anchor = JSON.parse(row.anchor_json);
4339
+ } catch {
4340
+ anchor = null;
4341
+ }
4342
+ }
4343
+ return {
4344
+ id: row.id,
4345
+ nestId: row.nest_id,
4346
+ nodeId: row.node_id,
4347
+ snapshotVersion: row.snapshot_version,
4348
+ anchor,
4349
+ status: row.status,
4350
+ createdBy: row.created_by,
4351
+ createdAt: row.created_at,
4352
+ resolvedBy: row.resolved_by,
4353
+ resolvedAt: row.resolved_at,
4354
+ comments: await loadComments(row.id)
4355
+ };
4356
+ }
4357
+ async function getThreadRow(threadId) {
4358
+ return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
4359
+ }
4360
+ async function countThreadsByNode(nestId) {
4361
+ const rows = await getDb().all(
4362
+ `SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
4363
+ FROM annotation_threads
4364
+ WHERE nest_id = ?
4365
+ GROUP BY node_id`,
4366
+ [nestId]
4367
+ );
4368
+ const out = {};
4369
+ for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
4370
+ return out;
4371
+ }
4372
+ async function listThreads(nestId, nodeId) {
4373
+ const rows = await getDb().all(
4374
+ "SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
4375
+ [nestId, nodeId]
4376
+ );
4377
+ return Promise.all(rows.map(rowToThread));
4378
+ }
4379
+ async function createThread(nestId, nodeId, input, authorEmail) {
4380
+ const body = (input.body ?? "").trim();
4381
+ if (!body) {
4382
+ throw new Error("comment body is required");
4383
+ }
4384
+ const db = getDb();
4385
+ const id = uuid5();
4386
+ const anchor = clampAnchor(input.anchor);
4387
+ const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
4388
+ await db.transaction(async (tx) => {
4389
+ await tx.run(
4390
+ `INSERT INTO annotation_threads
4391
+ (id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
4392
+ VALUES (?, ?, ?, ?, ?, 'open', ?)`,
4393
+ [
4394
+ id,
4395
+ nestId,
4396
+ nodeId,
4397
+ snapshot,
4398
+ anchor ? JSON.stringify(anchor) : null,
4399
+ authorEmail
4400
+ ]
4401
+ );
4402
+ await tx.run(
4403
+ "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4404
+ [uuid5(), id, authorEmail, body]
4405
+ );
4406
+ });
4407
+ return rowToThread(await getThreadRow(id));
4408
+ }
4409
+ async function getScopedThreadRow(threadId, nestId, nodeId) {
4410
+ const row = await getThreadRow(threadId);
4411
+ if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
4412
+ throw new NotFoundError(`Thread not found: ${threadId}`);
4413
+ }
4414
+ return row;
4415
+ }
4416
+ async function addComment(nestId, nodeId, threadId, authorEmail, body) {
4417
+ const trimmed = (body ?? "").trim();
4418
+ if (!trimmed) {
4419
+ throw new Error("comment body is required");
4420
+ }
4421
+ await getScopedThreadRow(threadId, nestId, nodeId);
4422
+ await getDb().run(
4423
+ "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4424
+ [uuid5(), threadId, authorEmail, trimmed]
4425
+ );
4426
+ return rowToThread(await getThreadRow(threadId));
4427
+ }
4428
+ async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
4429
+ await getScopedThreadRow(threadId, nestId, nodeId);
4430
+ const db = getDb();
4431
+ if (status === "resolved") {
4432
+ await db.run(
4433
+ `UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
4434
+ [byEmail, threadId]
4435
+ );
4436
+ } else {
4437
+ await db.run(
4438
+ "UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
4439
+ [threadId]
4363
4440
  );
4364
4441
  }
4442
+ return rowToThread(await getThreadRow(threadId));
4443
+ }
4444
+ async function readArtifact(nestId, nodeId, version) {
4445
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
4446
+ const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
4447
+ let title = nodeId;
4448
+ let type = null;
4449
+ let liveHtml = "";
4450
+ let liveExists = true;
4365
4451
  try {
4366
- await storage.deleteDocument(nodeId);
4452
+ const node = await storage.readDocument(nodeId);
4453
+ title = node.frontmatter?.title || nodeId;
4454
+ type = node.frontmatter?.type || null;
4455
+ liveHtml = node.body || "";
4367
4456
  } catch {
4368
- throw new NotFoundError(`Node not found: ${nodeId}`);
4457
+ liveExists = false;
4369
4458
  }
4370
- await removeNodeFromTagIndex(nestId, nodeId);
4371
- const derivedId = derivedAnnotationsId(nodeId);
4372
- try {
4373
- await storage.deleteDocument(derivedId);
4374
- await removeNodeFromTagIndex(nestId, derivedId);
4375
- } catch {
4459
+ if (target != null) {
4460
+ try {
4461
+ const raw = await versionManager.reconstructVersion(nodeId, target);
4462
+ return { title, html: bodyOnly(nodeId, raw), version: target, type };
4463
+ } catch (err) {
4464
+ console.warn(
4465
+ `[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
4466
+ err
4467
+ );
4468
+ }
4376
4469
  }
4377
- const db = getDb();
4378
- await db.transaction(async (tx) => {
4379
- 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]
4403
- );
4404
- await tx.run(
4405
- "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
4406
- [nestId, derivedId]
4407
- );
4408
- await tx.run(
4409
- "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
4410
- [nestId, derivedId]
4411
- );
4412
- await tx.run(
4413
- "DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
4414
- [nestId, derivedId]
4415
- );
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()]
4470
+ if (!liveExists) {
4471
+ throw new NotFoundError(`Artifact not found: ${nodeId}`);
4472
+ }
4473
+ return { title, html: liveHtml, version: target, type };
4474
+ }
4475
+ function derivedAnnotationsId(sourceNodeId) {
4476
+ const base = sourceNodeId.replace(/^nodes\//, "");
4477
+ return `nodes/${base}--annotations`;
4478
+ }
4479
+ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
4480
+ try {
4481
+ let title = nodeId;
4482
+ let type = null;
4483
+ try {
4484
+ const { storage } = await engineCache.get(nestId);
4485
+ const node = await storage.readDocument(nodeId);
4486
+ title = node.frontmatter?.title || nodeId;
4487
+ type = node.frontmatter?.type || "document";
4488
+ } catch {
4489
+ }
4490
+ if (type !== null && type !== "artifact") return;
4491
+ const threads = await listThreads(nestId, nodeId);
4492
+ const derivedTitle = `${title.slice(0, 100)} \u2014 Annotations`;
4493
+ const markdown = projectThreadsToMarkdown(title, threads);
4494
+ const derivedId = derivedAnnotationsId(nodeId);
4495
+ try {
4496
+ await updateNode(nestId, derivedId, { content: markdown }, userEmail);
4497
+ } catch (err) {
4498
+ if (err instanceof NotFoundError) {
4499
+ await createNode(
4500
+ nestId,
4501
+ {
4502
+ id: derivedId,
4503
+ title: derivedTitle,
4504
+ content: markdown,
4505
+ type: "document",
4506
+ tags: ["annotations"]
4507
+ },
4508
+ userEmail
4509
+ );
4510
+ } else {
4511
+ throw err;
4512
+ }
4513
+ }
4514
+ } catch (err) {
4515
+ console.warn(
4516
+ `[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
4517
+ err
4422
4518
  );
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";
4519
+ }
4432
4520
  }
4433
4521
 
4434
- // src/annotations/routes.ts
4435
- import { Hono as Hono9 } from "hono";
4436
-
4437
4522
  // src/annotations/types.ts
4438
4523
  var ARTIFACT_NODE_TYPE = "artifact";
4439
4524
 
@@ -6909,7 +6994,7 @@ var TOOL_DEFINITIONS = [
6909
6994
  },
6910
6995
  {
6911
6996
  name: "context_create",
6912
- description: "Create a new knowledge node in the vault.",
6997
+ 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
6998
  inputSchema: {
6914
6999
  type: "object",
6915
7000
  properties: {
@@ -6930,7 +7015,7 @@ var TOOL_DEFINITIONS = [
6930
7015
  scope: { type: "string", description: "Visibility scope" },
6931
7016
  folder: {
6932
7017
  type: "string",
6933
- description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
7018
+ 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
7019
  }
6935
7020
  },
6936
7021
  required: ["title", "content"]
@@ -6961,6 +7046,49 @@ var TOOL_DEFINITIONS = [
6961
7046
  required: ["title"]
6962
7047
  }
6963
7048
  },
7049
+ {
7050
+ name: "context_delete",
7051
+ // Mirrors the engine's canonical `context_delete` operation (core
7052
+ // namespace, alias delete_document): same name, same {id, title} input,
7053
+ // same "deleted" semantics. The engine's `uri` field is omitted — this
7054
+ // repo has no contextnest:// resolver on any surface, so accepting one
7055
+ // would promise a resolution it can't do.
7056
+ 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).",
7057
+ inputSchema: {
7058
+ type: "object",
7059
+ properties: {
7060
+ title: { type: "string", description: "Title of node to delete" },
7061
+ id: {
7062
+ type: "string",
7063
+ 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.'
7064
+ }
7065
+ }
7066
+ }
7067
+ },
7068
+ {
7069
+ name: "context_move",
7070
+ // NOT in the engine's operation catalog — `move` is a community-edition
7071
+ // concern (the id rewrite spans governance tables the engine doesn't own).
7072
+ // This mirrors the existing REST POST /nodes/:id/move rather than adding a
7073
+ // new concept, so the surfaces agree. If move is ever promoted to a core
7074
+ // operation upstream, adopt that descriptor and drop this schema.
7075
+ 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).",
7076
+ inputSchema: {
7077
+ type: "object",
7078
+ properties: {
7079
+ title: { type: "string", description: "Title of node to move" },
7080
+ id: {
7081
+ type: "string",
7082
+ 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.'
7083
+ },
7084
+ folder: {
7085
+ type: "string",
7086
+ description: 'Destination folder under nodes/ (e.g. "gtm/deals"); segments are slugified. Use "" to move to the nest root.'
7087
+ }
7088
+ },
7089
+ required: ["folder"]
7090
+ }
7091
+ },
6964
7092
  // ─── Governance Tools ───────────────────────────────────────────────
6965
7093
  {
6966
7094
  name: "context_stewards",
@@ -6975,6 +7103,73 @@ var TOOL_DEFINITIONS = [
6975
7103
  }
6976
7104
  }
6977
7105
  },
7106
+ {
7107
+ // Deletion requests, mirroring the REST + UI flow: anyone who can READ a
7108
+ // thing may ask for it to be deleted; only an owner/admin resolves it.
7109
+ // Not in the engine catalog (1.2.1) — deletion requests live in this
7110
+ // repo's governance tables, which the engine doesn't own. Adopt an engine
7111
+ // descriptor here if the operation is ever promoted upstream.
7112
+ name: "context_request_deletion",
7113
+ 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 declines with a note. Use this instead of context_delete when that returns a permission error, or when the document belongs to someone else.",
7114
+ inputSchema: {
7115
+ type: "object",
7116
+ properties: {
7117
+ title: { type: "string", description: "Title of the document to flag" },
7118
+ id: {
7119
+ type: "string",
7120
+ description: 'Document id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two documents share a title.'
7121
+ },
7122
+ folder: {
7123
+ type: "string",
7124
+ 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.'
7125
+ },
7126
+ nest: {
7127
+ type: "boolean",
7128
+ description: "Flag the ENTIRE nest for deletion. Only an owner/admin can honour it."
7129
+ },
7130
+ reason: {
7131
+ type: "string",
7132
+ description: "Why it should be deleted. Required \u2014 this is what the admin judges."
7133
+ }
7134
+ },
7135
+ required: ["reason"]
7136
+ }
7137
+ },
7138
+ {
7139
+ name: "context_deletion_queue",
7140
+ 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.",
7141
+ inputSchema: {
7142
+ type: "object",
7143
+ properties: {
7144
+ status: {
7145
+ type: "string",
7146
+ description: "pending (default) or declined \u2014 declined shows past verdicts and their notes."
7147
+ }
7148
+ }
7149
+ }
7150
+ },
7151
+ {
7152
+ name: "context_resolve_deletion",
7153
+ description: "Action a deletion request: delete the flagged document/folder/nest, or decline it with a note explaining why it stays. Owner/admin only. Declining tells the requester the verdict; deleting is irreversible.",
7154
+ inputSchema: {
7155
+ type: "object",
7156
+ properties: {
7157
+ request_id: {
7158
+ type: "string",
7159
+ description: "Request id from context_deletion_queue"
7160
+ },
7161
+ action: {
7162
+ type: "string",
7163
+ description: '"delete" to honour it, "decline" to keep the target'
7164
+ },
7165
+ note: {
7166
+ type: "string",
7167
+ description: "Why you declined. Required when action is decline."
7168
+ }
7169
+ },
7170
+ required: ["request_id", "action"]
7171
+ }
7172
+ },
6978
7173
  {
6979
7174
  name: "context_review_queue",
6980
7175
  description: "Show documents pending review. These need a steward to approve or reject them before they become available to AI.",
@@ -6988,6 +7183,14 @@ var TOOL_DEFINITIONS = [
6988
7183
  }
6989
7184
  }
6990
7185
  },
7186
+ {
7187
+ name: "context_my_drafts",
7188
+ 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.",
7189
+ inputSchema: {
7190
+ type: "object",
7191
+ properties: {}
7192
+ }
7193
+ },
6991
7194
  {
6992
7195
  name: "context_submit_review",
6993
7196
  description: "Submit a node for steward review. The node will be marked as pending review and stewards will be notified.",
@@ -7140,6 +7343,24 @@ async function handleToolCall(toolName, args, ctx) {
7140
7343
  });
7141
7344
  }
7142
7345
  }
7346
+ async function resolveTargetNode(storage, args) {
7347
+ if (!args.id && !args.title) return "Provide a title or an id.";
7348
+ if (args.id) {
7349
+ try {
7350
+ return await storage.readDocument(args.id);
7351
+ } catch {
7352
+ return `Node not found: ${args.id}`;
7353
+ }
7354
+ }
7355
+ const docs = await storage.discoverDocuments();
7356
+ const matches = docs.filter(
7357
+ (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7358
+ );
7359
+ if (matches.length > 1) {
7360
+ return `${matches.length} nodes share the title "${args.title}". Re-run with one of these ids instead: ${matches.map((n) => n.id).join(", ")}`;
7361
+ }
7362
+ return matches[0] ?? `Node not found: ${args.title}`;
7363
+ }
7143
7364
  async function runTool(toolName, args, ctx) {
7144
7365
  const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
7145
7366
  switch (toolName) {
@@ -7373,19 +7594,29 @@ ${sections}`;
7373
7594
  if (!await canCreateInNest(nestId, userEmail)) {
7374
7595
  return "You don't have permission to create documents in this nest.";
7375
7596
  }
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}`;
7597
+ const wanted = String(args.title ?? "").toLowerCase();
7598
+ return withNodeWriteLock(nodeWriteKey(nestId, "*create*"), async () => {
7599
+ const existingDocs = await storage.discoverDocuments();
7600
+ const twin = existingDocs.find(
7601
+ (n) => String(n.frontmatter.title ?? "").toLowerCase() === wanted
7602
+ );
7603
+ if (twin) {
7604
+ return `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.`;
7605
+ }
7606
+ const { node } = await createNode(
7607
+ nestId,
7608
+ {
7609
+ title: args.title,
7610
+ content: args.content,
7611
+ type: args.type,
7612
+ tags: args.tags,
7613
+ scope: args.scope,
7614
+ folder: args.folder
7615
+ },
7616
+ userEmail
7617
+ );
7618
+ return `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`;
7619
+ });
7389
7620
  }
7390
7621
  case "context_update": {
7391
7622
  const docs = await storage.discoverDocuments();
@@ -7409,6 +7640,39 @@ ${sections}`;
7409
7640
  );
7410
7641
  return `Updated node: **${updated.frontmatter.title}**`;
7411
7642
  }
7643
+ case "context_delete": {
7644
+ const node = await resolveTargetNode(storage, args);
7645
+ if (typeof node === "string") return node;
7646
+ const deleteCheck = await canUserEdit(nestId, node.id, userEmail);
7647
+ if (!deleteCheck.allowed) {
7648
+ return `You don't have permission to delete "${node.frontmatter.title}": ${deleteCheck.reason}
7649
+
7650
+ Use context_request_deletion to ask a nest owner/admin to delete it, with a reason.`;
7651
+ }
7652
+ await deleteNode(nestId, node.id, userEmail);
7653
+ return `Deleted node: **${node.frontmatter.title}** (${node.id})`;
7654
+ }
7655
+ case "context_move": {
7656
+ if (typeof args.folder !== "string") {
7657
+ return 'folder is required \u2014 a path like "gtm/deals", or "" for the nest root.';
7658
+ }
7659
+ const node = await resolveTargetNode(storage, args);
7660
+ if (typeof node === "string") return node;
7661
+ const moveCheck = await canUserEdit(nestId, node.id, userEmail);
7662
+ if (!moveCheck.allowed) {
7663
+ return `You don't have permission to move "${node.frontmatter.title}": ${moveCheck.reason}`;
7664
+ }
7665
+ const { node: moved, previousId } = await moveNode(
7666
+ nestId,
7667
+ node.id,
7668
+ args.folder,
7669
+ userEmail
7670
+ );
7671
+ if (previousId === moved.id) {
7672
+ return `**${moved.frontmatter.title}** is already at \`${moved.id}\` \u2014 nothing to move.`;
7673
+ }
7674
+ return `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}\``;
7675
+ }
7412
7676
  // ─── Governance Tool Handlers ──────────────────────────────────────
7413
7677
  case "context_stewards": {
7414
7678
  if (args.title) {
@@ -7435,18 +7699,138 @@ ${list}`;
7435
7699
  if (allStewards.length === 0) {
7436
7700
  return "No stewards configured. All changes are auto-approved.\n\nTo configure stewards, add a `stewards.yaml` file to the vault root, or use `context_assign_steward`.";
7437
7701
  }
7438
- const byScope = {};
7439
- for (const s of allStewards) {
7440
- const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodeTitle || s.nodePattern}`;
7441
- (byScope[key] = byScope[key] || []).push(s);
7702
+ const byScope = {};
7703
+ for (const s of allStewards) {
7704
+ const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodeTitle || s.nodePattern}`;
7705
+ (byScope[key] = byScope[key] || []).push(s);
7706
+ }
7707
+ const sections = Object.entries(byScope).map(
7708
+ ([scope, stewards]) => `## ${scope}
7709
+ ${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ? " \u2014 can approve" : ""}`).join("\n")}`
7710
+ ).join("\n\n");
7711
+ return `# Data Stewards
7712
+
7713
+ ${sections}`;
7714
+ }
7715
+ case "context_request_deletion": {
7716
+ const reason = typeof args.reason === "string" ? args.reason.trim() : "";
7717
+ if (!reason) {
7718
+ return "reason is required \u2014 say why it should be deleted; that is what the admin judges.";
7719
+ }
7720
+ if (args.nest === true) {
7721
+ const request2 = await requestDeletion({
7722
+ nestId,
7723
+ nodeId: nestId,
7724
+ targetType: "nest",
7725
+ requestedBy: userEmail,
7726
+ reason,
7727
+ baseUrl: ctx.baseUrl
7728
+ });
7729
+ return `Requested deletion of the **entire nest**. A nest owner or admin will delete it or decline.
7730
+ Request id: \`${request2.id}\``;
7731
+ }
7732
+ if (typeof args.folder === "string" && args.folder.trim()) {
7733
+ const folder = args.folder.trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
7734
+ const docs = await storage.discoverDocuments();
7735
+ const inside = docs.filter((d) => isIdUnderFolder(d.id, folder));
7736
+ if (inside.length === 0) {
7737
+ return `No folder "${folder}" \u2014 nothing is filed under it. Use context_list to see what exists.`;
7738
+ }
7739
+ const request2 = await requestDeletion({
7740
+ nestId,
7741
+ nodeId: folder,
7742
+ targetType: "folder",
7743
+ requestedBy: userEmail,
7744
+ reason,
7745
+ baseUrl: ctx.baseUrl
7746
+ });
7747
+ return `Requested deletion of folder **${folder}** (${inside.length} document(s) inside). A nest owner or admin will delete it or decline.
7748
+ Request id: \`${request2.id}\``;
7749
+ }
7750
+ const node = await resolveTargetNode(storage, args);
7751
+ if (typeof node === "string") return node;
7752
+ if (!await canReadNode(nestId, node.id, userId, userEmail)) {
7753
+ return `Node not found: ${node.id}`;
7754
+ }
7755
+ const request = await requestDeletion({
7756
+ nestId,
7757
+ nodeId: node.id,
7758
+ targetType: "document",
7759
+ requestedBy: userEmail,
7760
+ reason,
7761
+ baseUrl: ctx.baseUrl
7762
+ });
7763
+ return `Requested deletion of **${node.frontmatter.title}**. A nest owner or admin will delete it or decline \u2014 nothing is removed yet.
7764
+ Request id: \`${request.id}\``;
7765
+ }
7766
+ case "context_deletion_queue": {
7767
+ const status = args.status === "declined" ? "declined" : "pending";
7768
+ const requests = await listDeletionRequests({ nestId, status });
7769
+ if (requests.length === 0) {
7770
+ return status === "pending" ? "No deletion requests waiting. All caught up!" : "No declined deletion requests.";
7771
+ }
7772
+ const list = requests.map((r, i) => {
7773
+ const kind = r.targetType === "document" ? "" : `${r.targetType}: `;
7774
+ const verdict = r.status === "declined" ? `
7775
+ Declined by ${r.resolvedBy}: "${r.resolutionNote}"` : "";
7776
+ return `${i + 1}. **${kind}${r.title || r.nodeId}**
7777
+ Asked by ${r.requestedBy}: "${r.reason}"
7778
+ Request id: \`${r.id}\`${verdict}`;
7779
+ }).join("\n\n");
7780
+ return `# Deletion requests (${status})
7781
+ **${requests.length} item(s)**
7782
+
7783
+ ${list}`;
7784
+ }
7785
+ case "context_resolve_deletion": {
7786
+ const requestId = typeof args.request_id === "string" ? args.request_id : "";
7787
+ const action = args.action === "delete" ? "delete" : args.action === "decline" ? "decline" : null;
7788
+ if (!requestId || !action) {
7789
+ return 'request_id and action are required \u2014 action is "delete" or "decline". Use context_deletion_queue to list request ids.';
7790
+ }
7791
+ const request = await getDeletionRequest(requestId);
7792
+ if (!request || request.nestId !== nestId || request.status !== "pending") {
7793
+ return `No pending deletion request with id ${requestId}.`;
7794
+ }
7795
+ const roles = await resolveUserRoles(nestId, userEmail);
7796
+ if (!roles.includes("owner") && !roles.includes("admin")) {
7797
+ 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.`;
7798
+ }
7799
+ const label = await deletionTargetTitle(request);
7800
+ if (action === "decline") {
7801
+ const note = typeof args.note === "string" ? args.note.trim() : "";
7802
+ if (!note) {
7803
+ return "note is required when declining \u2014 the requester is told why it stays.";
7804
+ }
7805
+ await declineDeletion({
7806
+ nestId,
7807
+ id: requestId,
7808
+ declinedBy: userEmail,
7809
+ note,
7810
+ baseUrl: ctx.baseUrl
7811
+ });
7812
+ return `Declined the deletion request for **${label}**. It stays, and ${request.requestedBy} has been told why.`;
7442
7813
  }
7443
- const sections = Object.entries(byScope).map(
7444
- ([scope, stewards]) => `## ${scope}
7445
- ${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ? " \u2014 can approve" : ""}`).join("\n")}`
7446
- ).join("\n\n");
7447
- return `# Data Stewards
7448
-
7449
- ${sections}`;
7814
+ if (request.targetType === "nest") {
7815
+ await deleteNest(nestId);
7816
+ } else if (request.targetType === "folder") {
7817
+ await deleteFolder(nestId, request.nodeId, userEmail);
7818
+ await dropDeletionRequest(request.id);
7819
+ } else {
7820
+ await deleteNode(nestId, request.nodeId, userEmail);
7821
+ }
7822
+ await notifyDeletionResolved({
7823
+ nestId,
7824
+ nodeId: request.nodeId,
7825
+ docTitle: label,
7826
+ outcome: "deleted",
7827
+ requestedBy: request.requestedBy,
7828
+ resolvedBy: userEmail,
7829
+ requestId: request.id,
7830
+ targetType: request.targetType,
7831
+ baseUrl: ctx.baseUrl
7832
+ });
7833
+ return `Deleted **${label}** \u2014 ${request.requestedBy}'s request was honoured. Irreversible.`;
7450
7834
  }
7451
7835
  case "context_review_queue": {
7452
7836
  const status = args.status || "pending";
@@ -7466,6 +7850,30 @@ ${sections}`;
7466
7850
  **${result.total} item(s)**
7467
7851
 
7468
7852
  ${list}`;
7853
+ }
7854
+ case "context_my_drafts": {
7855
+ const { drafts, total } = await listMyDrafts({
7856
+ authorEmail: userEmail,
7857
+ nestIds: [ctx.nestId],
7858
+ limit: 100
7859
+ });
7860
+ if (total === 0) {
7861
+ return "No unsubmitted drafts \u2014 everything you've written here is in review or approved.";
7862
+ }
7863
+ const list = drafts.map(
7864
+ (d, i) => `${i + 1}. **${d.title}** v${d.version} \u2014 ${d.status}
7865
+ Path: ${d.nodeId}
7866
+ Last edited: ${d.updatedAt}`
7867
+ ).join("\n\n");
7868
+ const more = total > drafts.length ? `
7869
+
7870
+ _Showing the ${drafts.length} most recent of ${total}._` : "";
7871
+ return `# My Drafts
7872
+ **${total} unsubmitted document(s)**
7873
+
7874
+ ${list}${more}
7875
+
7876
+ Submit one with \`context_submit_review\`.`;
7469
7877
  }
7470
7878
  case "context_submit_review": {
7471
7879
  const docs = await storage.discoverDocuments();
@@ -7617,8 +8025,8 @@ ${list}`;
7617
8025
  if (!target) return "target is required (a node id or folder prefix).";
7618
8026
  if (!["read", "write"].includes(role)) return "role must be read or write.";
7619
8027
  try {
7620
- const { createGrant: createGrant2 } = await import("./grants-service-5ZFISCBH.js");
7621
- const db = (await import("./client-3MGIP57D.js")).getDb();
8028
+ const { createGrant: createGrant2 } = await import("./grants-service-O4AO326A.js");
8029
+ const db = (await import("./client-NFUHPLLM.js")).getDb();
7622
8030
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
7623
8031
  const e = normalizeEmail2(String(args.email || ""));
7624
8032
  if (!e) return "email is required.";
@@ -8797,6 +9205,7 @@ import { Hono as Hono19 } from "hono";
8797
9205
  import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
8798
9206
  import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
8799
9207
  import { z as z2 } from "zod";
9208
+ var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set(["nest_create", "context_create"]);
8800
9209
  async function getUserEmail3(userId) {
8801
9210
  const db = getDb();
8802
9211
  const user = await db.get("SELECT email FROM users WHERE id = ?", [
@@ -8978,6 +9387,38 @@ var SERVER_TOOL_DEFINITIONS = [
8978
9387
  },
8979
9388
  required: ["nest", "selector"]
8980
9389
  }
9390
+ },
9391
+ {
9392
+ name: "nest_create",
9393
+ description: "Create a new nest owned by the caller. Returns the new nest's id \u2014 target it with the other tools afterwards.",
9394
+ inputSchema: {
9395
+ type: "object",
9396
+ properties: {
9397
+ name: { type: "string", description: "Nest name" },
9398
+ description: { type: "string", description: "Optional description" }
9399
+ },
9400
+ required: ["name"]
9401
+ }
9402
+ },
9403
+ {
9404
+ name: "context_create",
9405
+ 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.",
9406
+ inputSchema: {
9407
+ type: "object",
9408
+ properties: {
9409
+ ...NEST_ARG,
9410
+ title: { type: "string", description: "Descriptive title" },
9411
+ content: { type: "string", description: "Markdown content body" },
9412
+ type: { type: "string", description: "Node type (default: document)" },
9413
+ tags: { type: "array", items: { type: "string" }, description: "Tags" },
9414
+ scope: { type: "string", description: "Visibility scope" },
9415
+ folder: {
9416
+ type: "string",
9417
+ description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
9418
+ }
9419
+ },
9420
+ required: ["nest", "title", "content"]
9421
+ }
8981
9422
  }
8982
9423
  ];
8983
9424
  async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
@@ -8986,6 +9427,31 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
8986
9427
  if (!nests.length) return "No nests accessible with these credentials.";
8987
9428
  return renderNestIndexMarkdown(nests, baseUrl);
8988
9429
  }
9430
+ if (SERVER_WRITE_TOOLS.has(toolName)) {
9431
+ if (isSuspended()) {
9432
+ const reason = getSuspensionReason();
9433
+ return `This server is suspended by PromptOwl; writes are blocked${reason ? ` \u2014 ${reason}` : ""}. Contact support@promptowl.ai.`;
9434
+ }
9435
+ if (!getCurrentLicense()?.valid) {
9436
+ return "This server has no valid PromptOwl license installed, so writes are disabled. Ask the server admin to install one (POST /license/install).";
9437
+ }
9438
+ }
9439
+ if (toolName === "nest_create") {
9440
+ if (nestScope) {
9441
+ return "This credential is scoped to a single nest and cannot create new ones.";
9442
+ }
9443
+ try {
9444
+ const nest2 = await createNest(
9445
+ userId,
9446
+ String(args.name ?? ""),
9447
+ args.description ? String(args.description) : void 0
9448
+ );
9449
+ return `Created nest "${nest2.name}" (id: ${nest2.id}). Add documents with context_create, retrieve with the other tools.`;
9450
+ } catch (err) {
9451
+ if (err instanceof ValidationError) return err.message;
9452
+ throw err;
9453
+ }
9454
+ }
8989
9455
  const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
8990
9456
  const nest = await resolveNestRef(String(args.nest ?? ""), userId);
8991
9457
  if (!nest) return inaccessible;
@@ -9144,7 +9610,9 @@ Streamable HTTP, stateless. Two endpoints:
9144
9610
  Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
9145
9611
  \`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
9146
9612
  each takes a \`nest\` argument (id or exact name) plus the same
9147
- selector/hops/max_tokens parameters as the REST API. Read-only.
9613
+ selector/hops/max_tokens parameters as the REST API. Two bootstrap writes:
9614
+ \`nest_create\` (new nest owned by the caller) and \`context_create\`
9615
+ (new document in a writable nest; a \`folder\` path creates the folder).
9148
9616
  - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
9149
9617
  governance (create/update, submit/approve/reject, stewards, sharing).
9150
9618
 
@@ -9737,6 +10205,123 @@ governanceRoutes.get("/review-queue", async (c) => {
9737
10205
  }
9738
10206
  return c.json({ ...result, requests });
9739
10207
  });
10208
+ function denyUnlessNestManager(c, what) {
10209
+ const permission = c.get("nestPermission");
10210
+ if (permission === "owner" || permission === "admin") return null;
10211
+ return c.json(
10212
+ {
10213
+ error: `Only the nest owner or an admin can delete ${what}. Request its deletion instead, or ask an admin to action it.`
10214
+ },
10215
+ 403
10216
+ );
10217
+ }
10218
+ governanceRoutes.post("/delete-folder", async (c) => {
10219
+ const nestId = c.req.param("nestId");
10220
+ const denied = denyUnlessNestManager(c, "a folder");
10221
+ if (denied) return denied;
10222
+ const body = await c.req.json().catch(() => ({}));
10223
+ if (!body.folder?.trim()) {
10224
+ throw new ValidationError("folder is required");
10225
+ }
10226
+ const deleted = await deleteFolder(
10227
+ nestId,
10228
+ body.folder.trim(),
10229
+ await getUserEmail4(c)
10230
+ );
10231
+ return c.json({ deleted: deleted.length, nodeIds: deleted });
10232
+ });
10233
+ governanceRoutes.post("/request-deletion", async (c) => {
10234
+ const nestId = c.req.param("nestId");
10235
+ const body = await c.req.json().catch(() => ({}));
10236
+ const reason = body.reason?.trim();
10237
+ if (!reason) {
10238
+ throw new ValidationError("A reason for the deletion request is required");
10239
+ }
10240
+ if (body.targetType !== "folder" && body.targetType !== "nest") {
10241
+ throw new ValidationError(
10242
+ 'targetType must be "folder" or "nest" \u2014 flag a single document via POST /nests/:nestId/nodes/:nodeId/request-deletion'
10243
+ );
10244
+ }
10245
+ const target = body.targetType === "nest" ? nestId : (body.target || "").trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
10246
+ if (body.targetType === "folder" && !target) {
10247
+ throw new ValidationError("target (the folder path) is required");
10248
+ }
10249
+ if (body.targetType === "folder") {
10250
+ const { storage } = await engineCache.get(nestId);
10251
+ const docs = await storage.discoverDocuments();
10252
+ if (!docs.some((d) => isIdUnderFolder(d.id, target))) {
10253
+ return c.json({ error: `Folder not found: ${target}` }, 404);
10254
+ }
10255
+ }
10256
+ const request = await requestDeletion({
10257
+ nestId,
10258
+ nodeId: target,
10259
+ targetType: body.targetType,
10260
+ requestedBy: await getUserEmail4(c),
10261
+ reason,
10262
+ baseUrl: requestBaseUrl(c.req.url)
10263
+ });
10264
+ return c.json({ request }, 201);
10265
+ });
10266
+ governanceRoutes.get("/deletion-requests", async (c) => {
10267
+ const nestId = c.req.param("nestId");
10268
+ const raw = c.req.query("status");
10269
+ const status = raw === "declined" || raw === "all" ? raw : "pending";
10270
+ const requests = await listDeletionRequests({ nestId, status });
10271
+ return c.json({ requests, total: requests.length });
10272
+ });
10273
+ governanceRoutes.post("/deletion-requests/:id/delete", async (c) => {
10274
+ const nestId = c.req.param("nestId");
10275
+ const request = await getDeletionRequest(c.req.param("id"));
10276
+ if (!request || request.nestId !== nestId || request.status !== "pending") {
10277
+ return c.json({ error: "Deletion request not found" }, 404);
10278
+ }
10279
+ const actor = await getUserEmail4(c);
10280
+ const docTitle = await deletionTargetTitle(request);
10281
+ if (request.targetType === "nest" || request.targetType === "folder") {
10282
+ const denied = denyUnlessNestManager(
10283
+ c,
10284
+ request.targetType === "nest" ? "a nest" : "a folder"
10285
+ );
10286
+ if (denied) return denied;
10287
+ }
10288
+ if (request.targetType === "nest") {
10289
+ await deleteNest(nestId);
10290
+ } else if (request.targetType === "folder") {
10291
+ await deleteFolder(nestId, request.nodeId, actor);
10292
+ await dropDeletionRequest(request.id);
10293
+ } else {
10294
+ await deleteNode(nestId, request.nodeId, actor);
10295
+ }
10296
+ await notifyDeletionResolved({
10297
+ nestId,
10298
+ nodeId: request.nodeId,
10299
+ docTitle,
10300
+ outcome: "deleted",
10301
+ requestedBy: request.requestedBy,
10302
+ resolvedBy: actor,
10303
+ requestId: request.id,
10304
+ targetType: request.targetType,
10305
+ baseUrl: requestBaseUrl(c.req.url)
10306
+ });
10307
+ return c.json({ deleted: true, nodeId: request.nodeId });
10308
+ });
10309
+ governanceRoutes.post("/deletion-requests/:id/decline", async (c) => {
10310
+ const nestId = c.req.param("nestId");
10311
+ const body = await c.req.json().catch(() => ({}));
10312
+ if (!body.note?.trim()) {
10313
+ throw new ValidationError("A note is required when declining");
10314
+ }
10315
+ const request = await declineDeletion({
10316
+ nestId,
10317
+ id: c.req.param("id"),
10318
+ declinedBy: await getUserEmail4(c),
10319
+ note: body.note.trim(),
10320
+ baseUrl: requestBaseUrl(c.req.url)
10321
+ });
10322
+ if (!request) return c.json({ error: "Deletion request not found" }, 404);
10323
+ return c.json({ request });
10324
+ });
9740
10325
  governanceRoutes.get("/feed", async (c) => {
9741
10326
  const nestId = c.req.param("nestId");
9742
10327
  const filterRaw = c.req.query("filter");
@@ -9798,25 +10383,71 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
9798
10383
  governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
9799
10384
  const nestId = c.req.param("nestId");
9800
10385
  const nodeId = c.req.param("nodeId");
10386
+ const userId = c.get("userId");
10387
+ const userEmail = await resolveCallerEmail(userId);
10388
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
10389
+ return c.json(
10390
+ { error: "Access denied \u2014 no steward assignment for this node" },
10391
+ 403
10392
+ );
10393
+ }
9801
10394
  const allVersions = await getVersions(nestId, nodeId);
9802
10395
  const approved = await getApprovedVersion(nestId, nodeId);
9803
10396
  const { versions: versionManager } = await engineCache.get(nestId);
9804
- const withContent = await Promise.all(
10397
+ const resolutions = await getDb().all(
10398
+ `SELECT version, status, resolved_by
10399
+ FROM review_requests
10400
+ WHERE nest_id = ? AND node_id = ?
10401
+ AND status IN ('approved', 'rejected')
10402
+ AND resolved_by IS NOT NULL
10403
+ ORDER BY resolved_at DESC`,
10404
+ [nestId, nodeId]
10405
+ );
10406
+ const byVersion = /* @__PURE__ */ new Map();
10407
+ for (const r of resolutions) {
10408
+ if (!byVersion.has(r.version)) {
10409
+ byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
10410
+ }
10411
+ }
10412
+ const enriched = await Promise.all(
9805
10413
  allVersions.map(async (v) => {
10414
+ const r = byVersion.get(v.version);
10415
+ const resolution = r ? { resolvedBy: r.resolvedBy, resolutionStatus: r.status } : {};
9806
10416
  try {
9807
- const raw = await versionManager.reconstructVersion(nodeId, v.version);
9808
- return { ...v, content: bodyOnly(nodeId, raw) };
10417
+ const diff = await versionManager.getDiff(nodeId, v.version);
10418
+ return { ...v, ...resolution, diff: diff ?? void 0 };
9809
10419
  } catch {
9810
- return v;
10420
+ return { ...v, ...resolution };
9811
10421
  }
9812
10422
  })
9813
10423
  );
9814
10424
  return c.json({
9815
- versions: withContent,
10425
+ versions: enriched,
9816
10426
  approvedVersion: approved,
9817
10427
  currentVersion: allVersions[0]?.version || 0
9818
10428
  });
9819
10429
  });
10430
+ governanceNodeRoutes.get("/:nodeId{.+}/versions/:version{[0-9]+}", async (c) => {
10431
+ const nestId = c.req.param("nestId");
10432
+ const nodeId = c.req.param("nodeId");
10433
+ const version = Number(c.req.param("version"));
10434
+ const userId = c.get("userId");
10435
+ const userEmail = await resolveCallerEmail(userId);
10436
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
10437
+ return c.json(
10438
+ { error: "Access denied \u2014 no steward assignment for this node" },
10439
+ 403
10440
+ );
10441
+ }
10442
+ const { versions: versionManager } = await engineCache.get(nestId);
10443
+ try {
10444
+ const raw = await versionManager.reconstructVersion(nodeId, version);
10445
+ return c.json({ version, content: bodyOnly(nodeId, raw) });
10446
+ } catch (err) {
10447
+ console.error("reconstructVersion failed", nestId, nodeId, version, err);
10448
+ return c.json({ version, content: null }, 200);
10449
+ }
10450
+ });
9820
10451
  governanceNodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
9821
10452
  const nestId = c.req.param("nestId");
9822
10453
  const nodeId = c.req.param("nodeId");
@@ -9929,6 +10560,33 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
9929
10560
  201
9930
10561
  );
9931
10562
  });
10563
+ governanceNodeRoutes.post("/:nodeId{.+}/request-deletion", async (c) => {
10564
+ const nestId = c.req.param("nestId");
10565
+ const nodeId = c.req.param("nodeId");
10566
+ const body = await c.req.json().catch(() => ({}));
10567
+ const reason = body.reason?.trim();
10568
+ if (!reason) {
10569
+ throw new ValidationError("A reason for the deletion request is required");
10570
+ }
10571
+ const userEmail = await getUserEmail4(c);
10572
+ if (!await canReadNode(nestId, nodeId, c.get("userId"), userEmail)) {
10573
+ return c.json({ error: `Node not found: ${nodeId}` }, 404);
10574
+ }
10575
+ const { storage } = await engineCache.get(nestId);
10576
+ try {
10577
+ await storage.readDocument(nodeId);
10578
+ } catch {
10579
+ return c.json({ error: `Node not found: ${nodeId}` }, 404);
10580
+ }
10581
+ const request = await requestDeletion({
10582
+ nestId,
10583
+ nodeId,
10584
+ requestedBy: userEmail,
10585
+ reason,
10586
+ baseUrl: requestBaseUrl(c.req.url)
10587
+ });
10588
+ return c.json({ request }, 201);
10589
+ });
9932
10590
  governanceNodeRoutes.post(
9933
10591
  "/:nodeId{.+}/external-edits/:suggestionId/approve",
9934
10592
  async (c) => {
@@ -10162,6 +10820,49 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
10162
10820
  }
10163
10821
  return c.json({ error: "Missing or invalid credentials" }, 401);
10164
10822
  });
10823
+ function nestPathParts(path) {
10824
+ return path.replace(/^\/nests\//, "").split("/").filter(Boolean);
10825
+ }
10826
+ var flexAuthWithPromptowlTicket = createMiddleware2(
10827
+ async (c, next) => {
10828
+ const header = c.req.header("Authorization") || "";
10829
+ const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
10830
+ const secret = config.OFFICIAL_COMMUNITY_SSO_SECRET;
10831
+ const looksLikeJwt = !!token && !token.startsWith("cnst_") && token.split(".").length === 3;
10832
+ if (!looksLikeJwt || !secret) return flexAuthMiddleware(c, next);
10833
+ let claims;
10834
+ try {
10835
+ claims = verifySsoTicket(token, secret);
10836
+ } catch {
10837
+ return c.json({ error: "Invalid service ticket" }, 401);
10838
+ }
10839
+ if (claims.scope !== "mcp" || claims.jti) {
10840
+ return c.json({ error: "Invalid service ticket" }, 401);
10841
+ }
10842
+ const expectedAud = config.PUBLIC_BASE_URL;
10843
+ if (expectedAud && (claims.aud || "").replace(/\/$/, "") !== expectedAud) {
10844
+ return c.json({ error: "Invalid service ticket" }, 401);
10845
+ }
10846
+ const sub = claims.sub.trim();
10847
+ if (!isValidTicketSub(sub)) {
10848
+ return c.json({ error: "Invalid service ticket" }, 401);
10849
+ }
10850
+ const db = getDb();
10851
+ const user = await db.get(
10852
+ "SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
10853
+ [sub]
10854
+ );
10855
+ if (!user) {
10856
+ return c.json(
10857
+ { error: "No ContextNest account exists for this PromptOwl user" },
10858
+ 404
10859
+ );
10860
+ }
10861
+ c.set("userId", user.id);
10862
+ c.set("nestScope", null);
10863
+ return next();
10864
+ }
10865
+ );
10165
10866
  var APPROVE_CACHE_TTL_MS = 3e4;
10166
10867
  var approveCache = /* @__PURE__ */ new Map();
10167
10868
  async function canApproveInNest(nestId, email) {
@@ -10837,6 +11538,25 @@ function createApp() {
10837
11538
  requested_at: r.requestedAt,
10838
11539
  priority: r.priority
10839
11540
  });
11541
+ const deletions = await listDeletionRequests({
11542
+ nestIds: [...nestName2.keys()],
11543
+ status: "pending"
11544
+ });
11545
+ const toDeletionItem = (r) => ({
11546
+ type: "deletion",
11547
+ nest_id: r.nestId,
11548
+ nest_name: nestName2.get(r.nestId),
11549
+ node_id: r.nodeId,
11550
+ title: r.title || r.nodeId,
11551
+ requested_by: r.requestedBy,
11552
+ requested_at: r.createdAt,
11553
+ priority: "normal",
11554
+ reason: r.reason,
11555
+ request_id: r.id,
11556
+ // node_id holds a folder path / nest id for those targets, so a client
11557
+ // must not treat it as a document to open.
11558
+ target_type: r.targetType
11559
+ });
10840
11560
  const byNewest = (a, b) => a.requested_at < b.requested_at ? 1 : -1;
10841
11561
  const others = requests.filter((r) => r.requestedBy !== me);
10842
11562
  const nestApprove = new Map(
@@ -10849,8 +11569,27 @@ function createApp() {
10849
11569
  )
10850
11570
  )
10851
11571
  );
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);
11572
+ const items = others.filter((r) => nestApprove.get(r.nestId)).map(toItem);
11573
+ const waiting = requests.filter((r) => r.requestedBy === me).map(toItem);
11574
+ const othersDeletions = deletions.filter((r) => r.requestedBy !== me);
11575
+ const nestManage = new Map(
11576
+ await Promise.all(
11577
+ [...new Set(othersDeletions.map((r) => r.nestId))].map(
11578
+ async (nestId) => {
11579
+ const roles = await resolveUserRoles(nestId, me);
11580
+ return [nestId, roles.includes("owner") || roles.includes("admin")];
11581
+ }
11582
+ )
11583
+ )
11584
+ );
11585
+ items.push(
11586
+ ...othersDeletions.filter((r) => nestManage.get(r.nestId)).map(toDeletionItem)
11587
+ );
11588
+ items.sort(byNewest);
11589
+ waiting.push(
11590
+ ...deletions.filter((r) => r.requestedBy === me).map(toDeletionItem)
11591
+ );
11592
+ waiting.sort(byNewest);
10854
11593
  const notifications = await listNotifications(me, { unreadOnly: true, limit: 50 });
10855
11594
  return c.json({
10856
11595
  items,
@@ -10861,6 +11600,37 @@ function createApp() {
10861
11600
  pagination: { total, limit, offset, has_more: offset + requests.length < total }
10862
11601
  });
10863
11602
  });
11603
+ app.get("/me/drafts", async (c) => {
11604
+ const userId = c.get("userId");
11605
+ const me = await resolveCallerEmail(userId);
11606
+ const nestName2 = /* @__PURE__ */ new Map();
11607
+ for (const n of await listVisibleNests(userId))
11608
+ nestName2.set(n.id, n.name ?? n.id);
11609
+ const limit = Math.min(Math.max(Number(c.req.query("limit")) || 25, 1), 100);
11610
+ const offset = Math.max(Number(c.req.query("offset")) || 0, 0);
11611
+ const { drafts, total } = await listMyDrafts({
11612
+ authorEmail: me,
11613
+ nestIds: [...nestName2.keys()],
11614
+ limit,
11615
+ offset
11616
+ });
11617
+ return c.json({
11618
+ drafts: drafts.map((d) => ({
11619
+ nest_id: d.nestId,
11620
+ nest_name: nestName2.get(d.nestId),
11621
+ node_id: d.nodeId,
11622
+ title: d.title,
11623
+ // Where it actually lives — the thing that was impossible to find.
11624
+ folder: folderForNode(d.nodeId),
11625
+ status: d.status,
11626
+ version: d.version,
11627
+ updated_at: d.updatedAt
11628
+ })),
11629
+ // has_more off the REQUESTED page size, not the returned row count: the
11630
+ // existence filter can shorten a page, and that must not read as "last".
11631
+ pagination: { total, limit, offset, has_more: offset + limit < total }
11632
+ });
11633
+ });
10864
11634
  app.get("/me/notifications", async (c) => {
10865
11635
  const me = await resolveCallerEmail(c.get("userId"));
10866
11636
  const unreadOnly = c.req.query("unread") === "true";
@@ -10900,15 +11670,18 @@ function createApp() {
10900
11670
  app.route("/runs", runDetailRoutes);
10901
11671
  app.route("/hooks", hookFireRoutes);
10902
11672
  app.route("/llms.txt", llmsTxtRoutes);
10903
- app.use("/index", flexAuthMiddleware);
11673
+ app.use("/index", flexAuthWithPromptowlTicket);
10904
11674
  app.route("/index", nestIndexRoutes);
10905
- app.use("/mcp", flexAuthMiddleware);
11675
+ app.use("/mcp", flexAuthWithPromptowlTicket);
10906
11676
  app.route("/mcp", serverMcpRoutes);
10907
11677
  const nestsApp = new Hono21();
10908
- nestsApp.use("*", flexAuthMiddleware);
10909
11678
  nestsApp.use("*", async (c, next) => {
10910
- const localPath = c.req.path.replace(/^\/nests\//, "");
10911
- const parts = localPath.split("/").filter(Boolean);
11679
+ const parts = nestPathParts(c.req.path);
11680
+ const ticketEligible = parts.length === 2 && (c.req.method === "POST" && parts[1] === "context" || c.req.method === "GET" && parts[1] === "nodes" || parts[1] === "mcp");
11681
+ return ticketEligible ? flexAuthWithPromptowlTicket(c, next) : flexAuthMiddleware(c, next);
11682
+ });
11683
+ nestsApp.use("*", async (c, next) => {
11684
+ const parts = nestPathParts(c.req.path);
10912
11685
  if (parts.length < 2) return next();
10913
11686
  if (parts[0] === "unsynced") return next();
10914
11687
  const nestId = parts[0];
@@ -10945,7 +11718,8 @@ function createApp() {
10945
11718
  const isCommentPath = /\/comments$/.test(path2) || /\/comments\/[^/]+\/resolve$/.test(path2);
10946
11719
  const isActivityPath = /\/activity$/.test(path2);
10947
11720
  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;
11721
+ const isDeletionQueuePath = parts.length === 2 && parts[1] === "deletion-requests";
11722
+ 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
11723
  const needsLicense = c.req.method !== "GET" || isGovernance;
10950
11724
  if (needsLicense) {
10951
11725
  const lic = getCurrentLicense();
@@ -10991,6 +11765,7 @@ function createApp() {
10991
11765
  const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
10992
11766
  const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
10993
11767
  const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
11768
+ const isDeletionRequestAction = /\/request-deletion$/.test(path);
10994
11769
  const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
10995
11770
  if (isStewardRoster && permission !== "owner" && permission !== "admin") {
10996
11771
  return c.json(
@@ -11009,7 +11784,7 @@ function createApp() {
11009
11784
  required = c.req.method === "GET" ? "write" : "admin";
11010
11785
  } else if (parts[1] === "hooks") {
11011
11786
  required = "write";
11012
- } else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
11787
+ } else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isDeletionRequestAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
11013
11788
  required = "write";
11014
11789
  }
11015
11790
  const isNodeAction = c.req.method === "POST" && parts.length >= 4 && (parts[parts.length - 1] === "revert" || parts[parts.length - 1] === "move");
@@ -11289,6 +12064,111 @@ async function backfillNodeVersionsFromHistory(db) {
11289
12064
  `[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
11290
12065
  );
11291
12066
  }
12067
+ var NOTIFICATION_TITLES_MIGRATION_ID = "006_backfill_notification_titles";
12068
+ async function backfillNotificationTitles(db) {
12069
+ const already = await db.get(
12070
+ "SELECT id FROM schema_migrations WHERE id = ?",
12071
+ [NOTIFICATION_TITLES_MIGRATION_ID]
12072
+ );
12073
+ if (already) return;
12074
+ const rows = await db.all(
12075
+ "SELECT id, nest_id, message FROM notifications WHERE message LIKE '%nodes/%'"
12076
+ );
12077
+ let rewritten = 0;
12078
+ if (rows.length > 0) {
12079
+ const titlesByNest = /* @__PURE__ */ new Map();
12080
+ for (const nestId of new Set(rows.map((r) => r.nest_id))) {
12081
+ titlesByNest.set(nestId, await buildTitleMap(nestId));
12082
+ }
12083
+ for (const row of rows) {
12084
+ const titles = titlesByNest.get(row.nest_id) ?? /* @__PURE__ */ new Map();
12085
+ const next = row.message.replace(
12086
+ /"([^"]*nodes\/[^"]*)"/g,
12087
+ (whole, id) => {
12088
+ const label = describeNodeWith(titles, id);
12089
+ return `"${label.replace(/"/g, "")}"`;
12090
+ }
12091
+ );
12092
+ if (next === row.message) continue;
12093
+ await db.run("UPDATE notifications SET message = ? WHERE id = ?", [
12094
+ next,
12095
+ row.id
12096
+ ]);
12097
+ rewritten++;
12098
+ }
12099
+ }
12100
+ await db.run(
12101
+ insertOrIgnore(db, "INSERT INTO schema_migrations (id) VALUES (?)"),
12102
+ [NOTIFICATION_TITLES_MIGRATION_ID]
12103
+ );
12104
+ console.log(
12105
+ `[backfill] notifications: rewrote ${rewritten} of ${rows.length} message(s) carrying a node id`
12106
+ );
12107
+ }
12108
+ function describeNodeWith(titles, nodeId) {
12109
+ const at = nodeId.indexOf("nodes/");
12110
+ const normalized = at > 0 ? nodeId.slice(at) : nodeId;
12111
+ const title = titles.get(nodeId) || titles.get(normalized) || normalized.split("/").pop() || nodeId;
12112
+ const folder = folderForNode(normalized);
12113
+ return folder ? `${title} (in ${folder})` : title;
12114
+ }
12115
+
12116
+ // src/db/repair-version-chains.ts
12117
+ var MIGRATION_ID2 = "010_repair_grafted_version_chains";
12118
+ async function repairGraftedVersionChains(db) {
12119
+ const already = await db.get("SELECT id FROM schema_migrations WHERE id = ?", [
12120
+ MIGRATION_ID2
12121
+ ]);
12122
+ if (already) return;
12123
+ const nests = await db.all("SELECT id FROM nests");
12124
+ let scanned = 0;
12125
+ let repaired = 0;
12126
+ let externalized = 0;
12127
+ for (const { id: nestId } of nests) {
12128
+ let storage;
12129
+ let versions;
12130
+ try {
12131
+ ({ storage, versions } = await engineCache.get(nestId));
12132
+ } catch (err) {
12133
+ console.warn(
12134
+ `[repair] engine unavailable for nest ${nestId}:`,
12135
+ err.message
12136
+ );
12137
+ continue;
12138
+ }
12139
+ let docs;
12140
+ try {
12141
+ docs = await storage.discoverDocuments();
12142
+ } catch (err) {
12143
+ console.warn(
12144
+ `[repair] discoverDocuments failed for nest ${nestId}:`,
12145
+ err.message
12146
+ );
12147
+ continue;
12148
+ }
12149
+ for (const doc of docs) {
12150
+ scanned += 1;
12151
+ try {
12152
+ if (await versions.repairLatestVersion(doc.id)) {
12153
+ repaired += 1;
12154
+ console.log(`[repair] re-anchored version chain: ${nestId}/${doc.id}`);
12155
+ }
12156
+ externalized += await versions.externalizeDiffs(doc.id);
12157
+ } catch (err) {
12158
+ console.warn(
12159
+ `[repair] could not repair ${nestId}/${doc.id}:`,
12160
+ err.message
12161
+ );
12162
+ }
12163
+ }
12164
+ }
12165
+ await db.run(insertOrIgnore(db, "INSERT INTO schema_migrations (id) VALUES (?)"), [
12166
+ MIGRATION_ID2
12167
+ ]);
12168
+ console.log(
12169
+ `[repair] version chains: scanned ${scanned} docs across ${nests.length} nests, re-anchored ${repaired}, externalized ${externalized} inline change log(s)`
12170
+ );
12171
+ }
11292
12172
 
11293
12173
  // src/workflow/scheduler.ts
11294
12174
  import { v4 as uuid15 } from "uuid";
@@ -11366,6 +12246,16 @@ async function main() {
11366
12246
  } catch (err) {
11367
12247
  console.error("[backfill] node_versions backfill failed:", err);
11368
12248
  }
12249
+ try {
12250
+ await repairGraftedVersionChains(db);
12251
+ } catch (err) {
12252
+ console.error("[repair] version chain repair failed:", err);
12253
+ }
12254
+ try {
12255
+ await backfillNotificationTitles(db);
12256
+ } catch (err) {
12257
+ console.error("[backfill] notification title backfill failed:", err);
12258
+ }
11369
12259
  const accessCfg = loadAccessConfig();
11370
12260
  if (accessCfg) {
11371
12261
  console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);