@goodtek/vibeops 0.2.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +444 -0
  4. package/dist/agent/loader.js +71 -0
  5. package/dist/agent/prompt.js +66 -0
  6. package/dist/bootstrap/installer.js +149 -0
  7. package/dist/bootstrap/manifest.js +15 -0
  8. package/dist/bootstrap/substitute.js +35 -0
  9. package/dist/cli.js +241 -0
  10. package/dist/commands/agent-list.js +32 -0
  11. package/dist/commands/agent-prompt.js +59 -0
  12. package/dist/commands/agent-show.js +26 -0
  13. package/dist/commands/github-init.js +554 -0
  14. package/dist/commands/github-status.js +164 -0
  15. package/dist/commands/init.js +179 -0
  16. package/dist/commands/notion-init.js +764 -0
  17. package/dist/commands/notion-sync.js +405 -0
  18. package/dist/commands/notion-test.js +595 -0
  19. package/dist/commands/plan.js +114 -0
  20. package/dist/commands/status.js +17 -0
  21. package/dist/commands/task-check.js +155 -0
  22. package/dist/commands/task-done.js +98 -0
  23. package/dist/commands/task-generate.js +206 -0
  24. package/dist/commands/task-pull.js +277 -0
  25. package/dist/commands/task-rollback.js +174 -0
  26. package/dist/commands/task-start.js +90 -0
  27. package/dist/lib/brief.js +349 -0
  28. package/dist/lib/config.js +158 -0
  29. package/dist/lib/filesystem.js +67 -0
  30. package/dist/lib/git.js +237 -0
  31. package/dist/lib/github-cli.js +247 -0
  32. package/dist/lib/inquirer-helpers.js +111 -0
  33. package/dist/lib/logger.js +42 -0
  34. package/dist/lib/notion-client.js +459 -0
  35. package/dist/lib/notion-discovery.js +671 -0
  36. package/dist/lib/notion-env.js +140 -0
  37. package/dist/lib/notion-mappers.js +148 -0
  38. package/dist/lib/notion-schema.js +272 -0
  39. package/dist/lib/notion-sync.js +337 -0
  40. package/dist/lib/notion-target.js +247 -0
  41. package/dist/lib/package-json.js +133 -0
  42. package/dist/lib/paths.js +26 -0
  43. package/dist/lib/project-docs.js +95 -0
  44. package/dist/lib/prompt-builder.js +125 -0
  45. package/dist/lib/task-generator.js +183 -0
  46. package/dist/lib/task-prompt.js +23 -0
  47. package/dist/lib/task-pull.js +354 -0
  48. package/dist/lib/task-scaffold.js +128 -0
  49. package/dist/lib/task-summary.js +276 -0
  50. package/dist/lib/task.js +364 -0
  51. package/dist/status/collector.js +103 -0
  52. package/dist/status/format.js +177 -0
  53. package/dist/types/brief.js +126 -0
  54. package/dist/types/config.js +17 -0
  55. package/dist/types/task.js +1 -0
  56. package/dist/version.js +8 -0
  57. package/package.json +61 -0
  58. package/templates/.cursor/rules/00-project-governance.mdc +28 -0
  59. package/templates/.cursor/rules/01-agent-orchestration.mdc +48 -0
  60. package/templates/.cursor/rules/02-task-workflow.mdc +38 -0
  61. package/templates/.cursor/rules/03-git-safety.mdc +30 -0
  62. package/templates/.cursor/rules/04-docs-update.mdc +22 -0
  63. package/templates/.vibeops/agents/architect.md +47 -0
  64. package/templates/.vibeops/agents/builder.md +38 -0
  65. package/templates/.vibeops/agents/docs.md +54 -0
  66. package/templates/.vibeops/agents/orchestrator.md +40 -0
  67. package/templates/.vibeops/agents/planner.md +60 -0
  68. package/templates/.vibeops/agents/recovery.md +49 -0
  69. package/templates/.vibeops/agents/reviewer.md +47 -0
  70. package/templates/.vibeops/agents/tester.md +43 -0
  71. package/templates/.vibeops/prompts/create-plan.md +33 -0
  72. package/templates/.vibeops/prompts/generate-tasks.md +41 -0
  73. package/templates/.vibeops/prompts/implement-task.md +39 -0
  74. package/templates/.vibeops/prompts/review-task.md +34 -0
  75. package/templates/.vibeops/prompts/rollback.md +32 -0
  76. package/templates/.vibeops/prompts/start-project.md +39 -0
  77. package/templates/.vibeops/workflows/notion-sync.md +53 -0
  78. package/templates/.vibeops/workflows/project-start.md +73 -0
  79. package/templates/.vibeops/workflows/rollback.md +45 -0
  80. package/templates/.vibeops/workflows/task-lifecycle.md +71 -0
  81. package/templates/AGENTS.md +98 -0
  82. package/templates/docs/logs/README.md +38 -0
  83. package/templates/docs/project/00-overview.md +27 -0
  84. package/templates/docs/project/01-requirements.md +30 -0
  85. package/templates/docs/project/02-mvp-scope.md +36 -0
  86. package/templates/docs/project/03-architecture.md +34 -0
  87. package/templates/docs/project/04-tech-stack.md +29 -0
  88. package/templates/docs/project/05-current-state.md +35 -0
  89. package/templates/docs/project/06-decisions.md +20 -0
  90. package/templates/docs/project/07-backlog.md +23 -0
  91. package/templates/docs/project/08-env.md +29 -0
  92. package/templates/docs/project/09-deployment.md +28 -0
  93. package/templates/docs/tasks/TASK-000-template.md +72 -0
@@ -0,0 +1,671 @@
1
+ /**
2
+ * Database discovery for `vibeops notion init`.
3
+ *
4
+ * `vibeops notion init` calls `discoverDatabases(client)` after the user has
5
+ * pasted a `NOTION_TOKEN`. We hit `POST /v1/search` (objectFilter="data_source"),
6
+ * normalize each result into a `NotionDatabaseChoice`, and score it against
7
+ * the Projects and Tasks schema requirements so the init command can show the
8
+ * most likely candidates at the top of the select prompt.
9
+ *
10
+ * The current Notion API rejects `objectFilter: "database"` with a
11
+ * `validation_error` ("body.filter.value should be `\"page\"` or
12
+ * `\"data_source\"`"). `data_source` objects carry the same `{ id, title,
13
+ * properties }` shape as legacy database results, so the rest of the pipeline
14
+ * treats them identically. If `data_source` itself somehow trips an SDK
15
+ * environment, we fall back to `"page"` once (which simply returns 0 db hits
16
+ * after the kind filter, and gracefully drops the user into the manual id
17
+ * entry path).
18
+ *
19
+ * Read-only — no mutation, no token logging.
20
+ */
21
+ import { extractDataSourcesFromDatabaseResponse, } from "./notion-client.js";
22
+ import { PROJECTS_DB_PROPERTIES, TASKS_DB_PROPERTIES, getNotionProperties, } from "./notion-schema.js";
23
+ /**
24
+ * VibeOps cap. Notion API itself allows up to 100 results per page.
25
+ * 50 keeps the init select prompt readable.
26
+ */
27
+ export const NOTION_DISCOVERY_MAX = 50;
28
+ const UNTITLED = "(Untitled database)";
29
+ function readNotionTitle(raw) {
30
+ if (!Array.isArray(raw))
31
+ return "";
32
+ const text = raw
33
+ .map((seg) => seg.plain_text ?? "")
34
+ .join("");
35
+ return text.trim();
36
+ }
37
+ function emptyScore(total) {
38
+ return { matched: 0, missing: total, typeMismatch: 0, total };
39
+ }
40
+ function scoreAgainst(properties, required) {
41
+ if (properties === undefined || properties === null) {
42
+ return emptyScore(required.length);
43
+ }
44
+ let matched = 0;
45
+ let missing = 0;
46
+ let typeMismatch = 0;
47
+ for (const req of required) {
48
+ const prop = properties[req.name];
49
+ if (prop === undefined || prop === null) {
50
+ missing++;
51
+ continue;
52
+ }
53
+ const actualType = typeof prop.type === "string"
54
+ ? prop.type
55
+ : "";
56
+ if (req.allowedTypes.includes(actualType)) {
57
+ matched++;
58
+ }
59
+ else {
60
+ typeMismatch++;
61
+ }
62
+ }
63
+ return { matched, missing, typeMismatch, total: required.length };
64
+ }
65
+ export function normalizeHit(hit) {
66
+ const title = readNotionTitle(hit.title);
67
+ const properties = typeof hit.properties === "object" && hit.properties !== null
68
+ ? hit.properties
69
+ : undefined;
70
+ const projectsScore = scoreAgainst(properties, PROJECTS_DB_PROPERTIES);
71
+ const tasksScore = scoreAgainst(properties, TASKS_DB_PROPERTIES);
72
+ const schemaKindHint = projectsScore.matched >= 4 && projectsScore.matched >= tasksScore.matched
73
+ ? "projects"
74
+ : tasksScore.matched >= 5 && tasksScore.matched > projectsScore.matched
75
+ ? "tasks"
76
+ : "unknown";
77
+ return {
78
+ id: hit.id,
79
+ title: title.length > 0 ? title : UNTITLED,
80
+ object: hit.object,
81
+ ...(typeof hit.url === "string" && hit.url.length > 0 ? { url: hit.url } : {}),
82
+ ...(properties !== undefined ? { properties } : {}),
83
+ source: "search",
84
+ schemaKindHint,
85
+ projectsScore,
86
+ tasksScore,
87
+ };
88
+ }
89
+ function schemaKindHintForScores(projectsScore, tasksScore) {
90
+ return projectsScore.matched >= 4 && projectsScore.matched >= tasksScore.matched
91
+ ? "projects"
92
+ : tasksScore.matched >= 5 && tasksScore.matched > projectsScore.matched
93
+ ? "tasks"
94
+ : "unknown";
95
+ }
96
+ /**
97
+ * Best-effort detection of "current API rejects this object filter" errors.
98
+ * Notion currently returns:
99
+ * 400 / code = "validation_error"
100
+ * message contains: "should be `\"page\"` or `\"data_source\"`"
101
+ */
102
+ function isUnsupportedObjectFilterError(err) {
103
+ if (err === null || typeof err !== "object")
104
+ return false;
105
+ const e = err;
106
+ if (e.code === "validation_error")
107
+ return true;
108
+ if (e.status === 400 && typeof e.message === "string") {
109
+ const m = e.message.toLowerCase();
110
+ if (m.includes("data_source") || m.includes("body.filter.value")) {
111
+ return true;
112
+ }
113
+ }
114
+ return false;
115
+ }
116
+ async function runSearchPaginated(client, filter) {
117
+ const seen = new Set();
118
+ const databases = [];
119
+ let cursor = null;
120
+ let totalHits = 0;
121
+ let truncated = false;
122
+ while (databases.length < NOTION_DISCOVERY_MAX) {
123
+ const res = (await client.search({
124
+ objectFilter: filter,
125
+ pageSize: Math.min(50, NOTION_DISCOVERY_MAX - databases.length),
126
+ ...(cursor !== null ? { startCursor: cursor } : {}),
127
+ }));
128
+ totalHits += res.results.length;
129
+ for (const hit of res.results) {
130
+ if (typeof hit.id !== "string" || hit.id.length === 0)
131
+ continue;
132
+ if (seen.has(hit.id))
133
+ continue;
134
+ // Keep the historically-allowed kinds even if the request filter was
135
+ // different (Notion is mid-migration: some workspaces still echo
136
+ // `database` objects through `data_source` searches).
137
+ if (hit.object !== "database" &&
138
+ hit.object !== "data_source") {
139
+ continue;
140
+ }
141
+ seen.add(hit.id);
142
+ databases.push(normalizeHit(hit));
143
+ if (databases.length >= NOTION_DISCOVERY_MAX)
144
+ break;
145
+ }
146
+ if (databases.length >= NOTION_DISCOVERY_MAX) {
147
+ truncated = res.hasMore === true;
148
+ break;
149
+ }
150
+ if (!res.hasMore || res.nextCursor === null) {
151
+ truncated = false;
152
+ break;
153
+ }
154
+ cursor = res.nextCursor;
155
+ if (cursor === null)
156
+ break;
157
+ }
158
+ return { databases, truncated, totalHits, filterUsed: filter };
159
+ }
160
+ /**
161
+ * Hit `POST /v1/search` and normalize the response.
162
+ *
163
+ * Strategy:
164
+ * 1. Try `objectFilter: "data_source"` — this is the only value accepted by
165
+ * the current Notion API for database-style objects.
166
+ * 2. If Notion responds with `validation_error` (extremely rare — happens
167
+ * on legacy SDK builds that wrap the filter differently), fall back to
168
+ * `objectFilter: "page"` once. Pages get filtered out by our kind guard,
169
+ * so the caller sees an empty list and is steered to manual id entry.
170
+ * We surface `fallbackFrom` in the result so callers can log a hint.
171
+ * 3. The legacy `objectFilter: "database"` is **never** sent — Notion now
172
+ * rejects it.
173
+ *
174
+ * Read-only — no mutation, no token logging.
175
+ */
176
+ export async function discoverDatabases(client) {
177
+ try {
178
+ return await runSearchPaginated(client, "data_source");
179
+ }
180
+ catch (err) {
181
+ if (!isUnsupportedObjectFilterError(err)) {
182
+ // Re-throw — caller (notion-init) will format a user-friendly message.
183
+ throw err;
184
+ }
185
+ // Internal cause: the current Notion API rejected our object filter.
186
+ // We deliberately try `page` next so we still hit the API once with a
187
+ // valid filter. Notion only returns `page` objects under that filter, so
188
+ // the kind guard above drops them — callers see "0 databases" and the UI
189
+ // steers the user to manual id entry, which is the desired fallback.
190
+ try {
191
+ const pageResult = await runSearchPaginated(client, "page");
192
+ return { ...pageResult, fallbackFrom: "data_source" };
193
+ }
194
+ catch (err2) {
195
+ // Wrap with a clearer, sanitized message. Tokens are never echoed
196
+ // because we only forward Notion's own `message` field (which never
197
+ // contains the auth header). Internal cause stays in the message so it
198
+ // shows up in the caller's friendly-error formatter.
199
+ const reason = err2.message ??
200
+ err.message ??
201
+ "Notion API rejected the search filter.";
202
+ const wrapped = new Error(`Notion search failed after fallback: ${reason} ` +
203
+ `(Internal: current Notion API expects search filter "data_source"; ` +
204
+ `"database" is no longer accepted.)`);
205
+ const errAny = err2;
206
+ if (typeof errAny.code === "string")
207
+ wrapped.code = errAny.code;
208
+ if (typeof errAny.status === "number")
209
+ wrapped.status = errAny.status;
210
+ throw wrapped;
211
+ }
212
+ }
213
+ }
214
+ function scoreFor(kind, c) {
215
+ return kind === "projects" ? c.projectsScore : c.tasksScore;
216
+ }
217
+ function isRecommended(score) {
218
+ if (score.total === 0)
219
+ return false;
220
+ return score.matched / score.total >= 0.6;
221
+ }
222
+ /**
223
+ * Return the discovery list ordered so that the candidates most likely to
224
+ * match `kind` come first. Strong matches first, then partial matches, then
225
+ * everything else. Within a tier we sort by title for stability.
226
+ */
227
+ export function sortForKind(kind, databases) {
228
+ const enriched = databases.map((c) => ({
229
+ c,
230
+ s: scoreFor(kind, c),
231
+ }));
232
+ const strong = enriched.filter((e) => isRecommended(e.s));
233
+ const partial = enriched.filter((e) => !isRecommended(e.s) && e.s.matched > 0);
234
+ const rest = enriched.filter((e) => !isRecommended(e.s) && e.s.matched === 0);
235
+ const cmpTitle = (a, b) => a.c.title.localeCompare(b.c.title);
236
+ const cmpScore = (a, b) => {
237
+ if (b.s.matched !== a.s.matched)
238
+ return b.s.matched - a.s.matched;
239
+ if (a.s.typeMismatch !== b.s.typeMismatch)
240
+ return a.s.typeMismatch - b.s.typeMismatch;
241
+ return cmpTitle(a, b);
242
+ };
243
+ strong.sort(cmpScore);
244
+ partial.sort(cmpScore);
245
+ rest.sort(cmpTitle);
246
+ return {
247
+ ordered: [...strong, ...partial, ...rest].map((e) => e.c),
248
+ recommendedIds: strong.map((e) => e.c.id),
249
+ };
250
+ }
251
+ // ─── display helpers ──────────────────────────────────────────────────────
252
+ /** First 8 + last 4 hex chars (with `-` removed) for compact display. */
253
+ export function shortId(id) {
254
+ const hex = id.replace(/-/g, "");
255
+ if (hex.length <= 12)
256
+ return hex;
257
+ return `${hex.slice(0, 8)}…${hex.slice(-4)}`;
258
+ }
259
+ /**
260
+ * Build the visible string used in the select prompt for one DB choice.
261
+ * Example:
262
+ * "VibeOps Projects (1a2b3c4d…0001) — projects 8/8 matched"
263
+ * "VibeOps Tasks (4d5e6f7g…0002) — tasks 6/10 matched, 4 missing"
264
+ * "Tasks (4d5e6f7g…0002) — inline database in VibeOps page (no property info)"
265
+ */
266
+ export function buildChoiceLabel(inputs) {
267
+ const { database } = inputs;
268
+ const score = scoreFor(inputs.kind, database);
269
+ const isInline = database.source === "page-block" ||
270
+ database.source === "page-child-database";
271
+ // Lead tag — recommended > inline > kind
272
+ let lead;
273
+ if (inputs.isRecommended) {
274
+ lead = "recommended";
275
+ }
276
+ else if (isInline) {
277
+ const where = database.source === "page-child-database"
278
+ ? "page child database"
279
+ : typeof database.parentPageTitle === "string" &&
280
+ database.parentPageTitle.length > 0
281
+ ? `inline database in ${database.parentPageTitle}`
282
+ : "inline database (parent page)";
283
+ lead = where;
284
+ }
285
+ else {
286
+ lead = inputs.kind;
287
+ }
288
+ const schemaHint = database.schemaKindHint === "projects"
289
+ ? "✓ project schema"
290
+ : database.schemaKindHint === "tasks"
291
+ ? "✓ task schema"
292
+ : database.schemaKindHint === "unknown"
293
+ ? "? unknown schema"
294
+ : "";
295
+ const scoreDetail = score.total === 0
296
+ ? "no property info"
297
+ : `${score.matched}/${score.total} matched${score.missing > 0 ? `, ${score.missing} missing` : ""}${score.typeMismatch > 0 ? `, ${score.typeMismatch} mismatch` : ""}`;
298
+ const target = database.source === "page-child-database" && database.dataSourceId !== undefined
299
+ ? ` → data_source ${shortId(database.dataSourceId)}`
300
+ : "";
301
+ const detail = schemaHint.length > 0 ? `${schemaHint}; ${scoreDetail}` : scoreDetail;
302
+ return `${database.title} (${shortId(database.id)}) — ${lead}${target}: ${detail}`;
303
+ }
304
+ // ─── new public discovery API (TASK-010 follow-up #4) ──────────────────────
305
+ /**
306
+ * Cap on the number of child blocks we scan per page during inline-database
307
+ * discovery. We deliberately keep this small and **never** recurse: the
308
+ * VibeOps workflow expects the user to put each database directly inside the
309
+ * page they share with the integration. A typical VibeOps page has 2 inline
310
+ * DBs and a few intro paragraphs.
311
+ */
312
+ export const NOTION_PAGE_SCAN_MAX_BLOCKS = 100;
313
+ /**
314
+ * Pure `objectFilter: "data_source"` search. No fallback, no kind guard
315
+ * surprises beyond what `discoverDatabases` already does. Returns the same
316
+ * `DiscoveryResult` shape as `discoverDatabases`.
317
+ */
318
+ export async function searchDataSources(client) {
319
+ const res = await runSearchPaginated(client, "data_source");
320
+ const enriched = [];
321
+ for (const c of res.databases) {
322
+ if (c.properties !== undefined) {
323
+ enriched.push(c);
324
+ continue;
325
+ }
326
+ try {
327
+ const ds = await client.retrieveDataSource(c.id);
328
+ const props = ds === null ? null : getNotionProperties(ds);
329
+ if (props === null) {
330
+ enriched.push(c);
331
+ continue;
332
+ }
333
+ const projectsScore = scoreAgainst(props, PROJECTS_DB_PROPERTIES);
334
+ const tasksScore = scoreAgainst(props, TASKS_DB_PROPERTIES);
335
+ enriched.push({
336
+ ...c,
337
+ object: "data_source",
338
+ properties: props,
339
+ schemaKindHint: schemaKindHintForScores(projectsScore, tasksScore),
340
+ projectsScore,
341
+ tasksScore,
342
+ });
343
+ }
344
+ catch {
345
+ enriched.push(c);
346
+ }
347
+ }
348
+ return { ...res, databases: enriched };
349
+ }
350
+ /**
351
+ * Pure `objectFilter: "page"` search. Returns up to `NOTION_DISCOVERY_MAX`
352
+ * pages the integration has access to. Pages that the integration was given
353
+ * implicit access to via "Add connections" on a parent page should appear
354
+ * here even when their inline databases do not surface in data-source search.
355
+ */
356
+ export async function searchPages(client) {
357
+ const seen = new Set();
358
+ const pages = [];
359
+ let cursor = null;
360
+ let totalHits = 0;
361
+ let truncated = false;
362
+ while (pages.length < NOTION_DISCOVERY_MAX) {
363
+ const res = await client.search({
364
+ objectFilter: "page",
365
+ pageSize: Math.min(50, NOTION_DISCOVERY_MAX - pages.length),
366
+ ...(cursor !== null ? { startCursor: cursor } : {}),
367
+ });
368
+ totalHits += res.results.length;
369
+ for (const hit of res.results) {
370
+ if (typeof hit.id !== "string" || hit.id.length === 0)
371
+ continue;
372
+ if (seen.has(hit.id))
373
+ continue;
374
+ if (hit.object !== "page")
375
+ continue;
376
+ seen.add(hit.id);
377
+ pages.push(normalizePage(hit));
378
+ if (pages.length >= NOTION_DISCOVERY_MAX)
379
+ break;
380
+ }
381
+ if (pages.length >= NOTION_DISCOVERY_MAX) {
382
+ truncated = res.hasMore === true;
383
+ break;
384
+ }
385
+ if (!res.hasMore || res.nextCursor === null) {
386
+ truncated = false;
387
+ break;
388
+ }
389
+ cursor = res.nextCursor;
390
+ if (cursor === null)
391
+ break;
392
+ }
393
+ return { pages, truncated, totalHits };
394
+ }
395
+ /**
396
+ * Best-effort title extraction for a `page` search hit.
397
+ *
398
+ * Notion places the title in different fields depending on the page kind:
399
+ * - workspace pages: `properties.title.title[]` (array of rich text)
400
+ * - DB-row pages: `properties.<title-prop>.title[]`
401
+ * - some endpoints: `title[]` at the top level
402
+ * We scan all three locations in that order and return the first non-empty
403
+ * value, falling back to `"(Untitled page)"`.
404
+ */
405
+ function readPageTitle(hit) {
406
+ // top-level (search response usually does NOT include this for pages)
407
+ const top = readNotionTitle(hit.title);
408
+ if (top.length > 0)
409
+ return top;
410
+ const props = hit.properties;
411
+ if (props !== undefined && props !== null) {
412
+ for (const key of Object.keys(props)) {
413
+ const prop = props[key];
414
+ if (prop === undefined || prop === null)
415
+ continue;
416
+ if (prop.type === "title" || Array.isArray(prop.title)) {
417
+ const t = readNotionTitle(prop.title);
418
+ if (t.length > 0)
419
+ return t;
420
+ }
421
+ }
422
+ }
423
+ return "(Untitled page)";
424
+ }
425
+ function normalizePage(hit) {
426
+ return {
427
+ id: hit.id,
428
+ title: readPageTitle(hit),
429
+ ...(typeof hit.url === "string" && hit.url.length > 0 ? { url: hit.url } : {}),
430
+ };
431
+ }
432
+ /**
433
+ * Paginated `blocks.children.list(pageId)` — **1-depth only**, capped at
434
+ * `NOTION_PAGE_SCAN_MAX_BLOCKS`. We never follow `has_children` recursively.
435
+ *
436
+ * Read-only.
437
+ */
438
+ export async function listPageChildren(client, pageId) {
439
+ const blocks = [];
440
+ let cursor = null;
441
+ while (blocks.length < NOTION_PAGE_SCAN_MAX_BLOCKS) {
442
+ const remaining = NOTION_PAGE_SCAN_MAX_BLOCKS - blocks.length;
443
+ const res = await client.blocksChildrenList({
444
+ blockId: pageId,
445
+ pageSize: Math.min(50, remaining),
446
+ ...(cursor !== null ? { startCursor: cursor } : {}),
447
+ });
448
+ for (const block of res.results) {
449
+ if (typeof block.id !== "string" || block.id.length === 0)
450
+ continue;
451
+ blocks.push(block);
452
+ if (blocks.length >= NOTION_PAGE_SCAN_MAX_BLOCKS)
453
+ break;
454
+ }
455
+ if (blocks.length >= NOTION_PAGE_SCAN_MAX_BLOCKS)
456
+ break;
457
+ if (!res.hasMore || res.nextCursor === null)
458
+ break;
459
+ cursor = res.nextCursor;
460
+ if (cursor === null)
461
+ break;
462
+ }
463
+ return blocks;
464
+ }
465
+ /**
466
+ * Pull the plain-text title out of a child-database / data-source block.
467
+ *
468
+ * Notion's API surface is mid-migration here, so we look in several
469
+ * locations:
470
+ * 1. `block[type].title` — string (current `child_database` shape)
471
+ * 2. `block[type].title` — rich-text array (future-proof)
472
+ * 3. `block.title` — string/rich-text (legacy)
473
+ * Falls back to `"(Untitled database)"`.
474
+ */
475
+ function readBlockTitle(block) {
476
+ const type = typeof block.type === "string" ? block.type : "";
477
+ if (type.length > 0) {
478
+ const payload = block[type];
479
+ if (payload !== undefined && payload !== null) {
480
+ if (typeof payload.title === "string" && payload.title.trim().length > 0) {
481
+ return payload.title.trim();
482
+ }
483
+ const arr = readNotionTitle(payload.title);
484
+ if (arr.length > 0)
485
+ return arr;
486
+ }
487
+ }
488
+ const topLevel = block.title;
489
+ if (typeof topLevel === "string" && topLevel.trim().length > 0) {
490
+ return topLevel.trim();
491
+ }
492
+ const fromArr = readNotionTitle(topLevel);
493
+ if (fromArr.length > 0)
494
+ return fromArr;
495
+ return UNTITLED;
496
+ }
497
+ /**
498
+ * Pull the database id out of a child-database / data-source block.
499
+ *
500
+ * - For `child_database` blocks the **block id IS the database id** (Notion
501
+ * API quirk: you can call `databases.retrieve(blockId)` directly).
502
+ * - For hypothetical `data_source` blocks we prefer `block.data_source.id`
503
+ * if present, then fall back to `block.id` for the same reason.
504
+ */
505
+ function readBlockDatabaseId(block) {
506
+ const type = typeof block.type === "string" ? block.type : "";
507
+ if (type.length > 0) {
508
+ const payload = block[type];
509
+ if (payload !== undefined && payload !== null) {
510
+ if (typeof payload.id === "string" && payload.id.length > 0) {
511
+ return payload.id;
512
+ }
513
+ if (typeof payload.database_id === "string" &&
514
+ payload.database_id.length > 0) {
515
+ return payload.database_id;
516
+ }
517
+ }
518
+ }
519
+ if (typeof block.id === "string" && block.id.length > 0)
520
+ return block.id;
521
+ return null;
522
+ }
523
+ /** Block types we treat as "this is an inline database / data source". */
524
+ const INLINE_DB_BLOCK_TYPES = new Set(["child_database", "data_source"]);
525
+ /**
526
+ * Scan a page (1-depth) for inline child_database / data_source blocks and
527
+ * normalize them into actual **data_source** `NotionDatabaseChoice` records.
528
+ *
529
+ * Result candidates have:
530
+ * - `id = dataSourceId` (the value VibeOps should store/use)
531
+ * - `databaseId = child_database block id` (container/debug reference)
532
+ * - `source = "page-child-database"`
533
+ * - `parentPageId = pageId`
534
+ * - `parentPageTitle = parentTitle` (caller supplies if known)
535
+ * - `properties = data_source.properties`
536
+ *
537
+ * Candidates whose resolved data_source lacks a `properties` map are skipped:
538
+ * they cannot be used for schema validation or sync.
539
+ */
540
+ export async function discoverInlineDatabasesFromPage(client, pageId, parentTitle) {
541
+ const blocks = await listPageChildren(client, pageId);
542
+ const candidates = [];
543
+ const seen = new Set();
544
+ for (const block of blocks) {
545
+ const type = typeof block.type === "string" ? block.type : "";
546
+ if (!INLINE_DB_BLOCK_TYPES.has(type))
547
+ continue;
548
+ const dbId = readBlockDatabaseId(block);
549
+ if (dbId === null)
550
+ continue;
551
+ if (seen.has(dbId))
552
+ continue;
553
+ seen.add(dbId);
554
+ const title = readBlockTitle(block);
555
+ const dataSourceIds = [];
556
+ if (type === "data_source") {
557
+ dataSourceIds.push(dbId);
558
+ }
559
+ else {
560
+ const database = await client.retrieveDatabase(dbId);
561
+ const extracted = extractDataSourcesFromDatabaseResponse(database);
562
+ dataSourceIds.push(...extracted.items.map((it) => it.id));
563
+ }
564
+ for (const dataSourceId of dataSourceIds) {
565
+ const dataSource = await client.retrieveDataSource(dataSourceId);
566
+ if (dataSource === null)
567
+ continue;
568
+ const properties = getNotionProperties(dataSource);
569
+ if (properties === null)
570
+ continue;
571
+ const projectsScore = scoreAgainst(properties, PROJECTS_DB_PROPERTIES);
572
+ const tasksScore = scoreAgainst(properties, TASKS_DB_PROPERTIES);
573
+ const schemaKindHint = schemaKindHintForScores(projectsScore, tasksScore);
574
+ candidates.push({
575
+ id: dataSourceId,
576
+ title,
577
+ object: "data_source",
578
+ source: "page-child-database",
579
+ databaseId: dbId,
580
+ dataSourceId,
581
+ parentPageId: pageId,
582
+ ...(parentTitle !== undefined ? { parentPageTitle: parentTitle } : {}),
583
+ properties,
584
+ schemaKindHint,
585
+ projectsScore,
586
+ tasksScore,
587
+ });
588
+ }
589
+ }
590
+ return candidates;
591
+ }
592
+ /**
593
+ * Orchestrator used by `vibeops notion init`.
594
+ *
595
+ * Flow:
596
+ * 1. Try `searchDataSources(client)`.
597
+ * - If it returns ≥ 1 hit, return immediately — no page search needed.
598
+ * 2. If data-source search returned 0 results, run `searchPages(client)`
599
+ * so the UI can offer "Select a page to scan for inline databases".
600
+ * 3. We never throw on transport-level failures here — instead we annotate
601
+ * `dataSourceErrored = true` so the caller can fall back to manual
602
+ * entry. (Token / validation_error paths still throw so the caller's
603
+ * friendly-error formatter can run.)
604
+ *
605
+ * Read-only. 5 s SDK timeout. No mutation, no token logging.
606
+ */
607
+ export async function discoverNotionDatabases(client) {
608
+ const warnings = [];
609
+ let dataSources = [];
610
+ let dataSourcesTruncated = false;
611
+ let dataSourceErrored = false;
612
+ try {
613
+ const ds = await searchDataSources(client);
614
+ dataSources = ds.databases;
615
+ dataSourcesTruncated = ds.truncated;
616
+ if (ds.truncated) {
617
+ warnings.push(`Data-source search was capped at ${NOTION_DISCOVERY_MAX} results — Notion has more.`);
618
+ }
619
+ }
620
+ catch (err) {
621
+ // The orchestrator absorbs `validation_error` (legacy `database` filter)
622
+ // here because we want the CLI to keep going and still offer page-scan.
623
+ // Other errors (unauthorized / restricted_resource / timeout) propagate.
624
+ if (isUnsupportedObjectFilterError(err)) {
625
+ dataSourceErrored = true;
626
+ warnings.push("Notion rejected `data_source` filter — falling back to page search.");
627
+ }
628
+ else {
629
+ throw err;
630
+ }
631
+ }
632
+ if (dataSources.length > 0) {
633
+ return {
634
+ dataSources,
635
+ pages: [],
636
+ warnings,
637
+ dataSourcesEmpty: false,
638
+ dataSourceErrored,
639
+ dataSourcesTruncated,
640
+ pagesTruncated: false,
641
+ };
642
+ }
643
+ // Either data-source search returned 0 or we trapped a validation_error.
644
+ // Look for pages so the UI can offer page scanning.
645
+ let pages = [];
646
+ let pagesTruncated = false;
647
+ try {
648
+ const ps = await searchPages(client);
649
+ pages = ps.pages;
650
+ pagesTruncated = ps.truncated;
651
+ if (ps.truncated) {
652
+ warnings.push(`Page search was capped at ${NOTION_DISCOVERY_MAX} results — Notion has more.`);
653
+ }
654
+ }
655
+ catch (err) {
656
+ // Pages search rarely fails after a successful (empty) data-source
657
+ // search. We surface the error message but keep going (UI will land on
658
+ // manual entry).
659
+ const msg = err.message ?? "Notion page search failed.";
660
+ warnings.push(`Page search failed: ${msg}`);
661
+ }
662
+ return {
663
+ dataSources: [],
664
+ pages,
665
+ warnings,
666
+ dataSourcesEmpty: !dataSourceErrored,
667
+ dataSourceErrored,
668
+ dataSourcesTruncated,
669
+ pagesTruncated,
670
+ };
671
+ }