prism-mcp-server 7.3.3 → 7.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +176 -211
- package/dist/cli.js +0 -0
- package/dist/darkfactory/clawInvocation.js +62 -7
- package/dist/darkfactory/runner.js +188 -23
- package/dist/darkfactory/safetyController.js +48 -22
- package/dist/darkfactory/schema.js +2 -0
- package/dist/dashboard/intentHealth.js +62 -0
- package/dist/dashboard/server.js +35 -1
- package/dist/dashboard/ui.js +117 -11
- package/dist/server.js +19 -0
- package/dist/storage/sqlite.js +68 -7
- package/dist/storage/supabase.js +57 -4
- package/dist/tools/ledgerHandlers.js +13 -0
- package/package.json +2 -2
|
@@ -30,6 +30,37 @@ RULES:
|
|
|
30
30
|
- Do NOT use markdown code fences
|
|
31
31
|
- If you cannot complete the task, return: {"actions": [], "notes": "reason"}
|
|
32
32
|
`.trim();
|
|
33
|
+
const PLAN_CONTRACT_SCHEMA = `
|
|
34
|
+
You MUST respond with ONLY a valid JSON object matching this schema:
|
|
35
|
+
{
|
|
36
|
+
"criteria": [
|
|
37
|
+
{
|
|
38
|
+
"id": "string (unique identifier, e.g. 'req-1')",
|
|
39
|
+
"description": "string (clear, testable condition)"
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
`.trim();
|
|
44
|
+
const EVALUATE_SCHEMA = `
|
|
45
|
+
You MUST respond with ONLY a valid JSON object matching this schema:
|
|
46
|
+
{
|
|
47
|
+
"pass": true | false,
|
|
48
|
+
"plan_viable": true | false,
|
|
49
|
+
"notes": "string (optional summary)",
|
|
50
|
+
"findings": [
|
|
51
|
+
{
|
|
52
|
+
"severity": "critical" | "warning" | "info",
|
|
53
|
+
"criterion_id": "string (must match a contract criterion id)",
|
|
54
|
+
"pass_fail": true | false,
|
|
55
|
+
"evidence": {
|
|
56
|
+
"file": "string",
|
|
57
|
+
"line": 42,
|
|
58
|
+
"description": "string"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
`.trim();
|
|
33
64
|
/**
|
|
34
65
|
* Invocation wrapper that routes payload specs to the local Claw agent model (Qwen 2.5),
|
|
35
66
|
* or the active LLM provider as fallback.
|
|
@@ -49,16 +80,40 @@ export async function invokeClawAgent(spec, state, timeoutMs = 120000 // 2 min d
|
|
|
49
80
|
: getLLMProvider();
|
|
50
81
|
// Scope injection via SafetyController — single source of truth
|
|
51
82
|
const systemPrompt = SafetyController.generateBoundaryPrompt(spec, state);
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
83
|
+
// Inject the appropriate JSON schema according to the step
|
|
84
|
+
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
|
+
let isJsonMode = false;
|
|
86
|
+
if (state.current_step === 'EXECUTE') {
|
|
87
|
+
let revisionContext = '';
|
|
88
|
+
// If we are retrying after an EVALUATE failure, state.notes holds the serialized evaluator critique.
|
|
89
|
+
// Inject it so the Generator knows exactly what to fix rather than retrying blindly.
|
|
90
|
+
if (state.eval_revisions && state.eval_revisions > 0) {
|
|
91
|
+
revisionContext = `\n\n=== EVALUATOR CRITIQUE (revision ${state.eval_revisions}) ===\n${state.notes || 'Fix previous errors.'}\n\nYou MUST correct all issues listed above before submitting.`;
|
|
92
|
+
}
|
|
93
|
+
stepPrompt = `Based on the system instructions, execute the necessary actions for the current step (${state.current_step}).${revisionContext}\n\n${EXECUTE_JSON_SCHEMA}`;
|
|
94
|
+
isJsonMode = true;
|
|
95
|
+
}
|
|
96
|
+
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}`;
|
|
98
|
+
isJsonMode = true;
|
|
99
|
+
}
|
|
100
|
+
else if (state.current_step === 'EVALUATE') {
|
|
101
|
+
stepPrompt = `Based on the system instructions, evaluate the GENERATOR's execution against the PLAN_CONTRACT rubric. BE STRICT.
|
|
102
|
+
|
|
103
|
+
=== GENERATOR'S ACTIONS ===
|
|
104
|
+
${state.notes || 'No notes provided'}
|
|
105
|
+
|
|
106
|
+
=== CONTRACT RUBRIC ===
|
|
107
|
+
${state.contract_payload ? JSON.stringify(state.contract_payload.criteria, null, 2) : '(See contract_rubric.json on disk)'}
|
|
108
|
+
|
|
109
|
+
${EVALUATE_SCHEMA}`;
|
|
110
|
+
isJsonMode = true;
|
|
111
|
+
}
|
|
112
|
+
debugLog(`[ClawInvocation] Launching agent on pipeline ${state.id} step=${state.current_step} iter=${state.iteration} with ${timeoutMs}ms limit.${isJsonMode ? ' (JSON mode)' : ''}`);
|
|
58
113
|
try {
|
|
59
114
|
// Timeout Promise to ensure the runner thread does not block indefinitely
|
|
60
115
|
const timeboundExecution = Promise.race([
|
|
61
|
-
llm.generateText(
|
|
116
|
+
llm.generateText(stepPrompt, systemPrompt),
|
|
62
117
|
new Promise((_, reject) => setTimeout(() => reject(new Error('LLM_EXECUTION_TIMEOUT')), timeoutMs))
|
|
63
118
|
]);
|
|
64
119
|
const result = await timeboundExecution;
|
|
@@ -191,25 +191,21 @@ async function emitExperienceEvent(pipeline, eventType, outcome) {
|
|
|
191
191
|
*
|
|
192
192
|
* @internal Exported for unit testing only. Not part of the public API.
|
|
193
193
|
*/
|
|
194
|
-
|
|
194
|
+
function extractJsonFromLlmOutput(raw) {
|
|
195
195
|
if (!raw || typeof raw !== 'string' || raw.trim() === '') {
|
|
196
|
-
return {
|
|
196
|
+
return { json: null, error: 'JSON Parse Error: empty or non-string input' };
|
|
197
197
|
}
|
|
198
198
|
const cleaned = raw.trim();
|
|
199
199
|
let jsonCandidate = null;
|
|
200
|
-
// Strategy 1: Try raw trimmed input as-is
|
|
201
200
|
if (cleaned.startsWith('{')) {
|
|
202
201
|
jsonCandidate = cleaned;
|
|
203
202
|
}
|
|
204
|
-
// Strategy 2: Strip markdown code fences
|
|
205
203
|
if (!jsonCandidate) {
|
|
206
|
-
// Match ```json or ``` blocks anywhere in the text (not just start/end of string)
|
|
207
204
|
const fenceMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
|
|
208
205
|
if (fenceMatch) {
|
|
209
206
|
jsonCandidate = fenceMatch[1].trim();
|
|
210
207
|
}
|
|
211
208
|
}
|
|
212
|
-
// Strategy 3: Brace extraction — find first { to last }
|
|
213
209
|
if (!jsonCandidate) {
|
|
214
210
|
const firstBrace = cleaned.indexOf('{');
|
|
215
211
|
const lastBrace = cleaned.lastIndexOf('}');
|
|
@@ -218,17 +214,21 @@ export function parseExecuteOutput(raw) {
|
|
|
218
214
|
}
|
|
219
215
|
}
|
|
220
216
|
if (!jsonCandidate) {
|
|
221
|
-
return {
|
|
217
|
+
return { json: null, error: 'JSON Parse Error: no JSON object found in LLM output' };
|
|
222
218
|
}
|
|
223
|
-
|
|
219
|
+
return { json: jsonCandidate, error: null };
|
|
220
|
+
}
|
|
221
|
+
export function parseExecuteOutput(raw) {
|
|
222
|
+
const ext = extractJsonFromLlmOutput(raw);
|
|
223
|
+
if (ext.error || !ext.json)
|
|
224
|
+
return { parsed: null, error: ext.error };
|
|
224
225
|
let parsed;
|
|
225
226
|
try {
|
|
226
|
-
parsed = JSON.parse(
|
|
227
|
+
parsed = JSON.parse(ext.json);
|
|
227
228
|
}
|
|
228
229
|
catch {
|
|
229
230
|
return { parsed: null, error: 'JSON Parse Error: LLM output is not valid JSON' };
|
|
230
231
|
}
|
|
231
|
-
// Shape validation: must be an object with an 'actions' array
|
|
232
232
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
233
233
|
return { parsed: null, error: 'Shape Error: output is not a JSON object' };
|
|
234
234
|
}
|
|
@@ -236,7 +236,6 @@ export function parseExecuteOutput(raw) {
|
|
|
236
236
|
return { parsed: null, error: 'Shape Error: output missing required "actions" array' };
|
|
237
237
|
}
|
|
238
238
|
const result = parsed;
|
|
239
|
-
// Validate each action in the array
|
|
240
239
|
for (let i = 0; i < result.actions.length; i++) {
|
|
241
240
|
const action = result.actions[i];
|
|
242
241
|
if (!action || typeof action !== 'object' || Array.isArray(action)) {
|
|
@@ -251,6 +250,62 @@ export function parseExecuteOutput(raw) {
|
|
|
251
250
|
}
|
|
252
251
|
return { parsed: result, error: null };
|
|
253
252
|
}
|
|
253
|
+
export function parseContractOutput(raw) {
|
|
254
|
+
const ext = extractJsonFromLlmOutput(raw);
|
|
255
|
+
if (ext.error || !ext.json)
|
|
256
|
+
return { parsed: null, error: ext.error };
|
|
257
|
+
let parsed;
|
|
258
|
+
try {
|
|
259
|
+
parsed = JSON.parse(ext.json);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return { parsed: null, error: 'JSON Parse Error: LLM output is not valid JSON' };
|
|
263
|
+
}
|
|
264
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.criteria)) {
|
|
265
|
+
return { parsed: null, error: 'Shape Error: output missing required "criteria" array' };
|
|
266
|
+
}
|
|
267
|
+
// Validate each criterion element has the required string fields
|
|
268
|
+
for (let i = 0; i < parsed.criteria.length; i++) {
|
|
269
|
+
const c = parsed.criteria[i];
|
|
270
|
+
if (!c || typeof c !== 'object' || typeof c.id !== 'string' || typeof c.description !== 'string') {
|
|
271
|
+
return { parsed: null, error: `Shape Error: criteria[${i}] must have string "id" and "description"` };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { parsed: parsed, error: null };
|
|
275
|
+
}
|
|
276
|
+
export function parseEvaluationOutput(raw) {
|
|
277
|
+
const ext = extractJsonFromLlmOutput(raw);
|
|
278
|
+
if (ext.error || !ext.json)
|
|
279
|
+
return { parsed: null, error: ext.error };
|
|
280
|
+
let parsed;
|
|
281
|
+
try {
|
|
282
|
+
parsed = JSON.parse(ext.json);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return { parsed: null, error: 'JSON Parse Error: LLM output is not valid JSON' };
|
|
286
|
+
}
|
|
287
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.pass !== 'boolean') {
|
|
288
|
+
return { parsed: null, error: 'Shape Error: output missing required "pass" boolean' };
|
|
289
|
+
}
|
|
290
|
+
const p = parsed;
|
|
291
|
+
if (p.findings !== undefined) {
|
|
292
|
+
if (!Array.isArray(p.findings)) {
|
|
293
|
+
return { parsed: null, error: 'Shape Error: "findings" must be an array when present' };
|
|
294
|
+
}
|
|
295
|
+
// Fix #3: Each failing finding must supply an evidence object so the
|
|
296
|
+
// Evaluator cannot submit bare severity claims without evidence pointers.
|
|
297
|
+
for (let i = 0; i < p.findings.length; i++) {
|
|
298
|
+
const f = p.findings[i];
|
|
299
|
+
if (!f || typeof f !== 'object') {
|
|
300
|
+
return { parsed: null, error: `Shape Error: findings[${i}] must be an object` };
|
|
301
|
+
}
|
|
302
|
+
if (f.pass_fail === false && (!f.evidence || typeof f.evidence !== 'object')) {
|
|
303
|
+
return { parsed: null, error: `Shape Error: findings[${i}] is missing required "evidence" object for failure` };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return { parsed: parsed, error: null };
|
|
308
|
+
}
|
|
254
309
|
// ─── Step Execution ────────────────────────────────────────────
|
|
255
310
|
/**
|
|
256
311
|
* Execute a single step of the pipeline.
|
|
@@ -273,8 +328,8 @@ async function executeStep(pipeline, spec) {
|
|
|
273
328
|
// - BYOM model override
|
|
274
329
|
// - Timeout enforcement
|
|
275
330
|
const { success, resultText } = await invokeClawAgent(spec, pipeline);
|
|
276
|
-
// For non-
|
|
277
|
-
if (step !== 'EXECUTE') {
|
|
331
|
+
// For non-JSON steps, return as-is (free-form text)
|
|
332
|
+
if (step !== 'EXECUTE' && step !== 'PLAN_CONTRACT' && step !== 'EVALUATE') {
|
|
278
333
|
return {
|
|
279
334
|
iteration: pipeline.iteration,
|
|
280
335
|
step,
|
|
@@ -284,7 +339,6 @@ async function executeStep(pipeline, spec) {
|
|
|
284
339
|
notes: resultText.slice(0, 2000),
|
|
285
340
|
};
|
|
286
341
|
}
|
|
287
|
-
// ── v7.3.1: EXECUTE step — parse and validate structured output ──
|
|
288
342
|
if (!success) {
|
|
289
343
|
// LLM invocation itself failed (timeout, error, etc.)
|
|
290
344
|
return {
|
|
@@ -296,7 +350,59 @@ async function executeStep(pipeline, spec) {
|
|
|
296
350
|
notes: `LLM invocation failed: ${resultText.slice(0, 500)}`,
|
|
297
351
|
};
|
|
298
352
|
}
|
|
299
|
-
// Parse
|
|
353
|
+
// Parse appropriate JSON output depending on step
|
|
354
|
+
if (step === 'PLAN_CONTRACT') {
|
|
355
|
+
const { parsed, error: parseError } = parseContractOutput(resultText);
|
|
356
|
+
if (parseError || !parsed) {
|
|
357
|
+
debugLog(`[DarkFactory] PLAN_CONTRACT output parse failure: ${parseError}`);
|
|
358
|
+
return {
|
|
359
|
+
iteration: pipeline.iteration,
|
|
360
|
+
step,
|
|
361
|
+
started_at: stepStart,
|
|
362
|
+
completed_at: new Date().toISOString(),
|
|
363
|
+
success: false,
|
|
364
|
+
notes: parseError || 'Unknown parse error',
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
iteration: pipeline.iteration,
|
|
369
|
+
step,
|
|
370
|
+
started_at: stepStart,
|
|
371
|
+
completed_at: new Date().toISOString(),
|
|
372
|
+
success: true,
|
|
373
|
+
notes: `Contract accepted with ${parsed.criteria.length} criteria.`,
|
|
374
|
+
contractPayload: parsed, // Passthrough for runner to write to disk
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (step === 'EVALUATE') {
|
|
378
|
+
const { parsed, error: parseError } = parseEvaluationOutput(resultText);
|
|
379
|
+
if (parseError || !parsed) {
|
|
380
|
+
debugLog(`[DarkFactory] EVALUATE output parse failure: ${parseError}`);
|
|
381
|
+
return {
|
|
382
|
+
iteration: pipeline.iteration,
|
|
383
|
+
step,
|
|
384
|
+
started_at: stepStart,
|
|
385
|
+
completed_at: new Date().toISOString(),
|
|
386
|
+
success: false,
|
|
387
|
+
notes: parseError || 'Unknown parse error',
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
// Fix #2: Serialize findings array into notes so the Generator's retry
|
|
391
|
+
// prompt receives the full line-by-line critique, not just a summary string.
|
|
392
|
+
const findingsText = parsed.findings && parsed.findings.length > 0
|
|
393
|
+
? '\nFindings:\n' + parsed.findings.map((f) => `- [${f.severity}] Criterion ${f.criterion_id}: ${f.evidence?.description || 'Failed'} (${f.evidence?.file || 'unknown'}:${f.evidence?.line ?? '?'})`).join('\n')
|
|
394
|
+
: '';
|
|
395
|
+
return {
|
|
396
|
+
iteration: pipeline.iteration,
|
|
397
|
+
step,
|
|
398
|
+
started_at: stepStart,
|
|
399
|
+
completed_at: new Date().toISOString(),
|
|
400
|
+
success: parsed.pass,
|
|
401
|
+
notes: (parsed.notes || `Evaluation complete: ${parsed.pass ? 'PASS' : 'FAIL'}`) + findingsText,
|
|
402
|
+
evaluationPayload: parsed, // Passthrough for orchestrator logic
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
// EXECUTE
|
|
300
406
|
const { parsed, error: parseError } = parseExecuteOutput(resultText);
|
|
301
407
|
if (parseError || !parsed) {
|
|
302
408
|
debugLog(`[DarkFactory] EXECUTE output parse failure: ${parseError}`);
|
|
@@ -582,10 +688,57 @@ async function runnerTick() {
|
|
|
582
688
|
}
|
|
583
689
|
}
|
|
584
690
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
691
|
+
if (currentStep === 'PLAN_CONTRACT' && spec.workingDirectory && result.success && result.contractPayload) {
|
|
692
|
+
const contractPath = path.join(path.resolve(spec.workingDirectory), 'contract_rubric.json');
|
|
693
|
+
try {
|
|
694
|
+
fs.writeFileSync(contractPath, JSON.stringify(result.contractPayload, null, 2), 'utf8');
|
|
695
|
+
debugLog(`[DarkFactory] contract_rubric.json written to ${contractPath}`);
|
|
696
|
+
}
|
|
697
|
+
catch (writeErr) {
|
|
698
|
+
// Disk/permissions error — fail the pipeline immediately so it doesn't
|
|
699
|
+
// loop on PLAN_CONTRACT forever (each tick would re-attempt the write).
|
|
700
|
+
debugLog(`[DarkFactory] Failed to write contract_rubric.json: ${writeErr.message}`);
|
|
701
|
+
try {
|
|
702
|
+
await storage.savePipeline({
|
|
703
|
+
...pipeline,
|
|
704
|
+
status: 'FAILED',
|
|
705
|
+
error: `PLAN_CONTRACT failed: could not write contract_rubric.json — ${writeErr.message}`,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
catch { /* status guard */ }
|
|
709
|
+
await emitExperienceEvent(pipeline, 'failure', `contract_rubric.json write failed: ${writeErr.message}`);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (currentStep === 'EVALUATE' && result.evaluationPayload) {
|
|
714
|
+
// Emit ML learning event for evaluation outcome.
|
|
715
|
+
// Using 'learning' (valid LedgerEntry event type) rather than
|
|
716
|
+
// a non-existent 'evaluation_result' to avoid runtime cast issues.
|
|
717
|
+
try {
|
|
718
|
+
await storage.saveLedger({
|
|
719
|
+
project: pipeline.project,
|
|
720
|
+
conversation_id: `dark-factory-${pipeline.id}`,
|
|
721
|
+
user_id: pipeline.user_id,
|
|
722
|
+
event_type: 'learning',
|
|
723
|
+
summary: `[EVALUATE] ${result.success ? 'PASS' : 'FAIL'} on iter ${pipeline.iteration} rev ${pipeline.eval_revisions ?? 0}`,
|
|
724
|
+
keywords: ['dark-factory', 'evaluation', pipeline.project],
|
|
725
|
+
importance: result.success ? 3 : 1,
|
|
726
|
+
confidence_score: result.success ? 90 : 50,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
catch { /* advisory — never block execution */ }
|
|
730
|
+
}
|
|
731
|
+
// ─── Determine plan_viable from evaluation payload ───
|
|
732
|
+
// Default to false (conservative): a parse failure or missing payload means
|
|
733
|
+
// we don't know if the plan is viable, so escalate to PLAN re-planning
|
|
734
|
+
// rather than burning eval_revisions on more EXECUTE retries.
|
|
735
|
+
let evalPlanViable = false;
|
|
736
|
+
if (currentStep === 'EVALUATE' && result.evaluationPayload) {
|
|
737
|
+
// plan_viable defaults false if null/missing (same conservative principle)
|
|
738
|
+
evalPlanViable = result.evaluationPayload.plan_viable ?? false;
|
|
739
|
+
}
|
|
740
|
+
const nextStepInfo = SafetyController.getNextStep(pipeline, spec, result.success, evalPlanViable);
|
|
741
|
+
if (nextStepInfo === null || currentStep === 'FINALIZE') {
|
|
589
742
|
// Pipeline complete — determine final status
|
|
590
743
|
const finalStatus = result.success ? 'COMPLETED' : 'FAILED';
|
|
591
744
|
const finalError = result.success ? null : `Pipeline ended at step=${currentStep}: ${result.notes?.slice(0, 500)}`;
|
|
@@ -613,13 +766,25 @@ async function runnerTick() {
|
|
|
613
766
|
debugLog(`[DarkFactory] Pipeline ${pipeline.id} finished: ${finalStatus}`);
|
|
614
767
|
}
|
|
615
768
|
else {
|
|
616
|
-
// Advance to next step
|
|
617
769
|
try {
|
|
770
|
+
const updatedPayload = currentStep === 'PLAN_CONTRACT' && result.contractPayload
|
|
771
|
+
? result.contractPayload
|
|
772
|
+
: pipeline.contract_payload;
|
|
773
|
+
// Forward the most informative notes available:
|
|
774
|
+
// EXECUTE notes = what the generator did
|
|
775
|
+
// EVALUATE notes = what the evaluator found
|
|
776
|
+
// Other steps: preserve existing notes
|
|
777
|
+
const updatedNotes = (currentStep === 'EXECUTE' || currentStep === 'EVALUATE') && result.notes
|
|
778
|
+
? result.notes
|
|
779
|
+
: pipeline.notes;
|
|
618
780
|
await storage.savePipeline({
|
|
619
781
|
...pipeline,
|
|
620
|
-
current_step:
|
|
621
|
-
iteration:
|
|
782
|
+
current_step: nextStepInfo.step,
|
|
783
|
+
iteration: nextStepInfo.iteration,
|
|
784
|
+
eval_revisions: nextStepInfo.eval_revisions,
|
|
622
785
|
last_heartbeat: new Date().toISOString(),
|
|
786
|
+
contract_payload: updatedPayload,
|
|
787
|
+
notes: updatedNotes,
|
|
623
788
|
});
|
|
624
789
|
}
|
|
625
790
|
catch (err) {
|
|
@@ -630,7 +795,7 @@ async function runnerTick() {
|
|
|
630
795
|
}
|
|
631
796
|
throw err;
|
|
632
797
|
}
|
|
633
|
-
debugLog(`[DarkFactory] Pipeline ${pipeline.id} advanced: ${currentStep} → ${
|
|
798
|
+
debugLog(`[DarkFactory] Pipeline ${pipeline.id} advanced: ${currentStep} → ${nextStepInfo.step} (iter ${nextStepInfo.iteration}, rev ${nextStepInfo.eval_revisions ?? 0})`);
|
|
634
799
|
}
|
|
635
800
|
}
|
|
636
801
|
catch (err) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { VALID_ACTION_TYPES } from './schema.js';
|
|
1
|
+
import { VALID_ACTION_TYPES, DEFAULT_MAX_REVISIONS } from './schema.js';
|
|
2
2
|
import { PRISM_DARK_FACTORY_MAX_RUNTIME_MS } from '../config.js';
|
|
3
3
|
import { debugLog } from '../utils/logger.js';
|
|
4
4
|
import path from 'path';
|
|
@@ -31,13 +31,6 @@ export class SafetyController {
|
|
|
31
31
|
'COMPLETED': [], // Terminal — no exits
|
|
32
32
|
'FAILED': ['RUNNING'], // Allow retry from failed state
|
|
33
33
|
};
|
|
34
|
-
/**
|
|
35
|
-
* Legal step transitions for the pipeline execution state machine.
|
|
36
|
-
* FINALIZE is entered from VERIFY when iteration == maxIterations or success.
|
|
37
|
-
*/
|
|
38
|
-
static STEP_ORDER = [
|
|
39
|
-
'INIT', 'PLAN', 'EXECUTE', 'VERIFY', 'FINALIZE'
|
|
40
|
-
];
|
|
41
34
|
/**
|
|
42
35
|
* Prevents runaway LLM invocation loops by enforcing the max iteration envelope.
|
|
43
36
|
*/
|
|
@@ -147,8 +140,15 @@ export class SafetyController {
|
|
|
147
140
|
* Used by clawInvocation.ts instead of inline prompt construction.
|
|
148
141
|
*/
|
|
149
142
|
static generateBoundaryPrompt(spec, state) {
|
|
143
|
+
let modeDescription = 'an autonomous code agent';
|
|
144
|
+
if (state.current_step === 'PLAN_CONTRACT' || state.current_step === 'EVALUATE') {
|
|
145
|
+
modeDescription = 'an ADVERSARIAL EVALUATOR enforcing strict quality constraints against a generated output';
|
|
146
|
+
}
|
|
147
|
+
else if (state.current_step === 'EXECUTE') {
|
|
148
|
+
modeDescription = 'a GENERATOR executing code constrained by a strict rubric';
|
|
149
|
+
}
|
|
150
150
|
const lines = [
|
|
151
|
-
`You are Prism Dark Factory, operating in the background as
|
|
151
|
+
`You are Prism Dark Factory, operating in the background as ${modeDescription}.`,
|
|
152
152
|
`You are strictly limited to code actions within the defined scope.`,
|
|
153
153
|
``,
|
|
154
154
|
`── Operational Boundaries ──`,
|
|
@@ -156,6 +156,7 @@ export class SafetyController {
|
|
|
156
156
|
`Project: ${state.project}`,
|
|
157
157
|
`Current Step: ${state.current_step}`,
|
|
158
158
|
`Iteration: ${state.iteration} / ${spec.maxIterations}`,
|
|
159
|
+
`Revision: ${state.eval_revisions ?? 0} / ${spec.maxRevisions ?? DEFAULT_MAX_REVISIONS}`,
|
|
159
160
|
`Restricted Workspace: ${spec.workingDirectory || '(unrestricted)'}`,
|
|
160
161
|
];
|
|
161
162
|
if (spec.contextFiles && spec.contextFiles.length > 0) {
|
|
@@ -164,29 +165,54 @@ export class SafetyController {
|
|
|
164
165
|
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.`);
|
|
165
166
|
return lines.join('\n');
|
|
166
167
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
static getNextStep(currentStep, iteration, spec, verifyPassed) {
|
|
168
|
+
static getNextStep(state, spec, stepPassed, planViable = true) {
|
|
169
|
+
const currentStep = state.current_step;
|
|
170
|
+
const iteration = state.iteration;
|
|
171
|
+
const eval_revisions = state.eval_revisions ?? 0;
|
|
172
172
|
switch (currentStep) {
|
|
173
173
|
case 'INIT':
|
|
174
|
-
return { step: 'PLAN', iteration };
|
|
174
|
+
return { step: 'PLAN', iteration, eval_revisions };
|
|
175
175
|
case 'PLAN':
|
|
176
|
-
return { step: '
|
|
176
|
+
return { step: 'PLAN_CONTRACT', iteration, eval_revisions };
|
|
177
|
+
case 'PLAN_CONTRACT':
|
|
178
|
+
return { step: 'EXECUTE', iteration, eval_revisions };
|
|
177
179
|
case 'EXECUTE':
|
|
178
|
-
return { step: '
|
|
180
|
+
return { step: 'EVALUATE', iteration, eval_revisions };
|
|
181
|
+
case 'EVALUATE':
|
|
182
|
+
if (stepPassed) {
|
|
183
|
+
// Contract passed, move to VERIFY
|
|
184
|
+
return { step: 'VERIFY', iteration, eval_revisions: 0 };
|
|
185
|
+
}
|
|
186
|
+
// Contract failed.
|
|
187
|
+
if (planViable) {
|
|
188
|
+
// Fall back to EXECUTE but increment revision counter
|
|
189
|
+
const nextRevision = eval_revisions + 1;
|
|
190
|
+
const maxRev = spec.maxRevisions ?? DEFAULT_MAX_REVISIONS;
|
|
191
|
+
if (nextRevision >= maxRev) {
|
|
192
|
+
// Exceeded max revisions — pipeline fails
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return { step: 'EXECUTE', iteration, eval_revisions: nextRevision };
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
// Fall back all the way to PLAN
|
|
199
|
+
const nextIteration = iteration + 1;
|
|
200
|
+
if (!SafetyController.validateIterationLimit(nextIteration, spec)) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return { step: 'PLAN', iteration: nextIteration, eval_revisions: 0 };
|
|
204
|
+
}
|
|
179
205
|
case 'VERIFY':
|
|
180
|
-
if (
|
|
181
|
-
return { step: 'FINALIZE', iteration };
|
|
206
|
+
if (stepPassed) {
|
|
207
|
+
return { step: 'FINALIZE', iteration, eval_revisions };
|
|
182
208
|
}
|
|
183
209
|
// Verification failed — loop back to PLAN with incremented iteration
|
|
184
|
-
const
|
|
185
|
-
if (!SafetyController.validateIterationLimit(
|
|
210
|
+
const nextIterationVerify = iteration + 1;
|
|
211
|
+
if (!SafetyController.validateIterationLimit(nextIterationVerify, spec)) {
|
|
186
212
|
// Exceeded max iterations — force finalize with failure
|
|
187
213
|
return null;
|
|
188
214
|
}
|
|
189
|
-
return { step: 'PLAN', iteration:
|
|
215
|
+
return { step: 'PLAN', iteration: nextIterationVerify, eval_revisions: 0 };
|
|
190
216
|
case 'FINALIZE':
|
|
191
217
|
return null; // Terminal step
|
|
192
218
|
default:
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export function computeIntentHealth(ctx, staleThresholdDays = 30, nowMs = Date.now()) {
|
|
2
|
+
if (!Number.isFinite(staleThresholdDays) || staleThresholdDays <= 0)
|
|
3
|
+
staleThresholdDays = 30; // Guard against NaN / zero / negative
|
|
4
|
+
// 2. Compute staleness (Days since last session)
|
|
5
|
+
let stalenessDays = 0;
|
|
6
|
+
if (ctx.recent_sessions && ctx.recent_sessions.length > 0) {
|
|
7
|
+
const lastTimestamp = ctx.recent_sessions[0].created_at ? new Date(ctx.recent_sessions[0].created_at).getTime() : NaN;
|
|
8
|
+
stalenessDays = isNaN(lastTimestamp) ? 0 : Math.max(0, (nowMs - lastTimestamp) / (1000 * 60 * 60 * 24));
|
|
9
|
+
}
|
|
10
|
+
// 3. Count TODOs and Decisions
|
|
11
|
+
const todoCount = ctx.pending_todo?.length || 0;
|
|
12
|
+
const hasDecisions = ctx.recent_sessions?.some((s) => Array.isArray(s.decisions) && s.decisions.length > 0) ?? false;
|
|
13
|
+
// 4. Execute Intent Debt Scoring Algorithm
|
|
14
|
+
// Staleness (50 points): Linear decay until threshold
|
|
15
|
+
const stalenessScore = Math.max(0, 50 - (stalenessDays / staleThresholdDays) * 50);
|
|
16
|
+
// Open TODOs (30 points)
|
|
17
|
+
let todoScore = 30;
|
|
18
|
+
if (todoCount >= 10)
|
|
19
|
+
todoScore = 0;
|
|
20
|
+
else if (todoCount >= 7)
|
|
21
|
+
todoScore = 5;
|
|
22
|
+
else if (todoCount >= 4)
|
|
23
|
+
todoScore = 15;
|
|
24
|
+
else if (todoCount >= 1)
|
|
25
|
+
todoScore = 25;
|
|
26
|
+
// Decisions (20 points): 70% of max if missing
|
|
27
|
+
const decisionScore = hasDecisions ? 20 : 14;
|
|
28
|
+
const totalScore = Math.min(100, Math.round(stalenessScore + todoScore + decisionScore));
|
|
29
|
+
// 5. Generate Actionable Signals
|
|
30
|
+
const signals = [];
|
|
31
|
+
if (stalenessDays > staleThresholdDays) {
|
|
32
|
+
signals.push({ type: "staleness", message: `Stale: Last updated ${Math.round(stalenessDays)} days ago`, severity: "critical" });
|
|
33
|
+
}
|
|
34
|
+
else if (stalenessDays > staleThresholdDays / 2) {
|
|
35
|
+
signals.push({ type: "staleness", message: `Aging: Last updated ${Math.round(stalenessDays)} days ago`, severity: "warn" });
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
signals.push({ type: "staleness", message: `Fresh: Last updated ${Math.round(stalenessDays)} days ago`, severity: "ok" });
|
|
39
|
+
}
|
|
40
|
+
if (todoCount >= 10) {
|
|
41
|
+
signals.push({ type: "todos", message: `${todoCount} TODOs pending (Overwhelming)`, severity: "critical" });
|
|
42
|
+
}
|
|
43
|
+
else if (todoCount >= 4) {
|
|
44
|
+
signals.push({ type: "todos", message: `${todoCount} TODOs pending`, severity: "warn" });
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
signals.push({ type: "todos", message: `${todoCount} TODOs pending`, severity: "ok" });
|
|
48
|
+
}
|
|
49
|
+
if (hasDecisions) {
|
|
50
|
+
signals.push({ type: "decisions", message: "Active decisions captured", severity: "ok" });
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
signals.push({ type: "decisions", message: "No active decisions documented", severity: "warn" });
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
score: totalScore,
|
|
57
|
+
staleness_days: Math.round(stalenessDays),
|
|
58
|
+
open_todo_count: todoCount,
|
|
59
|
+
has_active_decisions: hasDecisions,
|
|
60
|
+
signals: signals
|
|
61
|
+
};
|
|
62
|
+
}
|
package/dist/dashboard/server.js
CHANGED
|
@@ -23,7 +23,8 @@ import * as fs from "fs";
|
|
|
23
23
|
import { getStorage } from "../storage/index.js";
|
|
24
24
|
import { PRISM_USER_ID, SERVER_CONFIG } from "../config.js";
|
|
25
25
|
import { renderDashboardHTML } from "./ui.js";
|
|
26
|
-
import {
|
|
26
|
+
import { computeIntentHealth } from "./intentHealth.js";
|
|
27
|
+
import { getAllSettings, setSetting, getSetting, getSettingSync } from "../storage/configStorage.js";
|
|
27
28
|
import { compactLedgerHandler } from "../tools/compactionHandler.js";
|
|
28
29
|
import { getLLMProvider } from "../utils/llm/factory.js";
|
|
29
30
|
import { buildVaultDirectory } from "../utils/vaultExporter.js";
|
|
@@ -1095,6 +1096,39 @@ self.addEventListener('message', (e) => {
|
|
|
1095
1096
|
});
|
|
1096
1097
|
return res.end(Buffer.from(pngBase64, "base64"));
|
|
1097
1098
|
}
|
|
1099
|
+
// ─── API: Intent Health (Feature A) ───
|
|
1100
|
+
// GET /api/intent-health?project=xxx
|
|
1101
|
+
if (url.pathname === "/api/intent-health" && req.method === "GET") {
|
|
1102
|
+
const project = url.searchParams.get("project");
|
|
1103
|
+
if (!project) {
|
|
1104
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1105
|
+
return res.end(JSON.stringify({ error: "project is required" }));
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
const storage = await getStorageSafe();
|
|
1109
|
+
if (!storage) {
|
|
1110
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1111
|
+
return res.end(JSON.stringify({ error: "Storage initializing..." }));
|
|
1112
|
+
}
|
|
1113
|
+
// Load 'standard' context to get active_decisions and recent_sessions
|
|
1114
|
+
// Note: API MUST use "standard" level (deep skips recent_sessions, which breaks staleness).
|
|
1115
|
+
const ctx = await storage.loadContext(project, "standard", PRISM_USER_ID);
|
|
1116
|
+
if (!ctx) {
|
|
1117
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1118
|
+
return res.end(JSON.stringify({ error: "Project not found" }));
|
|
1119
|
+
}
|
|
1120
|
+
// 1. Fetch configurable threshold (default 30 days, avoiding 7-day panic)
|
|
1121
|
+
const staleThresholdDays = parseInt(getSettingSync("intent_health_stale_threshold_days", "30"), 10) || 30;
|
|
1122
|
+
const healthResult = computeIntentHealth(ctx, staleThresholdDays);
|
|
1123
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1124
|
+
return res.end(JSON.stringify(healthResult));
|
|
1125
|
+
}
|
|
1126
|
+
catch (err) {
|
|
1127
|
+
console.error("[intent-health] Error computing intent health:", err);
|
|
1128
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1129
|
+
return res.end(JSON.stringify({ error: "Failed to compute intent health" }));
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1098
1132
|
// ─── 404 ───
|
|
1099
1133
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1100
1134
|
res.end("Not found");
|