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
package/src/db/plans.ts
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ndomo DB — Plan CRUD + FTS5 search.
|
|
3
|
+
*
|
|
4
|
+
* All functions take a Database instance and return camelCase TS types.
|
|
5
|
+
* Mutations that touch multiple rows use db.transaction().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Database, SQLQueryBindings } from "bun:sqlite";
|
|
9
|
+
import { escapeFtsQuery } from "./fts-escape.ts";
|
|
10
|
+
import { ensureSession } from "./sessions.ts";
|
|
11
|
+
import type { Plan, PlanCategory, PlanStatus } from "./types.ts";
|
|
12
|
+
import { planFromRow, planWithFilesFromRow } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
// Terminal statuses that should set completed_at when entered
|
|
15
|
+
const TERMINAL_STATUSES = new Set<PlanStatus>(["completed", "failed", "abandoned"]);
|
|
16
|
+
|
|
17
|
+
export function createPlan(db: Database, plan: Omit<Plan, "createdAt" | "updatedAt">): Plan {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
// v6: build original_plan_data snapshot (write-once) — M5: added files + metadata
|
|
20
|
+
const originalPlanData = JSON.stringify({
|
|
21
|
+
id: plan.id,
|
|
22
|
+
slug: plan.slug,
|
|
23
|
+
title: plan.title,
|
|
24
|
+
overview: plan.overview,
|
|
25
|
+
approach: plan.approach,
|
|
26
|
+
priority: plan.priority,
|
|
27
|
+
complexity: plan.complexity,
|
|
28
|
+
category: plan.category,
|
|
29
|
+
createdBy: plan.createdBy,
|
|
30
|
+
sourceSessionId: plan.sourceSessionId,
|
|
31
|
+
sourceMessageId: plan.sourceMessageId,
|
|
32
|
+
files: plan.files ?? [],
|
|
33
|
+
metadata: plan.metadata ?? {},
|
|
34
|
+
createdAt: now,
|
|
35
|
+
});
|
|
36
|
+
db.query(
|
|
37
|
+
`INSERT INTO plans (id, slug, title, status, priority, created_at, updated_at, approved_at, completed_at, session_id, overview, approach, complexity, metadata, created_by, updated_by, source_session_id, source_message_id, category, original_plan_data, created_by_agent, executed_by_agent, executed_by_session)
|
|
38
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
39
|
+
).run(
|
|
40
|
+
plan.id,
|
|
41
|
+
plan.slug,
|
|
42
|
+
plan.title,
|
|
43
|
+
plan.status,
|
|
44
|
+
plan.priority,
|
|
45
|
+
now,
|
|
46
|
+
now,
|
|
47
|
+
plan.approvedAt ?? null,
|
|
48
|
+
plan.completedAt ?? null,
|
|
49
|
+
plan.sessionId ?? null,
|
|
50
|
+
plan.overview,
|
|
51
|
+
plan.approach ?? null,
|
|
52
|
+
plan.complexity,
|
|
53
|
+
JSON.stringify(plan.metadata),
|
|
54
|
+
plan.createdBy,
|
|
55
|
+
plan.updatedBy,
|
|
56
|
+
plan.sourceSessionId ?? null,
|
|
57
|
+
plan.sourceMessageId ?? null,
|
|
58
|
+
plan.category ?? null,
|
|
59
|
+
originalPlanData,
|
|
60
|
+
plan.createdByAgent ?? null,
|
|
61
|
+
plan.executedByAgent ?? null,
|
|
62
|
+
plan.executedBySession ?? null,
|
|
63
|
+
);
|
|
64
|
+
return { ...plan, createdAt: now, updatedAt: now, originalPlanData };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getPlan(db: Database, id: string): Plan | null {
|
|
68
|
+
const row = db.query("SELECT * FROM plans WHERE id = ?").get(id);
|
|
69
|
+
if (row == null) return null;
|
|
70
|
+
const fileRows = db
|
|
71
|
+
.query("SELECT file_path, role FROM plan_files WHERE plan_id = ? ORDER BY file_path")
|
|
72
|
+
.all(id);
|
|
73
|
+
return planWithFilesFromRow(row, fileRows);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getPlanBySlug(db: Database, slug: string): Plan | null {
|
|
77
|
+
const row = db.query("SELECT * FROM plans WHERE slug = ?").get(slug);
|
|
78
|
+
if (row == null) return null;
|
|
79
|
+
// Need to get the plan id to query plan_files
|
|
80
|
+
const planId = (row as { id: string }).id;
|
|
81
|
+
const fileRows = db
|
|
82
|
+
.query("SELECT file_path, role FROM plan_files WHERE plan_id = ? ORDER BY file_path")
|
|
83
|
+
.all(planId);
|
|
84
|
+
return planWithFilesFromRow(row, fileRows);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function listPlans(
|
|
88
|
+
db: Database,
|
|
89
|
+
opts: { status?: PlanStatus; sessionId?: string; limit?: number; includeArchived?: boolean } = {},
|
|
90
|
+
): Plan[] {
|
|
91
|
+
const conditions: string[] = [];
|
|
92
|
+
const params: SQLQueryBindings[] = [];
|
|
93
|
+
|
|
94
|
+
if (opts.status !== undefined) {
|
|
95
|
+
conditions.push("status = ?");
|
|
96
|
+
params.push(opts.status);
|
|
97
|
+
}
|
|
98
|
+
if (opts.sessionId !== undefined) {
|
|
99
|
+
conditions.push("session_id = ?");
|
|
100
|
+
params.push(opts.sessionId);
|
|
101
|
+
}
|
|
102
|
+
if (!opts.includeArchived) {
|
|
103
|
+
conditions.push("archived_at IS NULL");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
107
|
+
const limit = opts.limit ?? 100;
|
|
108
|
+
|
|
109
|
+
const rows = db
|
|
110
|
+
.query(`SELECT * FROM plans ${where} ORDER BY created_at DESC LIMIT ?`)
|
|
111
|
+
.all(...params, limit);
|
|
112
|
+
|
|
113
|
+
if (rows.length === 0) return [];
|
|
114
|
+
|
|
115
|
+
// Get all plan IDs
|
|
116
|
+
const planIds = (rows as Array<{ id: string }>).map((r) => r.id);
|
|
117
|
+
|
|
118
|
+
// Fetch all files for these plans in one query
|
|
119
|
+
const placeholders = planIds.map(() => "?").join(",");
|
|
120
|
+
const fileRows = db
|
|
121
|
+
.query(
|
|
122
|
+
`SELECT plan_id, file_path, role FROM plan_files WHERE plan_id IN (${placeholders}) ORDER BY plan_id, file_path`,
|
|
123
|
+
)
|
|
124
|
+
.all(...planIds) as Array<{ plan_id: string; file_path: string; role: string }>;
|
|
125
|
+
|
|
126
|
+
// Group files by plan_id
|
|
127
|
+
const filesByPlanId = new Map<string, Array<{ file_path: string; role: string }>>();
|
|
128
|
+
for (const f of fileRows) {
|
|
129
|
+
const existing = filesByPlanId.get(f.plan_id) ?? [];
|
|
130
|
+
existing.push({ file_path: f.file_path, role: f.role });
|
|
131
|
+
filesByPlanId.set(f.plan_id, existing);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Map plans with their files
|
|
135
|
+
return (rows as unknown[]).map((row) => {
|
|
136
|
+
const planId = (row as { id: string }).id;
|
|
137
|
+
const planFiles = filesByPlanId.get(planId) ?? [];
|
|
138
|
+
return planWithFilesFromRow(row, planFiles);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function searchPlans(
|
|
143
|
+
db: Database,
|
|
144
|
+
query: string,
|
|
145
|
+
limit = 20,
|
|
146
|
+
opts: { includeArchived?: boolean } = {},
|
|
147
|
+
): Plan[] {
|
|
148
|
+
const archiveFilter = opts.includeArchived ? "" : "AND p.archived_at IS NULL";
|
|
149
|
+
const rows = db
|
|
150
|
+
.query(
|
|
151
|
+
`SELECT p.* FROM plans p
|
|
152
|
+
JOIN plans_fts_v2 fts ON p.id = fts.id
|
|
153
|
+
WHERE plans_fts_v2 MATCH ?
|
|
154
|
+
${archiveFilter}
|
|
155
|
+
ORDER BY rank
|
|
156
|
+
LIMIT ?`,
|
|
157
|
+
)
|
|
158
|
+
.all(escapeFtsQuery(query), limit);
|
|
159
|
+
return (rows as unknown[]).map(planFromRow);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Update a plan's status. Optionally links a session and records who made the change.
|
|
164
|
+
*
|
|
165
|
+
* When transitioning to "executing" or "approved" with a sessionId provided,
|
|
166
|
+
* the session is auto-linked to the plan (Fix #8). The sessionId is only
|
|
167
|
+
* validated/auto-created when it will actually be linked (executing/approved);
|
|
168
|
+
* terminal statuses (completed/failed/abandoned) skip FK check since sessionId
|
|
169
|
+
* is just metadata there (Fix #1 hybrid — scoped ensureSession).
|
|
170
|
+
*
|
|
171
|
+
* @see Plan.sessionId — foreign key constraint enforced at app level (Fix #1)
|
|
172
|
+
* @see ensureSession — idempotent auto-creation of session rows (hybrid fix)
|
|
173
|
+
*/
|
|
174
|
+
export function updatePlanStatus(
|
|
175
|
+
db: Database,
|
|
176
|
+
id: string,
|
|
177
|
+
status: PlanStatus,
|
|
178
|
+
opts: {
|
|
179
|
+
updatedBy?: string;
|
|
180
|
+
sessionId?: string;
|
|
181
|
+
executedByAgent?: string;
|
|
182
|
+
executedBySession?: string;
|
|
183
|
+
} = {},
|
|
184
|
+
): Plan | null {
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
|
|
187
|
+
// Fix #1 (scoped): validate sessionId only when status will actually link it (Fix #8)
|
|
188
|
+
if (opts.sessionId !== undefined && (status === "executing" || status === "approved")) {
|
|
189
|
+
ensureSession(db, opts.sessionId, "auto-created for plan transition");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Fix #8: auto-link session when entering executing or approved status
|
|
193
|
+
const linkSession =
|
|
194
|
+
(status === "executing" || status === "approved") && opts.sessionId !== undefined;
|
|
195
|
+
|
|
196
|
+
// v8: write-once executed_by_agent/session — only set on first executing transition
|
|
197
|
+
const setExecutedBy = status === "executing" && opts.executedByAgent !== undefined;
|
|
198
|
+
// Guaranteed non-undefined inside setExecutedBy branches (see guard above)
|
|
199
|
+
const executedAgent = opts.executedByAgent ?? null;
|
|
200
|
+
const executedSession = opts.executedBySession ?? null;
|
|
201
|
+
|
|
202
|
+
if (linkSession) {
|
|
203
|
+
// opts.sessionId is guaranteed non-undefined here (linkSession check above)
|
|
204
|
+
const sid = opts.sessionId as string;
|
|
205
|
+
if (setExecutedBy) {
|
|
206
|
+
db.query(
|
|
207
|
+
`UPDATE plans SET status = ?, updated_at = ?, updated_by = ?, session_id = ?,
|
|
208
|
+
executed_by_agent = COALESCE(executed_by_agent, ?),
|
|
209
|
+
executed_by_session = COALESCE(executed_by_session, ?)
|
|
210
|
+
WHERE id = ?`,
|
|
211
|
+
).run(status, now, opts.updatedBy ?? "unknown", sid, executedAgent, executedSession, id);
|
|
212
|
+
} else {
|
|
213
|
+
db.query(
|
|
214
|
+
"UPDATE plans SET status = ?, updated_at = ?, updated_by = ?, session_id = ? WHERE id = ?",
|
|
215
|
+
).run(status, now, opts.updatedBy ?? "unknown", sid, id);
|
|
216
|
+
}
|
|
217
|
+
} else if (opts.updatedBy !== undefined) {
|
|
218
|
+
if (setExecutedBy) {
|
|
219
|
+
db.query(
|
|
220
|
+
`UPDATE plans SET status = ?, updated_at = ?, updated_by = ?,
|
|
221
|
+
executed_by_agent = COALESCE(executed_by_agent, ?),
|
|
222
|
+
executed_by_session = COALESCE(executed_by_session, ?)
|
|
223
|
+
WHERE id = ?`,
|
|
224
|
+
).run(status, now, opts.updatedBy, executedAgent, executedSession, id);
|
|
225
|
+
} else {
|
|
226
|
+
db.query("UPDATE plans SET status = ?, updated_at = ?, updated_by = ? WHERE id = ?").run(
|
|
227
|
+
status,
|
|
228
|
+
now,
|
|
229
|
+
opts.updatedBy,
|
|
230
|
+
id,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
if (setExecutedBy) {
|
|
235
|
+
db.query(
|
|
236
|
+
`UPDATE plans SET status = ?, updated_at = ?,
|
|
237
|
+
executed_by_agent = COALESCE(executed_by_agent, ?),
|
|
238
|
+
executed_by_session = COALESCE(executed_by_session, ?)
|
|
239
|
+
WHERE id = ?`,
|
|
240
|
+
).run(status, now, executedAgent, executedSession, id);
|
|
241
|
+
} else {
|
|
242
|
+
db.query("UPDATE plans SET status = ?, updated_at = ? WHERE id = ?").run(status, now, id);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Set completed_at on terminal status (idempotent — only if NULL)
|
|
247
|
+
if (TERMINAL_STATUSES.has(status)) {
|
|
248
|
+
db.query("UPDATE plans SET completed_at = ? WHERE id = ? AND completed_at IS NULL").run(now, id);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return getPlan(db, id);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Approve a plan. Optionally links a session (Fix #8) and records who approved it.
|
|
256
|
+
*
|
|
257
|
+
* The sessionId is validated/auto-created when linking (approve always links).
|
|
258
|
+
* Uses ensureSession for idempotent FK integrity (Fix #1 hybrid).
|
|
259
|
+
*
|
|
260
|
+
* @see Plan.sessionId — foreign key constraint enforced at app level (Fix #1)
|
|
261
|
+
* @see ensureSession — idempotent auto-creation of session rows (hybrid fix)
|
|
262
|
+
*/
|
|
263
|
+
export function approvePlan(
|
|
264
|
+
db: Database,
|
|
265
|
+
id: string,
|
|
266
|
+
opts: { updatedBy?: string; sessionId?: string } = {},
|
|
267
|
+
): Plan | null {
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
|
|
270
|
+
// Fix #1 (scoped): validate sessionId when linking (Fix #8 — approve always links)
|
|
271
|
+
if (opts.sessionId !== undefined) {
|
|
272
|
+
ensureSession(db, opts.sessionId, "auto-created for plan approval");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Fix #8: auto-link session when approving
|
|
276
|
+
if (opts.sessionId !== undefined) {
|
|
277
|
+
db.query(
|
|
278
|
+
"UPDATE plans SET status = 'approved', approved_at = ?, updated_at = ?, updated_by = ?, session_id = ? WHERE id = ?",
|
|
279
|
+
).run(now, now, opts.updatedBy ?? "unknown", opts.sessionId, id);
|
|
280
|
+
} else if (opts.updatedBy !== undefined) {
|
|
281
|
+
db.query(
|
|
282
|
+
"UPDATE plans SET status = 'approved', approved_at = ?, updated_at = ?, updated_by = ? WHERE id = ?",
|
|
283
|
+
).run(now, now, opts.updatedBy, id);
|
|
284
|
+
} else {
|
|
285
|
+
db.query(
|
|
286
|
+
"UPDATE plans SET status = 'approved', approved_at = ?, updated_at = ? WHERE id = ?",
|
|
287
|
+
).run(now, now, id);
|
|
288
|
+
}
|
|
289
|
+
return getPlan(db, id);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ─── Plan deletion ──────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
export interface DeletePlanOpts {
|
|
295
|
+
/** Required confirmation flag — must be true to proceed */
|
|
296
|
+
confirm: boolean;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface DeletePlanResult {
|
|
300
|
+
planId: string;
|
|
301
|
+
slug: string;
|
|
302
|
+
tasksDeleted: number;
|
|
303
|
+
sessionsUnlinked: number;
|
|
304
|
+
filesDeleted: number;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Delete a plan and all associated data (CASCADE).
|
|
309
|
+
*
|
|
310
|
+
* Guards:
|
|
311
|
+
* - Requires confirm: true
|
|
312
|
+
* - Rejects if plan.status === 'draft' (use abandonPlan instead)
|
|
313
|
+
* - Rejects if any tasks are 'pending' or 'running'
|
|
314
|
+
*
|
|
315
|
+
* The actual deletion relies on ON DELETE CASCADE in the schema:
|
|
316
|
+
* - plan_tasks (FK plan_id)
|
|
317
|
+
* - plan_files (FK plan_id)
|
|
318
|
+
* - plan_tags (FK plan_id)
|
|
319
|
+
* - sessions.plan_id (SET NULL)
|
|
320
|
+
*/
|
|
321
|
+
export function deletePlan(db: Database, planId: string, opts: DeletePlanOpts): DeletePlanResult {
|
|
322
|
+
if (!opts.confirm) {
|
|
323
|
+
throw new Error("ndomo: deletePlan requires confirm: true");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const plan = getPlan(db, planId);
|
|
327
|
+
if (!plan) {
|
|
328
|
+
throw new Error(`ndomo: plan not found: ${planId}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (plan.status === "draft") {
|
|
332
|
+
throw new Error("ndomo: cannot delete a draft plan — use abandonPlan or approve first");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Check for active tasks
|
|
336
|
+
const activeTasks = db
|
|
337
|
+
.query<{ count: number }, [string]>(
|
|
338
|
+
"SELECT COUNT(*) as count FROM plan_tasks WHERE plan_id = ? AND status IN ('pending', 'running') AND archived_at IS NULL",
|
|
339
|
+
)
|
|
340
|
+
.get(planId);
|
|
341
|
+
|
|
342
|
+
if (activeTasks && activeTasks.count > 0) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`ndomo: cannot delete plan with ${activeTasks.count} active task(s) — complete or fail them first`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Count related records before deletion
|
|
349
|
+
const tasksCount =
|
|
350
|
+
db
|
|
351
|
+
.query<{ count: number }, [string]>(
|
|
352
|
+
"SELECT COUNT(*) as count FROM plan_tasks WHERE plan_id = ?",
|
|
353
|
+
)
|
|
354
|
+
.get(planId)?.count ?? 0;
|
|
355
|
+
|
|
356
|
+
const sessionsCount =
|
|
357
|
+
db
|
|
358
|
+
.query<{ count: number }, [string]>(
|
|
359
|
+
"SELECT COUNT(*) as count FROM sessions WHERE plan_id = ?",
|
|
360
|
+
)
|
|
361
|
+
.get(planId)?.count ?? 0;
|
|
362
|
+
|
|
363
|
+
const filesCount =
|
|
364
|
+
db
|
|
365
|
+
.query<{ count: number }, [string]>(
|
|
366
|
+
"SELECT COUNT(*) as count FROM plan_files WHERE plan_id = ?",
|
|
367
|
+
)
|
|
368
|
+
.get(planId)?.count ?? 0;
|
|
369
|
+
|
|
370
|
+
// Delete — CASCADE handles plan_tasks, plan_files, plan_tags
|
|
371
|
+
// sessions.plan_id is SET NULL per schema
|
|
372
|
+
db.query("DELETE FROM plans WHERE id = ?").run(planId);
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
planId,
|
|
376
|
+
slug: plan.slug,
|
|
377
|
+
tasksDeleted: tasksCount,
|
|
378
|
+
sessionsUnlinked: sessionsCount,
|
|
379
|
+
filesDeleted: filesCount,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── Write-once executed_by helpers ──────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Write-once: set executed_by_agent and executed_by_session on a plan.
|
|
387
|
+
* Uses COALESCE so the first write wins — subsequent calls are no-ops.
|
|
388
|
+
*/
|
|
389
|
+
export function setExecutedByOnce(
|
|
390
|
+
db: Database,
|
|
391
|
+
planId: string,
|
|
392
|
+
agent: string,
|
|
393
|
+
sessionId?: string | null,
|
|
394
|
+
): void {
|
|
395
|
+
db.query(
|
|
396
|
+
`UPDATE plans SET
|
|
397
|
+
executed_by_agent = COALESCE(executed_by_agent, ?),
|
|
398
|
+
executed_by_session = COALESCE(executed_by_session, ?)
|
|
399
|
+
WHERE id = ? AND archived_at IS NULL`,
|
|
400
|
+
).run(agent, sessionId ?? null, planId);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── Tag helpers ─────────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
export function addPlanTag(db: Database, planId: string, tag: string, addedBy: string): void {
|
|
406
|
+
db.query(
|
|
407
|
+
"INSERT OR IGNORE INTO plan_tags (plan_id, tag, added_by, added_at) VALUES (?, ?, ?, ?)",
|
|
408
|
+
).run(planId, tag, addedBy, Date.now());
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function removePlanTag(db: Database, planId: string, tag: string): void {
|
|
412
|
+
db.query("DELETE FROM plan_tags WHERE plan_id = ? AND tag = ?").run(planId, tag);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function getPlanTags(
|
|
416
|
+
db: Database,
|
|
417
|
+
planId: string,
|
|
418
|
+
): Array<{ tag: string; addedBy: string; addedAt: number }> {
|
|
419
|
+
const rows = db
|
|
420
|
+
.query("SELECT tag, added_by, added_at FROM plan_tags WHERE plan_id = ? ORDER BY tag")
|
|
421
|
+
.all(planId) as Array<{ tag: string; added_by: string; added_at: number }>;
|
|
422
|
+
return rows.map((r) => ({ tag: r.tag, addedBy: r.added_by, addedAt: r.added_at }));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function findPlansByTag(
|
|
426
|
+
db: Database,
|
|
427
|
+
tag: string,
|
|
428
|
+
limit = 20,
|
|
429
|
+
opts: { includeArchived?: boolean } = {},
|
|
430
|
+
): Plan[] {
|
|
431
|
+
const archiveFilter = opts.includeArchived ? "" : "AND p.archived_at IS NULL";
|
|
432
|
+
const rows = db
|
|
433
|
+
.query(
|
|
434
|
+
`SELECT p.* FROM plans p
|
|
435
|
+
JOIN plan_tags pt ON p.id = pt.plan_id
|
|
436
|
+
WHERE pt.tag = ? ${archiveFilter}
|
|
437
|
+
ORDER BY p.created_at DESC
|
|
438
|
+
LIMIT ?`,
|
|
439
|
+
)
|
|
440
|
+
.all(tag, limit);
|
|
441
|
+
return (rows as unknown[]).map(planFromRow);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function findPlansByCategory(
|
|
445
|
+
db: Database,
|
|
446
|
+
category: PlanCategory,
|
|
447
|
+
limit = 20,
|
|
448
|
+
opts: { includeArchived?: boolean } = {},
|
|
449
|
+
): Plan[] {
|
|
450
|
+
const archiveFilter = opts.includeArchived ? "" : "AND archived_at IS NULL";
|
|
451
|
+
const rows = db
|
|
452
|
+
.query(
|
|
453
|
+
`SELECT * FROM plans WHERE category = ? ${archiveFilter} ORDER BY created_at DESC LIMIT ?`,
|
|
454
|
+
)
|
|
455
|
+
.all(category, limit);
|
|
456
|
+
return (rows as unknown[]).map(planFromRow);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ─── Plan progress (v4) ──────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
export interface PlanProgress {
|
|
462
|
+
planId: string;
|
|
463
|
+
slug: string;
|
|
464
|
+
title: string;
|
|
465
|
+
status: string;
|
|
466
|
+
totalTasks: number;
|
|
467
|
+
done: number;
|
|
468
|
+
failed: number;
|
|
469
|
+
running: number;
|
|
470
|
+
pending: number;
|
|
471
|
+
blocked: number;
|
|
472
|
+
progressPct: number;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
interface PlanProgressRow {
|
|
476
|
+
plan_id: string;
|
|
477
|
+
slug: string;
|
|
478
|
+
title: string;
|
|
479
|
+
status: string;
|
|
480
|
+
total_tasks: number;
|
|
481
|
+
done: number;
|
|
482
|
+
failed: number;
|
|
483
|
+
running: number;
|
|
484
|
+
pending: number;
|
|
485
|
+
blocked: number;
|
|
486
|
+
progress_pct: number;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function planProgressFromRow(row: unknown): PlanProgress {
|
|
490
|
+
const r = row as PlanProgressRow;
|
|
491
|
+
return {
|
|
492
|
+
planId: r.plan_id,
|
|
493
|
+
slug: r.slug,
|
|
494
|
+
title: r.title,
|
|
495
|
+
status: r.status,
|
|
496
|
+
totalTasks: r.total_tasks,
|
|
497
|
+
done: r.done,
|
|
498
|
+
failed: r.failed,
|
|
499
|
+
running: r.running,
|
|
500
|
+
pending: r.pending,
|
|
501
|
+
blocked: r.blocked,
|
|
502
|
+
progressPct: r.progress_pct,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Query the plan_progress view for task aggregation.
|
|
508
|
+
*
|
|
509
|
+
* @param db - Database instance
|
|
510
|
+
* @param planId - optional plan id to filter by; omit for all plans
|
|
511
|
+
*/
|
|
512
|
+
export function getPlanProgress(db: Database, planId?: string): PlanProgress[] {
|
|
513
|
+
const sql =
|
|
514
|
+
planId !== undefined
|
|
515
|
+
? "SELECT * FROM plan_progress_active WHERE plan_id = ?"
|
|
516
|
+
: "SELECT * FROM plan_progress_active";
|
|
517
|
+
const rows = planId !== undefined ? db.query(sql).all(planId) : db.query(sql).all();
|
|
518
|
+
return (rows as unknown[]).map(planProgressFromRow);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Query the plan_progress_historical view for ALL plans (including archived).
|
|
523
|
+
*
|
|
524
|
+
* @param db - Database instance
|
|
525
|
+
* @param planId - optional plan id to filter by; omit for all plans
|
|
526
|
+
*/
|
|
527
|
+
export function getPlanProgressHistorical(db: Database, planId?: string): PlanProgress[] {
|
|
528
|
+
const sql =
|
|
529
|
+
planId !== undefined
|
|
530
|
+
? "SELECT * FROM plan_progress_historical WHERE plan_id = ?"
|
|
531
|
+
: "SELECT * FROM plan_progress_historical";
|
|
532
|
+
const rows = planId !== undefined ? db.query(sql).all(planId) : db.query(sql).all();
|
|
533
|
+
return (rows as unknown[]).map(planProgressFromRow);
|
|
534
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for resolveProjectDir() — plan bb805ff9.
|
|
3
|
+
*
|
|
4
|
+
* Validates the full resolution chain: worktree → directory → process.cwd(),
|
|
5
|
+
* including the pgadmin bug scenario (ctx.directory="/" → cwd fallback).
|
|
6
|
+
* Uses mkdtempSync + process.chdir for deterministic cwd mocking; original
|
|
7
|
+
* cwd is always restored in afterEach.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { resolveProjectDir } from "./resolve-project-dir.ts";
|
|
15
|
+
|
|
16
|
+
describe("resolveProjectDir", () => {
|
|
17
|
+
let originalCwd: string;
|
|
18
|
+
let tmpDir: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
originalCwd = process.cwd();
|
|
22
|
+
tmpDir = mkdtempSync(join(tmpdir(), "ndomo-resolve-test-"));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
process.chdir(originalCwd);
|
|
27
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ─── Happy path: worktree / directory precedence ─────────────────────────
|
|
31
|
+
|
|
32
|
+
test("(a) worktree wins over directory + cwd", () => {
|
|
33
|
+
process.chdir(tmpDir);
|
|
34
|
+
const result = resolveProjectDir({
|
|
35
|
+
worktree: "/home/user/worktree",
|
|
36
|
+
directory: "/home/user/project",
|
|
37
|
+
});
|
|
38
|
+
expect(result).toBe("/home/user/worktree");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("(b) directory wins when worktree is undefined", () => {
|
|
42
|
+
process.chdir(tmpDir);
|
|
43
|
+
const result = resolveProjectDir({
|
|
44
|
+
worktree: undefined,
|
|
45
|
+
directory: "/home/user/project",
|
|
46
|
+
});
|
|
47
|
+
expect(result).toBe("/home/user/project");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("(b2) directory wins when worktree is empty string", () => {
|
|
51
|
+
process.chdir(tmpDir);
|
|
52
|
+
const result = resolveProjectDir({
|
|
53
|
+
worktree: "",
|
|
54
|
+
directory: "/home/user/project",
|
|
55
|
+
});
|
|
56
|
+
expect(result).toBe("/home/user/project");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("(b3) directory wins when worktree is '/' (invalid)", () => {
|
|
60
|
+
process.chdir(tmpDir);
|
|
61
|
+
const result = resolveProjectDir({
|
|
62
|
+
worktree: "/",
|
|
63
|
+
directory: "/home/user/project",
|
|
64
|
+
});
|
|
65
|
+
expect(result).toBe("/home/user/project");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ─── Fallback to process.cwd() ───────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
test("(c) cwd fallback when directory is '/' (pgadmin bug)", () => {
|
|
71
|
+
process.chdir(tmpDir);
|
|
72
|
+
const result = resolveProjectDir({
|
|
73
|
+
worktree: undefined,
|
|
74
|
+
directory: "/",
|
|
75
|
+
});
|
|
76
|
+
expect(result).toBe(tmpDir);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("(c2) cwd fallback is silent (no console.warn)", () => {
|
|
80
|
+
process.chdir(tmpDir);
|
|
81
|
+
const originalWarn = console.warn;
|
|
82
|
+
const warnings: string[] = [];
|
|
83
|
+
console.warn = (...args: unknown[]) => {
|
|
84
|
+
warnings.push(args.map(String).join(" "));
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
const result = resolveProjectDir({ worktree: undefined, directory: "/" });
|
|
88
|
+
expect(result).toBe(tmpDir);
|
|
89
|
+
expect(warnings.length).toBe(0);
|
|
90
|
+
} finally {
|
|
91
|
+
console.warn = originalWarn;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("(d) cwd fallback when directory is undefined", () => {
|
|
96
|
+
process.chdir(tmpDir);
|
|
97
|
+
const result = resolveProjectDir({
|
|
98
|
+
worktree: undefined,
|
|
99
|
+
directory: undefined,
|
|
100
|
+
});
|
|
101
|
+
expect(result).toBe(tmpDir);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("(e) cwd fallback when directory is empty string", () => {
|
|
105
|
+
process.chdir(tmpDir);
|
|
106
|
+
const result = resolveProjectDir({
|
|
107
|
+
worktree: undefined,
|
|
108
|
+
directory: "",
|
|
109
|
+
});
|
|
110
|
+
expect(result).toBe(tmpDir);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("(e2) cwd fallback when both worktree and directory are '/'", () => {
|
|
114
|
+
process.chdir(tmpDir);
|
|
115
|
+
const result = resolveProjectDir({
|
|
116
|
+
worktree: "/",
|
|
117
|
+
directory: "/",
|
|
118
|
+
});
|
|
119
|
+
expect(result).toBe(tmpDir);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ─── Total failure ───────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
test("(f) throws when even cwd is invalid ('/')", () => {
|
|
125
|
+
process.chdir("/");
|
|
126
|
+
expect(() =>
|
|
127
|
+
resolveProjectDir({
|
|
128
|
+
worktree: undefined,
|
|
129
|
+
directory: "/",
|
|
130
|
+
}),
|
|
131
|
+
).toThrow(/no valid project directory/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("(f2) throws when both ctx paths and cwd are all invalid", () => {
|
|
135
|
+
process.chdir("/");
|
|
136
|
+
expect(() =>
|
|
137
|
+
resolveProjectDir({
|
|
138
|
+
worktree: "/",
|
|
139
|
+
directory: "",
|
|
140
|
+
}),
|
|
141
|
+
).toThrow(/no valid project directory/);
|
|
142
|
+
});
|
|
143
|
+
});
|