dsh-continual-evolve 0.1.0 → 0.2.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/lib/auto.js CHANGED
@@ -17,12 +17,16 @@
17
17
  */
18
18
  import { appendFileSync, mkdirSync } from "node:fs";
19
19
  import { join } from "node:path";
20
+ import { slug } from "./types.js";
20
21
  import { planWithLlm } from "./planner.js";
21
22
  import { reviewAutoRefine, serializeSurface } from "./review.js";
22
23
  import { goalServiceOf } from "./goal.js";
23
24
  import { notifyAutoReview } from "./notify.js";
25
+ import { runLocalFatePhase } from "./fate.js";
24
26
  import { entrySourceOf } from "./source.js";
25
27
  import { mergeHarnessStates } from "./state.js";
28
+ /** Turns a rejected skill candidate stays silent before being offered again. */
29
+ export const SKILL_CONSULT_COOLDOWN_TURNS = 10;
26
30
  /**
27
31
  * Count completed turns from agent/status transitions alone. The runtime
28
32
  * emits `agent/status` with a `{status}` payload and (per host consumers like
@@ -133,7 +137,14 @@ export function registerAutoReview(ctx, engine, config) {
133
137
  function stateFor(map, sessionId) {
134
138
  let state = map.get(sessionId);
135
139
  if (!state) {
136
- state = { turns: 0, lastReviewAt: 0, running: false };
140
+ state = {
141
+ turns: 0,
142
+ lastReviewAt: 0,
143
+ running: false,
144
+ skillRejects: new Map(),
145
+ lastFateAt: 0,
146
+ fateRejects: new Map(),
147
+ };
137
148
  map.set(sessionId, state);
138
149
  }
139
150
  return state;
@@ -150,7 +161,19 @@ function stateFor(map, sessionId) {
150
161
  export function loadGateHarnessView(engine, sessionId) {
151
162
  return mergeHarnessStates(engine.load("global", undefined), engine.load("local", sessionId));
152
163
  }
164
+ /**
165
+ * One gate run = review phase + local-fate phase (#11 P2). The review phase
166
+ * judges and applies local refinements; the local-fate phase then gives the
167
+ * session's existing local entries a running exit (promote/archive proposals,
168
+ * consulted before they land). Running fate AFTER the review keeps the
169
+ * review's baseline fresh — fate re-loads the store and never races the
170
+ * review's optimistic-concurrency checks.
171
+ */
153
172
  async function runGate(ctx, engine, agent, config, state, reason, record) {
173
+ await runReviewPhase(ctx, engine, agent, config, state, reason, record);
174
+ await runLocalFatePhase(ctx, engine, agent, config, state, reason, record);
175
+ }
176
+ async function runReviewPhase(ctx, engine, agent, config, state, reason, record) {
154
177
  const sessionId = agent.id;
155
178
  const turnsSinceLastReview = state.turns - state.lastReviewAt;
156
179
  const logger = ctx.logger("continual-evolve");
@@ -189,9 +212,31 @@ async function runGate(ctx, engine, agent, config, state, reason, record) {
189
212
  history,
190
213
  ...(review.instructions ? { instructions: review.instructions } : {}),
191
214
  global: false,
215
+ // Read the skill-creator template facts (fallback: builtin distilled
216
+ // guide) so skill proposals follow the standard.
217
+ skillsRoot: join(engine.baseDir, "skills"),
192
218
  });
219
+ // Skills are governed resources: an auto-created skill is OFFERED to the
220
+ // user for a decision (固化/不固化) before it lands — the gate never
221
+ // writes a skill silently. Without consent the skill edits are withheld
222
+ // and the rest of the proposal proceeds as usual.
223
+ const { skillEdits, otherEdits } = splitSkillEdits(proposal);
224
+ const skillConsented = await consultSkillEdits(ctx, agent, skillEdits, state);
225
+ const finalProposal = skillConsented
226
+ ? proposal
227
+ : {
228
+ ...proposal,
229
+ edits: otherEdits,
230
+ summary: skillEdits.length > 0 ? `${proposal.summary} (skill edits withheld — pending user decision)` : proposal.summary,
231
+ };
232
+ if (finalProposal.edits.length === 0) {
233
+ const withheld = skillEdits.length > 0 ? " (skill proposal withheld — user not consulted or declined)" : "";
234
+ logger.info(`auto-review declined (${reason}) [${sessionId}] after ${turnsSinceLastReview} turns: no consented edits${withheld} — ${review.rationale}`);
235
+ record({ sessionId, reason, turnsSinceLastReview, outcome: "declined", rationale: `${review.rationale}${withheld}` });
236
+ return;
237
+ }
193
238
  const source = entrySourceOf(agent, sessionId);
194
- const result = engine.apply("local", sessionId, proposal, {
239
+ const result = engine.apply("local", sessionId, finalProposal, {
195
240
  scope: "local",
196
241
  baselineState: localState,
197
242
  ...(source ? { source } : {}),
@@ -214,4 +259,65 @@ async function readTrajectory(ctx, agent, maxChars) {
214
259
  const snapshot = await sessionQuery.readSurface(agent.id);
215
260
  return serializeSurface(snapshot.events, maxChars);
216
261
  }
262
+ /**
263
+ * Split a proposal into skill edits and everything else. Skill edits are the
264
+ * governed part: they need explicit user consent before the gate applies
265
+ * them, while the remaining edits flow through the normal auto path.
266
+ */
267
+ export function splitSkillEdits(proposal) {
268
+ return {
269
+ skillEdits: proposal.edits.filter((edit) => edit.kind === "skill"),
270
+ otherEdits: proposal.edits.filter((edit) => edit.kind !== "skill"),
271
+ };
272
+ }
273
+ /**
274
+ * Ask the user whether to solidify proposed skill edits (guidance or
275
+ * executable) into the harness. Returns true when every skill edit is
276
+ * consented. Never writes a skill silently:
277
+ * - no question service available → false (conservative);
278
+ * - the same candidate was rejected within the cooldown window → false
279
+ * without asking again (no nagging);
280
+ * - the user declines → false and the rejection is recorded for cooldown;
281
+ * - the question call fails/aborts → false (conservative).
282
+ */
283
+ export async function consultSkillEdits(ctx, agent, skillEdits, gate) {
284
+ if (skillEdits.length === 0)
285
+ return true;
286
+ const key = skillEdits.map((edit) => edit.id ?? slug(edit.title ?? edit.kind, edit.kind)).join("|");
287
+ const lastReject = gate.skillRejects.get(key);
288
+ if (lastReject !== undefined && gate.turns - lastReject < SKILL_CONSULT_COOLDOWN_TURNS) {
289
+ return false;
290
+ }
291
+ const userQuestions = ctx.userQuestions;
292
+ if (!userQuestions) {
293
+ return false;
294
+ }
295
+ const description = skillEdits
296
+ .map((edit) => {
297
+ const form = edit.skill_kind === "guidance" ? "guidance 技能(SKILL.md 文档)" : "可执行技能";
298
+ return `- ${edit.action}「${edit.title ?? edit.id}」(${form})`;
299
+ })
300
+ .join("\n");
301
+ try {
302
+ const answer = await userQuestions.ask({
303
+ questions: [
304
+ {
305
+ id: "evolve-skill-consult",
306
+ question: `自进化检测到反复出现的流程/技能候选,建议沉淀:\n\n${description}\n\n是否固化?`,
307
+ options: [{ label: "固化" }, { label: "不固化" }],
308
+ },
309
+ ],
310
+ agent,
311
+ });
312
+ const item = answer.answers?.find((entry) => entry.id === "evolve-skill-consult");
313
+ const consented = item?.selected?.includes("固化") ?? false;
314
+ if (!consented) {
315
+ gate.skillRejects.set(key, gate.turns);
316
+ }
317
+ return consented;
318
+ }
319
+ catch {
320
+ return false;
321
+ }
322
+ }
217
323
  //# sourceMappingURL=auto.js.map
@@ -16,9 +16,23 @@ export interface BenchmarkDefinition {
16
16
  export interface CellScore {
17
17
  caseId: string;
18
18
  run: number;
19
+ /**
20
+ * Failure-cell protocol (gap A2): "ok" = a real score; "failed" = the
21
+ * unit could not produce one (rubric decrypt error, child crash, protocol
22
+ * error). A failed cell is NOT a zero — aggregation excludes it and
23
+ * counts it, and the acceptance rule rejects a round with more failures
24
+ * than the threshold instead of silently averaging a 0 into the mean.
25
+ */
26
+ status: "ok" | "failed";
19
27
  score: number;
20
28
  passed: boolean;
21
29
  notes: string;
30
+ /**
31
+ * Trace evidence pointer (gap A4): the executor child's session id whose
32
+ * transcript produced this cell's evidence — the score can be drilled
33
+ * back to the exact session steps that earned it.
34
+ */
35
+ sessionId?: string;
22
36
  }
23
37
  export interface EvaluationEntry {
24
38
  label: string;
package/lib/command.js CHANGED
@@ -2,7 +2,9 @@ import { ARCHIVED_AT_KEY } from "./types.js";
2
2
  import { formatHarnessStateForPrompt, historyForPrompt } from "./render.js";
3
3
  import { planWithLlm } from "./planner.js";
4
4
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
5
6
  import { requireGlobalApproval } from "./approval.js";
7
+ import { assessLocalEntries, candidateKey, filterPromotable, listLocalCandidates, splitArchiveGuards, splitPromoteBlocked, splitPromoteProposals, wholePromoteProposals } from "./wrapup.js";
6
8
  import { saveHarnessState } from "./state.js";
7
9
  import { loadLedger, mountSkill, unmountSkill } from "./mount.js";
8
10
  import { blockEvolutionGoal, completeEvolutionGoal, goalServiceOf, goalStatusText, upsertEvolutionGoal } from "./goal.js";
@@ -18,6 +20,8 @@ const USAGE = `Usage:
18
20
  /evolve history [global] show applied refinements (rollback ids)
19
21
  /evolve rollback <id> [global] deterministically revert a refinement
20
22
  /evolve plan [msg] run the LLM planner against the current store
23
+ /evolve wrapup assess this session's local entries: promote reusable ones
24
+ to the global store (approval required), archive one-offs
21
25
  /evolve archive <id> [global] hide an entry from injection (data kept, restorable)
22
26
  /evolve unarchive <id> [global] restore an archived entry
23
27
  /evolve log [tail N] show the recent plugin log (default 50 lines)
@@ -255,6 +259,8 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
255
259
  ...(instructions ? { instructions } : {}),
256
260
  global: scope === "global",
257
261
  signal: invocation.signal,
262
+ // skill-creator template facts (fallback: builtin guide).
263
+ skillsRoot: join(engine.baseDir, "skills"),
258
264
  });
259
265
  if (scope === "global" && opts.requireGlobalApproval && proposal.edits.length > 0) {
260
266
  await requireGlobalApproval(ctx, invocation.agent, invocation.signal, `/evolve plan global 将应用 ${proposal.edits.length} 条编辑到跨会话 store:${proposal.summary}`);
@@ -266,6 +272,9 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
266
272
  });
267
273
  return success(renderResult(result));
268
274
  }
275
+ case "wrapup": {
276
+ return await executeWrapupCommand(ctx, engine, invocation);
277
+ }
269
278
  case "goal": {
270
279
  return executeGoalCommand(ctx, invocation, rest);
271
280
  }
@@ -320,6 +329,211 @@ function executeGoalCommand(ctx, invocation, rest) {
320
329
  return error(cause instanceof Error ? cause.message : String(cause));
321
330
  }
322
331
  }
332
+ async function executeWrapupCommand(ctx, engine, invocation) {
333
+ const sessionId = invocation.agent.id;
334
+ const localState = engine.load("local", sessionId);
335
+ const globalState = engine.load("global", undefined);
336
+ const candidates = listLocalCandidates(localState, globalState);
337
+ if (candidates.length === 0) {
338
+ return success(`(nothing to wrap up: ${sessionId}'s local store has no active, un-promoted entries — use /evolve list to inspect it)`);
339
+ }
340
+ // 1. Classify: the model judges each audited candidate's fate.
341
+ const assessment = await assessLocalEntries(ctx, invocation.agent, candidates, { signal: invocation.signal });
342
+ const byKey = new Map(candidates.map((candidate) => [candidateKey(candidate.kind, candidate.id), candidate]));
343
+ // 2. Partition by action. Deterministic guards re-check the LIVE global
344
+ // store right before anything lands (state may have changed mid-call).
345
+ const { promotable, skipped } = filterPromotable(assessment.items, globalState, candidates);
346
+ const promoteItems = promotable.filter((item) => item.verdict === "promote");
347
+ const archiveItems = assessment.items.filter((item) => item.verdict === "archive");
348
+ // Split promotion (A-form): archive a mixed entry but promote ONLY the
349
+ // cleaned durable part the model extracted. Guarded the same way as whole
350
+ // promotes — a split that would duplicate a globally covered topic is
351
+ // dropped and the entry archives plain.
352
+ const splitItems = [];
353
+ const splitSkipped = [];
354
+ for (const item of archiveItems) {
355
+ if (!item.promote)
356
+ continue;
357
+ const candidate = byKey.get(item.key);
358
+ if (!candidate) {
359
+ splitSkipped.push({ key: item.key, reason: "not in the audited candidate list" });
360
+ continue;
361
+ }
362
+ const blocked = splitPromoteBlocked(item, globalState, candidate.kind);
363
+ if (blocked) {
364
+ splitSkipped.push({ key: item.key, reason: blocked });
365
+ continue;
366
+ }
367
+ splitItems.push({ item, candidate });
368
+ }
369
+ // Plain archives (no split payload): the symmetric guard — an archive that
370
+ // is NOT globally covered AND was distilled from real user messages must
371
+ // not proceed silently.
372
+ const plainArchives = archiveItems.filter((item) => !item.promote);
373
+ const { silent: silentArchives, review: reviewArchives } = splitArchiveGuards(plainArchives, candidates);
374
+ const keepItems = assessment.items.filter((item) => item.verdict === "keep");
375
+ // 3. Report the assessment before touching anything.
376
+ const lines = [
377
+ `wrapup assessment (${sessionId}): ${candidates.length} candidates${candidates.some((c) => c.coveredGlobally) ? `, ${candidates.filter((c) => c.coveredGlobally).length} covered globally` : ""}`,
378
+ `${assessment.rationale}`,
379
+ ];
380
+ for (const [heading, items] of [
381
+ ["PROMOTE (to global)", promoteItems],
382
+ ["SPLIT (archive + promote durable part)", splitItems.map((split) => split.item)],
383
+ ["ARCHIVE", silentArchives],
384
+ ["ARCHIVE (needs review)", reviewArchives],
385
+ ["KEEP", keepItems],
386
+ ]) {
387
+ lines.push(`${heading}: ${items.length}`);
388
+ for (const item of items) {
389
+ const candidate = byKey.get(item.key);
390
+ const title = candidate ? candidate.title : item.key;
391
+ const splitNote = item.promote ? ` → 拆出提升「${item.promote.title}」` : "";
392
+ lines.push(`- ${item.key} "${title}"${splitNote} — ${item.reason}`);
393
+ }
394
+ }
395
+ for (const skip of skipped) {
396
+ lines.push(`- promote skipped: ${skip.key} — ${skip.reason}`);
397
+ }
398
+ for (const skip of splitSkipped) {
399
+ lines.push(`- split skipped: ${skip.key} — ${skip.reason}`);
400
+ }
401
+ lines.push("");
402
+ const applied = [];
403
+ // 4. Global writes: governed resource — ONE human approval gate covers
404
+ // every create (whole promotes AND split promotions). On approval:
405
+ // - whole promote → create global copy + stamp local promotedTo+archivedAt;
406
+ // - split → create the cleaned durable part + archive the original with
407
+ // promotedTo. On rejection: whole promotes are not written, and each
408
+ // split's original STILL archives plain (its snapshot half deserves
409
+ // the archive; the durable half is reported for manual handling).
410
+ const wholeCreates = promoteItems.map((item) => ({ item, candidate: byKey.get(item.key) }));
411
+ const splitCreates = splitItems;
412
+ const allCreates = new Set([...wholeCreates.map((c) => c.item.key), ...splitCreates.map((c) => c.item.key)]);
413
+ if (allCreates.size > 0) {
414
+ const what = `wrapup 将写入跨会话 global store(共 ${allCreates.size} 条:${promoteItems.length} 条整条提升 + ${splitItems.length} 条拆解提升):\n${[
415
+ ...promoteItems.map((item) => `- 整条提升 ${item.key} "${byKey.get(item.key)?.title ?? item.key}"`),
416
+ ...splitItems.map((split) => `- 拆解提升 ${split.item.key} → 清洗「${split.item.promote?.title}」(原条目随之归档)`),
417
+ ].join("\n")}`;
418
+ let promoteAllowed = true;
419
+ try {
420
+ await requireGlobalApproval(ctx, invocation.agent, invocation.signal, what);
421
+ }
422
+ catch (cause) {
423
+ promoteAllowed = false;
424
+ const message = `global 写入未批准 — 整条提升与拆解提升均未写入 (${cause instanceof Error ? cause.message : String(cause)})`;
425
+ applied.push(message);
426
+ lines.push(message);
427
+ }
428
+ if (promoteAllowed) {
429
+ // Whole promotes: create global entry, retire the local copy.
430
+ // Shared proposal builders keep the wrap-up command and the gate's
431
+ // local-fate dimension writing IDENTICAL edits.
432
+ for (const { item, candidate } of wholeCreates) {
433
+ if (!candidate)
434
+ continue;
435
+ const proposals = wholePromoteProposals(item, candidate, sessionId);
436
+ const globalResult = engine.apply("global", undefined, proposals.global, { scope: "global" });
437
+ const createdId = globalResult.appliedEdits.find((edit) => edit.applied)?.id ?? candidate.id;
438
+ const localResult = engine.apply("local", sessionId, proposals.localStamp(createdId), {
439
+ scope: "local",
440
+ baselineState: localState,
441
+ });
442
+ applied.push(`promoted ${item.key} → global:${createdId} (${globalResult.id}; local stamped ${localResult.id})`);
443
+ }
444
+ // Split promotions: create the cleaned durable part, retire the
445
+ // original local entry (its snapshot half is archived along).
446
+ for (const { item, candidate } of splitCreates) {
447
+ if (!item.promote)
448
+ continue;
449
+ const proposals = splitPromoteProposals(item, candidate, sessionId);
450
+ const globalResult = engine.apply("global", undefined, proposals.global, { scope: "global" });
451
+ const createdId = globalResult.appliedEdits.find((edit) => edit.applied)?.id ?? candidate.id;
452
+ const localResult = engine.apply("local", sessionId, proposals.localStamp(createdId), {
453
+ scope: "local",
454
+ baselineState: localState,
455
+ });
456
+ applied.push(`split ${item.key}: promoted cleaned part → global:${createdId} (${globalResult.id}); original archived (${localResult.id})`);
457
+ }
458
+ }
459
+ else {
460
+ // Rejected: whole promotes stay un-written; each split's original
461
+ // still archives plain (reported, data restorable).
462
+ for (const { item, candidate } of splitCreates) {
463
+ if (!candidate)
464
+ continue;
465
+ const result = engine.apply("local", sessionId, {
466
+ summary: `wrapup: split promotion not approved — archive original ${item.key} plain`,
467
+ rationale: item.reason,
468
+ expectedOutcome: `The original leaves injection; the cleaned part was NOT written (reported for manual handling).`,
469
+ edits: [{ action: "archive", kind: candidate.kind, id: candidate.id }],
470
+ }, { scope: "local", baselineState: localState });
471
+ applied.push(`split ${item.key}: promotion not approved — original archived plain (${result.id})`);
472
+ }
473
+ }
474
+ }
475
+ // 5. Silent archives: deterministic local action (hidden from injection,
476
+ // data kept restorable) — covered topics and operational entries need no
477
+ // confirmation, matching the original behavior.
478
+ for (const item of silentArchives) {
479
+ const candidate = byKey.get(item.key);
480
+ if (!candidate)
481
+ continue;
482
+ const result = engine.apply("local", sessionId, {
483
+ summary: `wrapup: archive local ${item.key} — ${item.reason}`,
484
+ rationale: item.reason,
485
+ expectedOutcome: `The entry stops being injected but stays restorable.`,
486
+ edits: [{ action: "archive", kind: candidate.kind, id: candidate.id }],
487
+ }, { scope: "local", baselineState: localState });
488
+ applied.push(`archived ${item.key} (${result.id})`);
489
+ }
490
+ // 6. Review archives (symmetric guard): not covered globally + distilled
491
+ // from real user messages — the user decides before this content is
492
+ // hidden from future sessions. No question service → conservative keep.
493
+ const userQuestions = ctx.userQuestions;
494
+ for (const item of reviewArchives) {
495
+ const candidate = byKey.get(item.key);
496
+ if (!candidate)
497
+ continue;
498
+ if (!userQuestions) {
499
+ applied.push(`kept ${item.key} — archive pending user confirmation (no question service)`);
500
+ continue;
501
+ }
502
+ const questionId = "evolve-wrapup-archive-review";
503
+ let archiveConfirmed = false;
504
+ try {
505
+ const answer = await userQuestions.ask({
506
+ questions: [
507
+ {
508
+ id: questionId,
509
+ question: `wrapup:条目「${candidate.title}」未被全局覆盖且源自真实对话,直接归档会隐藏它(数据保留、可恢复)。确认归档?`,
510
+ options: [{ label: "归档" }, { label: "保留" }],
511
+ },
512
+ ],
513
+ agent: invocation.agent,
514
+ signal: invocation.signal,
515
+ });
516
+ archiveConfirmed = answer.answers?.find((entry) => entry.id === questionId)?.selected?.includes("归档") ?? false;
517
+ }
518
+ catch {
519
+ archiveConfirmed = false;
520
+ }
521
+ if (archiveConfirmed) {
522
+ const result = engine.apply("local", sessionId, {
523
+ summary: `wrapup: archive local ${item.key} (user-confirmed) — ${item.reason}`,
524
+ rationale: item.reason,
525
+ expectedOutcome: `The entry stops being injected but stays restorable.`,
526
+ edits: [{ action: "archive", kind: candidate.kind, id: candidate.id }],
527
+ }, { scope: "local", baselineState: localState });
528
+ applied.push(`archived ${item.key} (user-confirmed, ${result.id})`);
529
+ }
530
+ else {
531
+ applied.push(`kept ${item.key} — user declined the archive`);
532
+ }
533
+ }
534
+ lines.push(...(applied.length > 0 ? applied : ["(no changes applied — all entries kept)"]));
535
+ return success(lines.join("\n"));
536
+ }
323
537
  async function executeMountCommand(ctx, engine, invocation, rest) {
324
538
  const sub = rest[0] ?? "";
325
539
  if (sub === "list") {
@@ -351,6 +565,11 @@ async function executeMountCommand(ctx, engine, invocation, rest) {
351
565
  return error(cause instanceof Error ? cause.message : String(cause));
352
566
  }
353
567
  }
568
+ /** " (N failed)" suffix when an evaluation entry carries failed cells. */
569
+ function failedTextOf(entry) {
570
+ const failed = entry.cells.filter((cell) => cell.status === "failed").length;
571
+ return failed > 0 ? ` (${failed} failed)` : "";
572
+ }
354
573
  async function executeBenchmarkCommand(ctx, engine, invocation, rest, runtime) {
355
574
  const sub = rest[0] ?? "";
356
575
  const args = rest.slice(1);
@@ -409,13 +628,13 @@ async function executeBenchmarkCommand(ctx, engine, invocation, rest, runtime) {
409
628
  const board = loadScoreboard(baseDir, bid);
410
629
  const lines = [];
411
630
  if (board.reference) {
412
- lines.push(`reference "${board.reference.label}": overall=${board.reference.overall ?? "?"} cells=${board.reference.cells.length}`);
631
+ lines.push(`reference "${board.reference.label}": overall=${board.reference.overall ?? "?"} cells=${board.reference.cells.length}${failedTextOf(board.reference)}`);
413
632
  }
414
633
  else {
415
634
  lines.push("(no reference evaluation yet)");
416
635
  }
417
636
  for (const c of board.candidates) {
418
- lines.push(`candidate "${c.label}": overall=${c.overall ?? "?"} cells=${c.cells.length}${c.refinementId ? ` (${c.refinementId})` : ""}`);
637
+ lines.push(`candidate "${c.label}": overall=${c.overall ?? "?"} cells=${c.cells.length}${failedTextOf(c)}${c.refinementId ? ` (${c.refinementId})` : ""}`);
419
638
  }
420
639
  for (const d of board.decisions) {
421
640
  lines.push(`decision: ${d.accepted ? "ACCEPTED" : "rejected"} ${d.candidateLabel} — ${d.reasons.join("; ") || "ok"}`);
@@ -449,19 +668,29 @@ async function executeBenchmarkCommand(ctx, engine, invocation, rest, runtime) {
449
668
  signal: invocation.signal,
450
669
  });
451
670
  const entry = entryFromCells(label, outcome.cells, candidateId);
671
+ const failedCells = outcome.cells.filter((cell) => cell.status === "failed").length;
452
672
  const lines = [
453
- `evaluation "${label}": ${outcome.cells.length} cells, overall=${entry.overall ?? "?"}`,
673
+ `evaluation "${label}": ${outcome.cells.length} cells${failedCells > 0 ? `, ${failedCells} failed` : ""}, overall=${entry.overall ?? "?"}`,
454
674
  ...Object.entries(entry.aggregate)
455
- .filter(([key]) => key !== "overall")
675
+ .filter(([key]) => key !== "overall" && key !== "failed" && key !== "total")
456
676
  .map(([key, value]) => ` ${key}: ${value ?? "?"}`),
457
677
  ];
678
+ if (failedCells > 0) {
679
+ for (const cell of outcome.cells.filter((cell) => cell.status === "failed")) {
680
+ lines.push(` [failed] ${cell.caseId} r${cell.run}: ${cell.notes}`);
681
+ }
682
+ }
458
683
  if (candidateId) {
459
684
  if (!board.reference) {
460
685
  lines.push("(no reference yet — this run only recorded the candidate)");
461
686
  board.candidates.push(entry);
462
687
  }
463
688
  else {
464
- const decision = decide(board.reference, entry, { passThreshold: definition.passThreshold, regressionTolerance: 0 });
689
+ const decision = decide(board.reference, entry, {
690
+ passThreshold: definition.passThreshold,
691
+ regressionTolerance: 0,
692
+ maxFailedCells: 0,
693
+ });
465
694
  board.candidates.push(entry);
466
695
  board.decisions.push({
467
696
  candidateLabel: label,
package/lib/evaluate.d.ts CHANGED
@@ -1,14 +1,34 @@
1
1
  /**
2
- * The evaluation matrix runner: executes every case × run as a fresh
3
- * structured-output subagent, with the provider/model frozen to the calling
4
- * agent's own route. Raw per-cell scores come back to the host; aggregation
5
- * and acceptance happen in code (`src/score.ts`).
2
+ * The evaluation matrix runner: executes every case × run as a TWO-STAGE
3
+ * structured-output subagent pair, with the provider/model frozen to the
4
+ * calling agent's own route.
5
+ *
6
+ * Stage 1 — executor: a fresh child performs the case task with its tools
7
+ * and records CONCRETE EVIDENCE of what it did and found. It NEVER sees the
8
+ * rubric (gap A1, evaluator/scorer separation): the agent under test cannot
9
+ * optimize its behavior toward the grading criteria, and cannot grade its
10
+ * own execution.
11
+ *
12
+ * Stage 2 — reviewer: an INDEPENDENT child grades the executor's evidence
13
+ * strictly against the rubric. The rubric plaintext is decrypted in the host
14
+ * and flows ONLY into this reviewer branch; the executor branch never
15
+ * touches it. A separate model instance grading someone else's evidence
16
+ * removes the self-serving bias of self-scoring.
17
+ *
18
+ * Failure-cell protocol (gap A2): a unit that cannot produce a score
19
+ * (decrypt error, executor crash, reviewer crash, protocol error) returns a
20
+ * cell with `status: "failed"` — NEVER a zero — so aggregation can exclude
21
+ * it and the acceptance rule can reject rounds with too many failures instead
22
+ * of silently averaging a 0 into the mean.
23
+ *
24
+ * Each cell carries the executor child's session id (gap A4): the score can
25
+ * be drilled back to the exact session transcript that produced the evidence.
6
26
  *
7
27
  * Uses the host-plane `subagents` service (available in every profile) with
8
28
  * the native `outputSchema` structured-output seam: the provider validates
9
- * the child's reply against the cell schema, so the host never parses
10
- * model text for evaluations. (The workflow engine was rejected because the
11
- * web profile keeps it in a per-agent isolated realm a host plugin cannot
29
+ * each child's reply against its cell schema, so the host never parses model
30
+ * text for evaluations. (The workflow engine was rejected because the web
31
+ * profile keeps it in a per-agent isolated realm a host plugin cannot
12
32
  * resolve.)
13
33
  */
14
34
  import type { Context } from "@deepseek-ai/cordis";
@@ -32,7 +52,16 @@ export interface EvaluationOutcome {
32
52
  }
33
53
  /** How many evaluation units may run concurrently (bounded subagent fan-out). */
34
54
  export declare const DEFAULT_EVALUATION_CONCURRENCY = 4;
55
+ /** Evidence handed to the reviewer is capped so the grading call stays bounded. */
56
+ export declare const MAX_EVIDENCE_CHARS = 8000;
35
57
  export declare function evaluateState(ctx: Context, agent: Agent, options: EvaluateOptions): Promise<EvaluationOutcome>;
58
+ export interface ExecutorResult {
59
+ caseId: string;
60
+ run: number;
61
+ evidence: string;
62
+ }
63
+ /** Validate a provider-validated executor result; returns undefined when malformed. */
64
+ export declare function normalizeExecutor(value: unknown, caseId: string, run: number): ExecutorResult | undefined;
36
65
  /** Validate a provider-validated structured cell; returns undefined when malformed. */
37
66
  export declare function normalizeCell(value: unknown, caseId: string, run: number, passThreshold: number): CellScore | undefined;
38
67
  //# sourceMappingURL=evaluate.d.ts.map