prism-mcp-server 7.2.0 → 7.3.3

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.
@@ -18,6 +18,7 @@ import { debugLog } from "../utils/logger.js";
18
18
  import { PRISM_USER_ID } from "../config.js";
19
19
  import { getSetting as cfgGet, setSetting as cfgSet, getAllSettings as cfgGetAll } from "./configStorage.js";
20
20
  import { runAutoMigrations } from "./supabaseMigrations.js";
21
+ import { SafetyController } from "../darkfactory/safetyController.js";
21
22
  export class SupabaseStorage {
22
23
  // ─── Lifecycle ─────────────────────────────────────────────
23
24
  async initialize() {
@@ -1182,9 +1183,6 @@ export class SupabaseStorage {
1182
1183
  return Number(first.prism_prune_access_log) || 0;
1183
1184
  }
1184
1185
  }
1185
- if (rpcResult && typeof rpcResult.deleted_count !== "undefined") {
1186
- return Number(rpcResult.deleted_count) || 0;
1187
- }
1188
1186
  if (rpcResult && typeof rpcResult.prism_prune_access_log !== "undefined") {
1189
1187
  return Number(rpcResult.prism_prune_access_log) || 0;
1190
1188
  }
@@ -1195,4 +1193,198 @@ export class SupabaseStorage {
1195
1193
  return 0;
1196
1194
  }
1197
1195
  }
1196
+ // ─── Dark Factory (v7.3) ───────────────────────────────────
1197
+ async savePipeline(state) {
1198
+ const now = new Date().toISOString();
1199
+ const updatedState = { ...state, updated_at: now };
1200
+ // Status Guard: prevent overwriting a terminated pipeline
1201
+ const existing = await this.getPipeline(state.id, state.user_id);
1202
+ if (existing) {
1203
+ if (existing.status === 'ABORTED' || existing.status === 'COMPLETED') {
1204
+ throw new Error(`Cannot update pipeline ${state.id} because it is already ${existing.status}.`);
1205
+ }
1206
+ // Validate state machine transition
1207
+ if (!SafetyController.validateTransition(existing.status, updatedState.status)) {
1208
+ throw new Error(`Illegal pipeline transition: ${existing.status} → ${updatedState.status} ` +
1209
+ `for pipeline ${state.id}. Legal transitions from ${existing.status}: ` +
1210
+ `${SafetyController.getLegalTransitions(existing.status).join(', ') || 'NONE (terminal)'}.`);
1211
+ }
1212
+ }
1213
+ try {
1214
+ await supabasePost("dark_factory_pipelines", {
1215
+ id: updatedState.id,
1216
+ project: updatedState.project,
1217
+ user_id: updatedState.user_id,
1218
+ status: updatedState.status,
1219
+ current_step: updatedState.current_step,
1220
+ iteration: updatedState.iteration,
1221
+ started_at: updatedState.started_at,
1222
+ updated_at: updatedState.updated_at,
1223
+ spec: updatedState.spec,
1224
+ error: updatedState.error || null,
1225
+ last_heartbeat: updatedState.last_heartbeat || null
1226
+ }, { on_conflict: "id" }, { Prefer: "return=minimal,resolution=merge-duplicates" });
1227
+ }
1228
+ catch (e) {
1229
+ // PGRST202 fallback if the table doesn't exist yet
1230
+ if (e.message?.includes("PGRST202") || e.message?.includes("Could not find the relation")) {
1231
+ debugLog("[SupabaseStorage] dark_factory_pipelines missing — please run migration 038");
1232
+ return;
1233
+ }
1234
+ throw e;
1235
+ }
1236
+ }
1237
+ async getPipeline(id, userId) {
1238
+ try {
1239
+ const result = await supabaseGet("dark_factory_pipelines", {
1240
+ id: `eq.${id}`,
1241
+ user_id: `eq.${userId}`,
1242
+ limit: "1"
1243
+ });
1244
+ const rows = Array.isArray(result) ? result : [];
1245
+ if (rows.length === 0)
1246
+ return null;
1247
+ return rows[0];
1248
+ }
1249
+ catch (e) {
1250
+ if (e.message?.includes("PGRST202") || e.message?.includes("Could not find the relation"))
1251
+ return null;
1252
+ throw e;
1253
+ }
1254
+ }
1255
+ async listPipelines(project, status, userId) {
1256
+ try {
1257
+ const query = {
1258
+ user_id: `eq.${userId}`,
1259
+ order: "updated_at.desc"
1260
+ };
1261
+ if (project)
1262
+ query.project = `eq.${project}`;
1263
+ if (status)
1264
+ query.status = `eq.${status}`;
1265
+ const result = await supabaseGet("dark_factory_pipelines", query);
1266
+ return (Array.isArray(result) ? result : []);
1267
+ }
1268
+ catch (e) {
1269
+ if (e.message?.includes("PGRST202") || e.message?.includes("Could not find the relation"))
1270
+ return [];
1271
+ throw e;
1272
+ }
1273
+ }
1274
+ // ─── Verification Harness (v7.2.0) ───────────────────────────
1275
+ async saveVerificationHarness(harness, userId) {
1276
+ try {
1277
+ await supabasePost("verification_harnesses", {
1278
+ rubric_hash: harness.rubric_hash,
1279
+ project: harness.project,
1280
+ conversation_id: harness.conversation_id,
1281
+ created_at: harness.created_at,
1282
+ min_pass_rate: harness.min_pass_rate,
1283
+ tests: JSON.stringify(harness.tests),
1284
+ metadata: harness.metadata ? JSON.stringify(harness.metadata) : null,
1285
+ user_id: userId
1286
+ }, { on_conflict: "rubric_hash" }, { Prefer: "return=representation,resolution=merge-duplicates" });
1287
+ }
1288
+ catch (e) {
1289
+ if (e.message?.includes("PGRST116") || e.message?.includes("duplicate key")) {
1290
+ return;
1291
+ }
1292
+ throw e;
1293
+ }
1294
+ }
1295
+ async getVerificationHarness(rubric_hash, userId) {
1296
+ try {
1297
+ const rows = await supabaseGet("verification_harnesses", {
1298
+ "rubric_hash": `eq.${rubric_hash}`,
1299
+ "user_id": `eq.${userId}`
1300
+ });
1301
+ if (!Array.isArray(rows) || rows.length === 0)
1302
+ return null;
1303
+ const row = rows[0];
1304
+ return {
1305
+ ...row,
1306
+ tests: JSON.parse(row.tests),
1307
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined
1308
+ };
1309
+ }
1310
+ catch (e) {
1311
+ if (e.message?.includes("PGRST202") || e.message?.includes("Could not find the relation"))
1312
+ return null;
1313
+ throw e;
1314
+ }
1315
+ }
1316
+ async saveVerificationRun(result, userId) {
1317
+ try {
1318
+ await supabasePost("verification_runs", {
1319
+ id: result.id,
1320
+ rubric_hash: result.rubric_hash,
1321
+ project: result.project,
1322
+ conversation_id: result.conversation_id,
1323
+ run_at: result.run_at,
1324
+ // H2 fix: Use native booleans for Supabase/PostgreSQL (not 0/1 integers)
1325
+ passed: result.passed,
1326
+ pass_rate: result.pass_rate,
1327
+ critical_failures: result.critical_failures,
1328
+ coverage_score: result.coverage_score,
1329
+ result_json: result.result_json,
1330
+ gate_action: result.gate_action,
1331
+ gate_override: result.gate_override ?? false,
1332
+ override_reason: result.override_reason || null,
1333
+ user_id: userId
1334
+ }, { on_conflict: "id" }, { Prefer: "return=representation,resolution=ignore-duplicates" });
1335
+ }
1336
+ catch (e) {
1337
+ if (e.message?.includes("PGRST116") || e.message?.includes("duplicate key")) {
1338
+ return;
1339
+ }
1340
+ throw e;
1341
+ }
1342
+ }
1343
+ async listVerificationRuns(project, userId) {
1344
+ try {
1345
+ const query = {
1346
+ project: `eq.${project}`,
1347
+ user_id: `eq.${userId}`,
1348
+ order: "run_at.desc"
1349
+ };
1350
+ const rows = await supabaseGet("verification_runs", query);
1351
+ if (!Array.isArray(rows))
1352
+ return [];
1353
+ return rows.map((row) => ({
1354
+ ...row,
1355
+ passed: Boolean(row.passed),
1356
+ // H2 fix: Use Boolean() consistently (native booleans from Supabase)
1357
+ gate_override: Boolean(row.gate_override),
1358
+ override_reason: row.override_reason || undefined
1359
+ }));
1360
+ }
1361
+ catch (e) {
1362
+ if (e.message?.includes("PGRST202") || e.message?.includes("Could not find the relation"))
1363
+ return [];
1364
+ throw e;
1365
+ }
1366
+ }
1367
+ async getVerificationRun(id, userId) {
1368
+ try {
1369
+ const rows = await supabaseGet("verification_runs", {
1370
+ id: `eq.${id}`,
1371
+ user_id: `eq.${userId}`
1372
+ });
1373
+ if (!Array.isArray(rows) || rows.length === 0)
1374
+ return null;
1375
+ const row = rows[0];
1376
+ return {
1377
+ ...row,
1378
+ passed: Boolean(row.passed),
1379
+ // H2 fix: Use Boolean() consistently (native booleans from Supabase)
1380
+ gate_override: Boolean(row.gate_override),
1381
+ override_reason: row.override_reason || undefined
1382
+ };
1383
+ }
1384
+ catch (e) {
1385
+ if (e.message?.includes("PGRST202") || e.message?.includes("Could not find the relation"))
1386
+ return null;
1387
+ throw e;
1388
+ }
1389
+ }
1198
1390
  }
@@ -721,6 +721,58 @@ export const MIGRATIONS = [
721
721
  GRANT EXECUTE ON FUNCTION public.prism_seed_access_log_on_ledger_insert() TO service_role, authenticated;
722
722
  `
723
723
  },
724
+ {
725
+ // ─── v7.3: Dark Factory Pipelines ─────────────────────────────
726
+ //
727
+ // Creates the dark_factory_pipelines table for autonomous Plan-Execute-Verify
728
+ // pipeline orchestration. Includes status CHECK constraint for the canonical set.
729
+ //
730
+ // EXISTING DEPLOYMENT GUARD: If the table already exists (e.g., from running
731
+ // 038_dark_factory_pipelines.sql directly), CREATE TABLE IF NOT EXISTS is a no-op.
732
+ // We then ALTER TABLE to add the CHECK constraint for existing deployments.
733
+ version: 38,
734
+ name: "dark_factory_pipelines",
735
+ sql: `
736
+ -- Create the table if fresh install
737
+ CREATE TABLE IF NOT EXISTS public.dark_factory_pipelines (
738
+ id TEXT PRIMARY KEY,
739
+ project TEXT NOT NULL,
740
+ user_id TEXT NOT NULL DEFAULT 'default',
741
+ status TEXT NOT NULL,
742
+ current_step TEXT NOT NULL,
743
+ iteration INTEGER NOT NULL,
744
+ started_at TIMESTAMPTZ NOT NULL,
745
+ updated_at TIMESTAMPTZ NOT NULL,
746
+ spec TEXT NOT NULL,
747
+ error TEXT,
748
+ last_heartbeat TIMESTAMPTZ
749
+ );
750
+
751
+ CREATE INDEX IF NOT EXISTS idx_pipelines_status
752
+ ON public.dark_factory_pipelines(user_id, project, status);
753
+
754
+ ALTER TABLE public.dark_factory_pipelines ENABLE ROW LEVEL SECURITY;
755
+
756
+ -- Idempotent policy creation
757
+ DO $$
758
+ BEGIN
759
+ IF NOT EXISTS (
760
+ SELECT 1 FROM pg_policies WHERE tablename = 'dark_factory_pipelines' AND policyname = 'allow_all_dark_factory'
761
+ ) THEN
762
+ CREATE POLICY allow_all_dark_factory
763
+ ON public.dark_factory_pipelines AS PERMISSIVE FOR ALL USING (true);
764
+ END IF;
765
+ END $$;
766
+
767
+ -- Retrofit CHECK constraint for existing deployments.
768
+ -- DROP first (idempotent) then ADD — covers both fresh and upgraded tables.
769
+ ALTER TABLE public.dark_factory_pipelines
770
+ DROP CONSTRAINT IF EXISTS chk_pipeline_status;
771
+ ALTER TABLE public.dark_factory_pipelines
772
+ ADD CONSTRAINT chk_pipeline_status
773
+ CHECK (status IN ('PENDING', 'RUNNING', 'PAUSED', 'ABORTED', 'COMPLETED', 'FAILED'));
774
+ `
775
+ },
724
776
  ];
725
777
  /**
726
778
  * Current schema version — derived from the MIGRATIONS array.
@@ -47,3 +47,8 @@ export { agentRegisterHandler, agentHeartbeatHandler, agentListTeamHandler } fro
47
47
  // Registered when PRISM_TASK_ROUTER_ENABLED=true.
48
48
  // server.ts handles the conditional registration.
49
49
  export { sessionTaskRouteHandler } from "./taskRouterHandler.js";
50
+ // ── Dark Factory Pipeline Tools (v7.3 — Autonomous Execution, Optional) ──
51
+ // Registered when PRISM_DARK_FACTORY_ENABLED=true.
52
+ // server.ts handles the conditional registration.
53
+ export { SESSION_START_PIPELINE_TOOL, SESSION_CHECK_PIPELINE_STATUS_TOOL, SESSION_ABORT_PIPELINE_TOOL, isStartPipelineArgs, isCheckPipelineStatusArgs, isAbortPipelineArgs, } from "./pipelineDefinitions.js";
54
+ export { sessionStartPipelineHandler, sessionCheckPipelineStatusHandler, sessionAbortPipelineHandler, } from "./pipelineHandlers.js";
@@ -0,0 +1,131 @@
1
+ // ─── session_start_pipeline ─────────────────────────────────
2
+ export const SESSION_START_PIPELINE_TOOL = {
3
+ name: "session_start_pipeline",
4
+ description: "Start an autonomous Dark Factory pipeline. The pipeline runs in the background " +
5
+ "and executes a PLAN → EXECUTE → VERIFY cycle up to `max_iterations` times.\n\n" +
6
+ "**Requires:** `PRISM_DARK_FACTORY_ENABLED=true` in the environment.\n\n" +
7
+ "**How it works:**\n" +
8
+ "1. Call this tool with an objective (what to accomplish)\n" +
9
+ "2. The pipeline is queued and executes autonomously in the background\n" +
10
+ "3. Use `session_check_pipeline_status` to poll for results\n\n" +
11
+ "**Safety:**\n" +
12
+ "- Pipelines are scoped to a `working_directory` — no filesystem escape\n" +
13
+ "- Strict iteration cap (default: 3) prevents infinite loops\n" +
14
+ "- Wall-clock timeout (default: 15min) prevents runaway execution\n" +
15
+ "- All operations are logged to the session ledger for audit",
16
+ inputSchema: {
17
+ type: "object",
18
+ properties: {
19
+ project: {
20
+ type: "string",
21
+ description: "Project identifier. Required for scoping and audit.",
22
+ },
23
+ objective: {
24
+ type: "string",
25
+ description: "What the pipeline should accomplish. Be specific — this becomes the LLM's system prompt objective.",
26
+ },
27
+ working_directory: {
28
+ type: "string",
29
+ description: "Absolute path to the working directory. The pipeline can only modify " +
30
+ "files within this directory. Defaults to the project's repo_path if configured.",
31
+ },
32
+ max_iterations: {
33
+ type: "number",
34
+ description: "Maximum PLAN→EXECUTE→VERIFY loop iterations (default: 3, max: 10). " +
35
+ "Each iteration is one complete cycle. Most tasks complete in 1-2 iterations.",
36
+ },
37
+ context_files: {
38
+ type: "array",
39
+ items: { type: "string" },
40
+ description: "Optional list of specific files to focus on. Paths are relative " +
41
+ "to the working directory.",
42
+ },
43
+ model_override: {
44
+ type: "string",
45
+ description: "Optional model name to use instead of the default LLM. " +
46
+ "Useful for routing to a local model (e.g., 'qwen3') via Claw.",
47
+ },
48
+ },
49
+ required: ["project", "objective"],
50
+ },
51
+ };
52
+ // ─── session_check_pipeline_status ──────────────────────────
53
+ export const SESSION_CHECK_PIPELINE_STATUS_TOOL = {
54
+ name: "session_check_pipeline_status",
55
+ description: "Check the status of a Dark Factory pipeline. Returns the current step, " +
56
+ "iteration count, and any error messages.\n\n" +
57
+ "**Statuses:**\n" +
58
+ "- `PENDING` — Queued, waiting for runner pickup\n" +
59
+ "- `RUNNING` — Currently executing a step\n" +
60
+ "- `COMPLETED` — Successfully finished all steps\n" +
61
+ "- `FAILED` — Encountered an error or exceeded limits\n" +
62
+ "- `ABORTED` — Manually cancelled",
63
+ inputSchema: {
64
+ type: "object",
65
+ properties: {
66
+ pipeline_id: {
67
+ type: "string",
68
+ description: "The pipeline ID returned by `session_start_pipeline`.",
69
+ },
70
+ project: {
71
+ type: "string",
72
+ description: "Optional project filter. If omitted, searches across all projects.",
73
+ },
74
+ },
75
+ required: ["pipeline_id"],
76
+ },
77
+ };
78
+ // ─── session_abort_pipeline ─────────────────────────────────
79
+ export const SESSION_ABORT_PIPELINE_TOOL = {
80
+ name: "session_abort_pipeline",
81
+ description: "Abort a running Dark Factory pipeline. The pipeline will be marked as ABORTED " +
82
+ "and the background runner will stop processing it on the next tick.\n\n" +
83
+ "**Note:** This is a 'kill switch' — the runner detects the status change via " +
84
+ "the storage status guard and gracefully stops execution.",
85
+ inputSchema: {
86
+ type: "object",
87
+ properties: {
88
+ pipeline_id: {
89
+ type: "string",
90
+ description: "The pipeline ID to abort.",
91
+ },
92
+ },
93
+ required: ["pipeline_id"],
94
+ },
95
+ };
96
+ export function isStartPipelineArgs(args) {
97
+ if (typeof args !== "object" || args === null)
98
+ return false;
99
+ const a = args;
100
+ if (typeof a.project !== "string" || !a.project.trim())
101
+ return false;
102
+ if (typeof a.objective !== "string" || !a.objective.trim())
103
+ return false;
104
+ if (a.working_directory !== undefined && typeof a.working_directory !== "string")
105
+ return false;
106
+ if (a.max_iterations !== undefined && (typeof a.max_iterations !== "number" || a.max_iterations < 1 || a.max_iterations > 10))
107
+ return false;
108
+ if (a.context_files !== undefined && (!Array.isArray(a.context_files) || !a.context_files.every((f) => typeof f === "string")))
109
+ return false;
110
+ if (a.model_override !== undefined && typeof a.model_override !== "string")
111
+ return false;
112
+ return true;
113
+ }
114
+ export function isCheckPipelineStatusArgs(args) {
115
+ if (typeof args !== "object" || args === null)
116
+ return false;
117
+ const a = args;
118
+ if (typeof a.pipeline_id !== "string" || !a.pipeline_id.trim())
119
+ return false;
120
+ if (a.project !== undefined && typeof a.project !== "string")
121
+ return false;
122
+ return true;
123
+ }
124
+ export function isAbortPipelineArgs(args) {
125
+ if (typeof args !== "object" || args === null)
126
+ return false;
127
+ const a = args;
128
+ if (typeof a.pipeline_id !== "string" || !a.pipeline_id.trim())
129
+ return false;
130
+ return true;
131
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Pipeline Handlers (v7.3 — Dark Factory)
3
+ *
4
+ * MCP tool handlers for managing autonomous pipeline lifecycle:
5
+ * - session_start_pipeline: Create and enqueue a new pipeline
6
+ * - session_check_pipeline_status: Poll pipeline progress
7
+ * - session_abort_pipeline: Kill a running pipeline
8
+ *
9
+ * These handlers follow the exact same CallToolResult pattern as
10
+ * all other tools in /tools/*.ts.
11
+ */
12
+ import { randomUUID } from 'crypto';
13
+ import { getStorage } from '../storage/index.js';
14
+ import { PRISM_USER_ID } from '../config.js';
15
+ import { getSettingSync } from '../storage/configStorage.js';
16
+ import { isStartPipelineArgs, isCheckPipelineStatusArgs, isAbortPipelineArgs, } from './pipelineDefinitions.js';
17
+ import { debugLog } from '../utils/logger.js';
18
+ // ─── Start Pipeline Handler ─────────────────────────────────
19
+ export async function sessionStartPipelineHandler(args) {
20
+ if (!isStartPipelineArgs(args)) {
21
+ return {
22
+ content: [{ type: "text", text: "❌ Invalid arguments. Required: project (string), objective (string). Optional: working_directory, max_iterations (1-10), context_files, model_override." }],
23
+ isError: true,
24
+ };
25
+ }
26
+ const { project, objective, working_directory, max_iterations, context_files, model_override } = args;
27
+ // Resolve working directory: explicit arg > dashboard repo_path > reject
28
+ let resolvedWorkDir = working_directory;
29
+ if (!resolvedWorkDir) {
30
+ // Project-scoped key first (dashboard stores "repo_path:<project>"),
31
+ // then fall back to global "repo_path"
32
+ resolvedWorkDir = getSettingSync(`repo_path:${project}`, "") || getSettingSync("repo_path", "");
33
+ if (!resolvedWorkDir) {
34
+ return {
35
+ content: [{ type: "text", text: "❌ No working_directory provided and no repo_path configured for this project. Either pass working_directory or configure repo_path in the dashboard." }],
36
+ isError: true,
37
+ };
38
+ }
39
+ }
40
+ const pipelineId = randomUUID();
41
+ const now = new Date().toISOString();
42
+ const spec = {
43
+ objective,
44
+ maxIterations: Math.min(max_iterations ?? 3, 10),
45
+ workingDirectory: resolvedWorkDir,
46
+ contextFiles: context_files,
47
+ modelOverride: model_override,
48
+ };
49
+ const pipelineState = {
50
+ id: pipelineId,
51
+ project,
52
+ user_id: PRISM_USER_ID,
53
+ status: 'PENDING',
54
+ current_step: 'INIT',
55
+ iteration: 0,
56
+ spec: JSON.stringify(spec),
57
+ error: null,
58
+ started_at: now,
59
+ updated_at: now,
60
+ last_heartbeat: now,
61
+ };
62
+ try {
63
+ const storage = await getStorage();
64
+ await storage.savePipeline(pipelineState);
65
+ debugLog(`[PipelineHandler] Pipeline ${pipelineId} created for project=${project} objective="${objective.slice(0, 80)}"`);
66
+ return {
67
+ content: [{
68
+ type: "text",
69
+ text: [
70
+ `✅ Dark Factory pipeline started.`,
71
+ ``,
72
+ `**Pipeline ID:** \`${pipelineId}\``,
73
+ `**Project:** ${project}`,
74
+ `**Objective:** ${objective.slice(0, 200)}`,
75
+ `**Working Directory:** ${resolvedWorkDir}`,
76
+ `**Max Iterations:** ${spec.maxIterations}`,
77
+ `**Status:** PENDING (queued for runner pickup)`,
78
+ ``,
79
+ `The pipeline is now executing autonomously in the background.`,
80
+ `Use \`session_check_pipeline_status\` with the pipeline ID to poll for results.`,
81
+ `Use \`session_abort_pipeline\` to cancel the pipeline.`,
82
+ ].join('\n'),
83
+ }],
84
+ };
85
+ }
86
+ catch (err) {
87
+ const msg = err instanceof Error ? err.message : String(err);
88
+ debugLog(`[PipelineHandler] Failed to create pipeline: ${msg}`);
89
+ return {
90
+ content: [{ type: "text", text: `❌ Failed to create pipeline: ${msg}` }],
91
+ isError: true,
92
+ };
93
+ }
94
+ }
95
+ // ─── Check Pipeline Status Handler ──────────────────────────
96
+ export async function sessionCheckPipelineStatusHandler(args) {
97
+ if (!isCheckPipelineStatusArgs(args)) {
98
+ return {
99
+ content: [{ type: "text", text: "❌ Invalid arguments. Required: pipeline_id (string). Optional: project." }],
100
+ isError: true,
101
+ };
102
+ }
103
+ const { pipeline_id, project } = args;
104
+ try {
105
+ const storage = await getStorage();
106
+ const pipeline = await storage.getPipeline(pipeline_id, PRISM_USER_ID);
107
+ if (!pipeline) {
108
+ return {
109
+ content: [{ type: "text", text: `❌ Pipeline \`${pipeline_id}\` not found.` }],
110
+ isError: true,
111
+ };
112
+ }
113
+ // Project filter — if specified, ensure pipeline belongs to the project
114
+ if (project && pipeline.project !== project) {
115
+ return {
116
+ content: [{ type: "text", text: `❌ Pipeline \`${pipeline_id}\` does not belong to project "${project}".` }],
117
+ isError: true,
118
+ };
119
+ }
120
+ // Parse spec for display (safe — we handle parse failures)
121
+ let objective = 'Unknown';
122
+ let maxIter = '?';
123
+ try {
124
+ const spec = JSON.parse(pipeline.spec);
125
+ objective = spec.objective.slice(0, 200);
126
+ maxIter = String(spec.maxIterations);
127
+ }
128
+ catch {
129
+ objective = '(spec corrupted)';
130
+ }
131
+ const isTerminal = ['COMPLETED', 'FAILED', 'ABORTED'].includes(pipeline.status);
132
+ const emoji = pipeline.status === 'COMPLETED' ? '✅' :
133
+ pipeline.status === 'FAILED' ? '❌' :
134
+ pipeline.status === 'ABORTED' ? '🛑' :
135
+ pipeline.status === 'RUNNING' ? '⏳' :
136
+ pipeline.status === 'PENDING' ? '⏸' : '📋';
137
+ const lines = [
138
+ `${emoji} **Pipeline Status: ${pipeline.status}**`,
139
+ ``,
140
+ `| Field | Value |`,
141
+ `|-------|-------|`,
142
+ `| **ID** | \`${pipeline.id}\` |`,
143
+ `| **Project** | ${pipeline.project} |`,
144
+ `| **Objective** | ${objective} |`,
145
+ `| **Current Step** | ${pipeline.current_step} |`,
146
+ `| **Iteration** | ${pipeline.iteration} / ${maxIter} |`,
147
+ `| **Started** | ${pipeline.started_at} |`,
148
+ `| **Last Updated** | ${pipeline.updated_at} |`,
149
+ `| **Last Heartbeat** | ${pipeline.last_heartbeat || 'N/A'} |`,
150
+ ];
151
+ if (pipeline.error) {
152
+ lines.push(`| **Error** | ${pipeline.error.slice(0, 500)} |`);
153
+ }
154
+ if (!isTerminal) {
155
+ lines.push(``, `*Pipeline is still running. Poll again in 30-60 seconds.*`);
156
+ }
157
+ return {
158
+ content: [{ type: "text", text: lines.join('\n') }],
159
+ };
160
+ }
161
+ catch (err) {
162
+ const msg = err instanceof Error ? err.message : String(err);
163
+ return {
164
+ content: [{ type: "text", text: `❌ Failed to check pipeline status: ${msg}` }],
165
+ isError: true,
166
+ };
167
+ }
168
+ }
169
+ // ─── Abort Pipeline Handler ─────────────────────────────────
170
+ export async function sessionAbortPipelineHandler(args) {
171
+ if (!isAbortPipelineArgs(args)) {
172
+ return {
173
+ content: [{ type: "text", text: "❌ Invalid arguments. Required: pipeline_id (string)." }],
174
+ isError: true,
175
+ };
176
+ }
177
+ const { pipeline_id } = args;
178
+ try {
179
+ const storage = await getStorage();
180
+ const pipeline = await storage.getPipeline(pipeline_id, PRISM_USER_ID);
181
+ if (!pipeline) {
182
+ return {
183
+ content: [{ type: "text", text: `❌ Pipeline \`${pipeline_id}\` not found.` }],
184
+ isError: true,
185
+ };
186
+ }
187
+ // Already terminal?
188
+ if (['COMPLETED', 'FAILED', 'ABORTED'].includes(pipeline.status)) {
189
+ return {
190
+ content: [{ type: "text", text: `ℹ️ Pipeline \`${pipeline_id}\` is already in terminal state: **${pipeline.status}**. No action needed.` }],
191
+ };
192
+ }
193
+ // Abort — the status guard + kill switch in runner.ts will handle the rest
194
+ await storage.savePipeline({
195
+ ...pipeline,
196
+ status: 'ABORTED',
197
+ error: 'Manually aborted by user via session_abort_pipeline.',
198
+ });
199
+ debugLog(`[PipelineHandler] Pipeline ${pipeline_id} aborted by user.`);
200
+ return {
201
+ content: [{
202
+ type: "text",
203
+ text: `🛑 Pipeline \`${pipeline_id}\` has been **ABORTED**.\n\nThe background runner will stop processing this pipeline on the next tick.`,
204
+ }],
205
+ };
206
+ }
207
+ catch (err) {
208
+ const msg = err instanceof Error ? err.message : String(err);
209
+ return {
210
+ content: [{ type: "text", text: `❌ Failed to abort pipeline: ${msg}` }],
211
+ isError: true,
212
+ };
213
+ }
214
+ }
@@ -54,6 +54,20 @@ export async function getExperienceBias(project, taskKeywords, storageBackend) {
54
54
  relevantCount++;
55
55
  }
56
56
  }
57
+ // GAP-1 fix: Ingest validation_result events into ML routing bias.
58
+ // The v7.2 spec requires that "Router learning ingests raw verification
59
+ // signals (pass_rate, critical_failures, coverage_score, rubric_hash)."
60
+ // confidence_score >= 80 indicates a passing verification suite.
61
+ if (eventType === "validation_result") {
62
+ const confidence = raw.confidence_score || 50;
63
+ if (confidence >= 80) {
64
+ successCount++;
65
+ }
66
+ else {
67
+ failureCount++;
68
+ }
69
+ relevantCount++;
70
+ }
57
71
  }
58
72
  if (relevantCount < MIN_SAMPLES) {
59
73
  return {
@@ -899,7 +899,8 @@ export const SESSION_SAVE_EXPERIENCE_TOOL = {
899
899
  "- **correction**: Agent was corrected by user\n" +
900
900
  "- **success**: Task completed successfully\n" +
901
901
  "- **failure**: Task failed\n" +
902
- "- **learning**: New knowledge acquired",
902
+ "- **learning**: New knowledge acquired\n" +
903
+ "- **validation_result**: Verification sandbox passed or failed",
903
904
  inputSchema: {
904
905
  type: "object",
905
906
  properties: {
@@ -909,7 +910,7 @@ export const SESSION_SAVE_EXPERIENCE_TOOL = {
909
910
  },
910
911
  event_type: {
911
912
  type: "string",
912
- enum: ["correction", "success", "failure", "learning"],
913
+ enum: ["correction", "success", "failure", "learning", "validation_result"],
913
914
  description: "Type of behavioral event.",
914
915
  },
915
916
  context: {
@@ -952,7 +953,8 @@ export function isSessionSaveExperienceArgs(args) {
952
953
  (a.event_type !== "correction" &&
953
954
  a.event_type !== "success" &&
954
955
  a.event_type !== "failure" &&
955
- a.event_type !== "learning"))
956
+ a.event_type !== "learning" &&
957
+ a.event_type !== "validation_result"))
956
958
  return false;
957
959
  if (typeof a.context !== "string")
958
960
  return false;