@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,459 @@
1
+ /**
2
+ * Thin async wrapper around `@notionhq/client`.
3
+ *
4
+ * We lazy-import the SDK so that:
5
+ * - other commands (init / status / plan / task / …) don't pay the import
6
+ * cost when Notion is disabled,
7
+ * - a missing or broken install of `@notionhq/client` doesn't crash the
8
+ * entire CLI — only the notion subcommands.
9
+ *
10
+ * The wrapper exposes the surface VibeOps actually uses:
11
+ * - `users.me()` to validate the token, (TASK-010)
12
+ * - `databases.retrieve(id)` to verify access + schema, (TASK-010)
13
+ * - `dataSources.retrieve(id)` to read the actual schema (new API), (TASK-011 follow-up)
14
+ * - `databases.query(id, filter, page_size)` to look up a row, (TASK-011)
15
+ * - `pages.create({ parent, properties })` to insert, (TASK-011)
16
+ * - `pages.update({ page_id, properties })` to upsert in place, (TASK-011)
17
+ * - `search({ filter: { value: "data_source" }})` to discover DBs. (TASK-010 UX)
18
+ * - `blocks.children.list(block_id, page_size, start_cursor)` to walk
19
+ * the 1-depth children of a page when we need to find inline databases
20
+ * that Notion search doesn't surface. (TASK-010 UX)
21
+ *
22
+ * Network calls have a 5s timeout (per TASK-010 Risks).
23
+ *
24
+ * Notion API note (TASK-011 follow-up):
25
+ * In the current Notion API ("2025-09-03" and later), a `database` is a
26
+ * shell that can contain 0…N `data_source` children. Property schema lives
27
+ * on the `data_source`, not on the `database`. VibeOps therefore exposes
28
+ * `databasesRetrieve(id)` (returns `{ id, object, data_sources, ... }`)
29
+ * AND `dataSourcesRetrieve(id)` (returns `{ id, object, properties, ... }`)
30
+ * so the resolver (`notion-target.ts`) can fall back between them.
31
+ */
32
+ const NOTION_API_TIMEOUT_MS = 5_000;
33
+ /**
34
+ * Notion API version VibeOps pins on every Client construction.
35
+ *
36
+ * `"2025-09-03"` is the first version in which `database.retrieve` returns
37
+ * `{ object: "database", data_sources: [...] }` and the property schema
38
+ * (`properties`) lives on `data_source` objects rather than on the
39
+ * `database` shell. VibeOps' resolver (`notion-target.ts`) targets that
40
+ * surface. Bumping this constant later requires re-validating the resolver
41
+ * + schema validator against the new response shape.
42
+ */
43
+ export const NOTION_API_VERSION = "2025-09-03";
44
+ /**
45
+ * Defensive normaliser for the `data_sources` child array on a
46
+ * `databases.retrieve` response.
47
+ *
48
+ * Why this exists: Notion's official surface uses `data_sources`
49
+ * (snake_case), but VibeOps has seen / expects to support multiple naming
50
+ * shapes — `dataSources`, `child_data_sources`, `childDataSources` — as
51
+ * well as entries that wrap the id under `data_source.id` instead of `id`.
52
+ * This helper picks the first non-empty array it finds and normalises each
53
+ * entry to `{ id, name? }`. Returns `[]` when nothing usable is present.
54
+ *
55
+ * Tuple in the returned diagnostic: `[fieldName, items[]]` — `fieldName` is
56
+ * `null` when no array key was located, useful for `--debug-shape`.
57
+ */
58
+ export function extractDataSourcesFromDatabaseResponse(response) {
59
+ if (response === null || typeof response !== "object") {
60
+ return { field: null, items: [] };
61
+ }
62
+ const obj = response;
63
+ // Order matters: prefer the canonical snake_case if both styles appear.
64
+ const candidates = [
65
+ "data_sources",
66
+ "dataSources",
67
+ "child_data_sources",
68
+ "childDataSources",
69
+ ];
70
+ for (const key of candidates) {
71
+ const raw = obj[key];
72
+ if (!Array.isArray(raw))
73
+ continue;
74
+ if (raw.length === 0) {
75
+ // remember we saw the array even if empty (so the caller can show
76
+ // `field=data_sources, len=0` instead of `field=null, len=0`).
77
+ return { field: key, items: [] };
78
+ }
79
+ const items = raw
80
+ .map((entry) => {
81
+ if (entry === null || typeof entry !== "object")
82
+ return null;
83
+ const e = entry;
84
+ // Accept `id` OR `data_source.id` (some payloads nest the id).
85
+ const directId = typeof e.id === "string" ? e.id : "";
86
+ const nestedRaw = e.data_source !== undefined && typeof e.data_source === "object"
87
+ ? e.data_source
88
+ : null;
89
+ const nestedId = nestedRaw !== null && typeof nestedRaw.id === "string"
90
+ ? nestedRaw.id
91
+ : "";
92
+ const id = directId.length > 0 ? directId : nestedId;
93
+ if (id.length === 0)
94
+ return null;
95
+ // Pick the friendliest name: e.name, e.data_source.name, e.title text.
96
+ let name;
97
+ if (typeof e.name === "string" && e.name.length > 0) {
98
+ name = e.name;
99
+ }
100
+ else if (nestedRaw !== null &&
101
+ typeof nestedRaw.name === "string" &&
102
+ nestedRaw.name.length > 0) {
103
+ name = nestedRaw.name;
104
+ }
105
+ else if (Array.isArray(e.title)) {
106
+ const t = e.title
107
+ .map((seg) => seg.plain_text ?? "")
108
+ .join("")
109
+ .trim();
110
+ if (t.length > 0)
111
+ name = t;
112
+ }
113
+ return name === undefined ? { id } : { id, name };
114
+ })
115
+ .filter((x) => x !== null);
116
+ return { field: key, items };
117
+ }
118
+ return { field: null, items: [] };
119
+ }
120
+ /**
121
+ * Build a token-safe {@link DatabaseShapeProbe} from a raw retrieve
122
+ * response. Splits out from `probeDatabaseShape` so unit tests can hit it
123
+ * directly without an SDK client.
124
+ */
125
+ export function summariseDatabaseShape(inputId, raw) {
126
+ if (raw === null || typeof raw !== "object") {
127
+ return {
128
+ inputId,
129
+ object: "(unknown)",
130
+ id: inputId,
131
+ hasProperties: false,
132
+ propertiesKeysLength: 0,
133
+ hasDataSources: false,
134
+ dataSourcesLength: 0,
135
+ dataSources: [],
136
+ topLevelKeys: [],
137
+ };
138
+ }
139
+ const obj = raw;
140
+ const object = typeof obj.object === "string" ? obj.object : "(unknown)";
141
+ const id = typeof obj.id === "string" && obj.id.length > 0 ? obj.id : inputId;
142
+ let title;
143
+ if (Array.isArray(obj.title)) {
144
+ const text = obj.title
145
+ .map((seg) => seg.plain_text ?? "")
146
+ .join("")
147
+ .trim();
148
+ if (text.length > 0)
149
+ title = text;
150
+ }
151
+ else if (typeof obj.title === "string" && obj.title.length > 0) {
152
+ title = obj.title;
153
+ }
154
+ else if (typeof obj.name === "string" && obj.name.length > 0) {
155
+ title = obj.name;
156
+ }
157
+ const props = obj.properties;
158
+ const hasProperties = props !== null && props !== undefined && typeof props === "object";
159
+ const propertiesKeysLength = hasProperties
160
+ ? Object.keys(props).length
161
+ : 0;
162
+ const ds = extractDataSourcesFromDatabaseResponse(raw);
163
+ const topLevelKeys = Object.keys(obj).sort();
164
+ return {
165
+ inputId,
166
+ object,
167
+ id,
168
+ ...(title !== undefined ? { title } : {}),
169
+ hasProperties,
170
+ propertiesKeysLength,
171
+ hasDataSources: ds.field !== null,
172
+ ...(ds.field !== null ? { dataSourcesField: ds.field } : {}),
173
+ dataSourcesLength: ds.items.length,
174
+ dataSources: ds.items,
175
+ topLevelKeys,
176
+ };
177
+ }
178
+ export function notionApiError(err) {
179
+ const e = err;
180
+ return {
181
+ ok: false,
182
+ code: typeof e.code === "string" ? e.code : "unknown_error",
183
+ ...(typeof e.status === "number" ? { status: e.status } : {}),
184
+ message: typeof e.message === "string" ? e.message : String(err),
185
+ };
186
+ }
187
+ export async function createNotionClient(token) {
188
+ // Use a dynamic specifier so TS/Node import this lazily even under
189
+ // `--noEmitOnError`. Missing dep → caller catches and surfaces a friendly
190
+ // "install @notionhq/client" message.
191
+ const modSpecifier = "@notionhq/client";
192
+ const mod = await import(/* @vite-ignore */ modSpecifier);
193
+ const ClientCtor = mod.Client ??
194
+ mod.default?.Client;
195
+ if (typeof ClientCtor !== "function") {
196
+ throw new Error("Could not find `Client` export in `@notionhq/client`. Re-install the dependency: `pnpm add @notionhq/client`.");
197
+ }
198
+ // `notionVersion: "2025-09-03"` pins the Notion API version VibeOps was
199
+ // designed against. In this revision the schema (`properties`) lives on
200
+ // `data_source` objects rather than on the `database` shell, so this pin
201
+ // makes the response shape deterministic regardless of what default the
202
+ // installed `@notionhq/client` build chose.
203
+ //
204
+ // `logLevel: "error"` suppresses the SDK's WARN-level "request fail"
205
+ // chatter that otherwise polutes stderr on expected 4xx (e.g. when the
206
+ // resolver intentionally probes `dataSources.retrieve(id)` and gets a 404
207
+ // before falling back to `databases.retrieve(id)`). Real errors still
208
+ // throw; this only silences the SDK's diagnostic console.warn.
209
+ const client = new ClientCtor({
210
+ auth: token,
211
+ timeoutMs: NOTION_API_TIMEOUT_MS,
212
+ logLevel: "error",
213
+ notionVersion: NOTION_API_VERSION,
214
+ });
215
+ return {
216
+ async usersMe() {
217
+ const res = (await client.users.me({}));
218
+ return res;
219
+ },
220
+ async databasesRetrieve(databaseId) {
221
+ const res = (await client.databases.retrieve({
222
+ database_id: databaseId,
223
+ }));
224
+ return res;
225
+ },
226
+ async retrieveDatabase(databaseId) {
227
+ const res = (await client.databases.retrieve({
228
+ database_id: databaseId,
229
+ }));
230
+ return res;
231
+ },
232
+ async dataSourcesRetrieve(dataSourceId) {
233
+ // (A) preferred — typed SDK call when present.
234
+ if (client.dataSources !== undefined &&
235
+ typeof client.dataSources.retrieve === "function") {
236
+ const res = (await client.dataSources.retrieve({
237
+ data_source_id: dataSourceId,
238
+ }));
239
+ return res;
240
+ }
241
+ // (B) raw HTTP fallback via SDK's public arbitrary-request helper.
242
+ // The SDK already attaches Authorization / Notion-Version / JSON
243
+ // headers, so we never touch the token from here.
244
+ if (typeof client.request === "function") {
245
+ const res = (await client.request({
246
+ path: `data_sources/${dataSourceId}`,
247
+ method: "GET",
248
+ }));
249
+ return res;
250
+ }
251
+ // (C) neither path available — let the resolver decide what to do.
252
+ return null;
253
+ },
254
+ async retrieveDataSource(dataSourceId) {
255
+ if (client.dataSources !== undefined &&
256
+ typeof client.dataSources.retrieve === "function") {
257
+ const res = (await client.dataSources.retrieve({
258
+ data_source_id: dataSourceId,
259
+ }));
260
+ return res;
261
+ }
262
+ if (typeof client.request === "function") {
263
+ const res = (await client.request({
264
+ path: `data_sources/${dataSourceId}`,
265
+ method: "GET",
266
+ }));
267
+ return res;
268
+ }
269
+ return null;
270
+ },
271
+ async probeDatabaseShape(databaseId) {
272
+ const raw = (await client.databases.retrieve({
273
+ database_id: databaseId,
274
+ }));
275
+ return summariseDatabaseShape(databaseId, raw);
276
+ },
277
+ async databasesQuery(databaseId, options) {
278
+ const q = {};
279
+ if (options?.filter !== undefined)
280
+ q.filter = options.filter;
281
+ if (typeof options?.pageSize === "number")
282
+ q.page_size = options.pageSize;
283
+ if (typeof options?.startCursor === "string")
284
+ q.start_cursor = options.startCursor;
285
+ const res = (await (client.dataSources?.query !== undefined
286
+ ? client.dataSources.query({ ...q, data_source_id: databaseId })
287
+ : client.databases.query({ ...q, database_id: databaseId })));
288
+ return {
289
+ results: res.results ?? [],
290
+ hasMore: res.has_more === true,
291
+ nextCursor: res.next_cursor ?? null,
292
+ };
293
+ },
294
+ async pagesCreate({ databaseId, properties }) {
295
+ let res;
296
+ try {
297
+ res = (await client.pages.create({
298
+ parent: { data_source_id: databaseId },
299
+ properties,
300
+ }));
301
+ }
302
+ catch (err) {
303
+ const apiErr = notionApiError(err);
304
+ if (apiErr.code !== "validation_error" && apiErr.code !== "object_not_found") {
305
+ throw err;
306
+ }
307
+ // Legacy fallback for older configs that still store database ids.
308
+ res = (await client.pages.create({
309
+ parent: { database_id: databaseId },
310
+ properties,
311
+ }));
312
+ }
313
+ return { id: res.id };
314
+ },
315
+ async queryDataSource(dataSourceId, options) {
316
+ const body = {};
317
+ if (options?.filter !== undefined)
318
+ body.filter = options.filter;
319
+ if (typeof options?.pageSize === "number")
320
+ body.page_size = options.pageSize;
321
+ if (typeof options?.startCursor === "string")
322
+ body.start_cursor = options.startCursor;
323
+ let raw;
324
+ // (A) typed SDK call when the installed build exposes `dataSources.query`.
325
+ if (client.dataSources !== undefined &&
326
+ typeof client.dataSources.query === "function") {
327
+ raw = (await client.dataSources.query({
328
+ data_source_id: dataSourceId,
329
+ ...body,
330
+ }));
331
+ }
332
+ else if (typeof client.request === "function") {
333
+ // (B) raw HTTP fallback — SDK attaches Authorization / Notion-Version /
334
+ // Content-Type headers itself; we never touch the token from here.
335
+ raw = (await client.request({
336
+ path: `data_sources/${dataSourceId}/query`,
337
+ method: "POST",
338
+ body,
339
+ }));
340
+ }
341
+ else {
342
+ throw new Error("`@notionhq/client` does not expose `dataSources.query` nor `client.request`; cannot query data source.");
343
+ }
344
+ return {
345
+ results: Array.isArray(raw.results) ? raw.results : [],
346
+ hasMore: raw.has_more === true,
347
+ nextCursor: raw.next_cursor ?? null,
348
+ };
349
+ },
350
+ async createPageInDataSource({ dataSourceId, properties }) {
351
+ const body = {
352
+ parent: { type: "data_source_id", data_source_id: dataSourceId },
353
+ properties,
354
+ };
355
+ // (A) typed SDK call first — `parent` is typed as `unknown` in our cast
356
+ // so the new shape passes through transparently.
357
+ try {
358
+ const res = (await client.pages.create(body));
359
+ return { id: res.id };
360
+ }
361
+ catch (err) {
362
+ const apiErr = notionApiError(err);
363
+ // Only fall back when the SDK itself rejected the shape (validation
364
+ // error on the SDK side). 401/403/404/429 must propagate so the
365
+ // caller can show the precise reason.
366
+ if (apiErr.code !== "validation_error") {
367
+ throw err;
368
+ }
369
+ if (typeof client.request !== "function")
370
+ throw err;
371
+ const res = (await client.request({
372
+ path: "pages",
373
+ method: "POST",
374
+ body,
375
+ }));
376
+ return { id: res.id };
377
+ }
378
+ },
379
+ async pagesUpdate({ pageId, properties }) {
380
+ const res = (await client.pages.update({
381
+ page_id: pageId,
382
+ properties,
383
+ }));
384
+ return { id: res.id };
385
+ },
386
+ async updatePage({ pageId, properties }) {
387
+ const res = (await client.pages.update({
388
+ page_id: pageId,
389
+ properties,
390
+ }));
391
+ return { id: res.id };
392
+ },
393
+ async blocksChildrenList({ blockId, pageSize, startCursor }) {
394
+ const q = {
395
+ block_id: blockId,
396
+ };
397
+ // Notion API hard-caps page_size at 100; we keep ≤ 50 for snappy UX
398
+ // unless a caller asks for more, but never above 100.
399
+ const ps = typeof pageSize === "number" ? Math.min(100, Math.max(1, pageSize)) : 50;
400
+ q.page_size = ps;
401
+ if (typeof startCursor === "string")
402
+ q.start_cursor = startCursor;
403
+ const res = (await client.blocks.children.list(q));
404
+ return {
405
+ results: Array.isArray(res.results) ? res.results : [],
406
+ hasMore: res.has_more === true,
407
+ nextCursor: res.next_cursor ?? null,
408
+ };
409
+ },
410
+ async listBlockChildren(blockId, options) {
411
+ const q = {
412
+ block_id: blockId,
413
+ };
414
+ q.page_size =
415
+ typeof options?.limit === "number"
416
+ ? Math.min(100, Math.max(1, options.limit))
417
+ : 50;
418
+ if (typeof options?.startCursor === "string") {
419
+ q.start_cursor = options.startCursor;
420
+ }
421
+ const res = (await client.blocks.children.list(q));
422
+ return {
423
+ results: Array.isArray(res.results) ? res.results : [],
424
+ hasMore: res.has_more === true,
425
+ nextCursor: res.next_cursor ?? null,
426
+ };
427
+ },
428
+ async search(options) {
429
+ const q = {};
430
+ if (typeof options?.query === "string")
431
+ q.query = options.query;
432
+ if (options?.objectFilter !== undefined)
433
+ q.filter = { property: "object", value: options.objectFilter };
434
+ q.page_size = typeof options?.pageSize === "number" ? options.pageSize : 50;
435
+ if (typeof options?.startCursor === "string")
436
+ q.start_cursor = options.startCursor;
437
+ const res = (await client.search(q));
438
+ return {
439
+ results: Array.isArray(res.results) ? res.results : [],
440
+ hasMore: res.has_more === true,
441
+ nextCursor: res.next_cursor ?? null,
442
+ };
443
+ },
444
+ async searchPages(query) {
445
+ const q = {
446
+ filter: { property: "object", value: "page" },
447
+ page_size: 50,
448
+ };
449
+ if (typeof query === "string" && query.length > 0)
450
+ q.query = query;
451
+ const res = (await client.search(q));
452
+ return {
453
+ results: Array.isArray(res.results) ? res.results : [],
454
+ hasMore: res.has_more === true,
455
+ nextCursor: res.next_cursor ?? null,
456
+ };
457
+ },
458
+ };
459
+ }