diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalChecklistVerdict.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalChecklistVerdict.java
new file mode 100644
index 00000000..e976b1a2
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalChecklistVerdict.java
@@ -0,0 +1,24 @@
+package vip.mate.goal.model;
+
+import java.util.List;
+
+/**
+ * Evaluator output for the verdict round — applied once a goal's
+ * checklist already exists.
+ *
+ *
The evaluator only takes a position on existing criteria by id; it does
+ * not re-emit the criterion text. The service merges each {@link CriterionVerdict}
+ * into the persistent {@code List} by id (text and untouched
+ * criteria are preserved), then derives completion from "all passed".
+ *
+ * This is a per-round delta — never the full outward-facing checklist.
+ * Outward payloads always carry the full {@code GoalResponse.criteria} array.
+ */
+public record GoalChecklistVerdict(
+ List criterionVerdicts,
+ String summary) {
+
+ /** Per-criterion delta: latest passed state + evidence, keyed by id. */
+ public record CriterionVerdict(String id, boolean passed, String evidence) {
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java
index 3b2bcae7..fc084d98 100644
--- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java
@@ -2,6 +2,8 @@ package vip.mate.goal.model;
import lombok.Data;
+import java.util.List;
+
/**
* Request body for {@code POST /api/v1/goals}.
*
@@ -30,4 +32,12 @@ public class GoalCreateRequest {
private Integer llmCallBudget;
private Boolean autoFollowupEnabled;
private Integer followupCooldownSeconds;
+
+ /**
+ * Optional initial checklist. Callers supply only {@code text} per item;
+ * the service normalizes ids ({@code C1..Cn}), forces {@code passed=false}
+ * and clears {@code evidence} on create. An empty/omitted list defers to
+ * first-evaluation bootstrap.
+ */
+ private List criteria;
}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriteriaDraft.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriteriaDraft.java
new file mode 100644
index 00000000..590acbd4
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriteriaDraft.java
@@ -0,0 +1,19 @@
+package vip.mate.goal.model;
+
+import java.util.List;
+
+/**
+ * Evaluator output for the bootstrap round — the first evaluation of a
+ * goal that has no criteria yet.
+ *
+ * When {@code mate_agent_goal.criteria} is empty there is nothing to score
+ * by id, so the evaluator instead decomposes the goal (title / description /
+ * exit criteria) into a full checklist with text. The service persists this
+ * as the goal's initial criteria (all {@code passed=false}); completion is
+ * not judged on the bootstrap round.
+ *
+ *
Distinct from {@link GoalChecklistVerdict}, which is the per-round delta
+ * used once the checklist already exists.
+ */
+public record GoalCriteriaDraft(List criteria) {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriterion.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriterion.java
new file mode 100644
index 00000000..8f033520
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCriterion.java
@@ -0,0 +1,19 @@
+package vip.mate.goal.model;
+
+/**
+ * One checkable item of a goal's exit checklist — the persistent unit.
+ *
+ * Stored as part of the JSON array in {@code mate_agent_goal.criteria}
+ * and surfaced to clients as an element of {@code GoalResponse.criteria}.
+ * Completion of a goal is derived from "every criterion passed" rather than
+ * a fuzzy completion score.
+ *
+ * @param id stable identifier ({@code C1}, {@code C2}, ...), assigned
+ * by the service on create/append; callers never mint ids
+ * @param text the criterion statement (human + LLM readable)
+ * @param passed whether the evaluator has judged this criterion satisfied
+ * @param evidence concrete justification for {@code passed} (an output line,
+ * a file excerpt, a command result); empty until evaluated
+ */
+public record GoalCriterion(String id, String text, boolean passed, String evidence) {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java
index 96159277..bbff046e 100644
--- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java
@@ -90,6 +90,17 @@ public class GoalEntity {
@TableField(value = "completion_score", updateStrategy = FieldStrategy.ALWAYS)
private Double completionScore;
+ /**
+ * Checkable exit checklist as a JSON array of {@link GoalCriterion}.
+ * Completion is derived from "all criteria passed". Nullable: a missing
+ * list bootstraps on first evaluation. ALWAYS strategy so clearing /
+ * empty-list writes are persisted. Serialized as text; the service layer
+ * maps to/from {@code List} and exposes the parsed array
+ * to clients via {@code GoalResponse.criteria}.
+ */
+ @TableField(value = "criteria", updateStrategy = FieldStrategy.ALWAYS)
+ private String criteria;
+
@TableField(value = "last_evaluation_at", updateStrategy = FieldStrategy.ALWAYS)
private LocalDateTime lastEvaluationAt;
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java
index d1d87a4d..d1fa4cdb 100644
--- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java
+++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java
@@ -1,6 +1,7 @@
package vip.mate.goal.model;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
/**
@@ -8,15 +9,24 @@ import java.util.Map;
* {@code GoalEvaluationService} to {@code GoalEvaluationNode} and on to
* {@code GoalService.recordEvaluation}.
*
- * Defined in PR1 so the service-layer signature is stable; the actual
- * evaluator implementation lands in PR2.
- *
- *
{@link #completed} means "evaluator judged this turn satisfies all
- * exit criteria". It does not mean "graph FINISH_REASON should change" —
- * goal status and graph FinishReason are independent (RFC 48 §3.1 v2).
+ *
{@link #completed} means "evaluator judged this turn satisfies every
+ * exit criterion". It does not mean "graph FINISH_REASON should change" —
+ * goal status and graph FinishReason are independent.
*
*
{@link #llmCallsConsumed} is the evaluator-side delta only; the
* agent-side delta is read from graph state by the node itself.
+ *
+ *
{@link #criterionVerdicts} and {@link #bootstrapCriteria} are mutually
+ * exclusive carriers for the checklist:
+ *
+ * - verdict round (checklist already exists): {@code criterionVerdicts}
+ * holds the per-criterion delta (by id), {@code bootstrapCriteria} is null.
+ * - bootstrap round (no criteria yet): {@code bootstrapCriteria}
+ * holds the freshly decomposed full checklist, {@code criterionVerdicts}
+ * is empty.
+ *
+ * Neither is the outward-facing full list — clients always receive the merged
+ * checklist via {@code GoalResponse.criteria}.
*/
public record GoalEvaluationResult(
double score,
@@ -25,7 +35,9 @@ public record GoalEvaluationResult(
boolean completed,
String evaluatorModel,
int llmCallsConsumed,
- long latencyMs) {
+ long latencyMs,
+ List criterionVerdicts,
+ List bootstrapCriteria) {
public static final String DECISION_COMPLETED = "completed";
public static final String DECISION_CONTINUE = "continue";
@@ -37,7 +49,8 @@ public record GoalEvaluationResult(
return new GoalEvaluationResult(
0.0, "evaluator unavailable: " + reason,
DECISION_FALLBACK, false,
- "", 0, 0L);
+ "", 0, 0L,
+ List.of(), null);
}
public Map toMap() {
@@ -49,6 +62,9 @@ public record GoalEvaluationResult(
m.put("evaluatorModel", evaluatorModel == null ? "" : evaluatorModel);
m.put("llmCallsConsumed", llmCallsConsumed);
m.put("latencyMs", latencyMs);
+ // Per-round delta, for debugging/detail only. UI progress is driven by
+ // the full GoalResponse.criteria array, never reconstructed from this.
+ m.put("criterionVerdicts", criterionVerdicts == null ? List.of() : criterionVerdicts);
return m;
}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java
index 5d37f3e2..3f36cbc1 100644
--- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java
+++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java
@@ -271,7 +271,8 @@ public class GoalEvaluationService {
: GoalEvaluationResult.DECISION_CONTINUE;
return new GoalEvaluationResult(
score, gap, decision, completed,
- modelName != null ? modelName : "", 1, latencyMs);
+ modelName != null ? modelName : "", 1, latencyMs,
+ List.of(), null);
} catch (Exception e) {
log.warn("[GoalEvaluation] JSON parse failed: {} — body={}",
e.getMessage(),
diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java
index 0a264479..fdab8bc2 100644
--- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java
+++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java
@@ -14,6 +14,7 @@ import vip.mate.audit.service.AuditEventService;
import vip.mate.exception.MateClawException;
import vip.mate.goal.config.GoalProperties;
import vip.mate.goal.model.GoalCreateRequest;
+import vip.mate.goal.model.GoalCriterion;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalEvaluationResult;
import vip.mate.goal.model.GoalEventEntity;
@@ -115,6 +116,10 @@ public class GoalServiceImpl implements GoalService {
entity.setAutoFollowupEnabled(Boolean.TRUE.equals(req.getAutoFollowupEnabled()));
entity.setFollowupCooldownSeconds(req.getFollowupCooldownSeconds() != null
? req.getFollowupCooldownSeconds() : properties.getAutoFollowupCooldownSeconds());
+ // Normalize any caller-supplied checklist: assign C1..Cn, force
+ // passed=false, clear evidence. Empty/omitted -> null column so the
+ // first evaluation bootstraps the list.
+ entity.setCriteria(serializeCriteria(normalizeInitialCriteria(req.getCriteria())));
entity.setVersion(0);
entity.setDeleted(0);
LocalDateTime now = LocalDateTime.now();
@@ -435,6 +440,44 @@ public class GoalServiceImpl implements GoalService {
// ==================== Internals ====================
+ /**
+ * Normalize a caller-supplied initial checklist: keep only non-blank
+ * {@code text}, assign stable ids {@code C1..Cn} (ignore any caller ids),
+ * force {@code passed=false} and clear evidence. Returns {@code null} for
+ * an empty/null result so the column stays NULL and the first evaluation
+ * bootstraps the list.
+ */
+ private List normalizeInitialCriteria(List raw) {
+ if (raw == null || raw.isEmpty()) {
+ return null;
+ }
+ List out = new java.util.ArrayList<>(raw.size());
+ int n = 1;
+ for (GoalCriterion c : raw) {
+ if (c == null || c.text() == null || c.text().isBlank()) {
+ continue;
+ }
+ out.add(new GoalCriterion("C" + n, c.text().trim(), false, ""));
+ n++;
+ }
+ return out.isEmpty() ? null : out;
+ }
+
+ /** Serialize a checklist to JSON text, or {@code null} for a null list. */
+ private String serializeCriteria(List criteria) {
+ if (criteria == null) {
+ return null;
+ }
+ try {
+ return objectMapper.writeValueAsString(criteria);
+ } catch (JsonProcessingException e) {
+ // Should never happen for a plain record list; fail soft to NULL
+ // (bootstrap path) rather than aborting goal creation.
+ log.warn("[Goal] failed to serialize criteria, storing null: {}", e.getMessage());
+ return null;
+ }
+ }
+
private void validateCreate(GoalCreateRequest req) {
if (req == null) {
throw new MateClawException("err.goal.bad_request", 400, "Request body required");
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java
index 9b61bdcd..cebe11a3 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java
@@ -145,7 +145,8 @@ public class GoalManagementTool {
// Synthesize a completion-style evaluation result for the audit trail.
GoalEvaluationResult synthetic = new GoalEvaluationResult(
1.0, "completed by agent", GoalEvaluationResult.DECISION_COMPLETED,
- true, "manual", 0, 0L);
+ true, "manual", 0, 0L,
+ java.util.List.of(), null);
try {
GoalEntity completed = goalService.markCompleted(goal.getId(), synthetic);
// Broadcast a goal_completed event with the same shape as the
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V140__goal_criteria_checklist.sql b/mateclaw-server/src/main/resources/db/migration/h2/V140__goal_criteria_checklist.sql
new file mode 100644
index 00000000..1999e67d
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V140__goal_criteria_checklist.sql
@@ -0,0 +1,8 @@
+-- V140: Structured, checkable criteria for goals (H2).
+--
+-- Adds a nullable JSON-text column holding the goal's checklist:
+-- [{ "id": "C1", "text": "...", "passed": false, "evidence": "" }, ...]
+-- Completion is derived from "all criteria passed" rather than a fuzzy
+-- completion_score threshold. The column is additive and nullable, so
+-- existing goals load unchanged (a NULL list bootstraps on first evaluation).
+ALTER TABLE mate_agent_goal ADD COLUMN IF NOT EXISTS criteria CLOB;
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V140__goal_criteria_checklist.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V140__goal_criteria_checklist.sql
new file mode 100644
index 00000000..1ddf1da2
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V140__goal_criteria_checklist.sql
@@ -0,0 +1,16 @@
+-- V140: Structured, checkable criteria for goals (MySQL).
+--
+-- See the H2 counterpart for the full rationale. MySQL has no
+-- "ADD COLUMN IF NOT EXISTS", so the column is guarded with an
+-- INFORMATION_SCHEMA existence check + prepared statement for idempotency.
+--
+-- The column holds the goal's checklist as JSON:
+-- [{ "id": "C1", "text": "...", "passed": false, "evidence": "" }, ...]
+-- Additive and nullable, so existing goals load unchanged (a NULL list
+-- bootstraps on first evaluation).
+SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_agent_goal' AND COLUMN_NAME = 'criteria');
+SET @stmt := IF(@col_exists = 0,
+ 'ALTER TABLE mate_agent_goal ADD COLUMN criteria JSON NULL',
+ 'SELECT 1');
+PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java
index 3082e49b..530ddb66 100644
--- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java
@@ -38,7 +38,8 @@ class GoalFollowupServiceTest {
return new GoalEvaluationResult(
score, "missing X",
decision, false,
- "stub", 0, 0L);
+ "stub", 0, 0L,
+ java.util.List.of(), null);
}
@Test
diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java
index a39cfc9d..76d2b675 100644
--- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java
@@ -241,7 +241,8 @@ class GoalServiceTest {
GoalEvaluationResult r = new GoalEvaluationResult(
0.62, "DNS still missing", "continue", false,
- "qwen-turbo", 1, 800L);
+ "qwen-turbo", 1, 800L,
+ java.util.List.of(), null);
service.recordEvaluation(1L, r, 3, 1);
ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class);