@goodtek/vibeops 0.7.22 → 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 (121) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +62 -382
  3. package/dist/bootstrap/installer.js +4 -16
  4. package/dist/cli.js +26 -160
  5. package/dist/commands/init.js +3 -3
  6. package/dist/commands/status.js +145 -8
  7. package/dist/commands/task-add.js +64 -164
  8. package/dist/commands/task-done.js +117 -74
  9. package/dist/lib/config.js +2 -118
  10. package/dist/lib/git.js +1 -1
  11. package/dist/lib/paths.js +7 -3
  12. package/dist/lib/plan-llm-openai.js +0 -63
  13. package/dist/lib/resolve-task.js +34 -0
  14. package/dist/lib/task-add-llm.js +46 -233
  15. package/dist/lib/task-context.js +68 -0
  16. package/dist/lib/task-done-llm.js +148 -0
  17. package/dist/lib/task-merge.js +54 -159
  18. package/dist/lib/task-scaffold.js +39 -43
  19. package/dist/lib/task-start.js +72 -0
  20. package/dist/lib/task-summary.js +12 -239
  21. package/dist/lib/task.js +15 -24
  22. package/dist/types/config.js +0 -16
  23. package/package.json +3 -5
  24. package/templates/.cursor/rules/01-task-source-of-truth.mdc +12 -0
  25. package/templates/.cursor/rules/02-git-safety.mdc +11 -0
  26. package/templates/.cursor/rules/03-docs-on-done.mdc +13 -0
  27. package/templates/.cursor/skills/implement-task/SKILL.md +22 -0
  28. package/templates/.cursor/skills/plan-task/SKILL.md +19 -0
  29. package/templates/AGENTS.md +26 -87
  30. package/templates/docs/logs/README.md +4 -35
  31. package/templates/docs/project/03-architecture.md +10 -29
  32. package/templates/docs/project/05-current-state.md +7 -27
  33. package/templates/docs/project/06-decisions.md +6 -17
  34. package/templates/docs/tasks/TASK-000-template.md +9 -44
  35. package/dist/agent/loader.js +0 -71
  36. package/dist/agent/prompt.js +0 -66
  37. package/dist/commands/agent-list.js +0 -32
  38. package/dist/commands/agent-prompt.js +0 -59
  39. package/dist/commands/agent-show.js +0 -26
  40. package/dist/commands/done.js +0 -160
  41. package/dist/commands/github-init.js +0 -554
  42. package/dist/commands/github-status.js +0 -164
  43. package/dist/commands/next.js +0 -283
  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 -146
  48. package/dist/commands/rollback.js +0 -126
  49. package/dist/commands/start.js +0 -129
  50. package/dist/commands/task-check.js +0 -155
  51. package/dist/commands/task-generate.js +0 -206
  52. package/dist/commands/task-pull.js +0 -277
  53. package/dist/commands/task-rollback.js +0 -174
  54. package/dist/commands/task-start.js +0 -102
  55. package/dist/commands/task-status.js +0 -122
  56. package/dist/lib/brief.js +0 -356
  57. package/dist/lib/cursor-task-prompt.js +0 -60
  58. package/dist/lib/done-follow-up.js +0 -308
  59. package/dist/lib/github-cli.js +0 -247
  60. package/dist/lib/guide-state.js +0 -69
  61. package/dist/lib/mvp-artifacts.js +0 -204
  62. package/dist/lib/mvp-constants.js +0 -15
  63. package/dist/lib/mvp-diff-summary.js +0 -43
  64. package/dist/lib/mvp-done-summary.js +0 -335
  65. package/dist/lib/notion-client.js +0 -459
  66. package/dist/lib/notion-discovery.js +0 -671
  67. package/dist/lib/notion-env.js +0 -140
  68. package/dist/lib/notion-mappers.js +0 -148
  69. package/dist/lib/notion-schema.js +0 -272
  70. package/dist/lib/notion-sync.js +0 -337
  71. package/dist/lib/notion-target.js +0 -247
  72. package/dist/lib/package-json.js +0 -133
  73. package/dist/lib/plan-apply-planner.js +0 -131
  74. package/dist/lib/plan-codex-pkce.js +0 -234
  75. package/dist/lib/plan-llm-models.js +0 -237
  76. package/dist/lib/plan-llm-normalize.js +0 -146
  77. package/dist/lib/plan-llm-session.js +0 -682
  78. package/dist/lib/plan-post-apply-setup.js +0 -116
  79. package/dist/lib/project-docs.js +0 -95
  80. package/dist/lib/prompt-builder.js +0 -135
  81. package/dist/lib/resolve-command-task.js +0 -40
  82. package/dist/lib/task-add-build-prompt.js +0 -39
  83. package/dist/lib/task-add-cursor.js +0 -81
  84. package/dist/lib/task-generator.js +0 -183
  85. package/dist/lib/task-prompt.js +0 -23
  86. package/dist/lib/task-pull.js +0 -354
  87. package/dist/lib/task-status-brief.js +0 -132
  88. package/dist/lib/workflow-guide.js +0 -485
  89. package/dist/status/collector.js +0 -103
  90. package/dist/status/format.js +0 -181
  91. package/dist/types/brief.js +0 -126
  92. package/templates/.cursor/rules/00-project-governance.mdc +0 -28
  93. package/templates/.cursor/rules/01-agent-orchestration.mdc +0 -48
  94. package/templates/.cursor/rules/02-task-workflow.mdc +0 -38
  95. package/templates/.cursor/rules/03-git-safety.mdc +0 -30
  96. package/templates/.cursor/rules/04-docs-update.mdc +0 -22
  97. package/templates/.vibeops/agents/architect.md +0 -47
  98. package/templates/.vibeops/agents/builder.md +0 -38
  99. package/templates/.vibeops/agents/docs.md +0 -54
  100. package/templates/.vibeops/agents/orchestrator.md +0 -40
  101. package/templates/.vibeops/agents/planner.md +0 -60
  102. package/templates/.vibeops/agents/recovery.md +0 -49
  103. package/templates/.vibeops/agents/reviewer.md +0 -47
  104. package/templates/.vibeops/agents/tester.md +0 -43
  105. package/templates/.vibeops/prompts/create-plan.md +0 -33
  106. package/templates/.vibeops/prompts/generate-tasks.md +0 -41
  107. package/templates/.vibeops/prompts/implement-task.md +0 -39
  108. package/templates/.vibeops/prompts/review-task.md +0 -34
  109. package/templates/.vibeops/prompts/rollback.md +0 -32
  110. package/templates/.vibeops/prompts/start-project.md +0 -39
  111. package/templates/.vibeops/workflows/notion-sync.md +0 -53
  112. package/templates/.vibeops/workflows/project-start.md +0 -73
  113. package/templates/.vibeops/workflows/rollback.md +0 -45
  114. package/templates/.vibeops/workflows/task-lifecycle.md +0 -71
  115. package/templates/docs/project/00-overview.md +0 -27
  116. package/templates/docs/project/01-requirements.md +0 -30
  117. package/templates/docs/project/02-mvp-scope.md +0 -36
  118. package/templates/docs/project/04-tech-stack.md +0 -29
  119. package/templates/docs/project/07-backlog.md +0 -23
  120. package/templates/docs/project/08-env.md +0 -29
  121. package/templates/docs/project/09-deployment.md +0 -28
@@ -1,183 +0,0 @@
1
- export const TASK_GENERATE_SCHEMA_VERSION = 1;
2
- export const REQUIRED_TASK_SECTIONS = [
3
- "Status",
4
- "MVP Phase",
5
- "Goal",
6
- "Background",
7
- "Scope",
8
- "Out of Scope",
9
- "Acceptance Criteria",
10
- "Files to Inspect First",
11
- "Expected Files to Change",
12
- "Risks",
13
- "Test Plan",
14
- "Rollback Plan",
15
- "Git Context",
16
- "Notion Page",
17
- "Implementation Plan",
18
- "Result",
19
- "Test Result",
20
- "Review Notes",
21
- ];
22
- /**
23
- * Convert an arbitrary string to a TASK-filename slug.
24
- * - lowercase
25
- * - ASCII-only (non-ASCII letters dropped)
26
- * - non-alphanum collapsed to `-`
27
- * - leading/trailing `-` removed, repeated `-` compressed
28
- * - empty result → `task`
29
- */
30
- export function slugify(input, fallback = "task") {
31
- const cleaned = input
32
- .toLowerCase()
33
- .normalize("NFKD")
34
- .replace(/[\u0300-\u036f]/g, "")
35
- .replace(/[^a-z0-9]+/g, "-")
36
- .replace(/^-+|-+$/g, "")
37
- .replace(/-+/g, "-");
38
- return cleaned.length > 0 ? cleaned : fallback;
39
- }
40
- function header(inputs) {
41
- const lines = [];
42
- lines.push("# VibeOps Task-Generation Prompt — Cursor Planner Agent");
43
- lines.push("");
44
- lines.push("> This file was produced by `vibeops task generate`. Open a new chat in **Cursor** and paste the full contents of this file. VibeOps does not call LLM, Cursor CLI, Notion, or GitHub APIs directly — the **Planner Agent** receiving this prompt creates the `docs/tasks/TASK-*.md` files.");
45
- lines.push("");
46
- lines.push(`- VibeOps version: ${inputs.vibeopsVersion}`);
47
- lines.push(`- Generated: ${inputs.generatedAt}`);
48
- lines.push(`- Generated by: \`vibeops task generate\``);
49
- lines.push(`- Prompt location: \`${inputs.outputRelative}\``);
50
- lines.push(`- Schema version: ${TASK_GENERATE_SCHEMA_VERSION}`);
51
- lines.push("");
52
- return lines.join("\n");
53
- }
54
- function role() {
55
- return `## Role: Planner Agent
56
-
57
- Follow the definition in \`.vibeops/agents/planner.md\` if it is installed. You **do not write application code**. You produce \`docs/tasks/TASK-NNN-slug.md\` files. Each TASK must be small enough for a single Builder Agent to finish in one Cursor session.`;
58
- }
59
- function hardRules() {
60
- return `## Hard rules (failure if violated)
61
-
62
- - Never produce application code (\`src/**\`, \`lib/**\`, \`app/**\`, tests, build scripts, etc.). The deliverable for this round is \`docs/tasks/TASK-NNN-slug.md\` files only.
63
- - Do not touch VibeOps' own config (\`.vibeops/\`, \`.vibeops.json\`, \`templates/\`).
64
- - Never call LLM APIs, Cursor CLI, Notion APIs, or GitHub APIs directly. You only produce markdown.
65
- - Source-of-truth rules:
66
- - **\`docs/tasks/*.md\` = AI execution source of truth** (Builder/Reviewer/Tester agents only read these files).
67
- - **\`docs/project/*.md\` = design + current-state baseline** (the input the Planner reads for this round).
68
- - **Git commits/branches = change history**.
69
- - **Notion = human dashboard** (exposes summary / status / priority / docs path only — never execution details).
70
- - Do not trust chat memory.
71
- - One TASK, one focus. Surface inter-TASK dependencies in the body (e.g. "TASK-014 depends on TASK-013").
72
- - Never hide assumptions. Record them in the closing "Assumptions" section of the response.`;
73
- }
74
- function inventoryRow(slot) {
75
- const flag = slot.content === null ? "·" : "✓";
76
- const note = slot.content === null ? " _(missing)_" : "";
77
- const primaryTag = slot.primary ? " **(primary)**" : "";
78
- return `- [${flag}] \`${slot.relativePath}\` — ${slot.label}${primaryTag}${note}`;
79
- }
80
- function inventory(docs) {
81
- const lines = ["## Input documents (for this round)"];
82
- lines.push("");
83
- if (docs.from)
84
- lines.push(inventoryRow(docs.from));
85
- for (const slot of docs.slots)
86
- lines.push(inventoryRow(slot));
87
- lines.push(inventoryRow(docs.brief));
88
- lines.push("");
89
- lines.push("> `[✓]` = file read from disk. `[·]` = missing or empty slot — its content is not inlined below. Do not over-guess for missing slots; record them in \"Assumptions\".");
90
- return lines.join("\n");
91
- }
92
- function objective(inputs) {
93
- const lines = ["## Objective"];
94
- lines.push("");
95
- lines.push(`Read the inputs above and produce roughly **${inputs.count} new TASK files**. The first TASK number is \`${inputs.nextTaskId}\` (the next number after the highest existing \`docs/tasks/TASK-*.md\`). Skip any number that already exists on disk.`);
96
- if (typeof inputs.phase === "string" && inputs.phase.length > 0) {
97
- lines.push("");
98
- lines.push(`- **Phase filter**: this round only produces TASKs whose phase is \`${inputs.phase}\`. Even if you see work for other phases, leave it for a separate round.`);
99
- }
100
- if (inputs.countWarning) {
101
- lines.push("");
102
- lines.push(`- ⚠️ A recommended count of ${inputs.count} is on the high side. Make sure each TASK really does fit a single Cursor session, and lower the count if anything is vague.`);
103
- }
104
- lines.push("");
105
- lines.push("Each TASK must satisfy:");
106
- lines.push("");
107
- lines.push("- **Small, verifiable scope**. One Builder finishes it in one Cursor session.");
108
- lines.push("- **Self-contained**. A reader can determine the files to change and the Acceptance Criteria from the TASK body alone.");
109
- lines.push("- **Explicit dependencies**. If TASK depends on another, write \"depends on TASK-NNN\" in Background.");
110
- return lines.join("\n");
111
- }
112
- function fileFormat(inputs) {
113
- const sections = REQUIRED_TASK_SECTIONS.map((s) => ` - \`## ${s}\``).join("\n");
114
- return `## Output format
115
-
116
- Produce the response in this exact order:
117
-
118
- 1. **Plan summary** — 5 to 8 bullets. Priorities / decomposition derived from the inputs.
119
- 2. **TASK files** — each in its own fenced code block. The first line of each block must be \`<!-- file: docs/tasks/TASK-NNN-slug.md -->\` (an HTML comment) marking the path. Numbers increase sequentially from \`${inputs.nextTaskId}\`. Slugs are lowercase / ASCII / hyphen-separated (e.g. \`docs-and-readme\`).
120
- 3. **Changed file list** — every \`docs/tasks/TASK-NNN-*.md\` path produced above.
121
- 4. **Generated TASK summary** — table or bullet list: \`TASK-NNN · title · MVP Phase · one-line summary\`.
122
- 5. **Assumptions** — items a human needs to reconfirm (use \`(none)\` if there are none).
123
-
124
- ### Required sections per TASK file (same order, same heading text)
125
-
126
- Each TASK markdown begins with \`# ${inputs.nextTaskId} · <one-line title>\` and includes **all** of the following \`##\` sections:
127
-
128
- ${sections}
129
-
130
- Additional rules:
131
-
132
- - The body of \`## Status\` is the single word \`planned\`.
133
- - The body of \`## MVP Phase\` follows \`${typeof inputs.phase === "string" && inputs.phase.length > 0 ? inputs.phase : "MVP <n> · <phase name>"}\` (or \`(unassigned)\` when unknown).
134
- - The body of \`## Git Context\` is \`(populated by \\\`vibeops task start TASK-NNN\\\`)\`.
135
- - The body of \`## Notion Page\` is \`(populated by \\\`vibeops notion sync\\\`)\`.
136
- - The body of \`## Result\` / \`## Test Result\` / \`## Review Notes\` is \`(not yet)\`.`;
137
- }
138
- function workflowReminders() {
139
- return `## Workflow reminders
140
-
141
- - **VibeOps source of truth = \`docs/tasks/\` + \`docs/project/\` + Git.** Notion is a human dashboard and is never used for AI execution.
142
- - Once produced, each TASK must be ready for \`vibeops task start TASK-NNN\`. Keep slugs clean and numbers conflict-free.
143
- - Never produce application code — a separate Builder Agent does that in a different round.
144
- - Separate ambiguity into \`Risks\` (known unknowns) and \`Assumptions\` (need confirmation).`;
145
- }
146
- function fileSection(doc, fenceLang = "") {
147
- if (doc.content === null) {
148
- return `### \`${doc.relativePath}\` _(missing)_\n\n_(file not found — the Planner Agent must record this in Assumptions.)_`;
149
- }
150
- const fence = "```";
151
- const lang = fenceLang.length > 0 ? fenceLang : "markdown";
152
- return `### \`${doc.relativePath}\`\n\n${fence}${lang}\n${doc.content.trimEnd()}\n${fence}`;
153
- }
154
- function inlineInputs(docs) {
155
- const parts = ["## Inline inputs"];
156
- parts.push("");
157
- parts.push("The code blocks below are the on-disk snapshot at prompt generation time. Cursor may open additional context, but **start from these**.");
158
- parts.push("");
159
- if (docs.from)
160
- parts.push(fileSection(docs.from));
161
- for (const slot of docs.slots)
162
- parts.push(fileSection(slot));
163
- parts.push(fileSection(docs.brief));
164
- return parts.join("\n\n");
165
- }
166
- function footer() {
167
- return `---
168
-
169
- Apply the rules above and produce the response. After you respond, a human reviews the generated \`docs/tasks/TASK-*.md\` files via \`git diff\` and commits them, then starts the first TASK with \`vibeops task start TASK-NNN\`.`;
170
- }
171
- export function buildTaskGeneratePrompt(inputs) {
172
- return [
173
- header(inputs),
174
- role(),
175
- hardRules(),
176
- inventory(inputs.docs),
177
- objective(inputs),
178
- fileFormat(inputs),
179
- workflowReminders(),
180
- inlineInputs(inputs.docs),
181
- footer(),
182
- ].join("\n\n");
183
- }
@@ -1,23 +0,0 @@
1
- import { findAgent, listAgents } from "../agent/loader.js";
2
- import { buildPrompt } from "../agent/prompt.js";
3
- import { readConfig } from "./config.js";
4
- import { readText } from "./filesystem.js";
5
- import { readTaskFile } from "./task.js";
6
- export async function buildTaskPromptString(inputs) {
7
- const agent = await findAgent(inputs.agentsDir, inputs.agentName);
8
- if (!agent) {
9
- const available = (await listAgents(inputs.agentsDir)).map((a) => a.meta.name);
10
- return { ok: false, reason: "agent-not-found", available };
11
- }
12
- const config = await readConfig(inputs.projectRoot);
13
- const meta = await readTaskFile(inputs.taskFilePath);
14
- const body = await readText(inputs.taskFilePath);
15
- const prompt = await buildPrompt({
16
- agent,
17
- config,
18
- task: { meta, body },
19
- projectRoot: inputs.projectRoot,
20
- contextPaths: inputs.contextPaths ?? [],
21
- });
22
- return { ok: true, prompt };
23
- }
@@ -1,354 +0,0 @@
1
- /**
2
- * Planning + execution helpers for `vibeops task pull`.
3
- *
4
- * Notion is queried for TASK rows whose Status matches `--status` (default
5
- * "Planned"). For each row that does NOT yet have a local TASK file we plan
6
- * a new `docs/tasks/TASK-NNN-slug.md` skeleton.
7
- *
8
- * Rules:
9
- * - Never overwrite an existing local file.
10
- * - Never write any TASK body section beyond the Notion-sourced metadata.
11
- * - Always include a `## Notion Page` section so `notion sync` can later
12
- * update the same row in place.
13
- *
14
- * Mutation surface:
15
- * - `executePullEntry` writes the local file and (if Docs Path on Notion
16
- * was empty) optionally updates the Notion page's Docs Path. Callers in
17
- * dry-run mode MUST skip this.
18
- */
19
- import { readdir } from "node:fs/promises";
20
- import { basename, join, posix } from "node:path";
21
- import { pathExists, writeText } from "./filesystem.js";
22
- import { projectPaths } from "./paths.js";
23
- import { formatTaskId, highestTaskNumber, } from "./task.js";
24
- import { slugify } from "./task-generator.js";
25
- import { readRichText, readSelect, readStatus, readTitle, richTextEqualsFilter, richTextProperty, statusEqualsFilter, andFilter, } from "./notion-mappers.js";
26
- import { renderPulledTaskMarkdown, } from "./task-summary.js";
27
- export function rowFromNotionPage(page) {
28
- const p = page.properties;
29
- return {
30
- pageId: page.id,
31
- taskId: readRichText(p["Task ID"]),
32
- name: readTitle(p.Name),
33
- status: readStatus(p.Status),
34
- mvpPhase: readSelect(p["MVP Phase"]),
35
- priority: readSelect(p.Priority),
36
- summary: readRichText(p.Summary),
37
- docsPath: readRichText(p["Docs Path"]),
38
- };
39
- }
40
- const DEFAULT_STATUS_NAMES = ["Planned"];
41
- const DEFAULT_LIMIT = 20;
42
- const MAX_LIMIT = 100;
43
- function buildPullFilter(projectId, statusNames) {
44
- const statusFilters = statusNames.map((n) => statusEqualsFilter("Status", n));
45
- const statusFilter = statusFilters.length === 1
46
- ? statusFilters[0]
47
- : { or: statusFilters };
48
- return andFilter([
49
- richTextEqualsFilter("Project ID", projectId),
50
- statusFilter,
51
- ]);
52
- }
53
- /**
54
- * Notion `Docs Path` is **trusted only when its basename matches the TASK
55
- * ID**. A row whose Task ID is `TASK-099` but whose Docs Path points at
56
- * `docs/tasks/TASK-012-package-polish-readme.md` is a mismatch — surfacing
57
- * it as `local-file-exists` silently shadows the real TASK-099 candidate.
58
- *
59
- * Acceptable basenames (case sensitive — Notion is case sensitive here):
60
- * - `TASK-099.md`
61
- * - `TASK-099-anything.md` (the standard `TASK-NNN-slug.md` form)
62
- *
63
- * Anything else is a mismatch.
64
- */
65
- export function docsPathMatchesTaskId(docsRelativePath, taskId) {
66
- if (docsRelativePath.length === 0 || taskId.length === 0)
67
- return false;
68
- const base = basename(docsRelativePath);
69
- return base === `${taskId}.md` || base.startsWith(`${taskId}-`);
70
- }
71
- /** Scan `docs/tasks` for any file whose basename starts with `TASK-NNN`. */
72
- async function findLocalTaskFileForId(docsTasksDir, taskId) {
73
- let entries;
74
- try {
75
- entries = await readdir(docsTasksDir);
76
- }
77
- catch {
78
- return null;
79
- }
80
- for (const name of entries) {
81
- if (name === `${taskId}.md` || name.startsWith(`${taskId}-`)) {
82
- return join(docsTasksDir, name);
83
- }
84
- }
85
- return null;
86
- }
87
- export async function planPull(inputs) {
88
- const statusNames = inputs.statusNames ?? DEFAULT_STATUS_NAMES;
89
- const limit = Math.max(1, Math.min(inputs.limit ?? DEFAULT_LIMIT, MAX_LIMIT));
90
- const res = await inputs.client.queryDataSource(inputs.tasksDataSourceId, {
91
- filter: buildPullFilter(inputs.projectId, statusNames),
92
- pageSize: limit,
93
- });
94
- const paths = projectPaths(inputs.cwd);
95
- const startCounter = await highestTaskNumber(paths.docsTasks);
96
- let nextNumber = startCounter + 1;
97
- const entries = [];
98
- const skipped = [];
99
- const trace = [];
100
- // First pass: detect duplicate Task IDs across the Notion query result so
101
- // a second row with the same id cannot quietly create a second entry. We
102
- // keep the first row and skip subsequent ones with `duplicate-task-id`.
103
- const seenTaskIds = new Set();
104
- const duplicatePageIds = new Set();
105
- for (const page of res.results) {
106
- const id = rowFromNotionPage(page).taskId.trim();
107
- if (id.length === 0)
108
- continue;
109
- if (seenTaskIds.has(id)) {
110
- duplicatePageIds.add(page.id);
111
- }
112
- else {
113
- seenTaskIds.add(id);
114
- }
115
- }
116
- for (const page of res.results) {
117
- const row = rowFromNotionPage(page);
118
- const notionDocsPath = row.docsPath.trim();
119
- // (A) Task ID required — guard against blank ids before we touch local fs.
120
- const taskIdRaw = row.taskId.trim();
121
- if (taskIdRaw.length === 0 && notionDocsPath.length === 0) {
122
- // No Task ID and no Docs Path → allocate a fresh one, treated as a
123
- // legitimate "new" candidate (existing behaviour). Trace it so
124
- // `--verbose` makes the allocation visible.
125
- const taskId = formatTaskId(nextNumber);
126
- nextNumber++;
127
- const slug = slugify(row.name.length > 0 ? row.name : taskId, taskId.toLowerCase());
128
- const docsRelativePath = posix.join("docs/tasks", `${taskId}-${slug}.md`);
129
- const absPath = join(paths.docsTasks, `${taskId}-${slug}.md`);
130
- // Even an allocated id may already be present on disk (race / manual
131
- // creation). Re-check.
132
- if (await pathExists(absPath)) {
133
- skipped.push({
134
- pageId: page.id,
135
- taskId,
136
- reason: "local-file-exists",
137
- docsRelativePath,
138
- detail: `local resolved path: ${docsRelativePath}`,
139
- });
140
- trace.push({
141
- taskId,
142
- pageId: page.id,
143
- notionDocsPath,
144
- localResolvedPath: docsRelativePath,
145
- decision: "skip-local-file-exists",
146
- reason: "Task ID was empty, allocated next id already on disk",
147
- });
148
- continue;
149
- }
150
- entries.push({
151
- pageId: page.id,
152
- taskId,
153
- title: row.name.length > 0 ? row.name : taskId,
154
- status: row.status,
155
- mvpPhase: row.mvpPhase,
156
- summary: row.summary,
157
- docsRelativePath,
158
- absPath,
159
- notionNeedsDocsPath: true,
160
- detail: "allocated Task ID (Notion row had none)",
161
- });
162
- trace.push({
163
- taskId,
164
- pageId: page.id,
165
- notionDocsPath: "",
166
- localResolvedPath: docsRelativePath,
167
- decision: "new-file",
168
- reason: "Task ID empty → allocated; no local file with that id",
169
- });
170
- continue;
171
- }
172
- if (taskIdRaw.length === 0) {
173
- // Has a Docs Path but no Task ID — refuse to act. Renaming pages from
174
- // empty ids is risky and not in TASK-011 scope.
175
- skipped.push({
176
- pageId: page.id,
177
- taskId: "(none)",
178
- reason: "no-task-id",
179
- docsRelativePath: notionDocsPath,
180
- detail: `notion docs path: ${notionDocsPath}`,
181
- });
182
- trace.push({
183
- taskId: "(none)",
184
- pageId: page.id,
185
- notionDocsPath,
186
- localResolvedPath: notionDocsPath,
187
- decision: "skip-no-task-id",
188
- reason: "Notion row has Docs Path but no Task ID",
189
- });
190
- continue;
191
- }
192
- const taskId = taskIdRaw;
193
- // (A.2) duplicate Task ID across the considered rows.
194
- if (duplicatePageIds.has(page.id)) {
195
- skipped.push({
196
- pageId: page.id,
197
- taskId,
198
- reason: "duplicate-task-id",
199
- docsRelativePath: notionDocsPath,
200
- detail: `another Notion row already used this Task ID in the same query`,
201
- });
202
- trace.push({
203
- taskId,
204
- pageId: page.id,
205
- notionDocsPath,
206
- localResolvedPath: "",
207
- decision: "skip-duplicate-task-id",
208
- reason: "duplicate Task ID across considered rows — kept first row only",
209
- });
210
- continue;
211
- }
212
- // (B) Notion Docs Path exists but does NOT match this Task ID. Refuse
213
- // to create / overwrite — surface a `docs-path-mismatch` so the user
214
- // can fix Notion. We deliberately do NOT auto-rename — auto-fixing
215
- // Notion's Docs Path on a mismatch is reserved for a future
216
- // `--fix-docs-path` opt-in.
217
- if (notionDocsPath.length > 0 &&
218
- !docsPathMatchesTaskId(notionDocsPath, taskId)) {
219
- skipped.push({
220
- pageId: page.id,
221
- taskId,
222
- reason: "docs-path-mismatch",
223
- docsRelativePath: notionDocsPath,
224
- detail: `notion docs path: ${notionDocsPath}\n` +
225
- `expected basename prefix: ${taskId}- or ${taskId}.md\n` +
226
- `action: fix Notion 'Docs Path' for this row (auto-fix not enabled).`,
227
- });
228
- trace.push({
229
- taskId,
230
- pageId: page.id,
231
- notionDocsPath,
232
- localResolvedPath: notionDocsPath,
233
- decision: "skip-docs-path-mismatch",
234
- reason: `Notion Docs Path basename does not match ${taskId}- prefix`,
235
- });
236
- continue;
237
- }
238
- // (C) / (D) / (F) — resolve where the local file would live and check
239
- // for existing files. If Notion gave us a matching Docs Path, honour
240
- // it. Otherwise scan `docs/tasks` for any `TASK-NNN-*.md` already on
241
- // disk; if found, treat the row as `local-file-exists`. If not, plan a
242
- // new `TASK-NNN-slug.md`.
243
- const slug = slugify(row.name.length > 0 ? row.name : taskId, taskId.toLowerCase());
244
- let docsRelativePath;
245
- let absPath;
246
- let notionNeedsDocsPath;
247
- let decisionDetail;
248
- if (notionDocsPath.length > 0) {
249
- docsRelativePath = notionDocsPath;
250
- absPath = join(inputs.cwd, notionDocsPath);
251
- notionNeedsDocsPath = false;
252
- decisionDetail = `notion docs path: ${notionDocsPath}`;
253
- }
254
- else {
255
- const existing = await findLocalTaskFileForId(paths.docsTasks, taskId);
256
- if (existing !== null) {
257
- const rel = posix.join("docs/tasks", basename(existing));
258
- skipped.push({
259
- pageId: page.id,
260
- taskId,
261
- reason: "local-file-exists",
262
- docsRelativePath: rel,
263
- detail: `local resolved path: ${rel}\n` +
264
- `notion docs path: (empty)`,
265
- });
266
- trace.push({
267
- taskId,
268
- pageId: page.id,
269
- notionDocsPath: "",
270
- localResolvedPath: rel,
271
- decision: "skip-local-file-exists",
272
- reason: "Notion Docs Path empty, but a local file matching the Task ID was found on disk",
273
- });
274
- continue;
275
- }
276
- docsRelativePath = posix.join("docs/tasks", `${taskId}-${slug}.md`);
277
- absPath = join(paths.docsTasks, `${taskId}-${slug}.md`);
278
- notionNeedsDocsPath = true;
279
- decisionDetail = `local resolved path: ${docsRelativePath}`;
280
- }
281
- if (await pathExists(absPath)) {
282
- skipped.push({
283
- pageId: page.id,
284
- taskId,
285
- reason: "local-file-exists",
286
- docsRelativePath,
287
- detail: decisionDetail,
288
- });
289
- trace.push({
290
- taskId,
291
- pageId: page.id,
292
- notionDocsPath,
293
- localResolvedPath: docsRelativePath,
294
- decision: "skip-local-file-exists",
295
- reason: notionDocsPath.length > 0
296
- ? "Notion Docs Path matched Task ID and file already exists on disk"
297
- : "local search found a file matching the Task ID",
298
- });
299
- continue;
300
- }
301
- entries.push({
302
- pageId: page.id,
303
- taskId,
304
- title: row.name.length > 0 ? row.name : taskId,
305
- status: row.status,
306
- mvpPhase: row.mvpPhase,
307
- summary: row.summary,
308
- docsRelativePath,
309
- absPath,
310
- notionNeedsDocsPath,
311
- detail: decisionDetail,
312
- });
313
- trace.push({
314
- taskId,
315
- pageId: page.id,
316
- notionDocsPath,
317
- localResolvedPath: docsRelativePath,
318
- decision: "new-file",
319
- reason: notionDocsPath.length > 0
320
- ? "Notion Docs Path matched Task ID — local file does not exist yet"
321
- : "Notion Docs Path empty — planning fresh local file under docs/tasks",
322
- });
323
- }
324
- return { entries, skipped, considered: res.results.length, trace };
325
- }
326
- export async function executePullEntry(client, entry) {
327
- const md = renderPulledTaskMarkdown({
328
- taskId: entry.taskId,
329
- title: entry.title,
330
- status: entry.status,
331
- mvpPhase: entry.mvpPhase,
332
- summary: entry.summary,
333
- pageId: entry.pageId,
334
- docsRelativePath: entry.docsRelativePath,
335
- });
336
- await writeText(entry.absPath, md);
337
- let notionUpdated = false;
338
- if (entry.notionNeedsDocsPath) {
339
- await client.updatePage({
340
- pageId: entry.pageId,
341
- properties: {
342
- "Docs Path": richTextProperty(entry.docsRelativePath),
343
- },
344
- });
345
- notionUpdated = true;
346
- }
347
- return {
348
- taskId: entry.taskId,
349
- pageId: entry.pageId,
350
- absPath: entry.absPath,
351
- docsRelativePath: entry.docsRelativePath,
352
- notionUpdated,
353
- };
354
- }