ndomo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bun-version +1 -0
- package/.dockerignore +79 -0
- package/.editorconfig +18 -0
- package/.env.example +19 -0
- package/.github/CODEOWNERS +8 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
- package/.github/ISSUE_TEMPLATE/config.yml +2 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +34 -0
- package/.github/dependabot.yml +36 -0
- package/.github/pull_request_template.md +24 -0
- package/.github/release.yml +30 -0
- package/.github/workflows/gitleaks.yml +28 -0
- package/.github/workflows/release-please.yml +27 -0
- package/.github/workflows/smoke.yml +29 -0
- package/.husky/commit-msg +1 -0
- package/CHANGELOG.md +114 -0
- package/Dockerfile +32 -0
- package/README.es.md +174 -0
- package/README.md +187 -0
- package/agents/chronicler.md +98 -0
- package/agents/ci-smith.md +136 -0
- package/agents/craftsman.md +341 -0
- package/agents/deploy-smith.md +138 -0
- package/agents/foreman.md +377 -0
- package/agents/go-smith.md +164 -0
- package/agents/guild.md +188 -0
- package/agents/inspector.md +83 -0
- package/agents/js-smith.md +127 -0
- package/agents/ops-scout.md +173 -0
- package/agents/painter.md +200 -0
- package/agents/python-smith.md +120 -0
- package/agents/ranger.md +307 -0
- package/agents/release-smith.md +165 -0
- package/agents/rust-smith.md +159 -0
- package/agents/sage.md +178 -0
- package/agents/scout.md +144 -0
- package/agents/scribe.md +156 -0
- package/agents/smith.md +201 -0
- package/agents/vue-smith.md +155 -0
- package/agents/warden.md +216 -0
- package/agents/zig-smith.md +156 -0
- package/bin/ndomo-analyses.ts +4 -0
- package/bin/ndomo-status.ts +4 -0
- package/biome.json +57 -0
- package/bun.lock +514 -0
- package/commitlint.config.js +3 -0
- package/config/ndomo.config.json +258 -0
- package/config/ndomo.schema.json +166 -0
- package/docs/agents.md +375 -0
- package/docs/bugs/plan-create-orphan-fk.md +131 -0
- package/docs/bugs/task_create_batch-order-index-collision.md +158 -0
- package/docs/configuration.md +276 -0
- package/docs/database.md +364 -0
- package/docs/features/feature-flexible-builder-v1.md +724 -0
- package/docs/features/feature-flexible-builder-v2.md +882 -0
- package/docs/features/feature-flexible-builder.md +974 -0
- package/docs/http-server.md +244 -0
- package/docs/installation.md +259 -0
- package/docs/integrations.md +129 -0
- package/docs/operations/anti-pattern-sub-agent-verify-2026-06-21.md +32 -0
- package/docs/operations/audit-v1.md +417 -0
- package/docs/operations/audit-v2.md +197 -0
- package/docs/operations/audit-v3.md +306 -0
- package/docs/operations/db-optimize-foundations.md +123 -0
- package/docs/operations/verify-gate-architecture.md +82 -0
- package/docs/workflows.md +448 -0
- package/opencode.json +5 -0
- package/package.json +65 -0
- package/release-please-config.json +11 -0
- package/scripts/dev-bust-cache.sh +164 -0
- package/scripts/install.sh +688 -0
- package/scripts/smoke-e2e.ts +704 -0
- package/scripts/smoke-hot.ts +417 -0
- package/scripts/smoke-http.sh +228 -0
- package/scripts/smoke-v4.ts +256 -0
- package/scripts/smoke-v5.ts +397 -0
- package/scripts/smoke.sh +9 -0
- package/scripts/uninstall.sh +224 -0
- package/skills/api-security-best-practices/SKILL.md +915 -0
- package/skills/bash-scripting/SKILL.md +201 -0
- package/skills/bun/SKILL.md +313 -0
- package/skills/cavecrew/SKILL.md +82 -0
- package/skills/caveman/SKILL.md +74 -0
- package/skills/caveman-review/README.md +33 -0
- package/skills/caveman-review/SKILL.md +55 -0
- package/skills/find-skills/SKILL.md +142 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +55 -0
- package/skills/golang-patterns/SKILL.md +674 -0
- package/skills/golang-security/SKILL.md +185 -0
- package/skills/golang-security/evals/evals.json +595 -0
- package/skills/golang-security/references/architecture.md +268 -0
- package/skills/golang-security/references/checklist.md +80 -0
- package/skills/golang-security/references/cookies.md +200 -0
- package/skills/golang-security/references/cryptography.md +424 -0
- package/skills/golang-security/references/filesystem.md +285 -0
- package/skills/golang-security/references/injection.md +315 -0
- package/skills/golang-security/references/logging.md +163 -0
- package/skills/golang-security/references/memory-safety.md +241 -0
- package/skills/golang-security/references/network.md +253 -0
- package/skills/golang-security/references/secrets.md +189 -0
- package/skills/golang-security/references/third-party.md +159 -0
- package/skills/golang-security/references/threat-modeling.md +189 -0
- package/skills/golang-testing/SKILL.md +720 -0
- package/skills/grill-me/SKILL.md +7 -0
- package/skills/javascript-testing-patterns/SKILL.md +537 -0
- package/skills/javascript-testing-patterns/references/advanced-testing-patterns.md +513 -0
- package/skills/modern-javascript-patterns/SKILL.md +43 -0
- package/skills/modern-javascript-patterns/references/advanced-patterns.md +487 -0
- package/skills/modern-javascript-patterns/references/details.md +457 -0
- package/skills/python-anti-patterns/SKILL.md +349 -0
- package/skills/python-design-patterns/SKILL.md +85 -0
- package/skills/python-design-patterns/references/details.md +353 -0
- package/skills/python-error-handling/SKILL.md +193 -0
- package/skills/python-error-handling/references/details.md +171 -0
- package/skills/python-testing-patterns/SKILL.md +278 -0
- package/skills/python-testing-patterns/references/advanced-patterns.md +411 -0
- package/skills/python-testing-patterns/references/details.md +349 -0
- package/skills/rust-patterns/SKILL.md +500 -0
- package/skills/rust-testing/SKILL.md +501 -0
- package/skills/security-review/SKILL.md +504 -0
- package/skills/security-review/cloud-infrastructure-security.md +361 -0
- package/skills/vue-best-practices/SKILL.md +154 -0
- package/skills/vue-best-practices/references/animation-class-based-technique.md +254 -0
- package/skills/vue-best-practices/references/animation-state-driven-technique.md +291 -0
- package/skills/vue-best-practices/references/component-async.md +97 -0
- package/skills/vue-best-practices/references/component-data-flow.md +307 -0
- package/skills/vue-best-practices/references/component-fallthrough-attrs.md +174 -0
- package/skills/vue-best-practices/references/component-keep-alive.md +137 -0
- package/skills/vue-best-practices/references/component-slots.md +216 -0
- package/skills/vue-best-practices/references/component-suspense.md +228 -0
- package/skills/vue-best-practices/references/component-teleport.md +108 -0
- package/skills/vue-best-practices/references/component-transition-group.md +128 -0
- package/skills/vue-best-practices/references/component-transition.md +125 -0
- package/skills/vue-best-practices/references/composables.md +290 -0
- package/skills/vue-best-practices/references/directives.md +162 -0
- package/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md +159 -0
- package/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md +182 -0
- package/skills/vue-best-practices/references/perf-virtualize-large-lists.md +187 -0
- package/skills/vue-best-practices/references/plugins.md +166 -0
- package/skills/vue-best-practices/references/reactivity.md +344 -0
- package/skills/vue-best-practices/references/render-functions.md +201 -0
- package/skills/vue-best-practices/references/sfc.md +310 -0
- package/skills/vue-best-practices/references/state-management.md +135 -0
- package/skills/vue-best-practices/references/updated-hook-performance.md +187 -0
- package/skills/vue-pinia-best-practices/SKILL.md +21 -0
- package/skills/vue-pinia-best-practices/reference/pinia-no-active-pinia-error.md +248 -0
- package/skills/vue-pinia-best-practices/reference/pinia-setup-store-return-all-state.md +227 -0
- package/skills/vue-pinia-best-practices/reference/pinia-store-destructuring-breaks-reactivity.md +193 -0
- package/skills/vue-pinia-best-practices/reference/state-url-for-ephemeral-filters.md +238 -0
- package/skills/vue-pinia-best-practices/reference/state-use-pinia-for-large-apps.md +262 -0
- package/skills/vue-pinia-best-practices/reference/store-method-binding-parentheses.md +191 -0
- package/skills/zig-0.16/SKILL.md +840 -0
- package/skills/zig-0.16/scripts/check-zig-version.sh +21 -0
- package/src/cli/analyses.ts +280 -0
- package/src/cli/index.ts +108 -0
- package/src/cli/serve.ts +192 -0
- package/src/cli/smoke.ts +131 -0
- package/src/cli/status.test.ts +204 -0
- package/src/cli/status.ts +263 -0
- package/src/cli/vacuum.test.ts +82 -0
- package/src/cli/vacuum.ts +96 -0
- package/src/config/schema.test.ts +88 -0
- package/src/config/schema.ts +64 -0
- package/src/db/analyses-migration.test.ts +210 -0
- package/src/db/analyses.test.ts +466 -0
- package/src/db/analyses.ts +375 -0
- package/src/db/auto-checkpoint.ts +131 -0
- package/src/db/client.test.ts +129 -0
- package/src/db/client.ts +55 -0
- package/src/db/fts-escape.ts +20 -0
- package/src/db/incidents.test.ts +201 -0
- package/src/db/incidents.ts +93 -0
- package/src/db/index.ts +86 -0
- package/src/db/migrations-v13.test.ts +141 -0
- package/src/db/migrations-v8.test.ts +301 -0
- package/src/db/migrations.ts +147 -0
- package/src/db/plan-archive.test.ts +180 -0
- package/src/db/plan-archive.ts +274 -0
- package/src/db/plan-create.test.ts +276 -0
- package/src/db/plan-create.ts +78 -0
- package/src/db/plan-files.test.ts +289 -0
- package/src/db/plan-update-status.ts +287 -0
- package/src/db/plans.test.ts +490 -0
- package/src/db/plans.ts +534 -0
- package/src/db/resolve-project-dir.test.ts +143 -0
- package/src/db/resolve-project-dir.ts +75 -0
- package/src/db/rollbacks.test.ts +150 -0
- package/src/db/rollbacks.ts +67 -0
- package/src/db/schema.ts +907 -0
- package/src/db/sessions.test.ts +80 -0
- package/src/db/sessions.ts +135 -0
- package/src/db/shutdown.test.ts +147 -0
- package/src/db/shutdown.ts +45 -0
- package/src/db/tasks.test.ts +921 -0
- package/src/db/tasks.ts +747 -0
- package/src/db/types.ts +619 -0
- package/src/http/__tests__/auth.test.ts +196 -0
- package/src/http/__tests__/routes.test.ts +465 -0
- package/src/http/__tests__/sse.test.ts +317 -0
- package/src/http/auth.ts +72 -0
- package/src/http/middleware/cors.ts +53 -0
- package/src/http/middleware/security-headers.ts +21 -0
- package/src/http/routes/events.ts +112 -0
- package/src/http/routes/health.ts +51 -0
- package/src/http/routes/plans.ts +66 -0
- package/src/http/routes/sessions.ts +50 -0
- package/src/http/routes/tasks.ts +60 -0
- package/src/http/server.ts +95 -0
- package/src/http/sse.ts +116 -0
- package/src/index.ts +37 -0
- package/src/lib.ts +65 -0
- package/src/mem/scoped.ts +65 -0
- package/src/orchestrator/background.test.ts +268 -0
- package/src/orchestrator/background.ts +293 -0
- package/src/orchestrator/memory-hook.ts +182 -0
- package/src/orchestrator/reconciler.ts +123 -0
- package/src/orchestrator/scheduler.test.ts +300 -0
- package/src/orchestrator/scheduler.ts +243 -0
- package/src/plugin.test.ts +2574 -0
- package/src/plugin.ts +1690 -0
- package/src/sdk/client.ts +66 -0
- package/src/worktrees/manager.ts +236 -0
- package/src/worktrees/state.ts +87 -0
- package/tests/integration/ranger-flow.test.ts +257 -0
- package/tools/analysis_archive.ts +28 -0
- package/tools/analysis_create.ts +55 -0
- package/tools/analysis_get.ts +33 -0
- package/tools/analysis_link_plan.ts +44 -0
- package/tools/analysis_list.ts +48 -0
- package/tools/analysis_search.ts +36 -0
- package/tools/analysis_update.ts +44 -0
- package/tools/plan_approve.ts +31 -0
- package/tools/plan_create.ts +58 -0
- package/tools/plan_get.ts +40 -0
- package/tools/plan_list.ts +37 -0
- package/tools/plan_search.ts +34 -0
- package/tools/plan_update_status.ts +71 -0
- package/tools/session_checkpoint.ts +31 -0
- package/tools/session_end.ts +26 -0
- package/tools/session_start.ts +43 -0
- package/tools/task_create_batch.ts +70 -0
- package/tools/task_list.ts +35 -0
- package/tools/task_next_for_agent.ts +30 -0
- package/tools/task_search.ts +34 -0
- package/tools/task_update_status.ts +37 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ndomo DB — Plan archive (serialize to markdown + soft delete).
|
|
3
|
+
*
|
|
4
|
+
* When a plan reaches a terminal status (completed/failed/abandoned),
|
|
5
|
+
* this module serializes it to a markdown file in <projectDir>/.ndomo/archives/plans/
|
|
6
|
+
* (resolved by the caller via resolveArchiveDir) and sets archived_at on the
|
|
7
|
+
* plan, its tasks, and its sessions.
|
|
8
|
+
*
|
|
9
|
+
* The archive is transactional: if the DB updates fail, the markdown
|
|
10
|
+
* file is rolled back (deleted) to maintain consistency.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Database } from "bun:sqlite";
|
|
14
|
+
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { getPlan } from "./plans.ts";
|
|
17
|
+
import { listSessions } from "./sessions.ts";
|
|
18
|
+
import { listTasksByPlan } from "./tasks.ts";
|
|
19
|
+
import type { Plan, PlanTask, Session } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
export interface ArchiveResult {
|
|
22
|
+
planId: string;
|
|
23
|
+
slug: string;
|
|
24
|
+
filePath: string;
|
|
25
|
+
byteSize: number;
|
|
26
|
+
archivedAt: number;
|
|
27
|
+
tasksCount: number;
|
|
28
|
+
sessionsCount: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Sanitize a slug to safe ASCII kebab-case for filenames. */
|
|
32
|
+
function sanitizeSlug(slug: string): string {
|
|
33
|
+
return slug
|
|
34
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
35
|
+
.replace(/--+/g, "-")
|
|
36
|
+
.replace(/^-|-$/g, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Serialize a plan, its tasks, and sessions into a markdown string.
|
|
41
|
+
* Follows the exact format specified in the v5 migration spec.
|
|
42
|
+
*/
|
|
43
|
+
export function serializePlanToMarkdown(
|
|
44
|
+
plan: Plan,
|
|
45
|
+
tasks: PlanTask[],
|
|
46
|
+
sessions: Session[],
|
|
47
|
+
archivedAt: number,
|
|
48
|
+
): string {
|
|
49
|
+
const archivedDate = new Date(archivedAt).toISOString();
|
|
50
|
+
|
|
51
|
+
const doneTasks = tasks.filter((t) => t.status === "done").length;
|
|
52
|
+
const failedTasks = tasks.filter((t) => t.status === "failed").length;
|
|
53
|
+
|
|
54
|
+
const taskLines = tasks.map((t) => {
|
|
55
|
+
const checkbox = t.status === "done" ? "[x]" : "[ ]";
|
|
56
|
+
const parts = [
|
|
57
|
+
`- ${checkbox} **${t.description}** — agent: ${t.agent}, complexity: ${t.complexity}, status: ${t.status}`,
|
|
58
|
+
];
|
|
59
|
+
if (t.result) {
|
|
60
|
+
parts.push(` - result: ${t.result}`);
|
|
61
|
+
}
|
|
62
|
+
if (t.error) {
|
|
63
|
+
parts.push(` - error: ${t.error}`);
|
|
64
|
+
}
|
|
65
|
+
return parts.join("\n");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const sessionBlocks = sessions.map((s) => {
|
|
69
|
+
const idShort = s.id.slice(0, 8);
|
|
70
|
+
const lines = [
|
|
71
|
+
`### Session ${idShort}`,
|
|
72
|
+
"",
|
|
73
|
+
`- Started: ${s.startedAt ? new Date(s.startedAt).toISOString() : "N/A"}`,
|
|
74
|
+
`- Ended: ${s.endedAt ? new Date(s.endedAt).toISOString() : "ongoing"}`,
|
|
75
|
+
`- Goal: ${s.goal}`,
|
|
76
|
+
];
|
|
77
|
+
if (s.keyDecisions) {
|
|
78
|
+
lines.push(`- Key decisions: ${s.keyDecisions}`);
|
|
79
|
+
}
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const sections = [
|
|
84
|
+
`# Plan: ${plan.title}`,
|
|
85
|
+
"",
|
|
86
|
+
`**Slug:** ${plan.slug} `,
|
|
87
|
+
`**Status:** ${plan.status} `,
|
|
88
|
+
`**Archived:** ${archivedDate} `,
|
|
89
|
+
`**Priority:** ${plan.priority} `,
|
|
90
|
+
`**Complexity:** ${plan.complexity} `,
|
|
91
|
+
`**Plan ID:** ${plan.id}`,
|
|
92
|
+
"",
|
|
93
|
+
"## Overview",
|
|
94
|
+
"",
|
|
95
|
+
plan.overview,
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// v8: agent execution tracking
|
|
99
|
+
if (plan.createdByAgent || plan.executedByAgent) {
|
|
100
|
+
sections.push("", "## Agent Trail", "");
|
|
101
|
+
if (plan.createdByAgent) {
|
|
102
|
+
sections.push(`- **Created by agent:** ${plan.createdByAgent}`);
|
|
103
|
+
}
|
|
104
|
+
if (plan.executedByAgent) {
|
|
105
|
+
sections.push(`- **Executed by agent:** ${plan.executedByAgent}`);
|
|
106
|
+
}
|
|
107
|
+
if (plan.executedBySession) {
|
|
108
|
+
sections.push(`- **Executed by session:** ${plan.executedBySession}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// v6: write-once audit trail — original plan data snapshot
|
|
113
|
+
// HIGH 6: sanitize triple backticks inside JSON to prevent markdown breakage
|
|
114
|
+
if (plan.originalPlanData) {
|
|
115
|
+
const safeJson = plan.originalPlanData.replace(/```/g, "\\`\\`\\`");
|
|
116
|
+
sections.push("", "## Original Plan Data (write-once)", "", "```json", safeJson, "```");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (plan.approach) {
|
|
120
|
+
sections.push("", "## Approach", "", plan.approach);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
sections.push(
|
|
124
|
+
"",
|
|
125
|
+
`## Tasks (${tasks.length} total, ${doneTasks} done, ${failedTasks} failed)`,
|
|
126
|
+
"",
|
|
127
|
+
...taskLines,
|
|
128
|
+
"",
|
|
129
|
+
`## Sessions (${sessions.length})`,
|
|
130
|
+
"",
|
|
131
|
+
...sessionBlocks,
|
|
132
|
+
"",
|
|
133
|
+
"## Metadata",
|
|
134
|
+
"",
|
|
135
|
+
"```json",
|
|
136
|
+
JSON.stringify(plan.metadata, null, 2),
|
|
137
|
+
"```",
|
|
138
|
+
"",
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return sections.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build the archive filename from a plan slug and timestamp.
|
|
146
|
+
* Format: `<slug>-YYYY-MM-DD.md`
|
|
147
|
+
*/
|
|
148
|
+
export function buildArchiveFilename(plan: Plan, archivedAt: number): string {
|
|
149
|
+
const safeSlug = sanitizeSlug(plan.slug);
|
|
150
|
+
const d = new Date(archivedAt);
|
|
151
|
+
const year = d.getFullYear();
|
|
152
|
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
153
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
154
|
+
return `${safeSlug}-${year}-${month}-${day}.md`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resolve the per-project plan archive directory.
|
|
159
|
+
* Path: <projectDir>/.ndomo/archives/plans/
|
|
160
|
+
* Creates the directory if it does not exist.
|
|
161
|
+
*
|
|
162
|
+
* @param projectDir - Absolute path to the project root.
|
|
163
|
+
* @returns Absolute path to the archive directory.
|
|
164
|
+
*/
|
|
165
|
+
export function resolveArchiveDir(projectDir: string): string {
|
|
166
|
+
const dir = join(projectDir, ".ndomo", "archives", "plans");
|
|
167
|
+
mkdirSync(dir, { recursive: true });
|
|
168
|
+
return dir;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Archive a plan: serialize to markdown + soft delete in DB.
|
|
173
|
+
*
|
|
174
|
+
* Steps:
|
|
175
|
+
* 1. Load plan (throw if not found or already archived)
|
|
176
|
+
* 2. Load tasks and sessions (including archived ones)
|
|
177
|
+
* 3. Resolve mem directory and ensure it exists
|
|
178
|
+
* 4. Serialize to markdown
|
|
179
|
+
* 5. Build filename (with HHMMSS suffix if file exists)
|
|
180
|
+
* 6. Write markdown file
|
|
181
|
+
* 7. Transactional DB update (archived_at on plan, tasks, sessions)
|
|
182
|
+
* 8. Rollback: unlink file if DB update fails
|
|
183
|
+
*
|
|
184
|
+
* @param db - Database instance
|
|
185
|
+
* @param planId - Plan ID to archive
|
|
186
|
+
* @param opts - Archive options.
|
|
187
|
+
* @param opts.memDir - Absolute path to the archive directory (required).
|
|
188
|
+
* Typically resolved via {@link resolveArchiveDir}.
|
|
189
|
+
*/
|
|
190
|
+
export function archivePlan(db: Database, planId: string, opts: { memDir: string }): ArchiveResult {
|
|
191
|
+
// 1. Load plan
|
|
192
|
+
const plan = getPlan(db, planId);
|
|
193
|
+
if (!plan) {
|
|
194
|
+
throw new Error(`ndomo: plan not found: ${planId}`);
|
|
195
|
+
}
|
|
196
|
+
if (plan.archivedAt !== null) {
|
|
197
|
+
throw new Error("ndomo: plan already archived");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 2. Load tasks and sessions (include archived for completeness)
|
|
201
|
+
const tasks = listTasksByPlan(db, planId, { includeArchived: true });
|
|
202
|
+
const sessions = listSessions(db, { planId, includeArchived: true, limit: 1000 });
|
|
203
|
+
|
|
204
|
+
// 3. Resolve mem directory
|
|
205
|
+
const memDir = opts.memDir;
|
|
206
|
+
mkdirSync(memDir, { recursive: true });
|
|
207
|
+
|
|
208
|
+
// 4. Serialize
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
const md = serializePlanToMarkdown(plan, tasks, sessions, now);
|
|
211
|
+
|
|
212
|
+
// 5. Build filename (with HHMMSS suffix if collision)
|
|
213
|
+
let filename = buildArchiveFilename(plan, now);
|
|
214
|
+
let absPath = join(memDir, filename);
|
|
215
|
+
if (existsSync(absPath)) {
|
|
216
|
+
const d = new Date(now);
|
|
217
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
218
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
219
|
+
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
220
|
+
const base = filename.replace(/\.md$/, "");
|
|
221
|
+
filename = `${base}-${hh}${mm}${ss}.md`;
|
|
222
|
+
absPath = join(memDir, filename);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 6. Write markdown file
|
|
226
|
+
writeFileSync(absPath, md, "utf-8");
|
|
227
|
+
|
|
228
|
+
// 7. Transactional DB update
|
|
229
|
+
try {
|
|
230
|
+
const txn = db.transaction(() => {
|
|
231
|
+
db.query("UPDATE plans SET archived_at = ? WHERE id = ? AND archived_at IS NULL").run(
|
|
232
|
+
now,
|
|
233
|
+
planId,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Verify the plan was actually archived (changes count includes FTS
|
|
237
|
+
// trigger side-effects in bun:sqlite, so we verify with a SELECT)
|
|
238
|
+
const check = db.query("SELECT archived_at FROM plans WHERE id = ?").get(planId) as {
|
|
239
|
+
archived_at: number | null;
|
|
240
|
+
} | null;
|
|
241
|
+
if (!check || check.archived_at === null) {
|
|
242
|
+
throw new Error("ndomo: archive failed — plan not archived after UPDATE");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
db.query(
|
|
246
|
+
"UPDATE plan_tasks SET archived_at = ? WHERE plan_id = ? AND archived_at IS NULL",
|
|
247
|
+
).run(now, planId);
|
|
248
|
+
|
|
249
|
+
db.query("UPDATE sessions SET archived_at = ? WHERE plan_id = ? AND archived_at IS NULL").run(
|
|
250
|
+
now,
|
|
251
|
+
planId,
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
txn();
|
|
255
|
+
} catch (err) {
|
|
256
|
+
// 8. Rollback: remove the markdown file
|
|
257
|
+
try {
|
|
258
|
+
unlinkSync(absPath);
|
|
259
|
+
} catch {
|
|
260
|
+
// Best-effort cleanup — file removal failure is non-fatal
|
|
261
|
+
}
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
planId,
|
|
267
|
+
slug: plan.slug,
|
|
268
|
+
filePath: absPath,
|
|
269
|
+
byteSize: Buffer.byteLength(md, "utf-8"),
|
|
270
|
+
archivedAt: now,
|
|
271
|
+
tasksCount: tasks.length,
|
|
272
|
+
sessionsCount: sessions.length,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for planCreateExecutor — the extracted plan_create tool logic.
|
|
3
|
+
*
|
|
4
|
+
* Validates that the executor correctly:
|
|
5
|
+
* - Persists a plan with all mapped fields
|
|
6
|
+
* - Auto-creates a session row when ctx.sessionID is provided
|
|
7
|
+
* - Skips session creation when ctx.sessionID is undefined
|
|
8
|
+
* - Passes through metadata, priority, complexity, and other optional fields
|
|
9
|
+
*
|
|
10
|
+
* Uses in-memory SQLite via bun:sqlite. Each test gets a fresh DB
|
|
11
|
+
* with the full schema applied by runMigrations.
|
|
12
|
+
*
|
|
13
|
+
* NOTE: The DB enforces priority 1-4 via CHECK trigger, so all tests
|
|
14
|
+
* pass explicit priority values to avoid the default-0 constraint error.
|
|
15
|
+
* The executor's `priority: args.priority ?? 0` matches the original code.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Database } from "bun:sqlite";
|
|
19
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
20
|
+
import { runMigrations } from "./migrations.ts";
|
|
21
|
+
import { planCreateExecutor } from "./plan-create.ts";
|
|
22
|
+
import { getPlan } from "./plans.ts";
|
|
23
|
+
import { getSession } from "./sessions.ts";
|
|
24
|
+
import { createTasksBatch, getTask } from "./tasks.ts";
|
|
25
|
+
|
|
26
|
+
let db: Database;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
db = new Database(":memory:");
|
|
30
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
31
|
+
runMigrations(db);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("planCreateExecutor", () => {
|
|
35
|
+
test("creates plan in DB with correct fields", () => {
|
|
36
|
+
const plan = planCreateExecutor(
|
|
37
|
+
db,
|
|
38
|
+
{ slug: "test-2026-06-19", title: "My Test Plan", overview: "do stuff", priority: 2 },
|
|
39
|
+
{ agent: "foreman", sessionID: "ses_test_int", messageID: "msg_test_int" },
|
|
40
|
+
);
|
|
41
|
+
expect(plan.slug).toBe("test-2026-06-19");
|
|
42
|
+
expect(plan.title).toBe("My Test Plan");
|
|
43
|
+
expect(plan.overview).toBe("do stuff");
|
|
44
|
+
expect(plan.status).toBe("draft");
|
|
45
|
+
expect(plan.priority).toBe(2);
|
|
46
|
+
expect(plan.complexity).toBe(3);
|
|
47
|
+
expect(plan.createdBy).toBe("foreman");
|
|
48
|
+
expect(plan.updatedBy).toBe("foreman");
|
|
49
|
+
expect(plan.sourceSessionId).toBe("ses_test_int");
|
|
50
|
+
expect(plan.sourceMessageId).toBe("msg_test_int");
|
|
51
|
+
// Verify it actually persisted
|
|
52
|
+
const fetched = getPlan(db, plan.id);
|
|
53
|
+
expect(fetched).not.toBeNull();
|
|
54
|
+
expect(fetched?.slug).toBe("test-2026-06-19");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("auto-creates session row with goal derived from title", () => {
|
|
58
|
+
planCreateExecutor(
|
|
59
|
+
db,
|
|
60
|
+
{ slug: "s2", title: "Title Two", overview: "o", priority: 2 },
|
|
61
|
+
{ agent: "foreman", sessionID: "ses_auto_test" },
|
|
62
|
+
);
|
|
63
|
+
const sess = getSession(db, "ses_auto_test");
|
|
64
|
+
expect(sess).not.toBeNull();
|
|
65
|
+
expect(sess?.goal).toBe("Plan: Title Two");
|
|
66
|
+
expect(sess?.createdBy).toBe("auto");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("does NOT create session when ctx.sessionID is undefined", () => {
|
|
70
|
+
planCreateExecutor(
|
|
71
|
+
db,
|
|
72
|
+
{ slug: "s3", title: "No Sess", overview: "o", priority: 2 },
|
|
73
|
+
{ agent: "foreman" }, // no sessionID
|
|
74
|
+
);
|
|
75
|
+
// Verify NO row in sessions table (the executor guarded the call)
|
|
76
|
+
const rows = db.query("SELECT * FROM sessions").all();
|
|
77
|
+
expect(rows).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("defaults agent to 'unknown' when ctx.agent is undefined", () => {
|
|
81
|
+
const plan = planCreateExecutor(
|
|
82
|
+
db,
|
|
83
|
+
{ slug: "s4", title: "No Agent", overview: "o", priority: 2 },
|
|
84
|
+
{}, // no agent
|
|
85
|
+
);
|
|
86
|
+
expect(plan.createdBy).toBe("unknown");
|
|
87
|
+
expect(plan.updatedBy).toBe("unknown");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("respects custom priority and complexity", () => {
|
|
91
|
+
const plan = planCreateExecutor(
|
|
92
|
+
db,
|
|
93
|
+
{ slug: "s5", title: "Custom", overview: "o", priority: 4, complexity: 1 },
|
|
94
|
+
{ agent: "test" },
|
|
95
|
+
);
|
|
96
|
+
expect(plan.priority).toBe(4);
|
|
97
|
+
expect(plan.complexity).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("passes through approach field", () => {
|
|
101
|
+
const plan = planCreateExecutor(
|
|
102
|
+
db,
|
|
103
|
+
{ slug: "s6", title: "With Approach", overview: "o", priority: 2, approach: "TDD first" },
|
|
104
|
+
{ agent: "test" },
|
|
105
|
+
);
|
|
106
|
+
expect(plan.approach).toBe("TDD first");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("passes through metadata with category", () => {
|
|
110
|
+
const plan = planCreateExecutor(
|
|
111
|
+
db,
|
|
112
|
+
{
|
|
113
|
+
slug: "s7",
|
|
114
|
+
title: "With Meta",
|
|
115
|
+
overview: "o",
|
|
116
|
+
priority: 2,
|
|
117
|
+
metadata: { category: "bugfix" },
|
|
118
|
+
},
|
|
119
|
+
{ agent: "test" },
|
|
120
|
+
);
|
|
121
|
+
expect(plan.category).toBe("bugfix");
|
|
122
|
+
expect(plan.metadata.category).toBe("bugfix");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("sessionId arg is stored on plan (distinct from ctx.sessionID)", () => {
|
|
126
|
+
const plan = planCreateExecutor(
|
|
127
|
+
db,
|
|
128
|
+
{
|
|
129
|
+
slug: "s8",
|
|
130
|
+
title: "Linked",
|
|
131
|
+
overview: "o",
|
|
132
|
+
priority: 2,
|
|
133
|
+
sessionId: "ses_linked",
|
|
134
|
+
},
|
|
135
|
+
{ agent: "test", sessionID: "ses_source" },
|
|
136
|
+
);
|
|
137
|
+
// args.sessionId → plan.sessionId (the FK link)
|
|
138
|
+
expect(plan.sessionId).toBe("ses_linked");
|
|
139
|
+
// ctx.sessionID → plan.sourceSessionId (the origin)
|
|
140
|
+
expect(plan.sourceSessionId).toBe("ses_source");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("sets created_by_agent from ctx.agent", () => {
|
|
144
|
+
const plan = planCreateExecutor(
|
|
145
|
+
db,
|
|
146
|
+
{ slug: "s9", title: "Agent Test", overview: "o", priority: 2 },
|
|
147
|
+
{ agent: "foreman" },
|
|
148
|
+
);
|
|
149
|
+
expect(plan.createdByAgent).toBe("foreman");
|
|
150
|
+
|
|
151
|
+
const fetched = getPlan(db, plan.id);
|
|
152
|
+
expect(fetched?.createdByAgent).toBe("foreman");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("created_by_agent defaults to null when ctx.agent is undefined", () => {
|
|
156
|
+
const plan = planCreateExecutor(
|
|
157
|
+
db,
|
|
158
|
+
{ slug: "s10", title: "No Agent", overview: "o", priority: 2 },
|
|
159
|
+
{},
|
|
160
|
+
);
|
|
161
|
+
expect(plan.createdByAgent).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("original_plan_data is set on creation", () => {
|
|
165
|
+
const plan = planCreateExecutor(
|
|
166
|
+
db,
|
|
167
|
+
{ slug: "s11", title: "Snapshot", overview: "do stuff", priority: 2 },
|
|
168
|
+
{ agent: "foreman" },
|
|
169
|
+
);
|
|
170
|
+
expect(plan.originalPlanData).not.toBeNull();
|
|
171
|
+
|
|
172
|
+
const fetched = getPlan(db, plan.id);
|
|
173
|
+
const opd = fetched?.originalPlanData;
|
|
174
|
+
expect(opd).not.toBeNull();
|
|
175
|
+
|
|
176
|
+
const data = JSON.parse(opd as string);
|
|
177
|
+
expect(data.slug).toBe("s11");
|
|
178
|
+
expect(data.title).toBe("Snapshot");
|
|
179
|
+
expect(data.createdBy).toBe("foreman");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("createTasksBatch", () => {
|
|
184
|
+
test("updated_by defaults to createdBy when undefined", () => {
|
|
185
|
+
const plan = planCreateExecutor(
|
|
186
|
+
db,
|
|
187
|
+
{ slug: "task-test", title: "Task Test", overview: "test", priority: 2 },
|
|
188
|
+
{ agent: "foreman" },
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const tasks = createTasksBatch(db, plan.id, [
|
|
192
|
+
{
|
|
193
|
+
orderIndex: 0,
|
|
194
|
+
description: "test task",
|
|
195
|
+
agent: "js-smith",
|
|
196
|
+
files: [],
|
|
197
|
+
complexity: 1,
|
|
198
|
+
dependencies: [],
|
|
199
|
+
createdBy: "foreman",
|
|
200
|
+
updatedBy: undefined as unknown as string, // explicitly undefined
|
|
201
|
+
sourceSessionId: null,
|
|
202
|
+
sourceMessageId: null,
|
|
203
|
+
reviewedBy: null,
|
|
204
|
+
tokensUsed: null,
|
|
205
|
+
durationMs: null,
|
|
206
|
+
artifacts: [],
|
|
207
|
+
metadata: {},
|
|
208
|
+
},
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
expect(tasks).toHaveLength(1);
|
|
212
|
+
expect(tasks[0]).toBeDefined();
|
|
213
|
+
const task0id = tasks[0]?.id as string;
|
|
214
|
+
const fetched = getTask(db, task0id);
|
|
215
|
+
expect(fetched).not.toBeNull();
|
|
216
|
+
expect(fetched?.updatedBy).toBe("foreman"); // defaults to createdBy
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ─── Issue 3: plan_files insertion ───────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
describe("planCreateExecutor — plan_files insertion", () => {
|
|
223
|
+
test("with files → N rows in plan_files with role='input'", () => {
|
|
224
|
+
const plan = planCreateExecutor(
|
|
225
|
+
db,
|
|
226
|
+
{
|
|
227
|
+
slug: "files-test",
|
|
228
|
+
title: "Files Test",
|
|
229
|
+
overview: "o",
|
|
230
|
+
priority: 2,
|
|
231
|
+
files: ["src/a.ts", "src/b.ts"],
|
|
232
|
+
},
|
|
233
|
+
{ agent: "foreman" },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const rows = db
|
|
237
|
+
.query("SELECT * FROM plan_files WHERE plan_id = ? ORDER BY file_path")
|
|
238
|
+
.all(plan.id) as Array<{ plan_id: string; file_path: string; role: string }>;
|
|
239
|
+
|
|
240
|
+
expect(rows).toHaveLength(2);
|
|
241
|
+
expect(rows[0]?.file_path).toBe("src/a.ts");
|
|
242
|
+
expect(rows[0]?.role).toBe("input");
|
|
243
|
+
expect(rows[1]?.file_path).toBe("src/b.ts");
|
|
244
|
+
expect(rows[1]?.role).toBe("input");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("without files → 0 rows in plan_files", () => {
|
|
248
|
+
const plan = planCreateExecutor(
|
|
249
|
+
db,
|
|
250
|
+
{ slug: "no-files", title: "No Files", overview: "o", priority: 2 },
|
|
251
|
+
{ agent: "foreman" },
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const rows = db.query("SELECT * FROM plan_files WHERE plan_id = ?").all(plan.id);
|
|
255
|
+
|
|
256
|
+
expect(rows).toHaveLength(0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("duplicate files → no break (PK dedup)", () => {
|
|
260
|
+
const plan = planCreateExecutor(
|
|
261
|
+
db,
|
|
262
|
+
{
|
|
263
|
+
slug: "dup-files",
|
|
264
|
+
title: "Dup",
|
|
265
|
+
overview: "o",
|
|
266
|
+
priority: 2,
|
|
267
|
+
files: ["src/x.ts", "src/x.ts"],
|
|
268
|
+
},
|
|
269
|
+
{ agent: "foreman" },
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const rows = db.query("SELECT * FROM plan_files WHERE plan_id = ?").all(plan.id);
|
|
273
|
+
|
|
274
|
+
expect(rows).toHaveLength(1); // PK dedup
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ndomo DB — plan_create tool executor.
|
|
3
|
+
*
|
|
4
|
+
* Pure function extracted from the MCP tool wrapper in src/plugin.ts
|
|
5
|
+
* to enable unit testing without spinning up the full MCP harness.
|
|
6
|
+
*
|
|
7
|
+
* Behavior preserved verbatim from the original execute function.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Database } from "bun:sqlite";
|
|
11
|
+
import { createPlan } from "./plans.ts";
|
|
12
|
+
import { ensureSession } from "./sessions.ts";
|
|
13
|
+
import type { Plan, PlanMetadata } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
export interface PlanCreateArgs {
|
|
16
|
+
slug: string;
|
|
17
|
+
title: string;
|
|
18
|
+
overview: string;
|
|
19
|
+
approach?: string | undefined;
|
|
20
|
+
priority?: number | undefined;
|
|
21
|
+
complexity?: number | undefined;
|
|
22
|
+
sessionId?: string | undefined;
|
|
23
|
+
metadata?: PlanMetadata | undefined;
|
|
24
|
+
files?: string[] | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PlanCreateContext {
|
|
28
|
+
agent?: string | undefined;
|
|
29
|
+
sessionID?: string | undefined;
|
|
30
|
+
messageID?: string | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function planCreateExecutor(
|
|
34
|
+
db: Database,
|
|
35
|
+
args: PlanCreateArgs,
|
|
36
|
+
ctx: PlanCreateContext,
|
|
37
|
+
): Plan {
|
|
38
|
+
const typedMeta = (args.metadata ?? {}) as PlanMetadata;
|
|
39
|
+
// Fix: auto-create session row for FK integrity (hybrid (a) — eager)
|
|
40
|
+
if (ctx.sessionID) {
|
|
41
|
+
ensureSession(db, ctx.sessionID, `Plan: ${args.title}`);
|
|
42
|
+
}
|
|
43
|
+
const plan = createPlan(db, {
|
|
44
|
+
id: crypto.randomUUID(),
|
|
45
|
+
slug: args.slug,
|
|
46
|
+
title: args.title,
|
|
47
|
+
status: "draft" as const,
|
|
48
|
+
priority: args.priority ?? 0,
|
|
49
|
+
approvedAt: null,
|
|
50
|
+
completedAt: null,
|
|
51
|
+
sessionId: args.sessionId ?? null,
|
|
52
|
+
overview: args.overview,
|
|
53
|
+
approach: args.approach ?? null,
|
|
54
|
+
complexity: args.complexity ?? 3,
|
|
55
|
+
createdBy: ctx.agent ?? "unknown",
|
|
56
|
+
updatedBy: ctx.agent ?? "unknown",
|
|
57
|
+
sourceSessionId: ctx.sessionID ?? null,
|
|
58
|
+
sourceMessageId: ctx.messageID ?? null,
|
|
59
|
+
category: typedMeta.category ?? null,
|
|
60
|
+
metadata: typedMeta,
|
|
61
|
+
archivedAt: null,
|
|
62
|
+
originalPlanData: null,
|
|
63
|
+
createdByAgent: ctx.agent ?? null,
|
|
64
|
+
executedByAgent: null,
|
|
65
|
+
executedBySession: null,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Issue 3: insert files into plan_files with role='input'
|
|
69
|
+
if (args.files && args.files.length > 0) {
|
|
70
|
+
for (const filePath of args.files) {
|
|
71
|
+
db.query(
|
|
72
|
+
"INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, 'input')",
|
|
73
|
+
).run(plan.id, filePath);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return plan;
|
|
78
|
+
}
|