prism-mcp-server 20.21.21 → 20.21.23

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/README.md CHANGED
@@ -48,6 +48,12 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
48
48
  Where a host offers hooks (Claude Code, Codex), `prism connect` adds two
49
49
  small ones on top: mid-session prompt routing, and a post-compaction
50
50
  re-injection of the protected-floor digest.
51
+ - **Prompt-triggered Factory** — enabled by default for suitable bounded
52
+ implementation requests with explicit files, an allowed write list, and a
53
+ verification command. The host starts and monitors the recommended pipeline;
54
+ BCBA requests stay with the host. A dashboard toggle or
55
+ `PRISM_DARK_FACTORY_ENABLED=false` disables Factory after an MCP restart.
56
+ Try the [three-test filename fix](examples/factory-filename-fix/README.md).
51
57
  - **Safe escalation and observability** — inference outcomes are explicit,
52
58
  reserved text remains fail-closed (clinical images are processed locally,
53
59
  never sent to the cloud), and local/cloud usage is recorded for
@@ -158,6 +164,25 @@ or by re-enabling after each run.
158
164
  <details>
159
165
  <summary>Release history (optional)</summary>
160
166
 
167
+ ## What's New in v20.21.23
168
+
169
+ - Factory routes suitable bounded implementation prompts and executes only the
170
+ allowed file actions, with independent verification and explicit lifecycle results.
171
+ - Paid pipeline creation, updates, aborts, and verification records use the
172
+ authenticated Portal API. Startup waits for paid storage initialization before
173
+ reporting Factory readiness.
174
+ - The default-enabled toggle distinguishes the saved setting from the active
175
+ MCP setting and shows when a restart is required.
176
+
177
+ ## What's New in v20.21.22
178
+
179
+ - Factory pipeline lists for cloud accounts use the authenticated Portal API,
180
+ including exact project and status filters, without direct database credentials.
181
+ - Invalid, incomplete, or oversized reads show an unavailable state. A valid empty
182
+ list remains distinct from a failed read.
183
+ - This release covers pipeline lists. Cloud pipeline creation, controls, and
184
+ execution still need separate validation.
185
+
161
186
  ## What's New in v20.21.21
162
187
 
163
188
  - Prism Dashboard now reports unavailable analytics and factory reads clearly,
package/dist/config.js CHANGED
@@ -315,9 +315,9 @@ export const PRISM_VERIFICATION_LAYERS = (process.env.PRISM_VERIFICATION_LAYERS
315
315
  export const PRISM_VERIFICATION_DEFAULT_SEVERITY = (process.env.PRISM_VERIFICATION_DEFAULT_SEVERITY || "warn");
316
316
  // ─── v7.3: Dark Factory Orchestration ─────────────────────────
317
317
  // Autonomous pipeline runner: PLAN → EXECUTE → VERIFY → iterate.
318
- // Opt-in because it executes LLM calls in the background.
319
- /** Master switch for the Dark Factory background runner. */
320
- export const PRISM_DARK_FACTORY_ENABLED = process.env.PRISM_DARK_FACTORY_ENABLED === "true"; // Opt-in
318
+ // Enabled by default; the persisted preference or an explicit env override can disable it.
319
+ /** Compatibility default; startup resolver applies env or persisted dashboard state. */
320
+ export const PRISM_DARK_FACTORY_ENABLED = true; // Startup resolver applies env override or persisted dashboard setting.
321
321
  /** Poll interval for the runner loop (ms). Default: 30s. */
322
322
  export const PRISM_DARK_FACTORY_POLL_MS = parseInt(process.env.PRISM_DARK_FACTORY_POLL_MS || "30000", 10);
323
323
  /** Default max wall-clock time per pipeline (ms). Default: 15 minutes. */
package/dist/connect.js CHANGED
@@ -4,6 +4,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 as w
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
7
+ import { FACTORY_HOST_POLICY_LINES } from "./darkfactory/hostPolicy.js";
7
8
  import { CONTEXT_RECOVERY_POLICY_LINES } from "./contextRecoveryPolicy.js";
8
9
  import { EVIDENCE_WORKFLOW_POLICY_LINES } from "./evidenceWorkflowPolicy.js";
9
10
  import { LOCAL_FIRST_POLICY_ID, LOCAL_FIRST_POLICY_LINES } from "./localFirstPolicy.js";
@@ -52,6 +53,8 @@ const CODEX_STARTUP_BODY = [
52
53
  "",
53
54
  ...CONTEXT_RECOVERY_POLICY_LINES,
54
55
  "",
56
+ ...FACTORY_HOST_POLICY_LINES,
57
+ "",
55
58
  ...LOCAL_FIRST_POLICY_LINES,
56
59
  "",
57
60
  ...EVIDENCE_WORKFLOW_POLICY_LINES,
@@ -588,6 +591,8 @@ function serializeClaudeStartupBlock(newline) {
588
591
  "",
589
592
  ...CONTEXT_RECOVERY_POLICY_LINES,
590
593
  "",
594
+ ...FACTORY_HOST_POLICY_LINES,
595
+ "",
591
596
  ...LOCAL_FIRST_POLICY_LINES,
592
597
  "",
593
598
  ...EVIDENCE_WORKFLOW_POLICY_LINES,
@@ -683,6 +688,8 @@ function serializeGeminiStartupBlock(newline) {
683
688
  "",
684
689
  ...CONTEXT_RECOVERY_POLICY_LINES,
685
690
  "",
691
+ ...FACTORY_HOST_POLICY_LINES,
692
+ "",
686
693
  ...LOCAL_FIRST_POLICY_LINES,
687
694
  "",
688
695
  ...EVIDENCE_WORKFLOW_POLICY_LINES,
@@ -0,0 +1,327 @@
1
+ import { promises as fs, constants as fsConstants } from "node:fs";
2
+ import { dirname, resolve, relative, isAbsolute } from "node:path";
3
+ import { execFile, spawn } from "node:child_process";
4
+ const MAX_OUTPUT = 16_000;
5
+ const MAX_FILE = 512_000;
6
+ const COMMAND_LIVENESS_POLL_MS = 100;
7
+ const COMMAND_STATUS_POLL_MS = 1_000;
8
+ const COMMAND_LIVENESS_TIMEOUT_MS = 5_000;
9
+ const COMMAND_STOPPED = "Pipeline stopped during command execution";
10
+ const COMMAND_LIVENESS_UNAVAILABLE = "Pipeline command liveness check timed out";
11
+ const COMMAND_TIMED_OUT = "Pipeline command timed out";
12
+ const COMMAND_OUTPUT_EXCEEDED = "Pipeline command output exceeded its limit";
13
+ const WRITE_STAGE_PREFIX = '.prism-write-';
14
+ const WRITE_STAGE_FILE = 'content';
15
+ async function assertCommandLive(stillRunning) {
16
+ let timer;
17
+ try {
18
+ const live = await Promise.race([
19
+ stillRunning(),
20
+ new Promise((_, reject) => {
21
+ timer = setTimeout(() => reject(new Error(COMMAND_LIVENESS_UNAVAILABLE)), COMMAND_LIVENESS_TIMEOUT_MS);
22
+ }),
23
+ ]);
24
+ if (!live)
25
+ throw new Error(COMMAND_STOPPED);
26
+ }
27
+ finally {
28
+ if (timer)
29
+ clearTimeout(timer);
30
+ }
31
+ }
32
+ /** Execute only while the caller retains authority; reap inherited descendants too. */
33
+ async function runGuardedCommand(file, args, cwd, timeout, stillRunning, stillOwned = stillRunning) {
34
+ await assertCommandLive(stillRunning);
35
+ return new Promise((resolveCommand, rejectCommand) => {
36
+ let finished = false;
37
+ let checking = false;
38
+ let checkingStatus = false;
39
+ let stopReason;
40
+ let cleanup;
41
+ let stdout = '';
42
+ let stderr = '';
43
+ let outputBytes = 0;
44
+ const child = spawn(file, args, {
45
+ cwd, shell: false, detached: process.platform !== 'win32',
46
+ stdio: ['ignore', 'pipe', 'pipe'], env: { PATH: process.env.PATH },
47
+ });
48
+ child.once('error', error => { stopReason ??= error; });
49
+ child.once('close', (code, signal) => {
50
+ finished = true;
51
+ clearInterval(monitor);
52
+ clearInterval(statusMonitor);
53
+ clearTimeout(deadline);
54
+ void (async () => {
55
+ await terminateTree();
56
+ if (stopReason)
57
+ throw stopReason;
58
+ if (code !== 0)
59
+ throw Object.assign(new Error(`Command exited with ${signal || code}`), { stdout, stderr });
60
+ await assertCommandLive(stillRunning);
61
+ resolveCommand({ stdout, stderr });
62
+ })().catch(rejectCommand);
63
+ });
64
+ function terminateTree() {
65
+ if (cleanup)
66
+ return cleanup;
67
+ const pid = child.pid;
68
+ cleanup = !pid ? Promise.resolve() : new Promise((resolveCleanup, rejectCleanup) => {
69
+ if (process.platform === 'win32') {
70
+ execFile('taskkill', ['/PID', String(pid), '/T', '/F'], { shell: false, timeout: COMMAND_LIVENESS_TIMEOUT_MS }, error => {
71
+ if (error && child.exitCode === null && child.signalCode === null)
72
+ rejectCleanup(error);
73
+ else
74
+ resolveCleanup();
75
+ });
76
+ }
77
+ else {
78
+ try {
79
+ process.kill(-pid, 'SIGKILL');
80
+ resolveCleanup();
81
+ }
82
+ catch (error) {
83
+ if (error.code === 'ESRCH')
84
+ resolveCleanup();
85
+ else
86
+ rejectCleanup(error);
87
+ }
88
+ }
89
+ });
90
+ return cleanup;
91
+ }
92
+ function stop(error) {
93
+ stopReason ??= error;
94
+ void terminateTree().catch(cleanupError => { stopReason = new Error(`${error.message}; process cleanup failed: ${String(cleanupError)}`); });
95
+ }
96
+ const collect = (chunk, stream) => {
97
+ outputBytes += Buffer.byteLength(chunk);
98
+ if (outputBytes > MAX_OUTPUT * 2) {
99
+ stop(new Error(COMMAND_OUTPUT_EXCEEDED));
100
+ return;
101
+ }
102
+ if (stream === 'stdout')
103
+ stdout += chunk;
104
+ else
105
+ stderr += chunk;
106
+ };
107
+ child.stdout.setEncoding('utf8').on('data', chunk => collect(chunk, 'stdout'));
108
+ child.stderr.setEncoding('utf8').on('data', chunk => collect(chunk, 'stderr'));
109
+ // Reap inherited descendants on parent exit as well as on cancellation.
110
+ // Their output pipes must close before this command can settle.
111
+ child.once('exit', () => { void terminateTree().catch(error => { stopReason ??= error; }); });
112
+ const deadline = setTimeout(() => stop(new Error(COMMAND_TIMED_OUT)), timeout);
113
+ const monitor = setInterval(() => {
114
+ if (finished || checking)
115
+ return;
116
+ checking = true;
117
+ void assertCommandLive(stillOwned).catch(error => {
118
+ if (finished)
119
+ return;
120
+ stop(error instanceof Error ? error : new Error(String(error)));
121
+ }).finally(() => { checking = false; });
122
+ }, COMMAND_LIVENESS_POLL_MS);
123
+ // Local leases can be checked frequently without turning a repository
124
+ // command into hundreds of authenticated Portal reads per minute.
125
+ const statusMonitor = setInterval(() => {
126
+ if (finished || checkingStatus || stillOwned === stillRunning)
127
+ return;
128
+ checkingStatus = true;
129
+ void assertCommandLive(stillRunning).catch(error => {
130
+ if (!finished)
131
+ stop(error instanceof Error ? error : new Error(String(error)));
132
+ }).finally(() => { checkingStatus = false; });
133
+ }, COMMAND_STATUS_POLL_MS);
134
+ monitor.unref();
135
+ statusMonitor.unref();
136
+ });
137
+ }
138
+ function contained(root, target) {
139
+ const rel = relative(root, target);
140
+ return rel === "" || (rel !== ".." && !rel.startsWith(".." + "/") && !rel.startsWith(".." + "\\") && !isAbsolute(rel));
141
+ }
142
+ async function safePath(spec, targetPath, write) {
143
+ if (!spec.workingDirectory || !targetPath || isAbsolute(targetPath))
144
+ throw new Error("Action path must be relative to workingDirectory");
145
+ const root = await fs.realpath(spec.workingDirectory);
146
+ const candidate = resolve(root, targetPath);
147
+ const parent = await fs.realpath(dirname(candidate));
148
+ const target = resolve(parent, candidate.slice(candidate.lastIndexOf("/") + 1));
149
+ if (!contained(root, target))
150
+ throw new Error("Action path escapes workingDirectory");
151
+ try {
152
+ const final = await fs.realpath(target);
153
+ if (!contained(root, final))
154
+ throw new Error("Action path escapes workingDirectory");
155
+ }
156
+ catch (error) {
157
+ if (error.code !== "ENOENT")
158
+ throw error;
159
+ }
160
+ if (write && (!spec.allowedWritePaths || !spec.allowedWritePaths.some(p => resolve(root, p) === target))) {
161
+ throw new Error("Action path is outside allowedWritePaths");
162
+ }
163
+ return target;
164
+ }
165
+ async function commandArgs(command, spec) {
166
+ const parts = command.trim().split(/\s+/);
167
+ if (parts.length < 2 || parts.some(part => /[;&|`$<>]/.test(part)))
168
+ throw new Error("RUN_TEST command is not allowed");
169
+ const checkPath = async (value) => {
170
+ if (!value || value.startsWith('-') || isAbsolute(value) || value.split(/[\\/]/).includes('..'))
171
+ throw new Error("RUN_TEST path must stay within workingDirectory");
172
+ await safePath(spec, value, false);
173
+ };
174
+ if (parts[0] === "node" && parts[1] === "--test" && parts.length > 2) {
175
+ for (const value of parts.slice(2))
176
+ await checkPath(value);
177
+ return { file: "node", args: parts.slice(1) };
178
+ }
179
+ if (parts.length === 2 && parts[0] === "npm" && parts[1] === "test")
180
+ return { file: "npm", args: parts.slice(1) };
181
+ if (parts[0] === "pytest") {
182
+ for (const value of parts.slice(1))
183
+ await checkPath(value.split('::')[0]);
184
+ return { file: "pytest", args: parts.slice(1) };
185
+ }
186
+ if (parts[0] === "go" && parts[1] === "test" && parts.length > 2) {
187
+ for (const value of parts.slice(2)) {
188
+ if (!value.startsWith('./'))
189
+ throw new Error("RUN_TEST Go packages must be workspace relative");
190
+ await checkPath(value.endsWith('/...') ? value.slice(0, -4) || '.' : value);
191
+ }
192
+ return { file: "go", args: parts.slice(1) };
193
+ }
194
+ if (parts[0] === "cargo" && parts[1] === "test" && parts.slice(2).every(value => /^[A-Za-z0-9_]+$/.test(value)))
195
+ return { file: "cargo", args: parts.slice(1) };
196
+ throw new Error("RUN_TEST must use node --test, npm test, pytest, go test, or cargo test");
197
+ }
198
+ export async function executeActions(actions, spec, stillRunning, stillOwned = stillRunning) {
199
+ for (const action of actions) {
200
+ if (!action || !["READ_FILE", "WRITE_FILE", "PATCH_FILE", "RUN_TEST"].includes(action.type) || !action.targetPath)
201
+ throw new Error("Invalid action payload");
202
+ if (action.type === "WRITE_FILE" && typeof action.content !== "string")
203
+ throw new Error("WRITE_FILE content is required");
204
+ if (action.type === "PATCH_FILE" && typeof action.patch !== "string")
205
+ throw new Error("PATCH_FILE patch is required");
206
+ if (action.type === "RUN_TEST" && typeof action.command !== "string")
207
+ throw new Error("RUN_TEST command is required");
208
+ }
209
+ for (const action of actions) {
210
+ await safePath(spec, action.targetPath, action.type === "WRITE_FILE" || action.type === "PATCH_FILE");
211
+ if (action.type === "RUN_TEST") {
212
+ if (!spec.testCommand || action.command !== spec.testCommand)
213
+ throw new Error("RUN_TEST must exactly match spec.testCommand");
214
+ await commandArgs(action.command, spec);
215
+ }
216
+ }
217
+ const results = [];
218
+ for (const action of actions) {
219
+ if (!(await stillRunning()))
220
+ throw new Error("Pipeline stopped before action execution");
221
+ const write = action.type === "WRITE_FILE" || action.type === "PATCH_FILE";
222
+ const target = await safePath(spec, action.targetPath, write);
223
+ if (action.type === "READ_FILE") {
224
+ const content = await fs.readFile(target, "utf8");
225
+ results.push(`READ_FILE ${action.targetPath}: ${content.slice(0, MAX_OUTPUT)}`);
226
+ }
227
+ else if (action.type === "WRITE_FILE") {
228
+ if (typeof action.content !== "string" || action.content.length > MAX_FILE)
229
+ throw new Error("WRITE_FILE content is invalid or too large");
230
+ if (!(await stillRunning()))
231
+ throw new Error("Pipeline stopped before file mutation");
232
+ const flags = fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW;
233
+ let handle;
234
+ try {
235
+ handle = await fs.open(target, flags);
236
+ }
237
+ catch (error) {
238
+ if (error.code !== 'ENOENT')
239
+ throw error;
240
+ if (!(await stillRunning()))
241
+ throw new Error("Pipeline stopped before file mutation");
242
+ // Stage on the same filesystem, then publish a complete inode without
243
+ // replacing an existing pathname. Cancellation never unlinks target.
244
+ const stageDir = await fs.mkdtemp(resolve(dirname(target), WRITE_STAGE_PREFIX));
245
+ const staged = resolve(stageDir, WRITE_STAGE_FILE);
246
+ try {
247
+ const stagedHandle = await fs.open(staged, flags | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
248
+ try {
249
+ if (!(await stillRunning()))
250
+ throw new Error("Pipeline stopped before file mutation");
251
+ await stagedHandle.writeFile(action.content, "utf8");
252
+ if (!(await stillRunning()))
253
+ throw new Error("Pipeline stopped before file mutation");
254
+ await fs.link(staged, target);
255
+ }
256
+ finally {
257
+ await stagedHandle.close();
258
+ }
259
+ }
260
+ finally {
261
+ await fs.unlink(staged).catch((cleanupError) => { if (cleanupError.code !== 'ENOENT')
262
+ throw cleanupError; });
263
+ await fs.rmdir(stageDir);
264
+ }
265
+ results.push(`WRITE_FILE ${action.targetPath}: ok`);
266
+ continue;
267
+ }
268
+ try {
269
+ if (!(await stillRunning()))
270
+ throw new Error("Pipeline stopped before file mutation");
271
+ await handle.truncate(0);
272
+ await handle.writeFile(action.content, "utf8");
273
+ }
274
+ finally {
275
+ await handle.close();
276
+ }
277
+ results.push(`WRITE_FILE ${action.targetPath}: ok`);
278
+ }
279
+ else if (action.type === "PATCH_FILE") {
280
+ const header = action.patch.match(/^diff --git a\/([^\n]+) b\/([^\n]+)\n(?:index [^\n]+\n)?--- a\/([^\n]+)\n\+\+\+ b\/([^\n]+)\n/);
281
+ if (!header || header[1] !== action.targetPath || header[2] !== action.targetPath || header[3] !== action.targetPath || header[4] !== action.targetPath || action.patch.includes("rename from") || action.patch.includes("new file mode") || action.patch.includes("old mode") || (action.patch.match(/^diff --git /gm) || []).length !== 1)
282
+ throw new Error("PATCH_FILE must be a single-file content patch");
283
+ const patchFile = resolve(spec.workingDirectory, `.prism-patch-${process.pid}-${Date.now()}.diff`);
284
+ if (!(await stillRunning()))
285
+ throw new Error("Pipeline stopped before patch mutation");
286
+ await fs.writeFile(patchFile, action.patch, { mode: 0o600 });
287
+ try {
288
+ await runGuardedCommand("git", ["apply", "--check", "--whitespace=nowarn", patchFile], spec.workingDirectory, 30_000, stillRunning, stillOwned);
289
+ if (!(await stillRunning()))
290
+ throw new Error("Pipeline stopped before patch mutation");
291
+ await runGuardedCommand("git", ["apply", "--whitespace=nowarn", patchFile], spec.workingDirectory, 30_000, stillRunning, stillOwned);
292
+ }
293
+ finally {
294
+ await fs.rm(patchFile, { force: true });
295
+ }
296
+ results.push(`PATCH_FILE ${action.targetPath}: ok`);
297
+ }
298
+ else if (action.type === "RUN_TEST") {
299
+ if (!spec.testCommand || action.command !== spec.testCommand)
300
+ throw new Error("RUN_TEST must exactly match spec.testCommand");
301
+ const parsed = await commandArgs(action.command, spec);
302
+ try {
303
+ const out = await runGuardedCommand(parsed.file, parsed.args, spec.workingDirectory, 120_000, stillRunning, stillOwned);
304
+ if (!(await stillRunning()))
305
+ throw new Error("Pipeline stopped during test execution");
306
+ results.push(`RUN_TEST ${action.command}: passed\n${String(out.stdout).slice(0, MAX_OUTPUT)}`);
307
+ }
308
+ catch (error) {
309
+ throw new Error(`RUN_TEST failed: ${String(error?.stdout || error?.stderr || error).slice(0, MAX_OUTPUT)}`);
310
+ }
311
+ }
312
+ }
313
+ return results;
314
+ }
315
+ export async function readContextFiles(spec) {
316
+ if (!spec.contextFiles?.length)
317
+ return "";
318
+ if (spec.contextFiles.length > 20)
319
+ throw new Error("contextFiles exceeds limit");
320
+ const chunks = [];
321
+ for (const file of spec.contextFiles) {
322
+ const target = await safePath(spec, file, false);
323
+ const content = await fs.readFile(target, "utf8");
324
+ chunks.push(`=== ${file} ===\n${content.slice(0, 20_000)}`);
325
+ }
326
+ return chunks.join("\n");
327
+ }
@@ -0,0 +1,12 @@
1
+ import { getStorage, activeStorageBackend } from '../storage/index.js';
2
+ /** Resolve the instance before reading its capability: prewarm may still be pending. */
3
+ export async function factoryBackendReadiness() {
4
+ try {
5
+ const storage = await getStorage();
6
+ const paid = typeof storage.isFactoryReady === 'function';
7
+ return { storage_backend: paid ? 'synalux' : activeStorageBackend, factory_ready: paid ? storage.isFactoryReady() : true };
8
+ }
9
+ catch {
10
+ return { storage_backend: 'unavailable', factory_ready: false };
11
+ }
12
+ }
@@ -2,6 +2,7 @@ import { getLLMProvider } from '../utils/llm/factory.js';
2
2
  import { OpenAIAdapter } from '../utils/llm/adapters/openai.js';
3
3
  import { SafetyController } from './safetyController.js';
4
4
  import { debugLog } from '../utils/logger.js';
5
+ import { readContextFiles } from './actionExecutor.js';
5
6
  /**
6
7
  * JSON output schema instruction injected into EXECUTE step prompts.
7
8
  * Forces the LLM to respond with machine-parseable structured output
@@ -13,23 +14,25 @@ You MUST respond with ONLY a valid JSON object matching this schema:
13
14
  {
14
15
  "actions": [
15
16
  {
16
- "type": "READ_FILE" | "WRITE_FILE" | "PATCH_FILE" | "RUN_TEST",
17
+ "type": "READ_FILE" | "WRITE_FILE" | "PATCH_FILE",
17
18
  "targetPath": "<relative path within the workspace>",
18
19
  "content": "<file content for WRITE_FILE>",
19
- "patch": "<patch content for PATCH_FILE>",
20
- "command": "<test command for RUN_TEST>"
20
+ "patch": "<patch content for PATCH_FILE>"
21
21
  }
22
22
  ],
23
23
  "notes": "<optional summary of what you did>"
24
24
  }
25
25
 
26
26
  RULES:
27
- - type MUST be one of: READ_FILE, WRITE_FILE, PATCH_FILE, RUN_TEST
27
+ - type MUST be one of: READ_FILE, WRITE_FILE, PATCH_FILE
28
+ - Submit only file actions here. The host runs the verification command independently in VERIFY.
28
29
  - targetPath MUST be a relative path within the workspace
29
30
  - Do NOT include any text outside the JSON object
30
31
  - Do NOT use markdown code fences
31
32
  - If you cannot complete the task, return: {"actions": [], "notes": "reason"}
32
33
  `.trim();
34
+ // Thinking models consume this budget before emitting the structured response.
35
+ const FACTORY_OUTPUT_TOKEN_BUDGET = 4096;
33
36
  const PLAN_CONTRACT_SCHEMA = `
34
37
  You MUST respond with ONLY a valid JSON object matching this schema:
35
38
  {
@@ -80,6 +83,13 @@ export async function invokeClawAgent(spec, state, timeoutMs = 120000 // 2 min d
80
83
  : getLLMProvider();
81
84
  // Scope injection via SafetyController — single source of truth
82
85
  const systemPrompt = SafetyController.generateBoundaryPrompt(spec, state);
86
+ let contextObservation = '';
87
+ try {
88
+ contextObservation = await readContextFiles(spec);
89
+ }
90
+ catch (error) {
91
+ return { success: false, resultText: `Context read failed: ${error instanceof Error ? error.message : String(error)}` };
92
+ }
83
93
  // Inject the appropriate JSON schema according to the step
84
94
  let stepPrompt = `Based on the system instructions, execute the necessary task for the current step (${state.current_step}). Respond with your actions and observations.`;
85
95
  let isJsonMode = false;
@@ -94,7 +104,7 @@ export async function invokeClawAgent(spec, state, timeoutMs = 120000 // 2 min d
94
104
  isJsonMode = true;
95
105
  }
96
106
  else if (state.current_step === 'PLAN_CONTRACT') {
97
- stepPrompt = `Based on the system instructions from the PLAN phase, formulate a strict, boolean-testable contract rubric.\n\n${PLAN_CONTRACT_SCHEMA}`;
107
+ stepPrompt = `Based on the system instructions from the PLAN phase, formulate a strict, boolean-testable contract rubric.\n\nActual PLAN output:\n${state.notes || '(no plan output)'}\n\n${PLAN_CONTRACT_SCHEMA}`;
98
108
  isJsonMode = true;
99
109
  }
100
110
  else if (state.current_step === 'EVALUATE') {
@@ -109,11 +119,13 @@ ${state.contract_payload ? JSON.stringify(state.contract_payload.criteria, null,
109
119
  ${EVALUATE_SCHEMA}`;
110
120
  isJsonMode = true;
111
121
  }
122
+ if (contextObservation)
123
+ stepPrompt += `\n\n=== ACTUAL CONTEXT FILES ===\n${contextObservation}`;
112
124
  debugLog(`[ClawInvocation] Launching agent on pipeline ${state.id} step=${state.current_step} iter=${state.iteration} with ${timeoutMs}ms limit.${isJsonMode ? ' (JSON mode)' : ''}`);
113
125
  try {
114
126
  // Timeout Promise to ensure the runner thread does not block indefinitely
115
127
  const timeboundExecution = Promise.race([
116
- llm.generateText(stepPrompt, systemPrompt),
128
+ llm.generateText(stepPrompt, systemPrompt, { json: isJsonMode, model: spec.modelOverride, maxOutputTokens: FACTORY_OUTPUT_TOKEN_BUDGET }),
117
129
  new Promise((_, reject) => setTimeout(() => reject(new Error('LLM_EXECUTION_TIMEOUT')), timeoutMs))
118
130
  ]);
119
131
  const result = await timeboundExecution;
@@ -0,0 +1,29 @@
1
+ import { getSettingSync } from "../storage/configStorage.js";
2
+ const SETTING_KEY = "PRISM_DARK_FACTORY_ENABLED";
3
+ const DEFAULT_ENABLED = true;
4
+ export const DARK_FACTORY_TOOL_NAMES = ["session_start_pipeline", "session_check_pipeline_status", "session_abort_pipeline"];
5
+ export const DARK_FACTORY_SETTINGS_UNAVAILABLE = "Factory startup settings unavailable. Retry when the server is reachable.";
6
+ let startupDecision;
7
+ export function resolveDarkFactoryEnabled(env, saved) {
8
+ if (env === "true" || env === "false")
9
+ return env === "true";
10
+ return saved === "true" || saved === "false" ? saved === "true" : DEFAULT_ENABLED;
11
+ }
12
+ /** Freeze one enablement decision for tool registration, dispatch, and runner startup. */
13
+ export function initializeDarkFactoryEnabled() {
14
+ const env = process.env.PRISM_DARK_FACTORY_ENABLED;
15
+ startupDecision = resolveDarkFactoryEnabled(env, getSettingSync(SETTING_KEY, String(DEFAULT_ENABLED)));
16
+ return startupDecision;
17
+ }
18
+ export function isDarkFactoryEnabled() {
19
+ return startupDecision ?? initializeDarkFactoryEnabled();
20
+ }
21
+ export function buildDarkFactoryEnablementState(saved, active, env) {
22
+ const envOverride = env === "true" || env === "false" ? env === "true" : null;
23
+ return { saved, active, envOverride, pendingRestart: envOverride === null && saved !== active };
24
+ }
25
+ export function darkFactoryEnablementState() {
26
+ const env = process.env.PRISM_DARK_FACTORY_ENABLED;
27
+ const saved = resolveDarkFactoryEnabled(undefined, getSettingSync(SETTING_KEY, String(DEFAULT_ENABLED)));
28
+ return buildDarkFactoryEnablementState(saved, isDarkFactoryEnabled(), env);
29
+ }
@@ -0,0 +1,11 @@
1
+ import { registerFactoryAuthority, isFactoryAuthorized, claimFactoryLease, DEFAULT_FACTORY_LEASE_MS, FACTORY_LEASE_RENEW_MS, } from "../storage/configStorage.js";
2
+ export { DEFAULT_FACTORY_LEASE_MS, FACTORY_LEASE_RENEW_MS };
3
+ export async function registerPipelineAuthority(pipeline) {
4
+ return registerFactoryAuthority(pipeline);
5
+ }
6
+ export async function isPipelineAuthorized(pipeline) {
7
+ return isFactoryAuthorized(pipeline);
8
+ }
9
+ export async function claimPipelineLease(pipeline) {
10
+ return claimFactoryLease(pipeline);
11
+ }
@@ -0,0 +1,12 @@
1
+ /** Shared native-host instruction: routing is read-only; the host owns execution. */
2
+ export const FACTORY_HOST_POLICY_LINES = [
3
+ '## Prism Factory prompt routing',
4
+ 'After the startup display, call session_route_prompt for a bounded implementation request on the',
5
+ 'first task turn and when the task changes. Supply the verbatim prompt, current project, absolute',
6
+ 'working_directory, concrete files, test_command and the source-only allowed_write_paths.',
7
+ 'When prism_factory_route has target factory, automatically call recommended_tool with recommended_args',
8
+ 'exactly once, report the pipeline ID, and monitor until a terminal status. Use that pipeline before',
9
+ 'general local-worker delegation for the same request. Once a pipeline exists, monitor it instead of',
10
+ 'starting a duplicate. Disabled Factory, host decisions and clinical/BCBA requests remain with the host.',
11
+ 'The dashboard is optional. Do not change startup settings to force eligibility.',
12
+ ];
@@ -0,0 +1,59 @@
1
+ import path from 'node:path';
2
+ const MAX_FILES = 2;
3
+ const MAX_PROMPT_CHARS = 4096;
4
+ const IMPLEMENTATION_REQUEST = /^(?:(?:please|can you|could you|i want you to|i need you to)\s+)*(?:fix|implement|add|update|create|write|repair)\b/i;
5
+ const CLINICAL_REQUEST = /\b(?:bcba|bip|fba|aba|clinical|patient|client notes|behavio(?:u)?r (?:support|intervention|plan|assessment|analysis|reduction)|operational definition|fct|dra|dro|ncr|functional (?:analysis|assessment|communication training)|teaching procedure|reinforcement schedule|differential reinforcement|token economy|antecedent strateg(?:y|ies)|task analysis|preference assessment|sensory supports?|skill acquisition|prompting hierarchy|consequence (?:strateg(?:y|ies)|section)|crisis|restraint|seclusion|self[- ]injury|elopement|aggression)\b/i;
6
+ const RESERVED_REQUEST = /\b(?:deploy|publish|push|commit|production|credentials?|secrets?|payments?|billing|authentication|authorization|security|migration|delete|drop|truncate|architecture|redesign|entire codebase|entire repo)\b/i;
7
+ const CODE_FILES = /(?:^|[\s`"'(])([A-Za-z0-9_./-]+\.(?:cjs|mjs|js|ts|tsx|py|go|rs|swift|rb|java|kt|cs))\b/g;
8
+ const CODE_EXTENSION = /\.(?:cjs|mjs|js|ts|tsx|py|go|rs|swift|rb|java|kt|cs)$/;
9
+ const VERIFY_REQUEST = /\b(?:run|verify|test|check|validate)\b/i;
10
+ const NODE_TEST_COMMAND = /\b(node\s+--test\s+[A-Za-z0-9_./-]+\.(?:cjs|mjs|js|ts))\b/;
11
+ const MESSAGES = {
12
+ clinical: 'Clinical and non-coding BCBA requests stay with the clinical host/skill route; they do not start a coding pipeline.',
13
+ reserved: 'This request requires host judgment or external/destructive operations.',
14
+ ordinary: 'No bounded implementation request identified.',
15
+ files: 'Factory needs one or two concrete code files within the workspace.',
16
+ writes: 'Factory write paths must be a nonempty subset of the validated concrete code files.',
17
+ verify: 'Factory needs an explicit verification outcome.',
18
+ command: 'The host must supply the repository verification command before starting Factory.',
19
+ disabled: 'Factory is disabled for this MCP startup.',
20
+ context: 'The host must supply the project and absolute working_directory before starting Factory.',
21
+ paid: 'Paid Factory execution is unavailable until Portal pipeline and verification APIs and their schema are ready.',
22
+ candidate: 'Bounded implementation with concrete files and verification; the host should call the recommended pipeline tool automatically.',
23
+ };
24
+ /** Routing is read-only. Pipeline creation remains a separate, side-effecting MCP call. */
25
+ export function routeFactoryPrompt(prompt, context) {
26
+ const host = (reason, candidate = false) => ({ target: 'host', candidate, reason, recommended_tool: null });
27
+ if (CLINICAL_REQUEST.test(prompt))
28
+ return host(MESSAGES.clinical);
29
+ if (RESERVED_REQUEST.test(prompt))
30
+ return host(MESSAGES.reserved);
31
+ if (!IMPLEMENTATION_REQUEST.test(prompt.trim()) || prompt.length > MAX_PROMPT_CHARS)
32
+ return host(MESSAGES.ordinary);
33
+ const files = [...new Set(context.files_involved ?? [...prompt.matchAll(CODE_FILES)].map(match => match[1]))];
34
+ if (files.length === 0 || files.length > MAX_FILES || files.some(file => !CODE_EXTENSION.test(file) || path.isAbsolute(file) || file.split(/[\\/]/).includes('..')))
35
+ return host(MESSAGES.files);
36
+ const writePaths = [...new Set(context.allowed_write_paths ?? files)];
37
+ if (!writePaths.length || writePaths.some(file => path.isAbsolute(file) || file.split(/[\\/]/).includes('..') || !files.includes(file)))
38
+ return host(MESSAGES.writes);
39
+ if (!context.test_command && !VERIFY_REQUEST.test(prompt))
40
+ return host(MESSAGES.verify);
41
+ if (!context.enabled)
42
+ return host(MESSAGES.disabled, true);
43
+ if (context.factory_ready === false || context.storage_backend === 'unavailable' || (context.storage_backend === 'synalux' && context.factory_ready !== true))
44
+ return host(MESSAGES.paid, true);
45
+ if (!context.project?.trim() || !context.working_directory || !path.isAbsolute(context.working_directory))
46
+ return host(MESSAGES.context, true);
47
+ const testCommand = context.test_command || NODE_TEST_COMMAND.exec(prompt)?.[1];
48
+ if (!testCommand)
49
+ return host(MESSAGES.command, true);
50
+ const objective = `${prompt}\nVerification command: ${testCommand}`;
51
+ return {
52
+ target: 'factory', candidate: true, reason: MESSAGES.candidate,
53
+ recommended_tool: 'session_start_pipeline',
54
+ recommended_args: { project: context.project.trim(), objective, working_directory: context.working_directory, context_files: files, max_iterations: 3, test_command: testCommand, allowed_write_paths: writePaths },
55
+ };
56
+ }
57
+ export function formatFactoryPromptRoute(route) {
58
+ return `<prism_factory_route>${JSON.stringify(route)}</prism_factory_route>`;
59
+ }