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.
@@ -18,11 +18,13 @@
18
18
  * Using console.log() (stdout) will corrupt the MCP JSON-RPC stream.
19
19
  */
20
20
  import { getStorage } from '../storage/index.js';
21
+ import { SqliteStorage } from '../storage/sqlite.js';
21
22
  import { VALID_ACTION_TYPES } from './schema.js';
22
23
  import { SafetyController } from './safetyController.js';
23
24
  import { invokeClawAgent } from './clawInvocation.js';
24
25
  import { PRISM_DARK_FACTORY_POLL_MS, PRISM_DARK_FACTORY_MAX_RUNTIME_MS, PRISM_USER_ID, PRISM_VERIFICATION_LAYERS, PRISM_VERIFICATION_DEFAULT_SEVERITY } from '../config.js';
25
26
  import { debugLog } from '../utils/logger.js';
27
+ import { executeActions } from './actionExecutor.js';
26
28
  import path from 'path';
27
29
  import fs from 'fs';
28
30
  import * as crypto from 'crypto';
@@ -30,6 +32,19 @@ import { Gatekeeper } from '../verification/gatekeeper.js';
30
32
  import { VerificationRunner } from '../verification/runner.js';
31
33
  import { computeRubricHash } from '../verification/schema.js';
32
34
  import { VerificationGateError } from '../errors.js';
35
+ import { registerPipelineAuthority, isPipelineAuthorized, claimPipelineLease, FACTORY_LEASE_RENEW_MS } from './executionAuthority.js';
36
+ const LEASED_STORAGE_WRITES = new Set(['savePipeline', 'saveVerificationHarness', 'saveVerificationRun']);
37
+ async function locallyAuthorized(pipeline, backend) {
38
+ if (await isPipelineAuthorized(pipeline))
39
+ return true;
40
+ // Existing local SQLite queues already belong to this machine. Remote
41
+ // rows require a receipt created by the local start handler.
42
+ if (backend instanceof SqliteStorage && pipeline.status === 'PENDING') {
43
+ await registerPipelineAuthority(pipeline);
44
+ return true;
45
+ }
46
+ return false;
47
+ }
33
48
  /** Interval handle for graceful shutdown */
34
49
  let runnerInterval = null;
35
50
  /** Tracks whether the runner is currently processing a tick (prevents overlap) */
@@ -48,15 +63,28 @@ async function recoverStalePipelines() {
48
63
  debugLog('[DarkFactory] No stale pipelines found on startup.');
49
64
  return;
50
65
  }
51
- debugLog(`[DarkFactory] Found ${runningPipelines.length} stale RUNNING pipeline(s) — marking as FAILED.`);
66
+ debugLog(`[DarkFactory] Checking ${runningPipelines.length} RUNNING pipeline(s) for locally authorized recovery.`);
52
67
  for (const pipeline of runningPipelines) {
68
+ if (!(await locallyAuthorized(pipeline, storage)))
69
+ continue;
70
+ const lease = await claimPipelineLease(pipeline);
71
+ if (!lease)
72
+ continue;
53
73
  try {
74
+ const live = await storage.getPipeline(pipeline.id, pipeline.user_id);
75
+ if (!live || live.status !== 'RUNNING' || !(await isPipelineAuthorized(live)))
76
+ continue;
77
+ if (!lease.reclaimed && !SafetyController.isHeartbeatLapsed(live))
78
+ continue;
79
+ if (!(await lease.isHeld()))
80
+ continue;
54
81
  await storage.savePipeline({
55
- ...pipeline,
82
+ ...live,
56
83
  status: 'FAILED',
57
84
  error: 'Unexpected Server Termination: pipeline was RUNNING when server restarted.',
58
85
  current_step: pipeline.current_step,
59
86
  });
87
+ await lease.acknowledgeRecovery();
60
88
  debugLog(`[DarkFactory] Pipeline ${pipeline.id} marked FAILED (was RUNNING on restart).`);
61
89
  }
62
90
  catch (err) {
@@ -64,6 +92,9 @@ async function recoverStalePipelines() {
64
92
  // by a concurrent process — safe to ignore.
65
93
  console.error(`[DarkFactory] Failed to recover pipeline ${pipeline.id}: ${err instanceof Error ? err.message : String(err)}`);
66
94
  }
95
+ finally {
96
+ await lease.release();
97
+ }
67
98
  }
68
99
  }
69
100
  catch (err) {
@@ -76,8 +107,10 @@ async function recoverStalePipelines() {
76
107
  * Called periodically during LLM execution to prove liveness.
77
108
  * This is intentionally cheap — just a timestamp update.
78
109
  */
79
- async function pulseHeartbeat(pipelineId, userId) {
110
+ async function pulseHeartbeat(pipelineId, userId, lease) {
80
111
  try {
112
+ if (!(await lease.isHeld()))
113
+ return;
81
114
  const storage = await getStorage();
82
115
  const pipeline = await storage.getPipeline(pipelineId, userId);
83
116
  if (!pipeline || pipeline.status !== 'RUNNING')
@@ -95,9 +128,9 @@ async function pulseHeartbeat(pipelineId, userId) {
95
128
  * Creates a heartbeat interval that pulses every 15 seconds during execution.
96
129
  * Returns a cleanup function to stop the interval.
97
130
  */
98
- function startHeartbeatInterval(pipelineId, userId) {
131
+ function startHeartbeatInterval(pipelineId, userId, lease) {
99
132
  const interval = setInterval(() => {
100
- pulseHeartbeat(pipelineId, userId).catch(() => { });
133
+ pulseHeartbeat(pipelineId, userId, lease).catch(() => { });
101
134
  }, 15_000);
102
135
  return () => clearInterval(interval);
103
136
  }
@@ -113,17 +146,29 @@ async function sweepZombies() {
113
146
  const running = await storage.listPipelines(undefined, 'RUNNING', PRISM_USER_ID);
114
147
  for (const pipeline of running) {
115
148
  if (SafetyController.isHeartbeatLapsed(pipeline)) {
149
+ if (!(await locallyAuthorized(pipeline, storage)))
150
+ continue;
151
+ const lease = await claimPipelineLease(pipeline);
152
+ if (!lease)
153
+ continue;
116
154
  debugLog(`[DarkFactory] Zombie detected: pipeline ${pipeline.id} heartbeat lapsed.`);
117
155
  try {
156
+ const live = await storage.getPipeline(pipeline.id, pipeline.user_id);
157
+ if (!live || live.status !== 'RUNNING' || !SafetyController.isHeartbeatLapsed(live) || !(await lease.isHeld()))
158
+ continue;
118
159
  await storage.savePipeline({
119
- ...pipeline,
160
+ ...live,
120
161
  status: 'FAILED',
121
162
  error: `Zombie pipeline: no heartbeat for ${SafetyController.HEARTBEAT_TIMEOUT_MS / 1000}s.`,
122
163
  });
164
+ await lease.acknowledgeRecovery();
123
165
  }
124
166
  catch {
125
167
  // Status guard may fire — pipeline was already terminated
126
168
  }
169
+ finally {
170
+ await lease.release();
171
+ }
127
172
  // Emit failure experience event
128
173
  await emitExperienceEvent(pipeline, 'failure', 'Pipeline zombie-swept due to heartbeat lapse.');
129
174
  }
@@ -316,12 +361,12 @@ export function parseEvaluationOutput(raw) {
316
361
  * The `scopeViolation` field on the result signals the runner to
317
362
  * terminate the entire pipeline (not just the step).
318
363
  */
319
- async function executeStep(pipeline, spec) {
364
+ async function executeStep(pipeline, spec, lease) {
320
365
  const stepStart = new Date().toISOString();
321
366
  const step = pipeline.current_step;
322
367
  debugLog(`[DarkFactory] Executing step=${step} iter=${pipeline.iteration} pipeline=${pipeline.id}`);
323
368
  // Start heartbeat pulse during LLM execution
324
- const stopHeartbeat = startHeartbeatInterval(pipeline.id, pipeline.user_id);
369
+ const stopHeartbeat = startHeartbeatInterval(pipeline.id, pipeline.user_id, lease);
325
370
  try {
326
371
  // All steps use the Claw invocation wrapper which applies:
327
372
  // - SafetyController boundary prompt
@@ -440,14 +485,25 @@ async function executeStep(pipeline, spec) {
440
485
  scopeViolation: scopeError,
441
486
  };
442
487
  }
443
- // All actions validated — return success with structured notes
488
+ let observations;
489
+ try {
490
+ observations = await executeActions(parsed.actions, spec, async () => {
491
+ if (!runnerInterval || !(await lease.isHeld()))
492
+ return false;
493
+ const live = await (await getStorage()).getPipeline(pipeline.id, pipeline.user_id);
494
+ return !!runnerInterval && await lease.isHeld() && live?.status === 'RUNNING' && live.current_step === pipeline.current_step;
495
+ }, async () => !!runnerInterval && await lease.isHeld());
496
+ }
497
+ catch (error) {
498
+ return { iteration: pipeline.iteration, step, started_at: stepStart, completed_at: new Date().toISOString(), success: false, notes: String(error instanceof Error ? error.message : error) };
499
+ }
444
500
  return {
445
501
  iteration: pipeline.iteration,
446
502
  step,
447
503
  started_at: stepStart,
448
504
  completed_at: new Date().toISOString(),
449
505
  success: true,
450
- notes: parsed.notes || `Executed ${parsed.actions.length} action(s) successfully`,
506
+ notes: `${parsed.notes || `Executed ${parsed.actions.length} action(s) successfully`}\n${observations.join('\n')}`,
451
507
  };
452
508
  }
453
509
  finally {
@@ -469,25 +525,72 @@ async function runnerTick() {
469
525
  return;
470
526
  }
471
527
  tickInProgress = true;
528
+ let lease = null;
529
+ let renewalInterval;
472
530
  try {
473
531
  // Phase 1: Zombie sweep (cheap — just DB reads)
474
532
  await sweepZombies();
475
533
  // Phase 2: Promote PENDING → RUNNING, then find a RUNNING pipeline to advance
476
- const storage = await getStorage();
477
- // Pick up PENDING pipelines and promote to RUNNING (queue → active)
478
- const pending = await storage.listPipelines(undefined, 'PENDING', PRISM_USER_ID);
479
- if (pending.length > 0) {
480
- // Promote oldest PENDING pipeline (FIFO)
481
- const toPromote = pending.sort((a, b) => new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime())[0];
482
- debugLog(`[DarkFactory] Promoting PENDING pipeline ${toPromote.id} → RUNNING`);
483
- await storage.savePipeline({ ...toPromote, status: 'RUNNING' });
534
+ const backend = await getStorage();
535
+ const candidates = [
536
+ ...await backend.listPipelines(undefined, 'PENDING', PRISM_USER_ID),
537
+ ...await backend.listPipelines(undefined, 'RUNNING', PRISM_USER_ID),
538
+ ].sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at));
539
+ let selected;
540
+ for (const candidate of candidates) {
541
+ if (!(await locallyAuthorized(candidate, backend)))
542
+ continue;
543
+ lease = await claimPipelineLease(candidate);
544
+ if (lease) {
545
+ selected = candidate;
546
+ break;
547
+ }
484
548
  }
485
- const running = await storage.listPipelines(undefined, 'RUNNING', PRISM_USER_ID);
486
- if (running.length === 0) {
487
- return; // Nothing to do
549
+ if (!selected || !lease)
550
+ return;
551
+ const heldLease = lease;
552
+ renewalInterval = setInterval(() => {
553
+ void heldLease.renew().catch(error => {
554
+ console.error(`[DarkFactory] Execution lease renewal failed: ${error instanceof Error ? error.message : String(error)}`);
555
+ });
556
+ }, FACTORY_LEASE_RENEW_MS);
557
+ renewalInterval.unref();
558
+ const pipeline = await backend.getPipeline(selected.id, selected.user_id);
559
+ if (!pipeline || !(await isPipelineAuthorized(pipeline)))
560
+ return;
561
+ if (!['PENDING', 'RUNNING'].includes(pipeline.status)) {
562
+ if (['FAILED', 'ABORTED', 'COMPLETED'].includes(pipeline.status))
563
+ await heldLease.acknowledgeRecovery();
564
+ return;
565
+ }
566
+ // Every persisted lifecycle/verification write must retain this process's
567
+ // authority. File actions and the test command check the same lease below.
568
+ const storage = new Proxy(backend, { get(target, key) {
569
+ const value = Reflect.get(target, key);
570
+ if (typeof value !== 'function')
571
+ return value;
572
+ if (!LEASED_STORAGE_WRITES.has(String(key)))
573
+ return value.bind(target);
574
+ return async (...args) => {
575
+ if (!(await heldLease.isHeld()) || !(await isPipelineAuthorized(pipeline)))
576
+ throw new Error('Factory execution lease lost');
577
+ return Reflect.apply(value, target, args);
578
+ };
579
+ } });
580
+ if (heldLease.reclaimed && pipeline.status === 'RUNNING') {
581
+ await storage.savePipeline({ ...pipeline, status: 'FAILED', error: 'Execution owner was lost; refusing to replay an unfinished step.' });
582
+ await heldLease.acknowledgeRecovery();
583
+ return;
584
+ }
585
+ if (pipeline.status === 'PENDING') {
586
+ await storage.savePipeline({ ...pipeline, status: 'RUNNING' });
587
+ const promoted = await backend.getPipeline(pipeline.id, pipeline.user_id);
588
+ if (!promoted || promoted.status !== 'RUNNING')
589
+ return;
590
+ Object.assign(pipeline, promoted);
591
+ if (heldLease.reclaimed)
592
+ await heldLease.acknowledgeRecovery();
488
593
  }
489
- // Pick the oldest updated pipeline (FIFO fairness)
490
- const pipeline = running.sort((a, b) => new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime())[0];
491
594
  // Poison Pill Guard: Parse spec with localized error handling.
492
595
  // If a pipeline has corrupt/invalid JSON in its spec column,
493
596
  // we MUST mark it FAILED immediately. Otherwise the runner will
@@ -578,7 +681,25 @@ async function runnerTick() {
578
681
  return;
579
682
  }
580
683
  // Execute the current step
581
- const result = await executeStep(pipeline, spec);
684
+ const result = await executeStep(pipeline, spec, heldLease);
685
+ if (!(await heldLease.isHeld()))
686
+ return;
687
+ // Heartbeat pulses persist their own optimistic version while the model runs.
688
+ // Re-read after stopping the pulse and rebase only onto the same live step;
689
+ // never carry a step result across an abort or another runner's advance.
690
+ const afterStep = await storage.getPipeline(pipeline.id, pipeline.user_id);
691
+ if (!afterStep || afterStep.status !== 'RUNNING') {
692
+ debugLog(`[DarkFactory] Step result discarded for ${pipeline.id}: pipeline is no longer RUNNING.`);
693
+ return;
694
+ }
695
+ if (afterStep.current_step !== pipeline.current_step ||
696
+ afterStep.iteration !== pipeline.iteration ||
697
+ (afterStep.eval_revisions ?? 0) !== (pipeline.eval_revisions ?? 0)) {
698
+ debugLog(`[DarkFactory] Step result discarded for ${pipeline.id}: pipeline advanced concurrently.`);
699
+ return;
700
+ }
701
+ pipeline.updated_at = afterStep.updated_at;
702
+ pipeline.last_heartbeat = afterStep.last_heartbeat;
582
703
  // v7.3.1: Scope violation in EXECUTE step → immediate pipeline termination
583
704
  if ('scopeViolation' in result && result.scopeViolation) {
584
705
  debugLog(`[DarkFactory] Pipeline ${pipeline.id} terminated: scope violation in EXECUTE step.`);
@@ -594,6 +715,27 @@ async function runnerTick() {
594
715
  return;
595
716
  }
596
717
  const currentStep = pipeline.current_step;
718
+ if (currentStep === 'VERIFY') {
719
+ if (!spec.testCommand) {
720
+ result.success = false;
721
+ result.notes = 'Deterministic verification requires spec.testCommand.';
722
+ }
723
+ else
724
+ try {
725
+ const verificationNotes = await executeActions([{ type: 'RUN_TEST', targetPath: '.', command: spec.testCommand }], spec, async () => {
726
+ if (!runnerInterval || !(await heldLease.isHeld()))
727
+ return false;
728
+ const live = await (await getStorage()).getPipeline(pipeline.id, pipeline.user_id);
729
+ return !!runnerInterval && await heldLease.isHeld() && live?.status === 'RUNNING' && live.current_step === pipeline.current_step;
730
+ }, async () => !!runnerInterval && await heldLease.isHeld());
731
+ result.notes = `${result.notes || ''}\n${verificationNotes.join('\n')}`;
732
+ result.success = true;
733
+ }
734
+ catch (error) {
735
+ result.success = false;
736
+ result.notes = `Deterministic verification failed: ${error instanceof Error ? error.message : String(error)}`;
737
+ }
738
+ }
597
739
  // ── Phase 4: Verification Pipeline Orchestrator ──
598
740
  if (currentStep === 'VERIFY' && spec.workingDirectory) {
599
741
  const harnessPath = path.join(path.resolve(spec.workingDirectory), 'verification_harness.json');
@@ -602,13 +744,15 @@ async function runnerTick() {
602
744
  const rawHarness = fs.readFileSync(harnessPath, 'utf8');
603
745
  const harnessData = JSON.parse(rawHarness);
604
746
  // GAP-5 fix: Persist the harness so CLI drift detection works for DarkFactory runs
605
- const rubricHash = computeRubricHash(harnessData.tests);
747
+ const minPassRate = harnessData.min_pass_rate ?? 1;
748
+ const rubricHash = computeRubricHash(harnessData.tests, minPassRate);
606
749
  const harness = {
607
750
  ...harnessData,
608
751
  project: pipeline.project,
609
752
  conversation_id: `dark-factory-${pipeline.id}`,
610
- created_at: new Date().toISOString(),
753
+ created_at: pipeline.started_at,
611
754
  rubric_hash: rubricHash,
755
+ min_pass_rate: minPassRate,
612
756
  };
613
757
  await storage.saveVerificationHarness(harness, pipeline.user_id);
614
758
  // GAP-2 fix: Build VerificationConfig from env vars so PRISM_VERIFICATION_LAYERS
@@ -633,7 +777,7 @@ async function runnerTick() {
633
777
  project: pipeline.project,
634
778
  conversation_id: `dark-factory-${pipeline.id}`,
635
779
  run_at: new Date().toISOString(),
636
- passed: passRate >= harnessData.min_pass_rate && verificationResult.severity_gate.action !== "abort",
780
+ passed: passRate >= minPassRate && verificationResult.severity_gate.action !== "abort",
637
781
  pass_rate: passRate,
638
782
  critical_failures: verificationResult.severity_gate.failed_assertions.length,
639
783
  coverage_score: coverageScore,
@@ -641,8 +785,8 @@ async function runnerTick() {
641
785
  gate_action: verificationResult.severity_gate.action,
642
786
  gate_override: false,
643
787
  };
788
+ await storage.saveVerificationRun(valResult, pipeline.user_id);
644
789
  const { canContinue, validatedResult } = Gatekeeper.executeGate(valResult);
645
- await storage.saveVerificationRun(validatedResult, pipeline.user_id);
646
790
  // GAP-3 fix: Emit verification experience event for ML routing feedback
647
791
  try {
648
792
  const confidenceScore = Math.round(passRate * 100);
@@ -774,9 +918,7 @@ async function runnerTick() {
774
918
  // EXECUTE notes = what the generator did
775
919
  // EVALUATE notes = what the evaluator found
776
920
  // Other steps: preserve existing notes
777
- const updatedNotes = (currentStep === 'EXECUTE' || currentStep === 'EVALUATE') && result.notes
778
- ? result.notes
779
- : pipeline.notes;
921
+ const updatedNotes = result.notes || pipeline.notes;
780
922
  await storage.savePipeline({
781
923
  ...pipeline,
782
924
  current_step: nextStepInfo.step,
@@ -803,6 +945,12 @@ async function runnerTick() {
803
945
  console.error(`[DarkFactory] Runner tick error (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
804
946
  }
805
947
  finally {
948
+ if (renewalInterval)
949
+ clearInterval(renewalInterval);
950
+ if (lease)
951
+ await lease.release().catch(error => {
952
+ console.error(`[DarkFactory] Execution lease release failed: ${error instanceof Error ? error.message : String(error)}`);
953
+ });
806
954
  tickInProgress = false;
807
955
  }
808
956
  }
@@ -25,7 +25,8 @@ export class SafetyController {
25
25
  */
26
26
  static LEGAL_TRANSITIONS = {
27
27
  'PENDING': ['RUNNING', 'ABORTED'], // Queued → Runner promotes or user aborts
28
- 'RUNNING': ['PAUSED', 'ABORTED', 'COMPLETED', 'FAILED'],
28
+ // Step progress and heartbeats retain RUNNING throughout the pipeline.
29
+ 'RUNNING': ['RUNNING', 'PAUSED', 'ABORTED', 'COMPLETED', 'FAILED'],
29
30
  'PAUSED': ['RUNNING', 'ABORTED'],
30
31
  'ABORTED': [], // Terminal — no exits
31
32
  'COMPLETED': [], // Terminal — no exits
@@ -168,6 +169,14 @@ export class SafetyController {
168
169
  if (spec.contextFiles && spec.contextFiles.length > 0) {
169
170
  lines.push(`Context Files: ${spec.contextFiles.join(', ')}`);
170
171
  }
172
+ if (spec.allowedWritePaths) {
173
+ lines.push(`Allowed WRITE_FILE/PATCH_FILE targetPath values (relative, exactly as listed): ${JSON.stringify(spec.allowedWritePaths)}`);
174
+ }
175
+ if (spec.testCommand) {
176
+ lines.push(`Caller-authorized test command (exact string): ${spec.testCommand}`);
177
+ lines.push(`The VERIFY phase runs this command independently. In EXECUTE, return file changes only; do not add a RUN_TEST action.`);
178
+ }
179
+ lines.push(`All targetPath values must be relative. The executor supplies the working directory; never prefix targetPath with the working directory.`);
171
180
  lines.push(``, `── Objective ──`, spec.objective, ``, `── Safety Rules ──`, `1. Do NOT modify files outside the Restricted Workspace.`, `2. Do NOT make network requests unless the objective explicitly requires it.`, `3. Do NOT execute destructive operations (rm -rf, DROP TABLE, etc.).`, `4. Respond ONLY with actions relevant to the current step.`, `5. If you cannot complete the step, explain why and stop.`);
172
181
  return lines.join('\n');
173
182
  }
@@ -40,6 +40,7 @@ import { createDashboardStorageAccessor } from "./storageAccessor.js";
40
40
  import { DASHBOARD_READ_MESSAGES } from "./readMessages.js";
41
41
  import { isDashboardSettingKeyAllowed, isDashboardSettingValueAllowed } from "./settingsPolicy.js";
42
42
  import { isTrustedRequest, isRebindGuardedPath } from "./hostGuard.js";
43
+ import { darkFactoryEnablementState, DARK_FACTORY_SETTINGS_UNAVAILABLE } from "../darkfactory/enablement.js";
43
44
  import { resolveDashboardToken, requestHasToken, buildTokenCookie, dashboardTokenCookieName, isDashboardTokenProtectedPath, } from "./dashboardToken.js";
44
45
  import { safeCompare, generateToken, isAuthenticated, createRateLimiter, initJWKS, } from "./authUtils.js";
45
46
  const PORT = parseInt(process.env.PRISM_DASHBOARD_PORT || "3000", 10);
@@ -392,7 +393,7 @@ return false;}
392
393
  type: "boolean",
393
394
  title: "Enable Dark Factory",
394
395
  description: "Set to true to enable autonomous evaluation pipelines.",
395
- default: false
396
+ default: true
396
397
  },
397
398
  prismTaskRouterEnabled: {
398
399
  type: "boolean",
@@ -732,11 +733,11 @@ return false;}
732
733
  const { getAllSettings } = await import("../storage/configStorage.js");
733
734
  const settings = await getAllSettings();
734
735
  res.writeHead(200, { "Content-Type": "application/json" });
735
- return res.end(JSON.stringify({ settings }));
736
+ return res.end(JSON.stringify({ settings, darkFactory: darkFactoryEnablementState() }));
736
737
  }
737
738
  catch {
738
- res.writeHead(200, { "Content-Type": "application/json" });
739
- return res.end(JSON.stringify({ settings: {} }));
739
+ res.writeHead(503, { "Content-Type": "application/json" });
740
+ return res.end(JSON.stringify({ error: DARK_FACTORY_SETTINGS_UNAVAILABLE }));
740
741
  }
741
742
  }
742
743
  // ─── API: Settings — POST (v3.0 Dashboard Settings) ───
@@ -29,6 +29,8 @@ export function isDashboardSettingValueAllowed(key, value) {
29
29
  if (["auto_capture", "hivemind_enabled", "task_router_enabled"].includes(key)) {
30
30
  return value === "true" || value === "false";
31
31
  }
32
+ if (key === "PRISM_DARK_FACTORY_ENABLED")
33
+ return value === "true" || value === "false";
32
34
  if (key === "agent_name")
33
35
  return value.length <= 80 && !/[<>]/.test(value);
34
36
  if (key === "default_role")
@@ -14,6 +14,7 @@
14
14
  * ═══════════════════════════════════════════════════════════════════
15
15
  */
16
16
  import { ANALYTICS_DATE_PATTERN, ANALYTICS_METRIC_IDS, DASHBOARD_READ_MESSAGES } from './readMessages.js';
17
+ import { DARK_FACTORY_SETTINGS_UNAVAILABLE } from '../darkfactory/enablement.js';
17
18
  export function renderDashboardLocalOpenHTML() {
18
19
  return `<!DOCTYPE html>
19
20
  <html lang="en">
@@ -505,8 +506,10 @@ export function renderDashboardHTML(version) {
505
506
  .modal-close:hover { color: var(--text-primary); }
506
507
  .setting-row {
507
508
  display: flex; justify-content: space-between; align-items: center;
509
+ gap: 1rem;
508
510
  padding: 0.75rem 0; border-bottom: 1px solid rgba(255,255,255,0.05);
509
511
  }
512
+ .setting-row > div { min-width: 0; }
510
513
  .setting-row:last-child { border-bottom: none; }
511
514
  .setting-label { font-size: 0.85rem; color: var(--text-secondary); }
512
515
  .setting-desc { font-size: 0.7rem; color: var(--text-muted); margin-top: 0.2rem; }
@@ -1313,6 +1316,14 @@ export function renderDashboardHTML(version) {
1313
1316
  </div>
1314
1317
  <div class="toggle" id="toggle-task-router" onclick="toggleBootSetting('task_router_enabled', this)"></div>
1315
1318
  </div>
1319
+ <div class="setting-row">
1320
+ <div>
1321
+ <div class="setting-label">Dark Factory</div>
1322
+ <div class="setting-desc">Enable pipeline execution tools and the background runner. Restart required; an environment override wins.</div>
1323
+ <div id="dark-factory-state" style="color:var(--text-muted);font-size:0.78rem;margin-top:0.25rem">Loading startup state…</div>
1324
+ </div>
1325
+ <button class="toggle" id="toggle-dark-factory" type="button" role="switch" aria-label="Dark Factory enablement" aria-checked="false" disabled onclick="toggleBootSetting('PRISM_DARK_FACTORY_ENABLED', this)"></button>
1326
+ </div>
1316
1327
  <div class="setting-row">
1317
1328
  <div>
1318
1329
  <div class="setting-label">Storage Backend</div>
@@ -3993,9 +4004,11 @@ function loadSettings() {
3993
4004
  return [4 /*yield*/, fetch('/api/settings?t=' + Date.now())];
3994
4005
  case 1:
3995
4006
  res = _a.sent();
4007
+ if (!res.ok) throw new Error(DARK_FACTORY_SETTINGS_UNAVAILABLE_MESSAGE);
3996
4008
  return [4 /*yield*/, res.json()];
3997
4009
  case 2:
3998
4010
  data = _a.sent();
4011
+ if (!data.darkFactory) throw new Error(DARK_FACTORY_SETTINGS_UNAVAILABLE_MESSAGE);
3999
4012
  s = data.settings || {};
4000
4013
  // Runtime toggles
4001
4014
  if (s.auto_capture === 'true')
@@ -4019,6 +4032,21 @@ function loadSettings() {
4019
4032
  document.getElementById('toggle-task-router').classList.add('active');
4020
4033
  else
4021
4034
  document.getElementById('toggle-task-router').classList.remove('active');
4035
+ if ((s.PRISM_DARK_FACTORY_ENABLED || (data.darkFactory && data.darkFactory.saved ? 'true' : 'false')) === 'true')
4036
+ document.getElementById('toggle-dark-factory').classList.add('active');
4037
+ else
4038
+ document.getElementById('toggle-dark-factory').classList.remove('active');
4039
+ document.getElementById('toggle-dark-factory').setAttribute('aria-checked', document.getElementById('toggle-dark-factory').classList.contains('active') ? 'true' : 'false');
4040
+ if (data.darkFactory) {
4041
+ var df = data.darkFactory;
4042
+ var effective = df.envOverride === null ? df.saved : df.envOverride;
4043
+ var state = df.envOverride === null ? 'Saved: ' + (df.saved ? 'on' : 'off') + ' · Active: ' + (df.active ? 'on' : 'off') + (df.pendingRestart ? ' · restart required' : '') : 'Saved: ' + (df.saved ? 'on' : 'off') + ' · Active override: ' + (df.envOverride ? 'on' : 'off');
4044
+ document.getElementById('dark-factory-state').textContent = state;
4045
+ document.getElementById('toggle-dark-factory').disabled = df.envOverride !== null;
4046
+ document.getElementById('toggle-dark-factory').setAttribute('aria-disabled', df.envOverride !== null ? 'true' : 'false');
4047
+ document.getElementById('toggle-dark-factory').classList.toggle('active', effective);
4048
+ document.getElementById('toggle-dark-factory').setAttribute('aria-checked', effective ? 'true' : 'false');
4049
+ }
4022
4050
  // Storage Backend
4023
4051
  if (s.PRISM_STORAGE) {
4024
4052
  document.getElementById('storageBackendSelect').value = s.PRISM_STORAGE;
@@ -4041,6 +4069,9 @@ function loadSettings() {
4041
4069
  case 3:
4042
4070
  e_19 = _a.sent();
4043
4071
  console.warn('Settings load failed:', e_19);
4072
+ document.getElementById('dark-factory-state').textContent = DARK_FACTORY_SETTINGS_UNAVAILABLE_MESSAGE;
4073
+ document.getElementById('toggle-dark-factory').disabled = true;
4074
+ document.getElementById('toggle-dark-factory').setAttribute('aria-disabled', 'true');
4044
4075
  return [3 /*break*/, 4];
4045
4076
  case 4: return [2 /*return*/];
4046
4077
  }
@@ -4093,15 +4124,20 @@ function toggleSetting(key, el) {
4093
4124
  }
4094
4125
  function toggleBootSetting(key, el) {
4095
4126
  var isActive = el.classList.toggle('active');
4127
+ el.setAttribute('aria-checked', isActive ? 'true' : 'false');
4096
4128
  saveSetting(key, isActive ? 'true' : 'false').then(function (ok) {
4097
4129
  if (!ok) {
4098
4130
  el.classList.toggle('active'); // rollback on failure
4131
+ el.setAttribute('aria-checked', isActive ? 'false' : 'true');
4099
4132
  }
4100
4133
  else {
4101
- showToast('Saved. Restart your AI client for this to take effect.');
4134
+ showToast(DARK_FACTORY_RESTART_MESSAGE);
4135
+ loadSettings();
4102
4136
  }
4103
4137
  });
4104
4138
  }
4139
+ var DARK_FACTORY_SETTINGS_UNAVAILABLE_MESSAGE = ${JSON.stringify(DARK_FACTORY_SETTINGS_UNAVAILABLE)};
4140
+ var DARK_FACTORY_RESTART_MESSAGE = 'Saved. Restart your AI client for this to take effect.';
4105
4141
  function saveBootSetting(key, value) {
4106
4142
  saveSetting(key, value).then(function (ok) {
4107
4143
  if (ok)
@@ -158,7 +158,7 @@ function getAdvancedTourContent() {
158
158
  title: "🚀 Advanced Features",
159
159
  description: "Prism has much more to offer. Here's a quick tour of advanced capabilities:",
160
160
  instructions: [
161
- "**Dark Factory** — Autonomous AI pipelines that run in the background (set PRISM_DARK_FACTORY_ENABLED=true)",
161
+ "**Dark Factory** — Autonomous AI pipelines that run in the background (enabled by default; use the dashboard or PRISM_DARK_FACTORY_ENABLED=false to disable)",
162
162
  "**Hivemind** — Multi-agent coordination with role-based access (set PRISM_ENABLE_HIVEMIND=true)",
163
163
  "**Auto-Scholar** — Autonomous research agent that discovers and synthesizes papers",
164
164
  "**Mind Palace Dashboard** — Web UI at localhost:3080 for managing projects, viewing analytics, and configuring settings",
package/dist/server.js CHANGED
@@ -60,7 +60,8 @@ ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequ
60
60
  // Claude Desktop that the attached resource has changed.
61
61
  // Without this, the paperclipped context becomes stale.
62
62
  SubscribeRequestSchema, UnsubscribeRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
63
- import { SERVER_CONFIG, SESSION_MEMORY_ENABLED, PRISM_USER_ID, PRISM_ENABLE_HIVEMIND, WATCHDOG_INTERVAL_MS, WATCHDOG_STALE_MIN, WATCHDOG_FROZEN_MIN, WATCHDOG_OFFLINE_MIN, WATCHDOG_LOOP_THRESHOLD, PRISM_SCHEDULER_ENABLED, PRISM_SCHEDULER_INTERVAL_MS, PRISM_HDC_ENABLED, PRISM_TASK_ROUTER_ENABLED_ENV, PRISM_DARK_FACTORY_ENABLED, YDC_API_KEY, } from "./config.js";
63
+ import { SERVER_CONFIG, SESSION_MEMORY_ENABLED, PRISM_USER_ID, PRISM_ENABLE_HIVEMIND, WATCHDOG_INTERVAL_MS, WATCHDOG_STALE_MIN, WATCHDOG_FROZEN_MIN, WATCHDOG_OFFLINE_MIN, WATCHDOG_LOOP_THRESHOLD, PRISM_SCHEDULER_ENABLED, PRISM_SCHEDULER_INTERVAL_MS, PRISM_HDC_ENABLED, PRISM_TASK_ROUTER_ENABLED_ENV, YDC_API_KEY, } from "./config.js";
64
+ import { initializeDarkFactoryEnabled, isDarkFactoryEnabled } from "./darkfactory/enablement.js";
64
65
  import { startWatchdog, drainAlerts } from "./hivemindWatchdog.js";
65
66
  import { startScheduler } from "./backgroundScheduler.js";
66
67
  import { startDarkFactoryRunner } from "./darkfactory/runner.js";
@@ -345,7 +346,7 @@ export function getAvailableTools() {
345
346
  ...SESSION_MEMORY_TOOLS,
346
347
  ...(PRISM_ENABLE_HIVEMIND ? AGENT_REGISTRY_TOOLS : []),
347
348
  ...(getSettingSync("task_router_enabled", String(PRISM_TASK_ROUTER_ENABLED_ENV)) === "true" ? [SESSION_TASK_ROUTE_TOOL] : []),
348
- ...(PRISM_DARK_FACTORY_ENABLED ? [SESSION_START_PIPELINE_TOOL, SESSION_CHECK_PIPELINE_STATUS_TOOL, SESSION_ABORT_PIPELINE_TOOL] : []),
349
+ ...(isDarkFactoryEnabled() ? [SESSION_START_PIPELINE_TOOL, SESSION_CHECK_PIPELINE_STATUS_TOOL, SESSION_ABORT_PIPELINE_TOOL] : []),
349
350
  ];
350
351
  }
351
352
  export const PRISM_SERVER_INSTRUCTIONS = `Prism MCP — The Mind Palace for AI Agents. On the first user turn of every conversation, ` +
@@ -357,7 +358,7 @@ export const PRISM_SERVER_INSTRUCTIONS = `Prism MCP — The Mind Palace for AI A
357
358
  `trigger rules — read each before proposing any change. Surfacing a name is not loading it: hosts that ` +
358
359
  `do not auto-load skill files must fetch each body with knowledge_search, passing the skill name ` +
359
360
  `exactly as listed. ` +
360
- `AFTER that first turn, routing does not stop: call session_route_prompt at the start of any turn where the user states a new task, changes the kind of work, or reports a defect, passing their verbatim message as {prompt} and the skills you already hold as {loaded}. session_bootstrap routes only turn one, so without this a long session keeps working with whatever it happened to load hours earlier. The call is cheap — a single line when nothing new matches, and never the same skill twice. When it returns skills, read and follow them before doing the work. ` +
361
+ `AFTER turn one, call session_route_prompt at the start of any turn with a new task, changed work, or reported defect. Pass the verbatim message as {prompt} and held skills as {loaded}; supply current project, working_directory, files_involved and test_command when known. Read and follow returned skills. When prism_factory_route has target factory, automatically call recommended_tool with recommended_args, then monitor status. Routing itself never executes code. ` +
361
362
  `Emit no preamble. Print the complete tool result verbatim as the entire first-turn startup display, before any optional ` +
362
363
  `answer. Do not summarize, paraphrase, rename headings, reformat, or omit any returned section. Preserve its order and ` +
363
364
  `line content. For a greeting-only prompt, stop after the verbatim startup display. ` +
@@ -981,22 +982,22 @@ export function createServer() {
981
982
  case "session_start_pipeline":
982
983
  if (!SESSION_MEMORY_ENABLED)
983
984
  throw new Error("Session memory not configured.");
984
- if (!PRISM_DARK_FACTORY_ENABLED)
985
- throw new Error("Dark Factory not enabled. Set PRISM_DARK_FACTORY_ENABLED=true.");
985
+ if (!isDarkFactoryEnabled())
986
+ throw new Error("Dark Factory not enabled. Enable it in the dashboard or set PRISM_DARK_FACTORY_ENABLED=true.");
986
987
  result = await sessionStartPipelineHandler(args);
987
988
  break;
988
989
  case "session_check_pipeline_status":
989
990
  if (!SESSION_MEMORY_ENABLED)
990
991
  throw new Error("Session memory not configured.");
991
- if (!PRISM_DARK_FACTORY_ENABLED)
992
- throw new Error("Dark Factory not enabled. Set PRISM_DARK_FACTORY_ENABLED=true.");
992
+ if (!isDarkFactoryEnabled())
993
+ throw new Error("Dark Factory not enabled. Enable it in the dashboard or set PRISM_DARK_FACTORY_ENABLED=true.");
993
994
  result = await sessionCheckPipelineStatusHandler(args);
994
995
  break;
995
996
  case "session_abort_pipeline":
996
997
  if (!SESSION_MEMORY_ENABLED)
997
998
  throw new Error("Session memory not configured.");
998
- if (!PRISM_DARK_FACTORY_ENABLED)
999
- throw new Error("Dark Factory not enabled. Set PRISM_DARK_FACTORY_ENABLED=true.");
999
+ if (!isDarkFactoryEnabled())
1000
+ throw new Error("Dark Factory not enabled. Enable it in the dashboard or set PRISM_DARK_FACTORY_ENABLED=true.");
1000
1001
  result = await sessionAbortPipelineHandler(args);
1001
1002
  break;
1002
1003
  // ─── v12.1: Developer Onboarding & Framework Bridge ───
@@ -1288,6 +1289,7 @@ export async function startServer() {
1288
1289
  // during the Initialize handshake — zero extra latency for resource reads.
1289
1290
  // initConfigStorage() is local SQLite only (~5ms), safe to await.
1290
1291
  await initConfigStorage();
1292
+ initializeDarkFactoryEnabled();
1291
1293
  // v4.6.0: Initialize OTel AFTER the settings cache is warm so that
1292
1294
  // initTelemetry() can read otel_enabled/otel_endpoint from getSettingSync()
1293
1295
  // synchronously. This is a synchronous call — no await needed.
@@ -1597,7 +1599,7 @@ async function resumeServerStartup(server) {
1597
1599
  // Autonomous pipeline orchestration engine. Picks up RUNNING
1598
1600
  // pipelines and advances them through PLAN → EXECUTE → VERIFY
1599
1601
  // cycles. Non-blocking — uses setInterval to yield between ticks.
1600
- if (PRISM_DARK_FACTORY_ENABLED && SESSION_MEMORY_ENABLED) {
1602
+ if (isDarkFactoryEnabled() && SESSION_MEMORY_ENABLED) {
1601
1603
  storageReady?.then(() => {
1602
1604
  startDarkFactoryRunner();
1603
1605
  }).catch(err => {