@goodtek/vibeops 0.4.0 → 1.0.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 (106) hide show
  1. package/CHANGELOG.md +198 -1
  2. package/README.md +62 -402
  3. package/dist/bootstrap/installer.js +4 -16
  4. package/dist/cli.js +52 -244
  5. package/dist/commands/init.js +3 -3
  6. package/dist/commands/status.js +145 -8
  7. package/dist/commands/task-add.js +100 -0
  8. package/dist/commands/task-done.js +117 -74
  9. package/dist/lib/config.js +2 -118
  10. package/dist/lib/git.js +190 -8
  11. package/dist/lib/llm-complete.js +62 -0
  12. package/dist/lib/paths.js +7 -3
  13. package/dist/lib/plan-llm-openai.js +0 -63
  14. package/dist/lib/resolve-task.js +34 -0
  15. package/dist/lib/slug.js +11 -0
  16. package/dist/lib/task-add-llm.js +81 -0
  17. package/dist/lib/task-context.js +68 -0
  18. package/dist/lib/task-done-llm.js +148 -0
  19. package/dist/lib/task-merge.js +115 -0
  20. package/dist/lib/task-scaffold.js +70 -92
  21. package/dist/lib/task-start.js +72 -0
  22. package/dist/lib/task-summary.js +12 -239
  23. package/dist/lib/task.js +80 -37
  24. package/dist/types/config.js +0 -16
  25. package/package.json +3 -4
  26. package/templates/.cursor/rules/01-task-source-of-truth.mdc +12 -0
  27. package/templates/.cursor/rules/02-git-safety.mdc +11 -0
  28. package/templates/.cursor/rules/03-docs-on-done.mdc +13 -0
  29. package/templates/.cursor/skills/implement-task/SKILL.md +22 -0
  30. package/templates/.cursor/skills/plan-task/SKILL.md +19 -0
  31. package/templates/AGENTS.md +26 -87
  32. package/templates/docs/logs/README.md +4 -35
  33. package/templates/docs/project/03-architecture.md +10 -29
  34. package/templates/docs/project/05-current-state.md +7 -27
  35. package/templates/docs/project/06-decisions.md +6 -17
  36. package/templates/docs/tasks/TASK-000-template.md +9 -44
  37. package/dist/agent/loader.js +0 -71
  38. package/dist/agent/prompt.js +0 -66
  39. package/dist/commands/agent-list.js +0 -32
  40. package/dist/commands/agent-prompt.js +0 -59
  41. package/dist/commands/agent-show.js +0 -26
  42. package/dist/commands/github-init.js +0 -554
  43. package/dist/commands/github-status.js +0 -164
  44. package/dist/commands/notion-init.js +0 -764
  45. package/dist/commands/notion-sync.js +0 -405
  46. package/dist/commands/notion-test.js +0 -595
  47. package/dist/commands/plan.js +0 -343
  48. package/dist/commands/task-check.js +0 -155
  49. package/dist/commands/task-generate.js +0 -206
  50. package/dist/commands/task-pull.js +0 -277
  51. package/dist/commands/task-rollback.js +0 -174
  52. package/dist/commands/task-start.js +0 -102
  53. package/dist/lib/brief.js +0 -356
  54. package/dist/lib/github-cli.js +0 -247
  55. package/dist/lib/notion-client.js +0 -459
  56. package/dist/lib/notion-discovery.js +0 -671
  57. package/dist/lib/notion-env.js +0 -140
  58. package/dist/lib/notion-mappers.js +0 -148
  59. package/dist/lib/notion-schema.js +0 -272
  60. package/dist/lib/notion-sync.js +0 -337
  61. package/dist/lib/notion-target.js +0 -247
  62. package/dist/lib/package-json.js +0 -133
  63. package/dist/lib/plan-apply-planner.js +0 -131
  64. package/dist/lib/plan-codex-pkce.js +0 -234
  65. package/dist/lib/plan-llm-models.js +0 -237
  66. package/dist/lib/plan-llm-normalize.js +0 -146
  67. package/dist/lib/plan-llm-session.js +0 -682
  68. package/dist/lib/plan-post-apply-setup.js +0 -116
  69. package/dist/lib/project-docs.js +0 -95
  70. package/dist/lib/prompt-builder.js +0 -135
  71. package/dist/lib/task-generator.js +0 -183
  72. package/dist/lib/task-prompt.js +0 -23
  73. package/dist/lib/task-pull.js +0 -354
  74. package/dist/status/collector.js +0 -103
  75. package/dist/status/format.js +0 -177
  76. package/dist/types/brief.js +0 -126
  77. package/templates/.cursor/rules/00-project-governance.mdc +0 -28
  78. package/templates/.cursor/rules/01-agent-orchestration.mdc +0 -48
  79. package/templates/.cursor/rules/02-task-workflow.mdc +0 -38
  80. package/templates/.cursor/rules/03-git-safety.mdc +0 -30
  81. package/templates/.cursor/rules/04-docs-update.mdc +0 -22
  82. package/templates/.vibeops/agents/architect.md +0 -47
  83. package/templates/.vibeops/agents/builder.md +0 -38
  84. package/templates/.vibeops/agents/docs.md +0 -54
  85. package/templates/.vibeops/agents/orchestrator.md +0 -40
  86. package/templates/.vibeops/agents/planner.md +0 -60
  87. package/templates/.vibeops/agents/recovery.md +0 -49
  88. package/templates/.vibeops/agents/reviewer.md +0 -47
  89. package/templates/.vibeops/agents/tester.md +0 -43
  90. package/templates/.vibeops/prompts/create-plan.md +0 -33
  91. package/templates/.vibeops/prompts/generate-tasks.md +0 -41
  92. package/templates/.vibeops/prompts/implement-task.md +0 -39
  93. package/templates/.vibeops/prompts/review-task.md +0 -34
  94. package/templates/.vibeops/prompts/rollback.md +0 -32
  95. package/templates/.vibeops/prompts/start-project.md +0 -39
  96. package/templates/.vibeops/workflows/notion-sync.md +0 -53
  97. package/templates/.vibeops/workflows/project-start.md +0 -73
  98. package/templates/.vibeops/workflows/rollback.md +0 -45
  99. package/templates/.vibeops/workflows/task-lifecycle.md +0 -71
  100. package/templates/docs/project/00-overview.md +0 -27
  101. package/templates/docs/project/01-requirements.md +0 -30
  102. package/templates/docs/project/02-mvp-scope.md +0 -36
  103. package/templates/docs/project/04-tech-stack.md +0 -29
  104. package/templates/docs/project/07-backlog.md +0 -23
  105. package/templates/docs/project/08-env.md +0 -29
  106. package/templates/docs/project/09-deployment.md +0 -28
@@ -1,405 +0,0 @@
1
- import { resolve } from "node:path";
2
- import { bold, cyan, dim, gray, green, log, red, yellow } from "../lib/logger.js";
3
- import { maskToken } from "../lib/notion-env.js";
4
- import { notionApiError } from "../lib/notion-client.js";
5
- import { MISSING_PROPERTIES_HINT, PROJECTS_DB_PROPERTIES, STATUS_OPTIONS_HINT, TASKS_DB_PROPERTIES, } from "../lib/notion-schema.js";
6
- import { createNotionClient, executeProjectUpsert, executeTaskUpsert, fetchSchemas, loadSyncContext, planSync, } from "../lib/notion-sync.js";
7
- function emptyReport(cwd, dryRun) {
8
- return {
9
- cwd,
10
- ok: false,
11
- dryRun,
12
- tokenMasked: null,
13
- notion: null,
14
- project: null,
15
- counts: {
16
- project: { create: 0, update: 0 },
17
- tasks: { create: 0, update: 0 },
18
- },
19
- entries: [],
20
- errors: [],
21
- };
22
- }
23
- export async function notionSyncCommand(options = {}) {
24
- const cwd = resolve(options.cwd ?? process.cwd());
25
- const dryRun = options.dryRun === true;
26
- const wantJson = options.json === true;
27
- const report = emptyReport(cwd, dryRun);
28
- const ctxRes = await loadSyncContext(cwd);
29
- if (!ctxRes.ok) {
30
- report.errors.push({ reason: ctxRes.reason, message: ctxRes.message });
31
- return finalize(report, wantJson, "preflight");
32
- }
33
- const ctx = ctxRes;
34
- report.tokenMasked = maskToken(ctx.token);
35
- report.notion = {
36
- enabled: ctx.notion.enabled,
37
- projectsTargetId: ctx.notion.projectsTargetId,
38
- tasksTargetId: ctx.notion.tasksTargetId,
39
- projectsDatabaseId: ctx.notion.projectsDatabaseId,
40
- tasksDatabaseId: ctx.notion.tasksDatabaseId,
41
- };
42
- report.project = {
43
- projectId: ctx.project.projectId,
44
- name: ctx.project.projectName,
45
- currentPhase: ctx.project.currentPhase,
46
- gitRepoUrl: ctx.project.gitRepoUrl,
47
- docsPath: ctx.project.docsProjectPath,
48
- };
49
- let client;
50
- try {
51
- client = await createNotionClient(ctx.token);
52
- }
53
- catch (err) {
54
- const apiErr = notionApiError(err);
55
- report.errors.push({
56
- reason: "sdk-load",
57
- message: `Failed to load @notionhq/client — ${apiErr.message}`,
58
- details: apiErr,
59
- });
60
- return finalize(report, wantJson, "preflight");
61
- }
62
- const schemaRes = await fetchSchemas(client, ctx.notion);
63
- if (!schemaRes.ok) {
64
- report.errors.push({
65
- reason: schemaRes.reason,
66
- message: explainNotionError(schemaRes.error),
67
- details: schemaRes.error,
68
- });
69
- return finalize(report, wantJson, "preflight");
70
- }
71
- report.schemas = [
72
- {
73
- kind: "projects",
74
- inputId: schemaRes.projects.inputId,
75
- inputObject: schemaRes.projects.inputObject,
76
- resolvedId: schemaRes.projects.resolvedId,
77
- resolvedObject: schemaRes.projects.resolvedObject,
78
- source: schemaRes.projects.source,
79
- parentKind: schemaRes.projects.resolvedObject === "data_source"
80
- ? "data_source_id"
81
- : "database_id",
82
- ...(schemaRes.projects.parentDatabaseId !== undefined
83
- ? { parentDatabaseId: schemaRes.projects.parentDatabaseId }
84
- : {}),
85
- propertiesMissing: schemaRes.projects.propertiesMissing,
86
- violationsCount: schemaRes.projects.violations.length,
87
- warnings: schemaRes.projects.warnings,
88
- },
89
- {
90
- kind: "tasks",
91
- inputId: schemaRes.tasks.inputId,
92
- inputObject: schemaRes.tasks.inputObject,
93
- resolvedId: schemaRes.tasks.resolvedId,
94
- resolvedObject: schemaRes.tasks.resolvedObject,
95
- source: schemaRes.tasks.source,
96
- parentKind: schemaRes.tasks.resolvedObject === "data_source"
97
- ? "data_source_id"
98
- : "database_id",
99
- ...(schemaRes.tasks.parentDatabaseId !== undefined
100
- ? { parentDatabaseId: schemaRes.tasks.parentDatabaseId }
101
- : {}),
102
- propertiesMissing: schemaRes.tasks.propertiesMissing,
103
- violationsCount: schemaRes.tasks.violations.length,
104
- warnings: schemaRes.tasks.warnings,
105
- },
106
- ];
107
- const violations = [
108
- ...schemaRes.projects.violations,
109
- ...schemaRes.tasks.violations,
110
- ];
111
- if (violations.length > 0) {
112
- const propertiesMissing = schemaRes.projects.propertiesMissing ||
113
- schemaRes.tasks.propertiesMissing;
114
- const statusOptionsMissing = violations.some((v) => v.kind === "status-options-missing" ||
115
- v.kind === "status-options-unreadable");
116
- let message;
117
- let reason;
118
- if (propertiesMissing) {
119
- message = `Notion DB response is missing a properties object. ${MISSING_PROPERTIES_HINT}`;
120
- reason = "schema-missing-properties";
121
- }
122
- else if (statusOptionsMissing) {
123
- message = `Notion Status property is missing required options (${violations.length} violation${violations.length === 1 ? "" : "s"}). ${STATUS_OPTIONS_HINT}`;
124
- reason = "schema-status-options";
125
- }
126
- else {
127
- message = `Notion DB schema does not match VibeOps requirements (${violations.length} violation${violations.length === 1 ? "" : "s"}).`;
128
- reason = "schema";
129
- }
130
- report.errors.push({
131
- reason,
132
- message,
133
- details: violations,
134
- });
135
- // Sync fast-fails BEFORE any mutation when schema is incomplete — even in
136
- // actual run we never partially upsert when status options are missing.
137
- return finalize(report, wantJson, "schema");
138
- }
139
- let plan;
140
- try {
141
- plan = await planSync({
142
- ctx,
143
- schemas: schemaRes,
144
- detectExisting: true,
145
- onlyTasks: options.onlyTasks === true,
146
- onlyProject: options.onlyProject === true,
147
- client,
148
- });
149
- }
150
- catch (err) {
151
- const apiErr = notionApiError(err);
152
- report.errors.push({
153
- reason: "query",
154
- message: explainNotionError(apiErr),
155
- details: apiErr,
156
- });
157
- return finalize(report, wantJson, "query");
158
- }
159
- report.counts = plan.counts;
160
- if (plan.project) {
161
- report.entries.push(planEntryToReport(plan.project, ctx));
162
- }
163
- for (const t of plan.tasks) {
164
- report.entries.push(taskEntryToReport(t));
165
- }
166
- if (dryRun) {
167
- report.ok = true;
168
- return finalize(report, wantJson, "dry-run");
169
- }
170
- let mutateFailed = false;
171
- const projectsTargetId = schemaRes.projects.resolvedId;
172
- const projectsParentKind = schemaRes.projects.resolvedObject === "data_source" ? "data_source_id" : "database_id";
173
- const tasksTargetId = schemaRes.tasks.resolvedId;
174
- const tasksParentKind = schemaRes.tasks.resolvedObject === "data_source" ? "data_source_id" : "database_id";
175
- if (plan.project) {
176
- try {
177
- const r = await executeProjectUpsert(client, projectsTargetId, plan.project);
178
- const idx = report.entries.findIndex((e) => e.kind === "project");
179
- if (idx >= 0)
180
- report.entries[idx].pageId = r.pageId;
181
- }
182
- catch (err) {
183
- mutateFailed = true;
184
- const apiErr = notionApiError(err);
185
- report.errors.push({
186
- reason: "project-upsert",
187
- message: formatMutateError({
188
- err: apiErr,
189
- action: plan.project.verb === "update" ? "update-page" : "create-page",
190
- parentKind: projectsParentKind,
191
- targetId: projectsTargetId,
192
- }),
193
- details: apiErr,
194
- });
195
- }
196
- }
197
- for (const entry of plan.tasks) {
198
- try {
199
- const r = await executeTaskUpsert(client, tasksTargetId, entry);
200
- const idx = report.entries.findIndex((e) => e.kind === "task" && e.taskId === entry.taskId);
201
- if (idx >= 0)
202
- report.entries[idx].pageId = r.pageId;
203
- }
204
- catch (err) {
205
- mutateFailed = true;
206
- const apiErr = notionApiError(err);
207
- report.errors.push({
208
- reason: "task-upsert",
209
- message: `${entry.taskId}: ${formatMutateError({
210
- err: apiErr,
211
- action: entry.verb === "update" ? "update-page" : "create-page",
212
- parentKind: tasksParentKind,
213
- targetId: tasksTargetId,
214
- })}`,
215
- details: apiErr,
216
- });
217
- }
218
- }
219
- report.ok = !mutateFailed;
220
- finalize(report, wantJson, mutateFailed ? "mutate-error" : "ok");
221
- }
222
- /**
223
- * Mutation-time error message. Always carries:
224
- * - action (create/update/query)
225
- * - target id (the data_source id we used)
226
- * - parent kind (data_source_id vs database_id) for create
227
- * NEVER prints the bearer token.
228
- */
229
- function formatMutateError(inputs) {
230
- const head = explainNotionError(inputs.err);
231
- const parts = [`action=${inputs.action}`, `target=${inputs.targetId}`];
232
- if (inputs.action === "create-page" && inputs.parentKind !== undefined) {
233
- parts.push(`parent=${inputs.parentKind}`);
234
- }
235
- const hint = mutateHint(inputs.err);
236
- return `${head} [${parts.join(", ")}]${hint}`;
237
- }
238
- function mutateHint(err) {
239
- if (err.status === 404) {
240
- return " — Notion could not find the target id. Verify the resolved data_source id, and confirm the integration is connected to that data_source directly. Diagnose with `vibeops notion test --debug-shape`.";
241
- }
242
- // "Invalid status option" / "Invalid select option" — the options VibeOps
243
- // uses are not registered in Notion. The status-options validator should
244
- // catch this earlier, but environments where Notion does not return
245
- // status options can still trip on the actual run; surface guidance here.
246
- if (err.code === "validation_error" &&
247
- /Invalid (status|select) option/i.test(err.message)) {
248
- return ` — ${STATUS_OPTIONS_HINT}`;
249
- }
250
- return "";
251
- }
252
- function planEntryToReport(entry, ctx) {
253
- return {
254
- kind: "project",
255
- verb: entry.verb,
256
- title: ctx.project.projectName,
257
- taskId: ctx.project.projectId,
258
- pageId: entry.existingPageId,
259
- docsRelativePath: ctx.project.docsProjectPath,
260
- };
261
- }
262
- function taskEntryToReport(entry) {
263
- return {
264
- kind: "task",
265
- verb: entry.verb,
266
- taskId: entry.taskId,
267
- title: entry.title,
268
- pageId: entry.existingPageId,
269
- docsRelativePath: entry.docsRelativePath,
270
- };
271
- }
272
- function explainNotionError(err) {
273
- const tail = err.status ? ` (HTTP ${err.status})` : "";
274
- switch (err.code) {
275
- case "unauthorized":
276
- return `NOTION_TOKEN was rejected. Verify the integration is not expired and the value is correct.${tail}`;
277
- case "restricted_resource":
278
- return `The Notion DB is not shared with the integration. Add it via Notion DB → Connections.${tail}`;
279
- case "object_not_found":
280
- return `Notion resource not found. Verify the database id / page id.${tail}`;
281
- case "validation_error":
282
- return `Request rejected (validation_error): ${err.message}${tail}`;
283
- case "rate_limited":
284
- return `Notion API rate limit — retry shortly.${tail}`;
285
- case "request_timeout":
286
- case "ETIMEDOUT":
287
- return `Notion API 5s timeout. Check your network.${tail}`;
288
- default:
289
- return `${err.code}: ${err.message}${tail}`;
290
- }
291
- }
292
- function finalize(report, wantJson, phase) {
293
- if (wantJson) {
294
- process.stdout.write(`${JSON.stringify({ phase, ...report }, null, 2)}\n`);
295
- process.exitCode = report.ok ? 0 : 1;
296
- return;
297
- }
298
- log.info(bold("vibeops notion sync"));
299
- log.info(` ${dim("cwd")} ${report.cwd}${report.dryRun ? ` ${yellow("[dry-run]")}` : ""}`);
300
- if (report.tokenMasked) {
301
- log.info(` ${dim("token")} ${report.tokenMasked}`);
302
- }
303
- if (report.project) {
304
- log.info(` ${dim("project")} ${report.project.name} ${gray(`(id=${report.project.projectId})`)}`);
305
- log.info(` ${dim("phase")} ${report.project.currentPhase} ${dim("git remote")} ${report.project.gitRepoUrl.length > 0 ? report.project.gitRepoUrl : gray("(none)")}`);
306
- }
307
- log.blank();
308
- if (report.schemas !== undefined && report.schemas.length > 0) {
309
- for (const s of report.schemas) {
310
- const status = s.propertiesMissing
311
- ? red("missing-properties")
312
- : s.violationsCount > 0
313
- ? red(`${s.violationsCount} violation${s.violationsCount === 1 ? "" : "s"}`)
314
- : green("schema valid");
315
- log.info(` ${bold(`${s.kind} DB target`)}`);
316
- log.info(` ${dim("input id ")} ${cyan(s.inputId)}`);
317
- log.info(` ${dim("input object ")} ${s.inputObject}`);
318
- log.info(` ${dim("resolved id ")} ${cyan(s.resolvedId)}`);
319
- log.info(` ${dim("resolved object")} ${s.resolvedObject}`);
320
- log.info(` ${dim("source ")} ${s.source}`);
321
- if (s.parentDatabaseId !== undefined) {
322
- log.info(` ${dim("parent database")} ${gray(s.parentDatabaseId)}`);
323
- }
324
- log.info(` ${dim("create parent ")} ${s.parentKind} ${cyan(s.resolvedId)}`);
325
- log.info(` ${dim("query target ")} ${s.parentKind === "data_source_id" ? "data_source" : "database"} ${cyan(s.resolvedId)}`);
326
- log.info(` ${dim("schema ")} ${status}`);
327
- for (const w of s.warnings) {
328
- log.info(` ${dim("warning ")} ${yellow(w)}`);
329
- }
330
- }
331
- log.blank();
332
- }
333
- if (report.errors.length > 0) {
334
- for (const e of report.errors) {
335
- log.error(`${cyan(e.reason)} — ${e.message}`);
336
- if ((e.reason === "schema" ||
337
- e.reason === "schema-missing-properties" ||
338
- e.reason === "schema-status-options") &&
339
- Array.isArray(e.details)) {
340
- for (const v of e.details) {
341
- if (v.kind === "status-options-missing" ||
342
- v.kind === "status-options-unreadable") {
343
- log.info(` · ${red(v.kind)} ${cyan(`${v.db}.${v.property}`)}`);
344
- if (v.kind === "status-options-missing" &&
345
- v.missingOptions !== undefined &&
346
- v.missingOptions.length > 0) {
347
- log.info(` ${dim("missing")} ${v.missingOptions.join(", ")}`);
348
- }
349
- if (v.requiredOptions !== undefined &&
350
- v.requiredOptions.length > 0) {
351
- log.info(` ${dim("Add these options in Notion")}: Status property → Edit options → ${v.requiredOptions.join(", ")}`);
352
- }
353
- if (v.kind === "status-options-missing" &&
354
- v.foundOptions !== undefined &&
355
- v.foundOptions.length > 0) {
356
- log.info(` ${dim("found in Notion")}: ${v.foundOptions.join(", ")}`);
357
- }
358
- continue;
359
- }
360
- const detail = v.kind === "missing-properties"
361
- ? // The resolver puts the actionable hint into description —
362
- // surface that instead of the static "no properties object" line.
363
- v.description
364
- : v.kind === "missing"
365
- ? `expected types: ${v.allowedTypes.join(" | ")}`
366
- : `expected ${v.allowedTypes.join(" | ")} but got ${v.actualType ?? "?"}`;
367
- log.info(` · ${red(v.kind)} ${cyan(`${v.db}.${v.property}`)} — ${dim(detail)}`);
368
- }
369
- log.info(dim(` All 8 Projects DB properties, 10 Tasks DB properties, and the required Status options must be present. Inspect details with \`vibeops notion test\`.`));
370
- }
371
- }
372
- log.blank();
373
- }
374
- const total = report.counts.project.create + report.counts.project.update;
375
- const tasksTotal = report.counts.tasks.create + report.counts.tasks.update;
376
- log.info(` ${bold("Project")} ${green(`create ${report.counts.project.create}`)} ${cyan(`update ${report.counts.project.update}`)} ${dim(`total ${total}`)}`);
377
- log.info(` ${bold("Tasks")} ${green(`create ${report.counts.tasks.create}`)} ${cyan(`update ${report.counts.tasks.update}`)} ${dim(`total ${tasksTotal}`)}`);
378
- log.blank();
379
- if (report.entries.length > 0) {
380
- log.info(bold("preview"));
381
- for (const e of report.entries) {
382
- const verbTag = e.verb === "create" ? green("create") : cyan("update");
383
- const kindTag = e.kind === "project" ? gray("project") : gray("task ");
384
- const id = e.taskId ?? "";
385
- const path = e.docsRelativePath ? dim(` ${e.docsRelativePath}`) : "";
386
- const title = e.title ? ` ${e.title}` : "";
387
- log.info(` ${verbTag} ${kindTag} ${cyan(id)}${title}${path}`);
388
- }
389
- log.blank();
390
- }
391
- log.info(dim(` schemas covered: Projects(${PROJECTS_DB_PROPERTIES.length}) · Tasks(${TASKS_DB_PROPERTIES.length}).`));
392
- if (report.dryRun) {
393
- log.info(yellow(" dry-run — no Notion mutation performed."));
394
- }
395
- if (report.ok) {
396
- log.ok(report.dryRun
397
- ? "Sync plan OK — re-run without --dry-run to apply."
398
- : "Notion sync complete.");
399
- process.exitCode = 0;
400
- }
401
- else {
402
- log.error("Notion sync failed — see errors above.");
403
- process.exitCode = 1;
404
- }
405
- }