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.
@@ -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
- // v7.3.1: EXECUTE steps get structured JSON output instructions
53
- const isExecuteStep = state.current_step === 'EXECUTE';
54
- const executePrompt = isExecuteStep
55
- ? `Based on the system instructions, execute the necessary actions for the current step (${state.current_step}).\n\n${EXECUTE_JSON_SCHEMA}`
56
- : `Based on the system instructions, execute the necessary task for the current step (${state.current_step}). Respond with your actions and observations.`;
57
- debugLog(`[ClawInvocation] Launching agent on pipeline ${state.id} step=${state.current_step} iter=${state.iteration} with ${timeoutMs}ms limit.${isExecuteStep ? ' (JSON mode)' : ''}`);
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(executePrompt, systemPrompt),
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
- export function parseExecuteOutput(raw) {
194
+ function extractJsonFromLlmOutput(raw) {
195
195
  if (!raw || typeof raw !== 'string' || raw.trim() === '') {
196
- return { parsed: null, error: 'JSON Parse Error: empty or non-string input' };
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 { parsed: null, error: 'JSON Parse Error: no JSON object found in LLM output' };
217
+ return { json: null, error: 'JSON Parse Error: no JSON object found in LLM output' };
222
218
  }
223
- // Attempt JSON parse
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(jsonCandidate);
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-EXECUTE steps, return as-is (free-form text)
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 the structured JSON output
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
- // Determine next step based on result
586
- const nextStep = SafetyController.getNextStep(currentStep, pipeline.iteration, spec, result.success // For VERIFY step: success means tests passed
587
- );
588
- if (nextStep === null || currentStep === 'FINALIZE') {
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: nextStep.step,
621
- iteration: nextStep.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} → ${nextStep.step} (iter ${nextStep.iteration})`);
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 an autonomous code agent.`,
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
- * Determine the next step in the pipeline execution sequence.
169
- * Returns null if the pipeline should terminate (FINALIZE reached or iteration exceeded).
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: 'EXECUTE', iteration };
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: 'VERIFY', iteration };
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 (verifyPassed) {
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 nextIteration = iteration + 1;
185
- if (!SafetyController.validateIterationLimit(nextIteration, spec)) {
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: nextIteration };
215
+ return { step: 'PLAN', iteration: nextIterationVerify, eval_revisions: 0 };
190
216
  case 'FINALIZE':
191
217
  return null; // Terminal step
192
218
  default:
@@ -2,3 +2,5 @@
2
2
  export const VALID_ACTION_TYPES = [
3
3
  'READ_FILE', 'WRITE_FILE', 'PATCH_FILE', 'RUN_TEST'
4
4
  ];
5
+ /** Default max adversarial revisions per EXECUTE phase (used when spec.maxRevisions is unset). */
6
+ export const DEFAULT_MAX_REVISIONS = 3;
@@ -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
+ }
@@ -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 { getAllSettings, setSetting, getSetting } from "../storage/configStorage.js";
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");