@vitest-agent/mcp 3.0.4 → 4.0.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.
Files changed (54) hide show
  1. package/README.md +7 -6
  2. package/annotations.js +18 -0
  3. package/bin/vitest-agent-mcp.js +5 -140
  4. package/{middleware/idempotency.js → idempotency.js} +48 -49
  5. package/index.d.ts +5610 -1549
  6. package/index.js +35 -17
  7. package/main.d.ts +31 -0
  8. package/main.js +144 -0
  9. package/package.json +9 -6
  10. package/prompts/layer.js +108 -0
  11. package/register-toolkit.js +311 -0
  12. package/server.js +24 -894
  13. package/{context.js → session.js} +38 -22
  14. package/toolkit.js +86 -0
  15. package/tools/acceptance-metrics.js +26 -14
  16. package/tools/cache-health.js +28 -17
  17. package/tools/commit-changes.js +33 -10
  18. package/tools/configure.js +33 -10
  19. package/tools/coverage.js +34 -5
  20. package/tools/errors.js +36 -26
  21. package/tools/failure-signature-get.js +33 -10
  22. package/tools/file-coverage.js +37 -13
  23. package/tools/help.js +45 -4
  24. package/tools/history.js +39 -19
  25. package/tools/hypothesis.js +118 -102
  26. package/tools/inventory.js +149 -146
  27. package/tools/note.js +131 -113
  28. package/tools/overview.js +33 -10
  29. package/tools/ping.js +22 -10
  30. package/tools/register-agent.js +88 -62
  31. package/tools/run-tests.js +76 -23
  32. package/tools/settings-list.js +27 -10
  33. package/tools/status.js +35 -10
  34. package/tools/tdd-artifact.js +37 -22
  35. package/tools/tdd-behavior.js +120 -108
  36. package/tools/tdd-goal.js +98 -85
  37. package/tools/tdd-phase-transition-request.js +201 -166
  38. package/tools/tdd-progress-push.js +102 -0
  39. package/tools/tdd-task.js +140 -138
  40. package/tools/test.js +152 -138
  41. package/tools/trends.js +37 -18
  42. package/tools/triage-brief.js +34 -15
  43. package/tools/turn-search.js +39 -16
  44. package/tools/wrapup-prompt.js +37 -17
  45. package/utils/crash-guards.js +0 -22
  46. package/utils/replay-marker.js +12 -0
  47. package/utils/safe-format-fatal-error.js +0 -16
  48. package/utils/tool-error-envelope.js +3 -3
  49. package/version.js +13 -0
  50. package/layers/McpLive.js +0 -29
  51. package/prompts/index.js +0 -89
  52. package/router.js +0 -74
  53. package/session-env.js +0 -112
  54. package/utils/effect-to-zod.js +0 -158
@@ -1,6 +1,8 @@
1
- import { publicProcedure } from "../context.js";
1
+ import { RenderText } from "../annotations.js";
2
2
  import { Effect, Option, Schema } from "effect";
3
- import { DataReader, DataStore, requiredArtifactForTransition, transitionEnforcesBehaviorMatch, validatePhaseTransition } from "@vitest-agent/sdk";
3
+ import { DataReader, DataStore } from "@vitest-agent/engine";
4
+ import { Tool } from "effect/unstable/ai";
5
+ import { requiredArtifactForTransition, transitionEnforcesBehaviorMatch, validatePhaseTransition } from "@vitest-agent/sdk";
4
6
 
5
7
  //#region src/tools/tdd-phase-transition-request.ts
6
8
  /**
@@ -68,198 +70,231 @@ const PhaseTransitionDenied = Schema.Struct({
68
70
  denialReason: denialReasonLiteral.annotate({ description: "Categorical refusal reason; see `remediation` for the suggested fix." }),
69
71
  remediation: RemediationSchema
70
72
  }).annotate({ identifier: "PhaseTransitionDenied" });
73
+ /**
74
+ * The `tdd_phase_transition_request` tool's success payload.
75
+ *
76
+ * @public
77
+ */
71
78
  const PhaseTransitionResult = Schema.Union([PhaseTransitionAccepted, PhaseTransitionDenied]).annotate({
72
79
  identifier: "PhaseTransitionResult",
73
80
  title: "tdd_phase_transition_request result",
74
81
  description: "Discriminate on `accepted`. Acceptance carries the new phaseId; denial carries a typed reason and a remediation pointer."
75
82
  });
76
- const tddPhaseTransitionRequest = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
77
- tddTaskId: Schema.Number,
78
- goalId: Schema.Number,
79
- requestedPhase: phaseLiteral,
80
- citedArtifactId: Schema.optional(Schema.Number),
81
- citedArtifactKind: Schema.optional(artifactKindLiteral),
82
- behaviorId: Schema.optional(Schema.Number),
83
- reason: Schema.optional(Schema.String)
84
- }))).mutation(async ({ ctx, input }) => {
85
- return ctx.runtime.runPromise(Effect.gen(function* () {
86
- const reader = yield* DataReader;
87
- const store = yield* DataStore;
88
- const currentOpt = yield* reader.getCurrentTddPhase(input.tddTaskId);
89
- const currentPhase = Option.isSome(currentOpt) ? currentOpt.value.phase : "spike";
90
- const phaseStartedAt = Option.isSome(currentOpt) ? currentOpt.value.startedAt : (/* @__PURE__ */ new Date()).toISOString();
91
- const currentPhaseId = Option.isSome(currentOpt) ? currentOpt.value.id : null;
92
- const goalOpt = yield* reader.getGoalById(input.goalId);
93
- if (Option.isNone(goalOpt)) return {
94
- accepted: false,
95
- phase: currentPhase,
96
- denialReason: "goal_not_found",
97
- remediation: {
98
- suggestedTool: "tdd_goal",
99
- suggestedArgs: {
100
- action: "list",
101
- tddTaskId: input.tddTaskId
102
- },
103
- humanHint: `No tdd_session_goals row with id=${input.goalId}. Call tdd_goal({ action: "list" }) to find the correct goal id.`
104
- }
105
- };
106
- if (goalOpt.value.sessionId !== input.tddTaskId) return {
83
+ /**
84
+ * The `tdd_phase_transition_request` tool's parameters.
85
+ *
86
+ * `citedArtifactId` is optional in 2.0+. When omitted, the tool resolves
87
+ * the most recent matching artifact for this session, where "matching" is
88
+ * either the explicit `citedArtifactKind` value, or the kind required by
89
+ * the transition (per `requiredArtifactForTransition`), or "no artifact
90
+ * needed" for transitions like `spike→red` where the validator's
91
+ * `requiredArtifactForTransition` returns null. The accepted response
92
+ * carries `citedArtifactId` so the caller can see which row was used.
93
+ *
94
+ * @public
95
+ */
96
+ const PhaseTransitionInput = Schema.Struct({
97
+ tddTaskId: Schema.Finite.annotate({ description: "tdd_tasks.id" }),
98
+ goalId: Schema.Finite.annotate({ description: "tdd_session_goals.id (required; goal must be in_progress)" }),
99
+ requestedPhase: phaseLiteral.annotate({ description: "Phase to transition to" }),
100
+ citedArtifactId: Schema.optionalKey(Schema.Finite).annotate({ description: "tdd_artifacts.id supplying the evidence. Optional — auto-resolved when omitted." }),
101
+ citedArtifactKind: Schema.optionalKey(artifactKindLiteral).annotate({ description: "Kind to look up when citedArtifactId is omitted (defaults to the kind required by the transition)." }),
102
+ behaviorId: Schema.optionalKey(Schema.Finite).annotate({ description: "tdd_session_behaviors.id when transitioning a specific behavior (must belong to goalId)" }),
103
+ reason: Schema.optionalKey(Schema.String).annotate({ description: "Free-text reason for the transition" })
104
+ });
105
+ /**
106
+ * Handler for {@link tddPhaseTransitionRequestTool}.
107
+ *
108
+ * @public
109
+ */
110
+ const handlePhaseTransitionRequest = (input) => Effect.gen(function* () {
111
+ const reader = yield* DataReader;
112
+ const store = yield* DataStore;
113
+ const currentOpt = yield* reader.getCurrentTddPhase(input.tddTaskId);
114
+ const currentPhase = Option.isSome(currentOpt) ? currentOpt.value.phase : "spike";
115
+ const phaseStartedAt = Option.isSome(currentOpt) ? currentOpt.value.startedAt : (/* @__PURE__ */ new Date()).toISOString();
116
+ const currentPhaseId = Option.isSome(currentOpt) ? currentOpt.value.id : null;
117
+ const goalOpt = yield* reader.getGoalById(input.goalId);
118
+ if (Option.isNone(goalOpt)) return {
119
+ accepted: false,
120
+ phase: currentPhase,
121
+ denialReason: "goal_not_found",
122
+ remediation: {
123
+ suggestedTool: "tdd_goal",
124
+ suggestedArgs: {
125
+ action: "list",
126
+ tddTaskId: input.tddTaskId
127
+ },
128
+ humanHint: `No tdd_session_goals row with id=${input.goalId}. Call tdd_goal({ action: "list" }) to find the correct goal id.`
129
+ }
130
+ };
131
+ if (goalOpt.value.sessionId !== input.tddTaskId) return {
132
+ accepted: false,
133
+ phase: currentPhase,
134
+ denialReason: "goal_not_in_tdd_task",
135
+ remediation: {
136
+ suggestedTool: "tdd_goal",
137
+ suggestedArgs: {
138
+ action: "list",
139
+ tddTaskId: input.tddTaskId
140
+ },
141
+ humanHint: `Goal id=${input.goalId} belongs to TDD task ${goalOpt.value.sessionId}, not the requested tddTaskId=${input.tddTaskId}. Pass the tddTaskId of the goal's parent task, or pick a goal that belongs to the active task.`
142
+ }
143
+ };
144
+ if (goalOpt.value.status !== "in_progress") return {
145
+ accepted: false,
146
+ phase: currentPhase,
147
+ denialReason: "goal_not_in_progress",
148
+ remediation: {
149
+ suggestedTool: "tdd_goal_update",
150
+ suggestedArgs: {
151
+ id: input.goalId,
152
+ status: "in_progress"
153
+ },
154
+ humanHint: `Goal id=${input.goalId} has status '${goalOpt.value.status}'. Phase transitions require the goal to be in_progress. Call tdd_goal_update({status:'in_progress'}) before requesting transitions.`
155
+ }
156
+ };
157
+ if (input.behaviorId !== void 0) {
158
+ const behaviorOpt = yield* reader.getBehaviorById(input.behaviorId);
159
+ if (Option.isNone(behaviorOpt)) return {
107
160
  accepted: false,
108
161
  phase: currentPhase,
109
- denialReason: "goal_not_in_tdd_task",
162
+ denialReason: "behavior_not_found",
110
163
  remediation: {
111
- suggestedTool: "tdd_goal",
164
+ suggestedTool: "tdd_behavior_list",
112
165
  suggestedArgs: {
113
- action: "list",
114
- tddTaskId: input.tddTaskId
166
+ scope: "goal",
167
+ goalId: input.goalId
115
168
  },
116
- humanHint: `Goal id=${input.goalId} belongs to TDD task ${goalOpt.value.sessionId}, not the requested tddTaskId=${input.tddTaskId}. Pass the tddTaskId of the goal's parent task, or pick a goal that belongs to the active task.`
169
+ humanHint: `No tdd_session_behaviors row with id=${input.behaviorId}. Call tdd_behavior_list to find the correct behavior id.`
117
170
  }
118
171
  };
119
- if (goalOpt.value.status !== "in_progress") return {
172
+ if (behaviorOpt.value.goalId !== input.goalId) return {
120
173
  accepted: false,
121
174
  phase: currentPhase,
122
- denialReason: "goal_not_in_progress",
175
+ denialReason: "behavior_not_in_goal",
123
176
  remediation: {
124
- suggestedTool: "tdd_goal_update",
125
- suggestedArgs: {
126
- id: input.goalId,
127
- status: "in_progress"
128
- },
129
- humanHint: `Goal id=${input.goalId} has status '${goalOpt.value.status}'. Phase transitions require the goal to be in_progress. Call tdd_goal_update({status:'in_progress'}) before requesting transitions.`
177
+ suggestedTool: "tdd_behavior_get",
178
+ suggestedArgs: { id: input.behaviorId },
179
+ humanHint: `Behavior id=${input.behaviorId} belongs to goal ${behaviorOpt.value.goalId}, not the requested goalId=${input.goalId}. Pass the goalId of the behavior's parent goal.`
130
180
  }
131
181
  };
132
- if (input.behaviorId !== void 0) {
133
- const behaviorOpt = yield* reader.getBehaviorById(input.behaviorId);
134
- if (Option.isNone(behaviorOpt)) return {
135
- accepted: false,
136
- phase: currentPhase,
137
- denialReason: "behavior_not_found",
138
- remediation: {
139
- suggestedTool: "tdd_behavior_list",
140
- suggestedArgs: {
141
- scope: "goal",
142
- goalId: input.goalId
143
- },
144
- humanHint: `No tdd_session_behaviors row with id=${input.behaviorId}. Call tdd_behavior_list to find the correct behavior id.`
145
- }
146
- };
147
- if (behaviorOpt.value.goalId !== input.goalId) return {
148
- accepted: false,
149
- phase: currentPhase,
150
- denialReason: "behavior_not_in_goal",
151
- remediation: {
152
- suggestedTool: "tdd_behavior_get",
153
- suggestedArgs: { id: input.behaviorId },
154
- humanHint: `Behavior id=${input.behaviorId} belongs to goal ${behaviorOpt.value.goalId}, not the requested goalId=${input.goalId}. Pass the goalId of the behavior's parent goal.`
155
- }
156
- };
157
- }
158
- const requiredKindForTransition = requiredArtifactForTransition(currentPhase, input.requestedPhase);
159
- let resolvedArtifactId;
160
- let resolvedKindSource = "none";
161
- let kindToLookUp;
162
- if (input.citedArtifactId !== void 0) {
163
- resolvedArtifactId = input.citedArtifactId;
164
- resolvedKindSource = "explicit-id";
165
- } else if (input.citedArtifactKind !== void 0) {
166
- kindToLookUp = input.citedArtifactKind;
167
- resolvedKindSource = "explicit-kind";
168
- } else if (requiredKindForTransition !== null) {
169
- kindToLookUp = requiredKindForTransition.kind;
170
- resolvedKindSource = "transition-derived";
171
- }
172
- const scopeLookupByBehavior = input.behaviorId !== void 0 && transitionEnforcesBehaviorMatch(currentPhase, input.requestedPhase);
173
- if (resolvedArtifactId === void 0 && kindToLookUp !== void 0) {
174
- const recent = yield* reader.listTddArtifactsForTask({
182
+ }
183
+ const requiredKindForTransition = requiredArtifactForTransition(currentPhase, input.requestedPhase);
184
+ let resolvedArtifactId;
185
+ let resolvedKindSource = "none";
186
+ let kindToLookUp;
187
+ if (input.citedArtifactId !== void 0) {
188
+ resolvedArtifactId = input.citedArtifactId;
189
+ resolvedKindSource = "explicit-id";
190
+ } else if (input.citedArtifactKind !== void 0) {
191
+ kindToLookUp = input.citedArtifactKind;
192
+ resolvedKindSource = "explicit-kind";
193
+ } else if (requiredKindForTransition !== null) {
194
+ kindToLookUp = requiredKindForTransition.kind;
195
+ resolvedKindSource = "transition-derived";
196
+ }
197
+ const scopeLookupByBehavior = input.behaviorId !== void 0 && transitionEnforcesBehaviorMatch(currentPhase, input.requestedPhase);
198
+ if (resolvedArtifactId === void 0 && kindToLookUp !== void 0) {
199
+ const recent = yield* reader.listTddArtifactsForTask({
200
+ tddTaskId: input.tddTaskId,
201
+ artifactKind: kindToLookUp,
202
+ ...scopeLookupByBehavior && { behaviorId: input.behaviorId },
203
+ limit: 1
204
+ });
205
+ if (recent.length === 0) {
206
+ const sinceIso = (/* @__PURE__ */ new Date(Date.now() - DIAGNOSTIC_WINDOW_MINUTES * 6e4)).toISOString();
207
+ const crossSessionCount = yield* reader.countRecentArtifactsInOtherSessionsOfConversation({
175
208
  tddTaskId: input.tddTaskId,
176
- artifactKind: kindToLookUp,
177
- ...scopeLookupByBehavior && { behaviorId: input.behaviorId },
178
- limit: 1
209
+ sinceIso
179
210
  });
180
- if (recent.length === 0) {
181
- const sinceIso = (/* @__PURE__ */ new Date(Date.now() - DIAGNOSTIC_WINDOW_MINUTES * 6e4)).toISOString();
182
- const crossSessionCount = yield* reader.countRecentArtifactsInOtherSessionsOfConversation({
183
- tddTaskId: input.tddTaskId,
184
- sinceIso
185
- });
186
- const baseHint = `No '${kindToLookUp}' artifact has been recorded for tdd_task ${input.tddTaskId}. Artifacts are recorded by hooks observing your tool calls (Decision D7) — run the test (e.g. via run_tests) or make the file edit first; the post-tool-use hook will write the matching tdd_artifacts row and the next call to this tool will pick it up automatically.`;
187
- return {
188
- accepted: false,
189
- phase: currentPhase,
190
- denialReason: "missing_artifact_evidence",
191
- remediation: {
192
- suggestedTool: "run_tests",
193
- suggestedArgs: {},
194
- humanHint: crossSessionCount > 0 ? `${baseHint} ${crossSessionCount} artifact${crossSessionCount === 1 ? " was" : "s were"} recorded under a different session of this conversation in the last ${DIAGNOSTIC_WINDOW_MINUTES} minutes — the subagent's hooks may be attributing to a detached session; set VITEST_AGENT_TDD_TASK_ID=${input.tddTaskId} in the subagent's environment or dispatch it as an unnamed background subagent.` : baseHint
195
- }
196
- };
197
- }
198
- resolvedArtifactId = recent[0].id;
199
- }
200
- let citedArtifact;
201
- if (resolvedArtifactId !== void 0) {
202
- const artifactOpt = yield* reader.getTddArtifactWithContext(resolvedArtifactId);
203
- if (Option.isNone(artifactOpt)) return {
211
+ const baseHint = `No '${kindToLookUp}' artifact has been recorded for tdd_task ${input.tddTaskId}. Artifacts are recorded by hooks observing your tool calls (Decision D7) — run the test (e.g. via run_tests) or make the file edit first; the post-tool-use hook will write the matching tdd_artifacts row and the next call to this tool will pick it up automatically.`;
212
+ return {
204
213
  accepted: false,
205
214
  phase: currentPhase,
206
215
  denialReason: "missing_artifact_evidence",
207
216
  remediation: {
208
217
  suggestedTool: "run_tests",
209
218
  suggestedArgs: {},
210
- humanHint: `Cited artifact id ${resolvedArtifactId} does not exist. Artifacts are recorded by hooks observing your tool calls (Decision D7), so run the test (e.g. via the run_tests MCP tool) or make the file edit first; the post-tool-use hook will write the matching tdd_artifacts row and return its id, which can then be cited here.`
219
+ humanHint: crossSessionCount > 0 ? `${baseHint} ${crossSessionCount} artifact${crossSessionCount === 1 ? " was" : "s were"} recorded under a different session of this conversation in the last ${DIAGNOSTIC_WINDOW_MINUTES} minutes the subagent's hooks may be attributing to a detached session; set VITEST_AGENT_TDD_TASK_ID=${input.tddTaskId} in the subagent's environment or dispatch it as an unnamed background subagent.` : baseHint
211
220
  }
212
221
  };
213
- citedArtifact = artifactOpt.value;
214
- } else citedArtifact = {
215
- id: -1,
216
- phase_id: -1,
217
- artifact_kind: "test_written",
218
- test_case_id: null,
219
- test_case_created_turn_at: null,
220
- test_case_authored_in_session: false,
221
- test_run_id: null,
222
- test_first_failure_run_id: null,
223
- behavior_id: null,
224
- suite: "vitest"
225
- };
226
- const result = validatePhaseTransition({
227
- tdd_task_id: input.tddTaskId,
228
- current_phase: currentPhase,
229
- current_phase_id: currentPhaseId,
230
- phase_started_at: phaseStartedAt,
231
- now: (/* @__PURE__ */ new Date()).toISOString(),
232
- requested_phase: input.requestedPhase,
233
- cited_artifact: citedArtifact,
234
- requested_behavior_id: input.behaviorId ?? null
235
- });
236
- if (!result.accepted) return result;
237
- const out = yield* store.writeTddPhase({
238
- tddTaskId: input.tddTaskId,
239
- phase: result.phase,
240
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
241
- ...input.behaviorId !== void 0 && { behaviorId: input.behaviorId },
242
- ...input.reason !== void 0 && { transitionReason: input.reason }
243
- });
244
- if (input.behaviorId !== void 0) yield* Effect.gen(function* () {
245
- const behOpt = yield* reader.getBehaviorById(input.behaviorId);
246
- if (Option.isSome(behOpt) && behOpt.value.status === "pending") yield* store.updateBehavior({
247
- id: input.behaviorId,
248
- status: "in_progress"
249
- });
250
- }).pipe(Effect.catchCause((cause) => Effect.logDebug("behavior auto-promotion failed", cause)));
251
- return {
252
- accepted: true,
253
- phase: result.phase,
254
- newPhaseId: out.id,
255
- previousPhaseId: out.previousPhaseId,
256
- ...resolvedArtifactId !== void 0 && {
257
- citedArtifactId: resolvedArtifactId,
258
- citedArtifactSource: resolvedKindSource
222
+ }
223
+ resolvedArtifactId = recent[0].id;
224
+ }
225
+ let citedArtifact;
226
+ if (resolvedArtifactId !== void 0) {
227
+ const artifactOpt = yield* reader.getTddArtifactWithContext(resolvedArtifactId);
228
+ if (Option.isNone(artifactOpt)) return {
229
+ accepted: false,
230
+ phase: currentPhase,
231
+ denialReason: "missing_artifact_evidence",
232
+ remediation: {
233
+ suggestedTool: "run_tests",
234
+ suggestedArgs: {},
235
+ humanHint: `Cited artifact id ${resolvedArtifactId} does not exist. Artifacts are recorded by hooks observing your tool calls (Decision D7), so run the test (e.g. via the run_tests MCP tool) or make the file edit first; the post-tool-use hook will write the matching tdd_artifacts row and return its id, which can then be cited here.`
259
236
  }
260
237
  };
261
- }));
262
- });
238
+ citedArtifact = artifactOpt.value;
239
+ } else citedArtifact = {
240
+ id: -1,
241
+ phase_id: -1,
242
+ artifact_kind: "test_written",
243
+ test_case_id: null,
244
+ test_case_created_turn_at: null,
245
+ test_case_authored_in_session: false,
246
+ test_run_id: null,
247
+ test_first_failure_run_id: null,
248
+ behavior_id: null,
249
+ suite: "vitest"
250
+ };
251
+ const result = validatePhaseTransition({
252
+ tdd_task_id: input.tddTaskId,
253
+ current_phase: currentPhase,
254
+ current_phase_id: currentPhaseId,
255
+ phase_started_at: phaseStartedAt,
256
+ now: (/* @__PURE__ */ new Date()).toISOString(),
257
+ requested_phase: input.requestedPhase,
258
+ cited_artifact: citedArtifact,
259
+ requested_behavior_id: input.behaviorId ?? null
260
+ });
261
+ if (!result.accepted) return result;
262
+ const out = yield* store.writeTddPhase({
263
+ tddTaskId: input.tddTaskId,
264
+ phase: result.phase,
265
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
266
+ ...input.behaviorId !== void 0 && { behaviorId: input.behaviorId },
267
+ ...input.reason !== void 0 && { transitionReason: input.reason }
268
+ });
269
+ if (input.behaviorId !== void 0) yield* Effect.gen(function* () {
270
+ const behOpt = yield* reader.getBehaviorById(input.behaviorId);
271
+ if (Option.isSome(behOpt) && behOpt.value.status === "pending") yield* store.updateBehavior({
272
+ id: input.behaviorId,
273
+ status: "in_progress"
274
+ });
275
+ }).pipe(Effect.catchCause((cause) => Effect.logDebug("behavior auto-promotion failed", cause)));
276
+ return {
277
+ accepted: true,
278
+ phase: result.phase,
279
+ newPhaseId: out.id,
280
+ previousPhaseId: out.previousPhaseId,
281
+ ...resolvedArtifactId !== void 0 && {
282
+ citedArtifactId: resolvedArtifactId,
283
+ citedArtifactSource: resolvedKindSource
284
+ }
285
+ };
286
+ }).pipe(Effect.orDie);
287
+ /**
288
+ * The Effect-native `tdd_phase_transition_request` tool.
289
+ *
290
+ * @public
291
+ */
292
+ const tddPhaseTransitionRequestTool = Tool.make("tdd_phase_transition_request", {
293
+ description: "Use when advancing a TDD cycle and you need a phase transition validated and recorded. Validates goal status, behavior↔goal membership, and D2 artifact-evidence binding rules; returns accept/deny. On accept, auto-promotes a behavior 'pending' → 'in_progress' when behaviorId is supplied. citedArtifactId is OPTIONAL — when omitted, the most recent matching artifact is auto-resolved (kind comes from citedArtifactKind if supplied, otherwise from the transition's required-evidence rule). Transitions like spike→red that require no artifact need neither field. The accepted response echoes citedArtifactId + citedArtifactSource so the caller can see which row was picked.",
294
+ parameters: PhaseTransitionInput,
295
+ success: PhaseTransitionResult,
296
+ dependencies: [DataReader, DataStore]
297
+ }).annotate(Tool.Title, "TDD phase transition request").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true).annotate(RenderText, (encoded) => JSON.stringify(encoded, null, 2));
263
298
 
264
299
  //#endregion
265
- export { PhaseTransitionResult, tddPhaseTransitionRequest };
300
+ export { PhaseTransitionInput, PhaseTransitionResult, handlePhaseTransitionRequest, tddPhaseTransitionRequestTool };
@@ -0,0 +1,102 @@
1
+ import { RenderText } from "../annotations.js";
2
+ import { Effect, Option, Schema } from "effect";
3
+ import { DataReader } from "@vitest-agent/engine";
4
+ import { McpServer, Tool } from "effect/unstable/ai";
5
+ import { ChannelEvent } from "@vitest-agent/sdk";
6
+
7
+ //#region src/tools/tdd-progress-push.ts
8
+ /**
9
+ * The `tdd_progress_push` tool's parameters.
10
+ *
11
+ * @public
12
+ */
13
+ const TddProgressPushInput = Schema.Struct({ payload: Schema.String.annotate({ description: "Pre-stringified ChannelEvent JSON (see schemas/ChannelEvent in @vitest-agent/sdk)" }) });
14
+ /**
15
+ * The `tdd_progress_push` result — always `{ ok: true }`.
16
+ *
17
+ * @public
18
+ */
19
+ const TddProgressPushResult = Schema.Struct({ ok: Schema.Literal(true) }).annotate({
20
+ identifier: "TddProgressPushResult",
21
+ title: "tdd_progress_push result",
22
+ description: "Always ok:true — the push is best-effort and never reports delivery."
23
+ });
24
+ /** The logger name every channel notification is published under. */
25
+ const CHANNEL_LOGGER = "vitest-agent/channel";
26
+ /**
27
+ * For behavior-scoped events, resolve goalId/sessionId server-side from
28
+ * behaviorId so a stale orchestrator context cannot push the wrong tree
29
+ * coordinates. Goal-scoped events get sessionId resolved from goalId.
30
+ * Returns the enriched event object or the original on resolution failure.
31
+ *
32
+ * @internal
33
+ */
34
+ const resolveChannelEvent = (raw) => Effect.gen(function* () {
35
+ const decoded = yield* Schema.decodeUnknownEffect(ChannelEvent)(raw).pipe(Effect.option);
36
+ if (Option.isNone(decoded)) return raw;
37
+ const event = decoded.value;
38
+ const reader = yield* DataReader;
39
+ return yield* Effect.gen(function* () {
40
+ switch (event.type) {
41
+ case "behavior_started":
42
+ case "phase_transition":
43
+ case "behavior_completed":
44
+ case "behavior_abandoned":
45
+ case "blocked": {
46
+ const goalIdOpt = yield* reader.resolveGoalIdForBehavior(event.behaviorId);
47
+ if (Option.isNone(goalIdOpt)) return event;
48
+ const goalDetailOpt = yield* reader.getGoalById(goalIdOpt.value);
49
+ if (Option.isNone(goalDetailOpt)) return event;
50
+ return {
51
+ ...event,
52
+ goalId: goalIdOpt.value,
53
+ sessionId: goalDetailOpt.value.sessionId
54
+ };
55
+ }
56
+ case "goal_started":
57
+ case "goal_completed":
58
+ case "goal_abandoned": {
59
+ const goalDetailOpt = yield* reader.getGoalById(event.goalId);
60
+ if (Option.isNone(goalDetailOpt)) return event;
61
+ return {
62
+ ...event,
63
+ sessionId: goalDetailOpt.value.sessionId
64
+ };
65
+ }
66
+ default: return event;
67
+ }
68
+ }).pipe(Effect.orElseSucceed(() => event));
69
+ });
70
+ /**
71
+ * Handler for {@link tddProgressPushTool}. Malformed JSON is forwarded as
72
+ * the raw string; a DB read failure forwards the decoded event unenriched;
73
+ * a notification failure (no initialized client, transport gone) is
74
+ * ignored. The result is `{ ok: true }` on every path.
75
+ *
76
+ * @public
77
+ */
78
+ const handleTddProgressPush = (input) => Effect.gen(function* () {
79
+ const server = yield* McpServer.McpServer;
80
+ const parsed = yield* Effect.try(() => JSON.parse(input.payload)).pipe(Effect.option);
81
+ const data = Option.isSome(parsed) ? yield* resolveChannelEvent(parsed.value) : input.payload;
82
+ yield* server.notifications["notifications/message"]({
83
+ level: "info",
84
+ logger: CHANNEL_LOGGER,
85
+ data
86
+ }).pipe(Effect.ignore);
87
+ return { ok: true };
88
+ });
89
+ /**
90
+ * The Effect-native `tdd_progress_push` tool.
91
+ *
92
+ * @public
93
+ */
94
+ const tddProgressPushTool = Tool.make("tdd_progress_push", {
95
+ description: "Use when a TDD orchestrator needs to report progress to the main agent over a Claude Code channel. The MCP server validates the payload against the ChannelEvent union and resolves goalId/sessionId server-side from behaviorId for behavior-scoped events (so a stale orchestrator context cannot push the wrong tree coordinates). Best-effort — returns { ok: true } regardless of whether channels are active.",
96
+ parameters: TddProgressPushInput,
97
+ success: TddProgressPushResult,
98
+ dependencies: [DataReader, McpServer.McpServer]
99
+ }).annotate(Tool.Title, "TDD progress push").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false).annotate(RenderText, (encoded) => JSON.stringify(encoded, null, 2));
100
+
101
+ //#endregion
102
+ export { CHANNEL_LOGGER, TddProgressPushInput, TddProgressPushResult, handleTddProgressPush, resolveChannelEvent, tddProgressPushTool };