ndomo 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -0
- package/README.es.md +29 -23
- package/README.md +64 -24
- package/bun.lock +447 -0
- package/docs/configuration.md +4 -4
- package/docs/installation.md +53 -34
- package/docs/installer.md +164 -0
- package/docs/integrations.md +1 -1
- package/docs/web-ui.md +124 -0
- package/package.json +43 -4
- package/scripts/install.sh +28 -0
- package/scripts/smoke-install.sh +47 -0
- package/scripts/smoke-web.sh +335 -0
- package/src/cli/__tests__/install.test.ts +733 -0
- package/src/cli/index.ts +8 -0
- package/src/cli/install.ts +1292 -0
- package/src/config/__tests__/schema.test.ts +223 -0
- package/src/config/schema.ts +129 -16
- package/src/http/__tests__/auth.test.ts +10 -10
- package/src/http/__tests__/spa.test.ts +296 -0
- package/src/http/auth.ts +8 -1
- package/src/http/server.ts +71 -2
- package/.bun-version +0 -1
- package/.dockerignore +0 -79
- package/.editorconfig +0 -18
- package/.github/CODEOWNERS +0 -8
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
- package/.github/ISSUE_TEMPLATE/config.yml +0 -2
- package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -34
- package/.github/dependabot.yml +0 -36
- package/.github/pull_request_template.md +0 -24
- package/.github/release.yml +0 -30
- package/.github/workflows/gitleaks.yml +0 -28
- package/.github/workflows/release-please.yml +0 -27
- package/.github/workflows/smoke.yml +0 -29
- package/.husky/commit-msg +0 -1
- package/CHANGELOG.md +0 -114
- package/Dockerfile +0 -32
- package/bin/ndomo-analyses.ts +0 -4
- package/bin/ndomo-status.ts +0 -4
- package/biome.json +0 -57
- package/commitlint.config.js +0 -3
- package/opencode.json +0 -5
- package/release-please-config.json +0 -11
- package/scripts/dev-bust-cache.sh +0 -164
- package/scripts/smoke-e2e.ts +0 -704
- package/scripts/smoke-hot.ts +0 -417
- package/scripts/smoke-v4.ts +0 -256
- package/scripts/smoke-v5.ts +0 -397
- package/scripts/uninstall.sh +0 -224
- package/src/index.ts +0 -37
- package/src/lib.ts +0 -65
- package/src/mem/scoped.ts +0 -65
- package/src/orchestrator/background.test.ts +0 -268
- package/src/orchestrator/background.ts +0 -293
- package/src/orchestrator/memory-hook.ts +0 -182
- package/src/orchestrator/reconciler.ts +0 -123
- package/src/orchestrator/scheduler.test.ts +0 -300
- package/src/orchestrator/scheduler.ts +0 -243
- package/src/plugin.test.ts +0 -2574
- package/src/plugin.ts +0 -1690
- package/src/worktrees/manager.ts +0 -236
- package/src/worktrees/state.ts +0 -87
- package/tests/integration/ranger-flow.test.ts +0 -257
- package/tsconfig.json +0 -31
package/src/plugin.ts
DELETED
|
@@ -1,1690 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ndomo — OpenCode plugin implementation.
|
|
3
|
-
*
|
|
4
|
-
* Wraps ndomo's orchestrator, worktree, and memory libraries as
|
|
5
|
-
* OpenCode hooks and tools. All state lives in closures created
|
|
6
|
-
* when the plugin is instantiated — no module-level globals.
|
|
7
|
-
*
|
|
8
|
-
* Dependency: `@opencode-ai/plugin` lives in `.opencode/package.json`
|
|
9
|
-
* per OpenCode plugin convention (installed alongside the user's
|
|
10
|
-
* `config/ndomo.config.json`).
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { Database } from "bun:sqlite";
|
|
14
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { homedir } from "node:os";
|
|
16
|
-
import { join } from "node:path";
|
|
17
|
-
import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
18
|
-
import { tool } from "@opencode-ai/plugin";
|
|
19
|
-
import { AutoCheckpointDispatcher } from "./db/auto-checkpoint.ts";
|
|
20
|
-
import { openDb } from "./db/client.ts";
|
|
21
|
-
import {
|
|
22
|
-
archiveAnalysis,
|
|
23
|
-
createAnalysis,
|
|
24
|
-
getAnalysis,
|
|
25
|
-
linkAnalysisToPlan,
|
|
26
|
-
listAnalyses,
|
|
27
|
-
searchAnalyses,
|
|
28
|
-
unlinkAnalysisFromPlan,
|
|
29
|
-
updateAnalysis,
|
|
30
|
-
validateAnalysisFindings,
|
|
31
|
-
} from "./db/analyses.ts";
|
|
32
|
-
import { createIncident } from "./db/incidents.ts";
|
|
33
|
-
import { runMigrations } from "./db/migrations.ts";
|
|
34
|
-
import { resolveArchiveDir } from "./db/plan-archive.ts";
|
|
35
|
-
import { planCreateExecutor } from "./db/plan-create.ts";
|
|
36
|
-
import { planUpdateStatusExecutor } from "./db/plan-update-status.ts";
|
|
37
|
-
import {
|
|
38
|
-
approvePlan,
|
|
39
|
-
deletePlan,
|
|
40
|
-
getPlan,
|
|
41
|
-
getPlanBySlug,
|
|
42
|
-
listPlans,
|
|
43
|
-
searchPlans,
|
|
44
|
-
} from "./db/plans.ts";
|
|
45
|
-
import { resolveProjectDir } from "./db/resolve-project-dir.ts";
|
|
46
|
-
import { recordRollback } from "./db/rollbacks.ts";
|
|
47
|
-
import { checkpointSession, endSession, listSessions, startSession } from "./db/sessions.ts";
|
|
48
|
-
import { registerShutdownHandlers } from "./db/shutdown.ts";
|
|
49
|
-
import {
|
|
50
|
-
createTasksBatch,
|
|
51
|
-
listTasksByPlan,
|
|
52
|
-
nextTaskForAgent,
|
|
53
|
-
resolveTaskDependencies,
|
|
54
|
-
searchTasks,
|
|
55
|
-
updateTaskStatus,
|
|
56
|
-
} from "./db/tasks.ts";
|
|
57
|
-
import type {
|
|
58
|
-
IncidentSeverity,
|
|
59
|
-
InsertIncident,
|
|
60
|
-
InsertRollback,
|
|
61
|
-
PlanMetadata,
|
|
62
|
-
PlanStatus,
|
|
63
|
-
RollbackStatus,
|
|
64
|
-
SessionMetadata,
|
|
65
|
-
TaskMetadata,
|
|
66
|
-
TaskStatus,
|
|
67
|
-
} from "./db/types.ts";
|
|
68
|
-
import type { RoutingDecision } from "./lib.ts";
|
|
69
|
-
import { loadHttpConfig } from "./config/schema.ts";
|
|
70
|
-
import { startHttpServer, type HttpServerHandle } from "./http/server.ts";
|
|
71
|
-
import { getSdkClient } from "./sdk/client.ts";
|
|
72
|
-
import {
|
|
73
|
-
BackgroundDispatcher,
|
|
74
|
-
canRunParallel,
|
|
75
|
-
cavemanCompress,
|
|
76
|
-
createWorktree,
|
|
77
|
-
getProjectTag,
|
|
78
|
-
listActive,
|
|
79
|
-
memorySearchOptions,
|
|
80
|
-
removeWorktree,
|
|
81
|
-
routeTask,
|
|
82
|
-
verifyIntegrity,
|
|
83
|
-
} from "./lib.ts";
|
|
84
|
-
|
|
85
|
-
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Safely extract a filepath from tool args (write/edit tools).
|
|
89
|
-
* The SDK types `args` as `any` — it can be null, undefined, or any shape.
|
|
90
|
-
* Returns `undefined` when filepath is absent, null, or not a string.
|
|
91
|
-
*/
|
|
92
|
-
function extractFilePath(args: unknown): string | undefined {
|
|
93
|
-
if (args == null || typeof args !== "object") return undefined;
|
|
94
|
-
const record = args as Record<string, unknown>;
|
|
95
|
-
const fp = record.filePath ?? record.filepath;
|
|
96
|
-
return typeof fp === "string" ? fp : undefined;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* File lock registry for write/edit tools — replaces the raw `Map<string, string>`
|
|
101
|
-
* that previously held activeWrites. Each entry is stamped with the time it was
|
|
102
|
-
* acquired so a TTL sweep can recover from SDK hook-chain breaks where
|
|
103
|
-
* `tool.execute.after` never fires (regression: leaked write locks blocked
|
|
104
|
-
* subsequent writes indefinitely).
|
|
105
|
-
*
|
|
106
|
-
* Public API:
|
|
107
|
-
* - acquire(fp, key): null if can lock, or the existing holder's key.
|
|
108
|
-
* - release(fp, key): drop the lock IF caller is the holder (else no-op).
|
|
109
|
-
* - forceRelease(fp): admin override — drops the lock regardless of holder.
|
|
110
|
-
* - sweep(): prune entries older than ttlMs. Returns count removed.
|
|
111
|
-
*
|
|
112
|
-
* `acquire` auto-sweeps before checking so a stale lock never blocks a fresh
|
|
113
|
-
* caller — covers the "SDK never fired after-hook" scenario.
|
|
114
|
-
*/
|
|
115
|
-
export class FileLock {
|
|
116
|
-
private map = new Map<string, { key: string; setAt: number }>();
|
|
117
|
-
|
|
118
|
-
constructor(private readonly ttlMs: number) {}
|
|
119
|
-
|
|
120
|
-
acquire(filepath: string, key: string): string | null {
|
|
121
|
-
this.sweep();
|
|
122
|
-
const existing = this.map.get(filepath);
|
|
123
|
-
if (existing != null && existing.key !== key) return existing.key;
|
|
124
|
-
this.map.set(filepath, { key, setAt: Date.now() });
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
release(filepath: string, key: string): void {
|
|
129
|
-
const existing = this.map.get(filepath);
|
|
130
|
-
if (existing?.key === key) this.map.delete(filepath);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
forceRelease(filepath: string): boolean {
|
|
134
|
-
return this.map.delete(filepath);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
sweep(): number {
|
|
138
|
-
const cutoff = Date.now() - this.ttlMs;
|
|
139
|
-
let swept = 0;
|
|
140
|
-
for (const [fp, entry] of this.map) {
|
|
141
|
-
if (entry.setAt < cutoff) {
|
|
142
|
-
this.map.delete(fp);
|
|
143
|
-
swept++;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return swept;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
has(filepath: string): boolean {
|
|
150
|
-
return this.map.has(filepath);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
size(): number {
|
|
154
|
-
return this.map.size;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
keys(): string[] {
|
|
158
|
-
return Array.from(this.map.keys());
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// ─── Escalation helper (M2) ──────────────────────────────────────────────────
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Escalate a task from craftsman to foreman by creating a stub plan
|
|
166
|
-
* and optionally a foreman task, then checkpointing the session.
|
|
167
|
-
*
|
|
168
|
-
* Pure function taking `db` — testable via in-memory SQLite.
|
|
169
|
-
*/
|
|
170
|
-
export function escalateToForeman(
|
|
171
|
-
db: Database,
|
|
172
|
-
ctx: { agent?: string; sessionID?: string; messageID?: string },
|
|
173
|
-
args: {
|
|
174
|
-
sourcePlanId?: string;
|
|
175
|
-
sourceTaskId?: string;
|
|
176
|
-
reason: string;
|
|
177
|
-
suggestedApproach?: string;
|
|
178
|
-
},
|
|
179
|
-
): { escalationPlanId: string; notificationSent: boolean } {
|
|
180
|
-
const escalationId = crypto.randomUUID();
|
|
181
|
-
const slug = `escalation-${escalationId.slice(0, 8)}`;
|
|
182
|
-
|
|
183
|
-
// 1. Create stub plan with escalation metadata
|
|
184
|
-
const plan = planCreateExecutor(
|
|
185
|
-
db,
|
|
186
|
-
{
|
|
187
|
-
slug,
|
|
188
|
-
title: `Escalation: ${args.reason.slice(0, 80)}`,
|
|
189
|
-
overview: args.reason,
|
|
190
|
-
priority: 3, // mid-priority for escalation stubs
|
|
191
|
-
...(args.suggestedApproach !== undefined && { approach: args.suggestedApproach }),
|
|
192
|
-
metadata: {
|
|
193
|
-
escalatedFrom: args.sourcePlanId ?? null,
|
|
194
|
-
escalatedBy: "craftsman",
|
|
195
|
-
reason: args.reason,
|
|
196
|
-
} as PlanMetadata & Record<string, unknown>,
|
|
197
|
-
},
|
|
198
|
-
ctx,
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
// 2. If sourceTaskId, create a foreman task in the escalation plan
|
|
202
|
-
if (args.sourceTaskId) {
|
|
203
|
-
createTasksBatch(db, plan.id, [
|
|
204
|
-
{
|
|
205
|
-
orderIndex: 0,
|
|
206
|
-
description: args.reason,
|
|
207
|
-
agent: "foreman",
|
|
208
|
-
files: [],
|
|
209
|
-
complexity: 3,
|
|
210
|
-
dependencies: [],
|
|
211
|
-
createdBy: ctx.agent ?? "unknown",
|
|
212
|
-
updatedBy: ctx.agent ?? "unknown",
|
|
213
|
-
sourceSessionId: ctx.sessionID ?? null,
|
|
214
|
-
sourceMessageId: ctx.messageID ?? null,
|
|
215
|
-
reviewedBy: null,
|
|
216
|
-
tokensUsed: null,
|
|
217
|
-
durationMs: null,
|
|
218
|
-
artifacts: [],
|
|
219
|
-
metadata: {},
|
|
220
|
-
},
|
|
221
|
-
]);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// 3. Session checkpoint with escalation note
|
|
225
|
-
if (ctx.sessionID) {
|
|
226
|
-
checkpointSession(
|
|
227
|
-
db,
|
|
228
|
-
ctx.sessionID,
|
|
229
|
-
{ escalated: true, escalationPlanId: plan.id },
|
|
230
|
-
`escalated by craftsman: ${args.reason}`,
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
return { escalationPlanId: plan.id, notificationSent: true };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ─── Reconcile helper (M3) ───────────────────────────────────────────────────
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Reconcile plans that were left in 'executing' or 'approved' status
|
|
241
|
-
* when a session ends. Marks them as 'abandoned' with metadata reason.
|
|
242
|
-
*
|
|
243
|
-
* Pure function taking `db` — testable via in-memory SQLite.
|
|
244
|
-
*/
|
|
245
|
-
export function reconcileAbandonedPlans(db: Database, sessionId: string, endedBy: string): number {
|
|
246
|
-
// Find plans in non-terminal statuses belonging to this session
|
|
247
|
-
const rows = db
|
|
248
|
-
.query(
|
|
249
|
-
`SELECT id, metadata FROM plans
|
|
250
|
-
WHERE session_id = ? AND status IN ('executing', 'approved') AND archived_at IS NULL`,
|
|
251
|
-
)
|
|
252
|
-
.all(sessionId) as Array<{ id: string; metadata: string | null }>;
|
|
253
|
-
|
|
254
|
-
const now = Date.now();
|
|
255
|
-
for (const row of rows) {
|
|
256
|
-
// Merge reason into existing metadata
|
|
257
|
-
const existingMeta = row.metadata ? JSON.parse(row.metadata) : {};
|
|
258
|
-
const updatedMeta = {
|
|
259
|
-
...existingMeta,
|
|
260
|
-
reason: "session_ended",
|
|
261
|
-
endedBy,
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
db.query(
|
|
265
|
-
`UPDATE plans SET status = 'abandoned', updated_at = ?, updated_by = ?, metadata = ?
|
|
266
|
-
WHERE id = ?`,
|
|
267
|
-
).run(now, endedBy, JSON.stringify(updatedMeta), row.id);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return rows.length;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// ─── Public types ────────────────────────────────────────────────────────────
|
|
274
|
-
|
|
275
|
-
export type NdomoPluginOptions = {
|
|
276
|
-
preset?: "default" | "budget" | undefined;
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
export type NdomoConfig = {
|
|
280
|
-
$schema: string;
|
|
281
|
-
preset?: "default" | "budget" | undefined;
|
|
282
|
-
plugins: string[];
|
|
283
|
-
optionalPlugins?: string[] | undefined;
|
|
284
|
-
agentRouting: Record<
|
|
285
|
-
string,
|
|
286
|
-
{ description: string; mode: "primary" | "subagent" | "all"; delegates_to: string[] }
|
|
287
|
-
>;
|
|
288
|
-
protectedTools: string[];
|
|
289
|
-
caveman: { intensity: "lite" | "full" | "ultra"; autoClarity: boolean };
|
|
290
|
-
presets: Record<
|
|
291
|
-
string,
|
|
292
|
-
Record<string, { model: string; temperature: number; reasoning_effort?: string }>
|
|
293
|
-
>;
|
|
294
|
-
dcp_overrides?: Record<string, { minContextLimit: number; maxContextLimit: number }> | undefined;
|
|
295
|
-
mem: {
|
|
296
|
-
storagePath: string;
|
|
297
|
-
defaultScope: "project" | "all-projects";
|
|
298
|
-
autoCaptureEnabled: boolean;
|
|
299
|
-
cavemanCompress: boolean;
|
|
300
|
-
};
|
|
301
|
-
autoCheckpoint?: {
|
|
302
|
-
enabled?: boolean;
|
|
303
|
-
triggers?: string[];
|
|
304
|
-
minIntervalMs?: number;
|
|
305
|
-
captureState?: {
|
|
306
|
-
completedTasks?: boolean;
|
|
307
|
-
currentPhase?: boolean;
|
|
308
|
-
blockers?: boolean;
|
|
309
|
-
};
|
|
310
|
-
};
|
|
311
|
-
backgroundRetention?: {
|
|
312
|
-
softCap?: number;
|
|
313
|
-
maxAgeMs?: number;
|
|
314
|
-
};
|
|
315
|
-
fileLock?: {
|
|
316
|
-
/** TTL for write/edit locks in ms. Stale entries auto-release via sweep. */
|
|
317
|
-
ttlMs?: number;
|
|
318
|
-
};
|
|
319
|
-
/** HTTP server configuration. Loaded from environment variables if not set. */
|
|
320
|
-
http?: import("./config/schema.ts").HttpConfig;
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Load ndomo.json from the user's OpenCode config directory.
|
|
325
|
-
* Returns null if the file is missing or invalid; logs a warning either way.
|
|
326
|
-
*/
|
|
327
|
-
export function loadNdomoConfig(configPath?: string): NdomoConfig | null {
|
|
328
|
-
const path = configPath ?? join(homedir(), ".config", "opencode", "ndomo.json");
|
|
329
|
-
try {
|
|
330
|
-
if (!existsSync(path)) {
|
|
331
|
-
console.warn(`[ndomo] config not found at ${path} — using built-in defaults`);
|
|
332
|
-
return null;
|
|
333
|
-
}
|
|
334
|
-
const raw = readFileSync(path, "utf-8");
|
|
335
|
-
const parsed = JSON.parse(raw) as NdomoConfig;
|
|
336
|
-
// Minimal validation: plugins array is the only hard requirement
|
|
337
|
-
if (!parsed.plugins || !Array.isArray(parsed.plugins)) {
|
|
338
|
-
console.warn(`[ndomo] invalid config at ${path}: missing plugins array`);
|
|
339
|
-
return null;
|
|
340
|
-
}
|
|
341
|
-
return parsed;
|
|
342
|
-
} catch (err) {
|
|
343
|
-
console.warn(
|
|
344
|
-
`[ndomo] failed to load config at ${path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
345
|
-
);
|
|
346
|
-
return null;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Validate an agent name to prevent path traversal via malicious preset keys.
|
|
352
|
-
* Rejects names containing path separators, "..", or other unsafe characters.
|
|
353
|
-
*/
|
|
354
|
-
function validateAgentName(name: string): void {
|
|
355
|
-
if (typeof name !== "string" || !/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
356
|
-
throw new Error(`[ndomo] invalid agent name "${name}" — must match [a-zA-Z0-9_-]+`);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Sync agent `.md` frontmatter (model, temperature) from ndomo config presets.
|
|
362
|
-
* Allows hot-swapping agent models by editing `ndomo.json::presets[preset][agent].model`
|
|
363
|
-
* so the next OpenCode session picks up the new values via rewrite of
|
|
364
|
-
* `~/.config/opencode/agent/<agent>.md` frontmatter.
|
|
365
|
-
*
|
|
366
|
-
* Opt out via env `NDOMO_SKIP_FRONTMATTER_SYNC=1`.
|
|
367
|
-
* Also syncs reasoningEffort: (camelCase) when spec.reasoning_effort (snake_case) is set.
|
|
368
|
-
*/
|
|
369
|
-
export function syncAgentFrontmatter(
|
|
370
|
-
ndomoConfig: NdomoConfig,
|
|
371
|
-
effectivePreset: string,
|
|
372
|
-
agentsDir?: string,
|
|
373
|
-
): { synced: number; skipped: number; errors: number } {
|
|
374
|
-
let synced = 0;
|
|
375
|
-
let skipped = 0;
|
|
376
|
-
let errors = 0;
|
|
377
|
-
if (process.env.NDOMO_SKIP_FRONTMATTER_SYNC === "1") {
|
|
378
|
-
console.log("[ndomo] frontmatter sync skipped (NDOMO_SKIP_FRONTMATTER_SYNC=1)");
|
|
379
|
-
return { synced, skipped, errors };
|
|
380
|
-
}
|
|
381
|
-
const dir = agentsDir ?? join(homedir(), ".config", "opencode", "agent");
|
|
382
|
-
const preset = ndomoConfig?.presets?.[effectivePreset];
|
|
383
|
-
if (!preset || typeof preset !== "object") {
|
|
384
|
-
console.warn(`[ndomo] frontmatter sync: preset '${effectivePreset}' not found in config`);
|
|
385
|
-
return { synced, skipped, errors };
|
|
386
|
-
}
|
|
387
|
-
for (const [agentName, spec] of Object.entries(preset)) {
|
|
388
|
-
try {
|
|
389
|
-
validateAgentName(agentName);
|
|
390
|
-
} catch (err) {
|
|
391
|
-
console.warn(err instanceof Error ? err.message : String(err));
|
|
392
|
-
skipped++;
|
|
393
|
-
continue;
|
|
394
|
-
}
|
|
395
|
-
const agentPath = join(dir, `${agentName}.md`);
|
|
396
|
-
if (!existsSync(agentPath)) {
|
|
397
|
-
console.warn(`[ndomo] frontmatter sync: agent file not found ${agentPath}`);
|
|
398
|
-
errors++;
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
401
|
-
try {
|
|
402
|
-
const original = readFileSync(agentPath, "utf-8");
|
|
403
|
-
let updated = original;
|
|
404
|
-
if (spec?.model != null) {
|
|
405
|
-
const newModelLine = `model: ${spec.model}`;
|
|
406
|
-
const cur = original.match(/^model:.*$/m)?.[0];
|
|
407
|
-
if (cur !== newModelLine) {
|
|
408
|
-
updated = updated.replace(/^model:.*$/m, newModelLine);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
if (spec?.temperature != null) {
|
|
412
|
-
const newTempLine = `temperature: ${spec.temperature}`;
|
|
413
|
-
const cur = original.match(/^temperature:.*$/m)?.[0];
|
|
414
|
-
if (cur !== newTempLine) {
|
|
415
|
-
updated = updated.replace(/^temperature:.*$/m, newTempLine);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
if (spec?.reasoning_effort != null && spec.reasoning_effort !== "") {
|
|
419
|
-
const newEffortLine = `reasoningEffort: ${spec.reasoning_effort}`;
|
|
420
|
-
const cur = original.match(/^reasoningEffort:.*$/m)?.[0];
|
|
421
|
-
if (cur === newEffortLine) {
|
|
422
|
-
// already in sync, no-op
|
|
423
|
-
} else if (cur != null) {
|
|
424
|
-
// line exists with a different value → update in place
|
|
425
|
-
updated = updated.replace(/^reasoningEffort:.*$/m, newEffortLine);
|
|
426
|
-
} else {
|
|
427
|
-
// line missing → insert after temperature: line (or after model: if no temperature, or after the opening --- as last resort)
|
|
428
|
-
if (updated.match(/^temperature:.*$/m)) {
|
|
429
|
-
updated = updated.replace(/^(temperature:.*)$/m, `$1\n${newEffortLine}`);
|
|
430
|
-
} else if (updated.match(/^model:.*$/m)) {
|
|
431
|
-
updated = updated.replace(/^(model:.*)$/m, `$1\n${newEffortLine}`);
|
|
432
|
-
} else {
|
|
433
|
-
updated = updated.replace(/^(---.*)$/m, `$1\n${newEffortLine}`);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
if (updated === original) {
|
|
438
|
-
skipped++;
|
|
439
|
-
} else {
|
|
440
|
-
writeFileSync(agentPath, updated, "utf-8");
|
|
441
|
-
synced++;
|
|
442
|
-
}
|
|
443
|
-
} catch (err) {
|
|
444
|
-
console.warn(
|
|
445
|
-
`[ndomo] frontmatter sync: failed to sync ${agentName}: ${err instanceof Error ? err.message : String(err)}`,
|
|
446
|
-
);
|
|
447
|
-
errors++;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
console.log(
|
|
451
|
-
`[ndomo] frontmatter sync: preset=${effectivePreset} synced=${synced} skipped=${skipped} errors=${errors}`,
|
|
452
|
-
);
|
|
453
|
-
return { synced, skipped, errors };
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// ─── Plugin entry ────────────────────────────────────────────────────────────
|
|
457
|
-
|
|
458
|
-
export const NdomoPlugin: Plugin = async (
|
|
459
|
-
input: PluginInput,
|
|
460
|
-
options?: Record<string, unknown>,
|
|
461
|
-
): Promise<Hooks> => {
|
|
462
|
-
const { directory, worktree } = input;
|
|
463
|
-
const opts = (options ?? {}) as NdomoPluginOptions;
|
|
464
|
-
|
|
465
|
-
// Load ndomo.json config (gracefully degrades to null if missing/corrupt)
|
|
466
|
-
const ndomoConfig = loadNdomoConfig();
|
|
467
|
-
const effectivePreset = opts.preset ?? ndomoConfig?.preset ?? "default";
|
|
468
|
-
if (ndomoConfig) {
|
|
469
|
-
console.log(
|
|
470
|
-
`[ndomo] loaded config: preset=${effectivePreset} agents=${Object.keys(ndomoConfig.agentRouting).length} plugins=${ndomoConfig.plugins.length}`,
|
|
471
|
-
);
|
|
472
|
-
}
|
|
473
|
-
if (ndomoConfig) {
|
|
474
|
-
syncAgentFrontmatter(ndomoConfig, effectivePreset);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// HTTP config — merge from ndomoConfig.http or load from environment variables
|
|
478
|
-
const httpConfig = ndomoConfig?.http ?? loadHttpConfig();
|
|
479
|
-
if (httpConfig.enabled) {
|
|
480
|
-
console.log(
|
|
481
|
-
`[ndomo] HTTP server enabled: port=${httpConfig.port} auth=${httpConfig.auth.required} cors_origins=${httpConfig.cors.origins.length}`,
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// Shared state — lives for the lifetime of the plugin instance
|
|
486
|
-
const db: Database = openDb(resolveProjectDir({ worktree, directory }));
|
|
487
|
-
runMigrations(db);
|
|
488
|
-
registerShutdownHandlers(db);
|
|
489
|
-
const dispatcher = new BackgroundDispatcher(db);
|
|
490
|
-
|
|
491
|
-
// ─── SDK Client (for SSE events) ─────────────────────────────────────────────
|
|
492
|
-
let sdkClient: import("@opencode-ai/sdk/client").OpencodeClient | null = null;
|
|
493
|
-
if (httpConfig.enabled) {
|
|
494
|
-
try {
|
|
495
|
-
const handle = await getSdkClient();
|
|
496
|
-
sdkClient = handle.client;
|
|
497
|
-
console.log(`[ndomo] OpenCode SDK client connected: ${handle.baseUrl}`);
|
|
498
|
-
} catch (err) {
|
|
499
|
-
console.warn(
|
|
500
|
-
`[ndomo] OpenCode SDK client unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
501
|
-
);
|
|
502
|
-
console.warn(`[ndomo] /api/events will return 503 until SDK becomes reachable`);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// ─── HTTP Server ──────────────────────────────────────────────────────────
|
|
507
|
-
let httpServerHandle: HttpServerHandle | null = null;
|
|
508
|
-
if (httpConfig.enabled) {
|
|
509
|
-
try {
|
|
510
|
-
httpServerHandle = await startHttpServer({
|
|
511
|
-
db,
|
|
512
|
-
httpConfig,
|
|
513
|
-
...(sdkClient ? { sdkClient } : {}),
|
|
514
|
-
});
|
|
515
|
-
console.log(`[ndomo] HTTP server listening on port ${httpServerHandle.port}`);
|
|
516
|
-
} catch (err) {
|
|
517
|
-
console.error(
|
|
518
|
-
`[ndomo] HTTP server failed to start: ${err instanceof Error ? err.message : String(err)}`,
|
|
519
|
-
);
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// HTTP shutdown — separate from DB shutdown (registerShutdownHandlers uses process.once
|
|
524
|
-
// which self-removes; adding our own listener avoids modifying shared shutdown module).
|
|
525
|
-
let httpStopped = false;
|
|
526
|
-
const stopHttpServer = (): void => {
|
|
527
|
-
if (httpStopped || !httpServerHandle) return;
|
|
528
|
-
httpStopped = true;
|
|
529
|
-
httpServerHandle.stop().catch(() => {});
|
|
530
|
-
};
|
|
531
|
-
process.on("SIGINT", stopHttpServer);
|
|
532
|
-
process.on("SIGTERM", stopHttpServer);
|
|
533
|
-
|
|
534
|
-
// Background task retention — auto-finalize terminal tasks when row count
|
|
535
|
-
// exceeds soft cap. Defaults: soft cap 1000 rows, max age 24h. Prevents
|
|
536
|
-
// unbounded growth of background_tasks on long-running installs (audit
|
|
537
|
-
// finding fcb12dc5 #1).
|
|
538
|
-
const retentionSoftCap = ndomoConfig?.backgroundRetention?.softCap ?? 1000;
|
|
539
|
-
const retentionMaxAgeMs = ndomoConfig?.backgroundRetention?.maxAgeMs ?? 24 * 60 * 60 * 1000;
|
|
540
|
-
const totalRows =
|
|
541
|
-
dispatcher.stats().pending +
|
|
542
|
-
dispatcher.stats().running +
|
|
543
|
-
dispatcher.stats().completed +
|
|
544
|
-
dispatcher.stats().failed +
|
|
545
|
-
dispatcher.stats().cancelled;
|
|
546
|
-
if (totalRows > retentionSoftCap) {
|
|
547
|
-
const deleted = dispatcher.finalize(retentionMaxAgeMs);
|
|
548
|
-
if (deleted > 0) {
|
|
549
|
-
// eslint-disable-next-line no-console
|
|
550
|
-
console.log(
|
|
551
|
-
`[ndomo] background retention: pruned ${deleted} terminal tasks older than ${retentionMaxAgeMs}ms (rows were ${totalRows} > soft cap ${retentionSoftCap})`,
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/** filepath → `${sessionID}:${callID}` of the task that locked it. */
|
|
557
|
-
const fileLockTtlMs = ndomoConfig?.fileLock?.ttlMs ?? 60_000;
|
|
558
|
-
const activeWrites = new FileLock(fileLockTtlMs);
|
|
559
|
-
|
|
560
|
-
// Auto-checkpoint dispatcher (T3.3)
|
|
561
|
-
const autoCheckpoint = new AutoCheckpointDispatcher(db, ndomoConfig?.autoCheckpoint);
|
|
562
|
-
|
|
563
|
-
// ─── Hooks ───────────────────────────────────────────────────────────────
|
|
564
|
-
|
|
565
|
-
const hooks: Hooks = {
|
|
566
|
-
// (a) Inject orchestrator state into session compaction context
|
|
567
|
-
"experimental.session.compacting": async (input, output) => {
|
|
568
|
-
// Sweep stale write locks before snapshotting state — surfaces the
|
|
569
|
-
// true current lock count after any prior SDK hook-miss leaks.
|
|
570
|
-
const swept = activeWrites.sweep();
|
|
571
|
-
const count = dispatcher.getActive().length;
|
|
572
|
-
const paths = activeWrites.keys().join(", ");
|
|
573
|
-
if (swept > 0) {
|
|
574
|
-
// eslint-disable-next-line no-console
|
|
575
|
-
console.log(`[ndomo] file-lock: swept ${swept} stale entries during compaction`);
|
|
576
|
-
}
|
|
577
|
-
output.context.push(
|
|
578
|
-
[
|
|
579
|
-
"",
|
|
580
|
-
"## ndomo orchestrator state",
|
|
581
|
-
`- Active tasks: ${count}`,
|
|
582
|
-
`- Active writes: ${paths || "(none)"}`,
|
|
583
|
-
`- Project: ${worktree || directory}`,
|
|
584
|
-
"",
|
|
585
|
-
].join("\n"),
|
|
586
|
-
);
|
|
587
|
-
|
|
588
|
-
// Enrich compaction context with DB state
|
|
589
|
-
try {
|
|
590
|
-
const sessionId = input.sessionID ?? "";
|
|
591
|
-
if (sessionId) {
|
|
592
|
-
const activePlans = listPlans(db, { sessionId }).filter(
|
|
593
|
-
(p) => p.status === "approved" || p.status === "executing",
|
|
594
|
-
);
|
|
595
|
-
if (activePlans.length > 0) {
|
|
596
|
-
output.context.push(
|
|
597
|
-
`\n## ndomo active plans\n${JSON.stringify(
|
|
598
|
-
activePlans.map((p) => ({
|
|
599
|
-
id: p.id,
|
|
600
|
-
slug: p.slug,
|
|
601
|
-
title: p.title,
|
|
602
|
-
status: p.status,
|
|
603
|
-
tasks: listTasksByPlan(db, p.id).length,
|
|
604
|
-
})),
|
|
605
|
-
null,
|
|
606
|
-
2,
|
|
607
|
-
)}`,
|
|
608
|
-
);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
const recentSessions = listSessions(db, { limit: 3 });
|
|
612
|
-
if (recentSessions.length > 0) {
|
|
613
|
-
output.context.push(
|
|
614
|
-
`\n## ndomo recent sessions\n${JSON.stringify(
|
|
615
|
-
recentSessions.map((s) => ({
|
|
616
|
-
id: s.id,
|
|
617
|
-
goal: s.goal.slice(0, 100),
|
|
618
|
-
endedAt: s.endedAt,
|
|
619
|
-
keyDecisions: s.keyDecisions?.slice(0, 200) ?? null,
|
|
620
|
-
})),
|
|
621
|
-
null,
|
|
622
|
-
2,
|
|
623
|
-
)}`,
|
|
624
|
-
);
|
|
625
|
-
}
|
|
626
|
-
} catch (err) {
|
|
627
|
-
// DB errors should not break compaction
|
|
628
|
-
console.log("ndomo: compaction DB enrichment failed", (err as Error).message);
|
|
629
|
-
}
|
|
630
|
-
},
|
|
631
|
-
|
|
632
|
-
// (b) Enforce no-overlap rule for write/edit tools
|
|
633
|
-
"tool.execute.before": async (input, output) => {
|
|
634
|
-
if (input.tool !== "write" && input.tool !== "edit") return;
|
|
635
|
-
|
|
636
|
-
const filepath = extractFilePath(output.args);
|
|
637
|
-
if (!filepath) return;
|
|
638
|
-
|
|
639
|
-
const key = `${input.sessionID}:${input.callID}`;
|
|
640
|
-
const blockedBy = activeWrites.acquire(filepath, key);
|
|
641
|
-
if (blockedBy != null) {
|
|
642
|
-
throw new Error(`ndomo: file locked by active task ${blockedBy}`);
|
|
643
|
-
}
|
|
644
|
-
},
|
|
645
|
-
|
|
646
|
-
// (c) Remove filepath from activeWrites after tool completes — wrapped in
|
|
647
|
-
// try/finally so the lock releases even if downstream hook logic throws
|
|
648
|
-
// or the SDK aborts the chain mid-way (regression: lock leaks blocked
|
|
649
|
-
// subsequent writes indefinitely).
|
|
650
|
-
"tool.execute.after": async (input) => {
|
|
651
|
-
try {
|
|
652
|
-
// (future) post-write hooks (audit, git staging) go here
|
|
653
|
-
} finally {
|
|
654
|
-
if (input.tool !== "write" && input.tool !== "edit") return;
|
|
655
|
-
const filepath = extractFilePath(input.args);
|
|
656
|
-
if (filepath) {
|
|
657
|
-
const key = `${input.sessionID}:${input.callID}`;
|
|
658
|
-
activeWrites.release(filepath, key);
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
},
|
|
662
|
-
|
|
663
|
-
// (d) Note: `file.edited` hook is NOT present in @opencode-ai/plugin v1.17.7.
|
|
664
|
-
// The SDK's Hooks type does not include it. Logging file events must be
|
|
665
|
-
// handled via a different mechanism (e.g. tool.execute.after filtering).
|
|
666
|
-
|
|
667
|
-
// (e) Inject ndomo env vars into shell sessions
|
|
668
|
-
"shell.env": async (_input, output) => {
|
|
669
|
-
output.env.NDOMO_PRESET = opts.preset ?? "default";
|
|
670
|
-
output.env.NDOMO_PROJECT = worktree || directory;
|
|
671
|
-
},
|
|
672
|
-
|
|
673
|
-
// ─── Tools ───────────────────────────────────────────────────────────
|
|
674
|
-
|
|
675
|
-
tool: {
|
|
676
|
-
// ── Routing ────────────────────────────────────────────────────────
|
|
677
|
-
|
|
678
|
-
route: tool({
|
|
679
|
-
description: "Route a task to the appropriate specialist agent.",
|
|
680
|
-
args: {
|
|
681
|
-
description: tool.schema.string(),
|
|
682
|
-
type: tool.schema.enum([
|
|
683
|
-
"implement",
|
|
684
|
-
"explore",
|
|
685
|
-
"research",
|
|
686
|
-
"design",
|
|
687
|
-
"debug",
|
|
688
|
-
"audit",
|
|
689
|
-
"document",
|
|
690
|
-
"debate",
|
|
691
|
-
]),
|
|
692
|
-
stack: tool.schema
|
|
693
|
-
.enum(["go", "vue", "js", "python", "zig", "generic", "unknown"])
|
|
694
|
-
.optional(),
|
|
695
|
-
risk: tool.schema.enum(["low", "medium", "high"]).optional(),
|
|
696
|
-
files: tool.schema.array(tool.schema.string()).optional(),
|
|
697
|
-
},
|
|
698
|
-
execute: async (args) => {
|
|
699
|
-
const decision = routeTask({
|
|
700
|
-
description: args.description,
|
|
701
|
-
type: args.type,
|
|
702
|
-
stack: args.stack ?? "unknown",
|
|
703
|
-
risk: args.risk ?? "low",
|
|
704
|
-
files: args.files ?? [],
|
|
705
|
-
});
|
|
706
|
-
return JSON.stringify(decision);
|
|
707
|
-
},
|
|
708
|
-
}),
|
|
709
|
-
|
|
710
|
-
can_parallel: tool({
|
|
711
|
-
description: "Check whether a set of routing decisions can run in parallel.",
|
|
712
|
-
args: {
|
|
713
|
-
tasks: tool.schema.string(),
|
|
714
|
-
},
|
|
715
|
-
execute: async (args) => {
|
|
716
|
-
let parsed: RoutingDecision[];
|
|
717
|
-
try {
|
|
718
|
-
parsed = JSON.parse(args.tasks) as RoutingDecision[];
|
|
719
|
-
} catch {
|
|
720
|
-
throw new Error(
|
|
721
|
-
"ndomo: invalid JSON in tasks parameter — expected array of RoutingDecision",
|
|
722
|
-
);
|
|
723
|
-
}
|
|
724
|
-
const parallel = canRunParallel(parsed);
|
|
725
|
-
return JSON.stringify({ parallel });
|
|
726
|
-
},
|
|
727
|
-
}),
|
|
728
|
-
|
|
729
|
-
// ── Background dispatch ────────────────────────────────────────────
|
|
730
|
-
|
|
731
|
-
dispatch: tool({
|
|
732
|
-
description: "Dispatch a background task to a specialist agent and return its task ID.",
|
|
733
|
-
args: {
|
|
734
|
-
agent: tool.schema.string(),
|
|
735
|
-
description: tool.schema.string(),
|
|
736
|
-
files: tool.schema.array(tool.schema.string()).optional(),
|
|
737
|
-
worktree: tool.schema.string().optional(),
|
|
738
|
-
},
|
|
739
|
-
execute: async (args) => {
|
|
740
|
-
const taskId = dispatcher.dispatch({
|
|
741
|
-
agent: args.agent,
|
|
742
|
-
description: args.description,
|
|
743
|
-
...(args.files !== undefined && { files: args.files }),
|
|
744
|
-
...(args.worktree !== undefined && { worktree: args.worktree }),
|
|
745
|
-
});
|
|
746
|
-
return JSON.stringify({ taskId, status: "pending" });
|
|
747
|
-
},
|
|
748
|
-
}),
|
|
749
|
-
|
|
750
|
-
active_tasks: tool({
|
|
751
|
-
description: "List all currently active (pending + running) tasks.",
|
|
752
|
-
args: {},
|
|
753
|
-
execute: async () => {
|
|
754
|
-
return JSON.stringify(dispatcher.getActive());
|
|
755
|
-
},
|
|
756
|
-
}),
|
|
757
|
-
|
|
758
|
-
background_task_status: tool({
|
|
759
|
-
description: "Get the status of a background task by ID.",
|
|
760
|
-
args: { taskId: tool.schema.string() },
|
|
761
|
-
execute: async (args) => {
|
|
762
|
-
const task = dispatcher.getStatus(args.taskId);
|
|
763
|
-
if (!task) throw new Error(`ndomo: background task ${args.taskId} not found`);
|
|
764
|
-
return JSON.stringify(task);
|
|
765
|
-
},
|
|
766
|
-
}),
|
|
767
|
-
|
|
768
|
-
background_task_cancel: tool({
|
|
769
|
-
description:
|
|
770
|
-
"Cancel a pending or running background task. Returns true if cancelled, false if task was already terminal.",
|
|
771
|
-
args: { taskId: tool.schema.string() },
|
|
772
|
-
execute: async (args) => {
|
|
773
|
-
const cancelled = dispatcher.cancel(args.taskId);
|
|
774
|
-
return JSON.stringify({ taskId: args.taskId, cancelled });
|
|
775
|
-
},
|
|
776
|
-
}),
|
|
777
|
-
|
|
778
|
-
// ── Worktrees ──────────────────────────────────────────────────────
|
|
779
|
-
|
|
780
|
-
worktree_create: tool({
|
|
781
|
-
description: "Create a new git worktree for isolated coding.",
|
|
782
|
-
args: {
|
|
783
|
-
slug: tool.schema.string(),
|
|
784
|
-
branch: tool.schema.string(),
|
|
785
|
-
agent: tool.schema.string().optional(),
|
|
786
|
-
description: tool.schema.string().optional(),
|
|
787
|
-
},
|
|
788
|
-
execute: async (args, ctx) => {
|
|
789
|
-
const path = await createWorktree(
|
|
790
|
-
ctx.directory,
|
|
791
|
-
args.slug,
|
|
792
|
-
args.branch,
|
|
793
|
-
args.agent,
|
|
794
|
-
args.description,
|
|
795
|
-
);
|
|
796
|
-
return JSON.stringify({ path, slug: args.slug, branch: args.branch });
|
|
797
|
-
},
|
|
798
|
-
}),
|
|
799
|
-
|
|
800
|
-
worktree_list: tool({
|
|
801
|
-
description: "List all active worktrees in the current project.",
|
|
802
|
-
args: {},
|
|
803
|
-
execute: async (_args, ctx) => {
|
|
804
|
-
return JSON.stringify(await listActive(ctx.directory));
|
|
805
|
-
},
|
|
806
|
-
}),
|
|
807
|
-
|
|
808
|
-
worktree_remove: tool({
|
|
809
|
-
description: "Remove a git worktree by slug.",
|
|
810
|
-
args: {
|
|
811
|
-
slug: tool.schema.string(),
|
|
812
|
-
abandon: tool.schema.boolean().optional(),
|
|
813
|
-
},
|
|
814
|
-
execute: async (args, ctx) => {
|
|
815
|
-
await removeWorktree(ctx.directory, args.slug, args.abandon ?? false);
|
|
816
|
-
return JSON.stringify({ removed: true, slug: args.slug });
|
|
817
|
-
},
|
|
818
|
-
}),
|
|
819
|
-
|
|
820
|
-
worktree_verify: tool({
|
|
821
|
-
description: "Verify integrity of all active worktrees.",
|
|
822
|
-
args: {},
|
|
823
|
-
execute: async (_args, ctx) => {
|
|
824
|
-
return JSON.stringify(await verifyIntegrity(ctx.directory));
|
|
825
|
-
},
|
|
826
|
-
}),
|
|
827
|
-
|
|
828
|
-
// ── Memory ─────────────────────────────────────────────────────────
|
|
829
|
-
|
|
830
|
-
memory_search: tool({
|
|
831
|
-
description:
|
|
832
|
-
"Build memory search options for opencode-mem. The foreman agent passes the result to its mem tool.",
|
|
833
|
-
args: {
|
|
834
|
-
query: tool.schema.string(),
|
|
835
|
-
scope: tool.schema.enum(["project", "all-projects"]).optional(),
|
|
836
|
-
},
|
|
837
|
-
execute: async (args, ctx) => {
|
|
838
|
-
const tag = getProjectTag(ctx.directory);
|
|
839
|
-
const compressedQuery = cavemanCompress(args.query);
|
|
840
|
-
const options = memorySearchOptions(compressedQuery, args.scope ?? "project");
|
|
841
|
-
return JSON.stringify({ tag, options });
|
|
842
|
-
},
|
|
843
|
-
}),
|
|
844
|
-
|
|
845
|
-
memory_compress: tool({
|
|
846
|
-
description: "Compress arbitrary text into caveman format.",
|
|
847
|
-
args: {
|
|
848
|
-
text: tool.schema.string(),
|
|
849
|
-
},
|
|
850
|
-
execute: async (args) => {
|
|
851
|
-
const result = cavemanCompress(args.text);
|
|
852
|
-
return JSON.stringify({
|
|
853
|
-
original: args.text.length,
|
|
854
|
-
compressed: result.length,
|
|
855
|
-
result,
|
|
856
|
-
});
|
|
857
|
-
},
|
|
858
|
-
}),
|
|
859
|
-
|
|
860
|
-
// ── Health ─────────────────────────────────────────────────────────
|
|
861
|
-
|
|
862
|
-
ndomo_write_unlock: tool({
|
|
863
|
-
description:
|
|
864
|
-
"Admin: force-release a write/edit lock on a filepath. Use when a prior tool execution crashed or its SDK hook chain broke before `tool.execute.after` fired, leaving a stale lock. TTL sweep also handles this automatically — this tool is for manual recovery.",
|
|
865
|
-
args: {
|
|
866
|
-
filepath: tool.schema.string(),
|
|
867
|
-
},
|
|
868
|
-
execute: async (args) => {
|
|
869
|
-
const released = activeWrites.forceRelease(args.filepath);
|
|
870
|
-
return JSON.stringify({
|
|
871
|
-
filepath: args.filepath,
|
|
872
|
-
released,
|
|
873
|
-
activeWritesRemaining: activeWrites.size,
|
|
874
|
-
});
|
|
875
|
-
},
|
|
876
|
-
}),
|
|
877
|
-
|
|
878
|
-
status: tool({
|
|
879
|
-
description: "Plugin health check — returns ndomo state summary.",
|
|
880
|
-
args: {},
|
|
881
|
-
execute: async (_args, ctx) => {
|
|
882
|
-
return JSON.stringify({
|
|
883
|
-
plugin: "ndomo",
|
|
884
|
-
version: "0.1.0",
|
|
885
|
-
directory: ctx.directory,
|
|
886
|
-
worktree: ctx.worktree || null,
|
|
887
|
-
activeTasks: dispatcher.getActive().length,
|
|
888
|
-
activeWrites: activeWrites.size,
|
|
889
|
-
preset: opts.preset ?? "default",
|
|
890
|
-
});
|
|
891
|
-
},
|
|
892
|
-
}),
|
|
893
|
-
|
|
894
|
-
// ── Plans ──────────────────────────────────────────────────────
|
|
895
|
-
|
|
896
|
-
plan_create: tool({
|
|
897
|
-
description: "Create a new plan in the ndomo state database.",
|
|
898
|
-
args: {
|
|
899
|
-
slug: tool.schema.string(),
|
|
900
|
-
title: tool.schema.string(),
|
|
901
|
-
overview: tool.schema.string(),
|
|
902
|
-
approach: tool.schema.string().optional(),
|
|
903
|
-
priority: tool.schema.number().optional(),
|
|
904
|
-
complexity: tool.schema.number().int().min(1).max(5).optional(),
|
|
905
|
-
sessionId: tool.schema.string().optional(),
|
|
906
|
-
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional(),
|
|
907
|
-
files: tool.schema.array(tool.schema.string()).optional(),
|
|
908
|
-
},
|
|
909
|
-
execute: async (args, ctx) => {
|
|
910
|
-
return JSON.stringify(
|
|
911
|
-
planCreateExecutor(db, args, { ...ctx, agent: ctx.agent ?? "unknown" }),
|
|
912
|
-
);
|
|
913
|
-
},
|
|
914
|
-
}),
|
|
915
|
-
|
|
916
|
-
plan_get: tool({
|
|
917
|
-
description: "Get a plan by ID or slug.",
|
|
918
|
-
args: {
|
|
919
|
-
id: tool.schema.string().optional(),
|
|
920
|
-
slug: tool.schema.string().optional(),
|
|
921
|
-
},
|
|
922
|
-
execute: async (args) => {
|
|
923
|
-
if (!args.id && !args.slug) {
|
|
924
|
-
throw new Error("ndomo: plan_get requires id or slug");
|
|
925
|
-
}
|
|
926
|
-
let plan = null;
|
|
927
|
-
if (args.id) {
|
|
928
|
-
plan = getPlan(db, args.id);
|
|
929
|
-
} else if (args.slug) {
|
|
930
|
-
plan = getPlanBySlug(db, args.slug);
|
|
931
|
-
}
|
|
932
|
-
return JSON.stringify(plan);
|
|
933
|
-
},
|
|
934
|
-
}),
|
|
935
|
-
|
|
936
|
-
plan_list: tool({
|
|
937
|
-
description: "List plans, optionally filtered by status and session.",
|
|
938
|
-
args: {
|
|
939
|
-
status: tool.schema
|
|
940
|
-
.enum(["draft", "approved", "executing", "completed", "failed", "abandoned"])
|
|
941
|
-
.optional(),
|
|
942
|
-
sessionId: tool.schema.string().optional(),
|
|
943
|
-
limit: tool.schema.number().optional(),
|
|
944
|
-
},
|
|
945
|
-
execute: async (args) => {
|
|
946
|
-
const opts: { status?: PlanStatus; sessionId?: string; limit?: number } = {};
|
|
947
|
-
if (args.status) opts.status = args.status;
|
|
948
|
-
if (args.sessionId) opts.sessionId = args.sessionId;
|
|
949
|
-
if (args.limit !== undefined) opts.limit = args.limit;
|
|
950
|
-
return JSON.stringify(listPlans(db, opts));
|
|
951
|
-
},
|
|
952
|
-
}),
|
|
953
|
-
|
|
954
|
-
plan_search: tool({
|
|
955
|
-
description:
|
|
956
|
-
"Full-text search over plan titles, overviews, and approaches using SQLite FTS5.",
|
|
957
|
-
args: {
|
|
958
|
-
query: tool.schema.string(),
|
|
959
|
-
limit: tool.schema.number().optional(),
|
|
960
|
-
includeArchived: tool.schema.boolean().optional(),
|
|
961
|
-
},
|
|
962
|
-
execute: async (args) => {
|
|
963
|
-
return JSON.stringify(
|
|
964
|
-
searchPlans(db, args.query, args.limit ?? 20, {
|
|
965
|
-
includeArchived: args.includeArchived ?? false,
|
|
966
|
-
}),
|
|
967
|
-
);
|
|
968
|
-
},
|
|
969
|
-
}),
|
|
970
|
-
|
|
971
|
-
/**
|
|
972
|
-
* LEGACY: El flujo v2 de foreman (4 pasos) skip este tool.
|
|
973
|
-
* Solo invocado manualmente si quieres gating explícito antes de ejecutar.
|
|
974
|
-
* v2 flow: plan_create (draft) → task_create_batch (dispatch directo).
|
|
975
|
-
*/
|
|
976
|
-
plan_approve: tool({
|
|
977
|
-
description: "Mark a plan as approved. Sets approved_at to the current timestamp.",
|
|
978
|
-
args: { id: tool.schema.string() },
|
|
979
|
-
execute: async (args, ctx) => {
|
|
980
|
-
return JSON.stringify(
|
|
981
|
-
approvePlan(db, args.id, {
|
|
982
|
-
sessionId: ctx.sessionID,
|
|
983
|
-
updatedBy: ctx.agent ?? "unknown",
|
|
984
|
-
}),
|
|
985
|
-
);
|
|
986
|
-
},
|
|
987
|
-
}),
|
|
988
|
-
|
|
989
|
-
plan_delete: tool({
|
|
990
|
-
description:
|
|
991
|
-
"Permanently delete a plan and all its data (tasks, files, tags). Requires confirm: true. Rejects draft plans and plans with active tasks.",
|
|
992
|
-
args: {
|
|
993
|
-
id: tool.schema.string(),
|
|
994
|
-
confirm: tool.schema.boolean(),
|
|
995
|
-
},
|
|
996
|
-
execute: async (args) => {
|
|
997
|
-
return JSON.stringify(deletePlan(db, args.id, { confirm: args.confirm }));
|
|
998
|
-
},
|
|
999
|
-
}),
|
|
1000
|
-
|
|
1001
|
-
plan_update_status: tool({
|
|
1002
|
-
description:
|
|
1003
|
-
"Update a plan's status (draft, approved, executing, completed, failed, abandoned). Auto-archives to markdown on terminal status. Use dryRun=true to pre-check readiness (blockers/warnings) without mutating. Use force=true with forceReason to bypass blockers (except status_invalid) — captured to plan_audit.",
|
|
1004
|
-
args: {
|
|
1005
|
-
id: tool.schema.string(),
|
|
1006
|
-
status: tool.schema.enum([
|
|
1007
|
-
"draft",
|
|
1008
|
-
"approved",
|
|
1009
|
-
"executing",
|
|
1010
|
-
"completed",
|
|
1011
|
-
"failed",
|
|
1012
|
-
"abandoned",
|
|
1013
|
-
]),
|
|
1014
|
-
dryRun: tool.schema.boolean().optional(),
|
|
1015
|
-
force: tool.schema.boolean().optional(),
|
|
1016
|
-
forceReason: tool.schema.string().optional(),
|
|
1017
|
-
},
|
|
1018
|
-
execute: async (args, ctx) => {
|
|
1019
|
-
const archiveDir = resolveArchiveDir(worktree || directory);
|
|
1020
|
-
const executorArgs: {
|
|
1021
|
-
id: string;
|
|
1022
|
-
status: PlanStatus;
|
|
1023
|
-
dryRun?: boolean;
|
|
1024
|
-
force?: boolean;
|
|
1025
|
-
forceReason?: string;
|
|
1026
|
-
} = {
|
|
1027
|
-
id: args.id,
|
|
1028
|
-
status: args.status as PlanStatus,
|
|
1029
|
-
};
|
|
1030
|
-
if (args.dryRun !== undefined) executorArgs.dryRun = args.dryRun;
|
|
1031
|
-
if (args.force !== undefined) executorArgs.force = args.force;
|
|
1032
|
-
if (args.forceReason !== undefined) executorArgs.forceReason = args.forceReason;
|
|
1033
|
-
const result = planUpdateStatusExecutor(
|
|
1034
|
-
db,
|
|
1035
|
-
executorArgs,
|
|
1036
|
-
{
|
|
1037
|
-
agent: ctx.agent,
|
|
1038
|
-
sessionID: ctx.sessionID,
|
|
1039
|
-
messageID: ctx.messageID,
|
|
1040
|
-
directory,
|
|
1041
|
-
worktree,
|
|
1042
|
-
},
|
|
1043
|
-
archiveDir,
|
|
1044
|
-
);
|
|
1045
|
-
// T3.3: auto-checkpoint on phase transition
|
|
1046
|
-
if (result.statusChanged && !result.dryRun) {
|
|
1047
|
-
autoCheckpoint.dispatch("phase_transition", {
|
|
1048
|
-
planId: args.id,
|
|
1049
|
-
sessionId: ctx.sessionID,
|
|
1050
|
-
blockers: result.blockers.length > 0 ? result.blockers : undefined,
|
|
1051
|
-
});
|
|
1052
|
-
}
|
|
1053
|
-
return JSON.stringify(result);
|
|
1054
|
-
},
|
|
1055
|
-
}),
|
|
1056
|
-
|
|
1057
|
-
plan_progress: tool({
|
|
1058
|
-
description:
|
|
1059
|
-
"Get plan progress summary (task counts + percentage). Filterable by planId and/or owner (metadata.ownedBy).",
|
|
1060
|
-
args: {
|
|
1061
|
-
planId: tool.schema.string().optional(),
|
|
1062
|
-
owner: tool.schema.string().optional(),
|
|
1063
|
-
},
|
|
1064
|
-
execute: async (args) => {
|
|
1065
|
-
if (args.owner) {
|
|
1066
|
-
if (args.planId) {
|
|
1067
|
-
const rows = db
|
|
1068
|
-
.query(
|
|
1069
|
-
`SELECT pp.* FROM plan_progress_active pp
|
|
1070
|
-
JOIN plans p ON pp.plan_id = p.id
|
|
1071
|
-
WHERE pp.plan_id = ? AND json_extract(p.metadata, '$.ownedBy') = ?`,
|
|
1072
|
-
)
|
|
1073
|
-
.all(args.planId, args.owner);
|
|
1074
|
-
return JSON.stringify(rows);
|
|
1075
|
-
}
|
|
1076
|
-
const rows = db
|
|
1077
|
-
.query(
|
|
1078
|
-
`SELECT pp.* FROM plan_progress_active pp
|
|
1079
|
-
JOIN plans p ON pp.plan_id = p.id
|
|
1080
|
-
WHERE json_extract(p.metadata, '$.ownedBy') = ?`,
|
|
1081
|
-
)
|
|
1082
|
-
.all(args.owner);
|
|
1083
|
-
return JSON.stringify(rows);
|
|
1084
|
-
}
|
|
1085
|
-
if (args.planId) {
|
|
1086
|
-
const rows = db
|
|
1087
|
-
.query("SELECT * FROM plan_progress_active WHERE plan_id = ?")
|
|
1088
|
-
.all(args.planId);
|
|
1089
|
-
return JSON.stringify(rows);
|
|
1090
|
-
}
|
|
1091
|
-
const rows = db.query("SELECT * FROM plan_progress_active").all();
|
|
1092
|
-
return JSON.stringify(rows);
|
|
1093
|
-
},
|
|
1094
|
-
}),
|
|
1095
|
-
|
|
1096
|
-
plan_files_write: tool({
|
|
1097
|
-
description:
|
|
1098
|
-
"Register files for a plan in plan_files with explicit roles (e.g. 'input', 'modified', 'output', 'reference'). Uses INSERT OR IGNORE for idempotency.",
|
|
1099
|
-
args: {
|
|
1100
|
-
planId: tool.schema.string(),
|
|
1101
|
-
files: tool.schema.array(
|
|
1102
|
-
tool.schema.object({
|
|
1103
|
-
filePath: tool.schema.string(),
|
|
1104
|
-
role: tool.schema.string(),
|
|
1105
|
-
}),
|
|
1106
|
-
),
|
|
1107
|
-
},
|
|
1108
|
-
execute: async (args) => {
|
|
1109
|
-
let inserted = 0;
|
|
1110
|
-
for (const f of args.files) {
|
|
1111
|
-
const result = db
|
|
1112
|
-
.query("INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)")
|
|
1113
|
-
.run(args.planId, f.filePath, f.role);
|
|
1114
|
-
inserted += result.changes;
|
|
1115
|
-
}
|
|
1116
|
-
return JSON.stringify({
|
|
1117
|
-
planId: args.planId,
|
|
1118
|
-
inserted,
|
|
1119
|
-
totalRequested: args.files.length,
|
|
1120
|
-
});
|
|
1121
|
-
},
|
|
1122
|
-
}),
|
|
1123
|
-
|
|
1124
|
-
// ── Tasks ──────────────────────────────────────────────────────
|
|
1125
|
-
|
|
1126
|
-
task_create_batch: tool({
|
|
1127
|
-
description:
|
|
1128
|
-
"Create multiple tasks for a plan in a single transaction. Each task gets a UUID and sequential order_index.",
|
|
1129
|
-
args: {
|
|
1130
|
-
planId: tool.schema.string(),
|
|
1131
|
-
tasks: tool.schema.array(
|
|
1132
|
-
tool.schema.object({
|
|
1133
|
-
description: tool.schema.string(),
|
|
1134
|
-
agent: tool.schema.string(),
|
|
1135
|
-
files: tool.schema.array(tool.schema.string()).optional(),
|
|
1136
|
-
complexity: tool.schema.number().int().min(1).max(5).optional(),
|
|
1137
|
-
dependencies: tool.schema.array(tool.schema.string()).optional(),
|
|
1138
|
-
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional(),
|
|
1139
|
-
}),
|
|
1140
|
-
),
|
|
1141
|
-
},
|
|
1142
|
-
execute: async (args, ctx) => {
|
|
1143
|
-
const auditCtx = {
|
|
1144
|
-
createdBy: ctx.agent ?? "unknown",
|
|
1145
|
-
updatedBy: ctx.agent ?? "unknown",
|
|
1146
|
-
sourceSessionId: ctx.sessionID,
|
|
1147
|
-
sourceMessageId: ctx.messageID,
|
|
1148
|
-
};
|
|
1149
|
-
const tasks = createTasksBatch(
|
|
1150
|
-
db,
|
|
1151
|
-
args.planId,
|
|
1152
|
-
args.tasks.map((t) => {
|
|
1153
|
-
const typedMeta = (t.metadata ?? {}) as TaskMetadata;
|
|
1154
|
-
return {
|
|
1155
|
-
// orderIndex intentionally omitted — createTasksBatch allocates
|
|
1156
|
-
// dynamically via SELECT MAX+1 to avoid UNIQUE constraint collisions
|
|
1157
|
-
// on retries/splits. Caller-provided idx was the root cause of the
|
|
1158
|
-
// UNIQUE constraint bug (plan ca69222a).
|
|
1159
|
-
description: t.description,
|
|
1160
|
-
agent: t.agent,
|
|
1161
|
-
files: t.files ?? [],
|
|
1162
|
-
complexity: t.complexity ?? 3,
|
|
1163
|
-
dependencies: t.dependencies ?? [],
|
|
1164
|
-
...auditCtx,
|
|
1165
|
-
reviewedBy: typedMeta.reviewedBy ?? null,
|
|
1166
|
-
tokensUsed: typedMeta.tokensUsed ?? null,
|
|
1167
|
-
durationMs: typedMeta.durationMs ?? null,
|
|
1168
|
-
artifacts: typedMeta.artifacts ?? [],
|
|
1169
|
-
metadata: typedMeta,
|
|
1170
|
-
};
|
|
1171
|
-
}),
|
|
1172
|
-
);
|
|
1173
|
-
return JSON.stringify(tasks);
|
|
1174
|
-
},
|
|
1175
|
-
}),
|
|
1176
|
-
|
|
1177
|
-
task_list: tool({
|
|
1178
|
-
description:
|
|
1179
|
-
"List tasks for a plan, optionally filtered by status. Set includeArchived=true to include tasks from archived plans (archived_at IS NOT NULL).",
|
|
1180
|
-
args: {
|
|
1181
|
-
planId: tool.schema.string(),
|
|
1182
|
-
status: tool.schema.enum(["pending", "running", "done", "failed", "blocked"]).optional(),
|
|
1183
|
-
includeArchived: tool.schema.boolean().optional(),
|
|
1184
|
-
},
|
|
1185
|
-
execute: async (args) => {
|
|
1186
|
-
const opts: { status?: TaskStatus; includeArchived?: boolean } = {};
|
|
1187
|
-
if (args.status) opts.status = args.status as TaskStatus;
|
|
1188
|
-
if (args.includeArchived) opts.includeArchived = true;
|
|
1189
|
-
return JSON.stringify(listTasksByPlan(db, args.planId, opts));
|
|
1190
|
-
},
|
|
1191
|
-
}),
|
|
1192
|
-
|
|
1193
|
-
task_update_status: tool({
|
|
1194
|
-
description: "Update a task's status. Optionally record result or error text.",
|
|
1195
|
-
args: {
|
|
1196
|
-
id: tool.schema.string(),
|
|
1197
|
-
status: tool.schema.enum(["pending", "running", "done", "failed", "blocked"]),
|
|
1198
|
-
result: tool.schema.string().optional(),
|
|
1199
|
-
error: tool.schema.string().optional(),
|
|
1200
|
-
},
|
|
1201
|
-
execute: async (args, ctx) => {
|
|
1202
|
-
const fields: { result?: string; error?: string } = {};
|
|
1203
|
-
if (args.result !== undefined) fields.result = args.result;
|
|
1204
|
-
if (args.error !== undefined) fields.error = args.error;
|
|
1205
|
-
const result = updateTaskStatus(
|
|
1206
|
-
db,
|
|
1207
|
-
args.id,
|
|
1208
|
-
args.status as TaskStatus,
|
|
1209
|
-
fields,
|
|
1210
|
-
ctx.agent ?? "unknown",
|
|
1211
|
-
{ agent: ctx.agent, sessionId: ctx.sessionID },
|
|
1212
|
-
);
|
|
1213
|
-
// T3.3: auto-checkpoint when last task in plan completes
|
|
1214
|
-
if (result && args.status === "done" && result.planId) {
|
|
1215
|
-
const pending = listTasksByPlan(db, result.planId, { status: "pending" });
|
|
1216
|
-
if (pending.length === 0) {
|
|
1217
|
-
autoCheckpoint.dispatch("task_batch_complete", {
|
|
1218
|
-
planId: result.planId,
|
|
1219
|
-
sessionId: ctx.sessionID,
|
|
1220
|
-
});
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
return JSON.stringify(result);
|
|
1224
|
-
},
|
|
1225
|
-
}),
|
|
1226
|
-
|
|
1227
|
-
task_search: tool({
|
|
1228
|
-
description:
|
|
1229
|
-
"Full-text search over task descriptions, results, and errors using SQLite FTS5.",
|
|
1230
|
-
args: {
|
|
1231
|
-
query: tool.schema.string(),
|
|
1232
|
-
limit: tool.schema.number().optional(),
|
|
1233
|
-
includeArchived: tool.schema.boolean().optional(),
|
|
1234
|
-
},
|
|
1235
|
-
execute: async (args) => {
|
|
1236
|
-
return JSON.stringify(
|
|
1237
|
-
searchTasks(db, args.query, args.limit ?? 20, {
|
|
1238
|
-
includeArchived: args.includeArchived ?? false,
|
|
1239
|
-
}),
|
|
1240
|
-
);
|
|
1241
|
-
},
|
|
1242
|
-
}),
|
|
1243
|
-
|
|
1244
|
-
task_next_for_agent: tool({
|
|
1245
|
-
description:
|
|
1246
|
-
"Get the next pending task for a given agent (optionally within a specific plan).",
|
|
1247
|
-
args: {
|
|
1248
|
-
agent: tool.schema.string(),
|
|
1249
|
-
planId: tool.schema.string().optional(),
|
|
1250
|
-
},
|
|
1251
|
-
execute: async (args) => {
|
|
1252
|
-
const opts = args.planId ? { planId: args.planId } : {};
|
|
1253
|
-
return JSON.stringify(nextTaskForAgent(db, args.agent, opts));
|
|
1254
|
-
},
|
|
1255
|
-
}),
|
|
1256
|
-
|
|
1257
|
-
task_dependency_resolver: tool({
|
|
1258
|
-
description:
|
|
1259
|
-
"Resolve task dependencies: check whether a task's dependencies are all done, and list pending/running/failed/blocked/missing deps. Accepts taskId, or planId+orderIndex to look up the task.",
|
|
1260
|
-
args: {
|
|
1261
|
-
taskId: tool.schema.string().optional(),
|
|
1262
|
-
planId: tool.schema.string().optional(),
|
|
1263
|
-
orderIndex: tool.schema.number().optional(),
|
|
1264
|
-
},
|
|
1265
|
-
execute: async (args) => {
|
|
1266
|
-
let resolvedId = args.taskId;
|
|
1267
|
-
if (!resolvedId) {
|
|
1268
|
-
if (!args.planId || args.orderIndex === undefined) {
|
|
1269
|
-
throw new Error(
|
|
1270
|
-
"ndomo: task_dependency_resolver requires either taskId or planId+orderIndex",
|
|
1271
|
-
);
|
|
1272
|
-
}
|
|
1273
|
-
const row = db
|
|
1274
|
-
.query(
|
|
1275
|
-
"SELECT id FROM plan_tasks WHERE plan_id = ? AND order_index = ? AND archived_at IS NULL",
|
|
1276
|
-
)
|
|
1277
|
-
.get(args.planId, args.orderIndex) as { id: string } | undefined;
|
|
1278
|
-
if (!row) {
|
|
1279
|
-
throw new Error(
|
|
1280
|
-
`ndomo: no task found for planId=${args.planId} orderIndex=${args.orderIndex}`,
|
|
1281
|
-
);
|
|
1282
|
-
}
|
|
1283
|
-
resolvedId = row.id;
|
|
1284
|
-
}
|
|
1285
|
-
return JSON.stringify(resolveTaskDependencies(db, resolvedId));
|
|
1286
|
-
},
|
|
1287
|
-
}),
|
|
1288
|
-
|
|
1289
|
-
task_peek_for_agent: tool({
|
|
1290
|
-
description:
|
|
1291
|
-
"List pending tasks for an agent without claiming them (read-only peek, no status change).",
|
|
1292
|
-
args: {
|
|
1293
|
-
agent: tool.schema.string(),
|
|
1294
|
-
planId: tool.schema.string().optional(),
|
|
1295
|
-
limit: tool.schema.number().optional(),
|
|
1296
|
-
},
|
|
1297
|
-
execute: async (args) => {
|
|
1298
|
-
const limit = args.limit ?? 10;
|
|
1299
|
-
const archiveFilter = "AND archived_at IS NULL";
|
|
1300
|
-
const rows = args.planId
|
|
1301
|
-
? db
|
|
1302
|
-
.query(
|
|
1303
|
-
`SELECT * FROM plan_tasks WHERE agent = ? AND plan_id = ? AND status = 'pending' ${archiveFilter} ORDER BY order_index LIMIT ?`,
|
|
1304
|
-
)
|
|
1305
|
-
.all(args.agent, args.planId, limit)
|
|
1306
|
-
: db
|
|
1307
|
-
.query(
|
|
1308
|
-
`SELECT * FROM plan_tasks WHERE agent = ? AND status = 'pending' ${archiveFilter} ORDER BY order_index LIMIT ?`,
|
|
1309
|
-
)
|
|
1310
|
-
.all(args.agent, limit);
|
|
1311
|
-
return JSON.stringify(rows);
|
|
1312
|
-
},
|
|
1313
|
-
}),
|
|
1314
|
-
|
|
1315
|
-
task_add_artifact: tool({
|
|
1316
|
-
description:
|
|
1317
|
-
"Append an artifact path to a task's artifacts array. Optionally register it in plan_files with a role.",
|
|
1318
|
-
args: {
|
|
1319
|
-
taskId: tool.schema.string(),
|
|
1320
|
-
artifact: tool.schema.string(),
|
|
1321
|
-
role: tool.schema.string().optional(),
|
|
1322
|
-
},
|
|
1323
|
-
execute: async (args) => {
|
|
1324
|
-
const row = db
|
|
1325
|
-
.query("SELECT artifacts, plan_id FROM plan_tasks WHERE id = ?")
|
|
1326
|
-
.get(args.taskId) as { artifacts: string; plan_id: string } | undefined;
|
|
1327
|
-
if (!row) throw new Error(`ndomo: task ${args.taskId} not found`);
|
|
1328
|
-
const currentArtifacts = JSON.parse(row.artifacts) as string[];
|
|
1329
|
-
if (currentArtifacts.includes(args.artifact)) {
|
|
1330
|
-
return JSON.stringify({ task: null, added: false, reason: "artifact already exists" });
|
|
1331
|
-
}
|
|
1332
|
-
const updatedArtifacts = [...currentArtifacts, args.artifact];
|
|
1333
|
-
db.query("UPDATE plan_tasks SET artifacts = ? WHERE id = ?").run(
|
|
1334
|
-
JSON.stringify(updatedArtifacts),
|
|
1335
|
-
args.taskId,
|
|
1336
|
-
);
|
|
1337
|
-
if (args.role) {
|
|
1338
|
-
db.query(
|
|
1339
|
-
"INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)",
|
|
1340
|
-
).run(row.plan_id, args.artifact, args.role);
|
|
1341
|
-
}
|
|
1342
|
-
const updatedRow = db.query("SELECT * FROM plan_tasks WHERE id = ?").get(args.taskId);
|
|
1343
|
-
return JSON.stringify({ task: updatedRow, added: true });
|
|
1344
|
-
},
|
|
1345
|
-
}),
|
|
1346
|
-
|
|
1347
|
-
task_review: tool({
|
|
1348
|
-
description:
|
|
1349
|
-
"Review a completed task. Sets reviewed_by and reviewed_verdict (stored in metadata). Only works on tasks with status='done'.",
|
|
1350
|
-
args: {
|
|
1351
|
-
taskId: tool.schema.string(),
|
|
1352
|
-
reviewedBy: tool.schema.string(),
|
|
1353
|
-
verdict: tool.schema.string(),
|
|
1354
|
-
},
|
|
1355
|
-
execute: async (args) => {
|
|
1356
|
-
const row = db
|
|
1357
|
-
.query("SELECT status, metadata FROM plan_tasks WHERE id = ?")
|
|
1358
|
-
.get(args.taskId) as { status: string; metadata: string | null } | undefined;
|
|
1359
|
-
if (!row) throw new Error(`ndomo: task ${args.taskId} not found`);
|
|
1360
|
-
if (row.status !== "done")
|
|
1361
|
-
throw new Error(`ndomo: task_review requires status='done', got '${row.status}'`);
|
|
1362
|
-
const currentMeta = row.metadata ? JSON.parse(row.metadata) : {};
|
|
1363
|
-
const updatedMeta = { ...currentMeta, reviewedVerdict: args.verdict };
|
|
1364
|
-
db.query("UPDATE plan_tasks SET reviewed_by = ?, metadata = ? WHERE id = ?").run(
|
|
1365
|
-
args.reviewedBy,
|
|
1366
|
-
JSON.stringify(updatedMeta),
|
|
1367
|
-
args.taskId,
|
|
1368
|
-
);
|
|
1369
|
-
const updatedRow = db.query("SELECT * FROM plan_tasks WHERE id = ?").get(args.taskId);
|
|
1370
|
-
return JSON.stringify({ task: updatedRow });
|
|
1371
|
-
},
|
|
1372
|
-
}),
|
|
1373
|
-
|
|
1374
|
-
// ── Ops (T2: warden) ──────────────────────────────────────────
|
|
1375
|
-
|
|
1376
|
-
incident_create: tool({
|
|
1377
|
-
description:
|
|
1378
|
-
"Create an ops incident record. Validates severity enum (sev1-4) and FK on triggered_by_deployment_id if provided. Sets metadata.created_by from ctx.agent.",
|
|
1379
|
-
args: {
|
|
1380
|
-
title: tool.schema.string(),
|
|
1381
|
-
severity: tool.schema.enum(["sev1", "sev2", "sev3", "sev4"]),
|
|
1382
|
-
summary: tool.schema.string().optional(),
|
|
1383
|
-
triggeredByDeploymentId: tool.schema.string().optional(),
|
|
1384
|
-
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional(),
|
|
1385
|
-
},
|
|
1386
|
-
execute: async (args, ctx) => {
|
|
1387
|
-
const input: InsertIncident = {
|
|
1388
|
-
title: args.title,
|
|
1389
|
-
severity: args.severity as IncidentSeverity,
|
|
1390
|
-
metadata: { ...(args.metadata ?? {}), created_by: ctx.agent ?? "unknown" },
|
|
1391
|
-
...(args.summary !== undefined && { summary: args.summary }),
|
|
1392
|
-
...(args.triggeredByDeploymentId !== undefined && {
|
|
1393
|
-
triggeredByDeploymentId: args.triggeredByDeploymentId,
|
|
1394
|
-
}),
|
|
1395
|
-
};
|
|
1396
|
-
const incident = createIncident(db, input);
|
|
1397
|
-
return JSON.stringify(incident);
|
|
1398
|
-
},
|
|
1399
|
-
}),
|
|
1400
|
-
|
|
1401
|
-
rollback_record: tool({
|
|
1402
|
-
description:
|
|
1403
|
-
"Record a rollback execution tied to a deployment (required) and optionally an incident and/or new_deployment. Validates FKs + status enum. Sets metadata.executed_by_agent from ctx.agent.",
|
|
1404
|
-
args: {
|
|
1405
|
-
deploymentId: tool.schema.string(),
|
|
1406
|
-
plan: tool.schema.string(),
|
|
1407
|
-
incidentId: tool.schema.string().optional(),
|
|
1408
|
-
status: tool.schema
|
|
1409
|
-
.enum(["planned", "approved", "dry_run", "executing", "success", "failed", "cancelled"])
|
|
1410
|
-
.optional(),
|
|
1411
|
-
newDeploymentId: tool.schema.string().optional(),
|
|
1412
|
-
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional(),
|
|
1413
|
-
},
|
|
1414
|
-
execute: async (args, ctx) => {
|
|
1415
|
-
const input: InsertRollback = {
|
|
1416
|
-
deploymentId: args.deploymentId,
|
|
1417
|
-
plan: args.plan,
|
|
1418
|
-
metadata: { ...(args.metadata ?? {}), executed_by_agent: ctx.agent ?? "unknown" },
|
|
1419
|
-
...(args.incidentId !== undefined && { incidentId: args.incidentId }),
|
|
1420
|
-
...(args.status !== undefined && { status: args.status as RollbackStatus }),
|
|
1421
|
-
...(args.newDeploymentId !== undefined && { newDeploymentId: args.newDeploymentId }),
|
|
1422
|
-
};
|
|
1423
|
-
const rollback = recordRollback(db, input);
|
|
1424
|
-
return JSON.stringify(rollback);
|
|
1425
|
-
},
|
|
1426
|
-
}),
|
|
1427
|
-
|
|
1428
|
-
task_escalate: tool({
|
|
1429
|
-
description:
|
|
1430
|
-
"Escalar tarea compleja al foreman. Crea un plan stub (foreman) con metadata.escalatedFrom=<planId_or_null> + metadata.escalatedBy='craftsman' y notifica via session_checkpoint. NO ejecuta código.",
|
|
1431
|
-
args: {
|
|
1432
|
-
sourcePlanId: tool.schema.string().optional(),
|
|
1433
|
-
sourceTaskId: tool.schema.string().optional(),
|
|
1434
|
-
reason: tool.schema.string(),
|
|
1435
|
-
suggestedApproach: tool.schema.string().optional(),
|
|
1436
|
-
},
|
|
1437
|
-
execute: async (args, ctx) => {
|
|
1438
|
-
if (!args.reason || args.reason.trim().length === 0) {
|
|
1439
|
-
throw new Error("ndomo: task_escalate requires a non-empty reason");
|
|
1440
|
-
}
|
|
1441
|
-
const escalateArgs: Parameters<typeof escalateToForeman>[2] = {
|
|
1442
|
-
reason: args.reason,
|
|
1443
|
-
};
|
|
1444
|
-
if (args.sourcePlanId !== undefined) escalateArgs.sourcePlanId = args.sourcePlanId;
|
|
1445
|
-
if (args.sourceTaskId !== undefined) escalateArgs.sourceTaskId = args.sourceTaskId;
|
|
1446
|
-
if (args.suggestedApproach !== undefined)
|
|
1447
|
-
escalateArgs.suggestedApproach = args.suggestedApproach;
|
|
1448
|
-
return JSON.stringify(escalateToForeman(db, ctx, escalateArgs));
|
|
1449
|
-
},
|
|
1450
|
-
}),
|
|
1451
|
-
|
|
1452
|
-
// ── Analyses (v14) ────────────────────────────────────────────
|
|
1453
|
-
|
|
1454
|
-
analysis_create: tool({
|
|
1455
|
-
description:
|
|
1456
|
-
"Create a new analysis record in the standalone analyses table. Use for analyst findings, architecture audits, onboarding notes, or cartography outputs. Optionally link to a source plan via sourcePlanId.",
|
|
1457
|
-
args: {
|
|
1458
|
-
slug: tool.schema.string(),
|
|
1459
|
-
title: tool.schema.string(),
|
|
1460
|
-
projectPath: tool.schema.string(),
|
|
1461
|
-
summary: tool.schema.string(),
|
|
1462
|
-
findingsJson: tool.schema.string(),
|
|
1463
|
-
sourcePlanId: tool.schema.string().optional(),
|
|
1464
|
-
agent: tool.schema.string().optional(),
|
|
1465
|
-
sessionId: tool.schema.string().optional(),
|
|
1466
|
-
},
|
|
1467
|
-
execute: async (args, ctx) => {
|
|
1468
|
-
try {
|
|
1469
|
-
JSON.parse(args.findingsJson);
|
|
1470
|
-
} catch {
|
|
1471
|
-
throw new Error("ndomo: findingsJson must be valid JSON");
|
|
1472
|
-
}
|
|
1473
|
-
// Agent boundary contract (v15): ranger emits observation-only findings.
|
|
1474
|
-
// Throws if ctx.agent === 'ranger' AND findings carry proposedAction.
|
|
1475
|
-
validateAnalysisFindings(args.findingsJson, ctx.agent);
|
|
1476
|
-
const input = {
|
|
1477
|
-
slug: args.slug,
|
|
1478
|
-
title: args.title,
|
|
1479
|
-
projectPath: args.projectPath,
|
|
1480
|
-
summary: args.summary,
|
|
1481
|
-
findingsJson: args.findingsJson,
|
|
1482
|
-
agent: args.agent ?? "ranger",
|
|
1483
|
-
createdBy: ctx.agent ?? "ranger",
|
|
1484
|
-
...(args.sourcePlanId !== undefined && { sourcePlanId: args.sourcePlanId }),
|
|
1485
|
-
...(args.sessionId !== undefined && { sessionId: args.sessionId }),
|
|
1486
|
-
};
|
|
1487
|
-
const result = createAnalysis(db, input);
|
|
1488
|
-
return JSON.stringify(result, null, 2);
|
|
1489
|
-
},
|
|
1490
|
-
}),
|
|
1491
|
-
|
|
1492
|
-
analysis_get: tool({
|
|
1493
|
-
description:
|
|
1494
|
-
"Get a single analysis by id. Returns the analysis with parsed findingsJson.",
|
|
1495
|
-
args: {
|
|
1496
|
-
id: tool.schema.string(),
|
|
1497
|
-
},
|
|
1498
|
-
execute: async (args) => {
|
|
1499
|
-
const result = getAnalysis(db, args.id);
|
|
1500
|
-
if (!result) {
|
|
1501
|
-
throw new Error(`ndomo: analysis '${args.id}' not found`);
|
|
1502
|
-
}
|
|
1503
|
-
return JSON.stringify(
|
|
1504
|
-
{ ...result, findingsJson: JSON.parse(result.findingsJson) },
|
|
1505
|
-
null,
|
|
1506
|
-
2,
|
|
1507
|
-
);
|
|
1508
|
-
},
|
|
1509
|
-
}),
|
|
1510
|
-
|
|
1511
|
-
analysis_list: tool({
|
|
1512
|
-
description:
|
|
1513
|
-
"List analyses with optional filters: sourcePlanId, agent, projectPath, archived, limit.",
|
|
1514
|
-
args: {
|
|
1515
|
-
sourcePlanId: tool.schema.string().optional(),
|
|
1516
|
-
agent: tool.schema.string().optional(),
|
|
1517
|
-
projectPath: tool.schema.string().optional(),
|
|
1518
|
-
archived: tool.schema.boolean().optional(),
|
|
1519
|
-
limit: tool.schema.number().optional(),
|
|
1520
|
-
},
|
|
1521
|
-
execute: async (args) => {
|
|
1522
|
-
const opts: {
|
|
1523
|
-
sourcePlanId?: string;
|
|
1524
|
-
agent?: string;
|
|
1525
|
-
projectPath?: string;
|
|
1526
|
-
archived?: boolean;
|
|
1527
|
-
limit?: number;
|
|
1528
|
-
} = {};
|
|
1529
|
-
if (args.sourcePlanId !== undefined) opts.sourcePlanId = args.sourcePlanId;
|
|
1530
|
-
if (args.agent !== undefined) opts.agent = args.agent;
|
|
1531
|
-
if (args.projectPath !== undefined) opts.projectPath = args.projectPath;
|
|
1532
|
-
if (args.archived !== undefined) opts.archived = args.archived;
|
|
1533
|
-
if (args.limit !== undefined) opts.limit = args.limit;
|
|
1534
|
-
const results = listAnalyses(db, opts);
|
|
1535
|
-
return JSON.stringify(
|
|
1536
|
-
results.map((r) => ({ ...r, findingsJson: JSON.parse(r.findingsJson) })),
|
|
1537
|
-
null,
|
|
1538
|
-
2,
|
|
1539
|
-
);
|
|
1540
|
-
},
|
|
1541
|
-
}),
|
|
1542
|
-
|
|
1543
|
-
analysis_search: tool({
|
|
1544
|
-
description:
|
|
1545
|
-
"Full-text search over analyses (title + summary + findings) using FTS5. Returns matching analyses.",
|
|
1546
|
-
args: {
|
|
1547
|
-
query: tool.schema.string(),
|
|
1548
|
-
limit: tool.schema.number().optional(),
|
|
1549
|
-
},
|
|
1550
|
-
execute: async (args) => {
|
|
1551
|
-
const opts: { limit?: number } = {};
|
|
1552
|
-
if (args.limit !== undefined) opts.limit = args.limit;
|
|
1553
|
-
const results = searchAnalyses(db, args.query, opts);
|
|
1554
|
-
return JSON.stringify(
|
|
1555
|
-
results.map((r) => ({ ...r, findingsJson: JSON.parse(r.findingsJson) })),
|
|
1556
|
-
null,
|
|
1557
|
-
2,
|
|
1558
|
-
);
|
|
1559
|
-
},
|
|
1560
|
-
}),
|
|
1561
|
-
|
|
1562
|
-
analysis_update: tool({
|
|
1563
|
-
description:
|
|
1564
|
-
"Update an existing analysis. Only provided fields are changed. Bumps updated_at.",
|
|
1565
|
-
args: {
|
|
1566
|
-
id: tool.schema.string(),
|
|
1567
|
-
title: tool.schema.string().optional(),
|
|
1568
|
-
summary: tool.schema.string().optional(),
|
|
1569
|
-
findingsJson: tool.schema.string().optional(),
|
|
1570
|
-
},
|
|
1571
|
-
execute: async (args, ctx) => {
|
|
1572
|
-
if (args.findingsJson !== undefined) {
|
|
1573
|
-
try {
|
|
1574
|
-
JSON.parse(args.findingsJson);
|
|
1575
|
-
} catch {
|
|
1576
|
-
throw new Error("ndomo: findingsJson must be valid JSON");
|
|
1577
|
-
}
|
|
1578
|
-
// Agent boundary contract (v15): same check as analysis_create.
|
|
1579
|
-
// Only triggered when findingsJson is being mutated (no-op otherwise).
|
|
1580
|
-
validateAnalysisFindings(args.findingsJson, ctx.agent);
|
|
1581
|
-
}
|
|
1582
|
-
const patch: Record<string, unknown> = {};
|
|
1583
|
-
if (args.title !== undefined) patch.title = args.title;
|
|
1584
|
-
if (args.summary !== undefined) patch.summary = args.summary;
|
|
1585
|
-
if (args.findingsJson !== undefined) patch.findingsJson = args.findingsJson;
|
|
1586
|
-
const result = updateAnalysis(db, args.id, patch);
|
|
1587
|
-
return JSON.stringify(result, null, 2);
|
|
1588
|
-
},
|
|
1589
|
-
}),
|
|
1590
|
-
|
|
1591
|
-
analysis_archive: tool({
|
|
1592
|
-
description:
|
|
1593
|
-
"Soft-delete an analysis by setting archived_at. Idempotent. The row is preserved but excluded from default list queries.",
|
|
1594
|
-
args: {
|
|
1595
|
-
id: tool.schema.string(),
|
|
1596
|
-
},
|
|
1597
|
-
execute: async (args) => {
|
|
1598
|
-
const result = archiveAnalysis(db, args.id);
|
|
1599
|
-
return JSON.stringify(
|
|
1600
|
-
{ ok: true, id: result.id, archivedAt: result.archivedAt },
|
|
1601
|
-
null,
|
|
1602
|
-
2,
|
|
1603
|
-
);
|
|
1604
|
-
},
|
|
1605
|
-
}),
|
|
1606
|
-
|
|
1607
|
-
analysis_link_plan: tool({
|
|
1608
|
-
description:
|
|
1609
|
-
"Link an existing analysis to a source plan (set source_plan_id). Pass null to unlink.",
|
|
1610
|
-
args: {
|
|
1611
|
-
id: tool.schema.string(),
|
|
1612
|
-
planId: tool.schema.string().nullable(),
|
|
1613
|
-
},
|
|
1614
|
-
execute: async (args) => {
|
|
1615
|
-
if (args.planId === null) {
|
|
1616
|
-
const result = unlinkAnalysisFromPlan(db, args.id);
|
|
1617
|
-
return JSON.stringify(
|
|
1618
|
-
{ ok: true, id: result.id, sourcePlanId: null },
|
|
1619
|
-
null,
|
|
1620
|
-
2,
|
|
1621
|
-
);
|
|
1622
|
-
}
|
|
1623
|
-
const result = linkAnalysisToPlan(db, args.id, args.planId);
|
|
1624
|
-
return JSON.stringify(
|
|
1625
|
-
{ ok: true, id: result.id, sourcePlanId: result.sourcePlanId },
|
|
1626
|
-
null,
|
|
1627
|
-
2,
|
|
1628
|
-
);
|
|
1629
|
-
},
|
|
1630
|
-
}),
|
|
1631
|
-
|
|
1632
|
-
// ── Sessions ───────────────────────────────────────────────────
|
|
1633
|
-
|
|
1634
|
-
session_start: tool({
|
|
1635
|
-
description:
|
|
1636
|
-
"Start a new ndomo session with a goal. Sessions track continuity across multiple agents.",
|
|
1637
|
-
args: {
|
|
1638
|
-
id: tool.schema.string(),
|
|
1639
|
-
goal: tool.schema.string(),
|
|
1640
|
-
planId: tool.schema.string().optional(),
|
|
1641
|
-
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional(),
|
|
1642
|
-
},
|
|
1643
|
-
execute: async (args, ctx) => {
|
|
1644
|
-
const typedMeta = (args.metadata ?? {}) as SessionMetadata;
|
|
1645
|
-
return JSON.stringify(
|
|
1646
|
-
startSession(db, {
|
|
1647
|
-
id: args.id,
|
|
1648
|
-
goal: args.goal,
|
|
1649
|
-
...(args.planId !== undefined && { planId: args.planId }),
|
|
1650
|
-
metadata: typedMeta,
|
|
1651
|
-
createdBy: ctx.agent ?? "unknown",
|
|
1652
|
-
sourceMessageId: ctx.messageID,
|
|
1653
|
-
}),
|
|
1654
|
-
);
|
|
1655
|
-
},
|
|
1656
|
-
}),
|
|
1657
|
-
|
|
1658
|
-
session_checkpoint: tool({
|
|
1659
|
-
description:
|
|
1660
|
-
"Save a checkpoint in an active session with arbitrary state and optional key decisions.",
|
|
1661
|
-
args: {
|
|
1662
|
-
id: tool.schema.string(),
|
|
1663
|
-
state: tool.schema.record(tool.schema.string(), tool.schema.unknown()),
|
|
1664
|
-
keyDecisions: tool.schema.string().optional(),
|
|
1665
|
-
},
|
|
1666
|
-
execute: async (args) => {
|
|
1667
|
-
return JSON.stringify(checkpointSession(db, args.id, args.state, args.keyDecisions));
|
|
1668
|
-
},
|
|
1669
|
-
}),
|
|
1670
|
-
|
|
1671
|
-
session_end: tool({
|
|
1672
|
-
description:
|
|
1673
|
-
"Mark a session as ended. Sets ended_at. Reconciliación: planes con status='executing' o 'approved' sin cerrar en esta session → 'abandoned' con metadata.reason='session_ended'.",
|
|
1674
|
-
args: { id: tool.schema.string() },
|
|
1675
|
-
execute: async (args, ctx) => {
|
|
1676
|
-
const plansAbandoned = reconcileAbandonedPlans(db, args.id, ctx.agent ?? "unknown");
|
|
1677
|
-
const session = endSession(db, args.id);
|
|
1678
|
-
return JSON.stringify({
|
|
1679
|
-
session,
|
|
1680
|
-
plansAbandoned,
|
|
1681
|
-
sessionEnded: session !== null,
|
|
1682
|
-
});
|
|
1683
|
-
},
|
|
1684
|
-
}),
|
|
1685
|
-
},
|
|
1686
|
-
};
|
|
1687
|
-
|
|
1688
|
-
return hooks;
|
|
1689
|
-
};
|
|
1690
|
-
export default NdomoPlugin;
|