@alisio/plugin-wayfinder 0.1.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 (62) hide show
  1. package/.agents/agents/archivist.md +18 -0
  2. package/.agents/agents/coordinator.md +16 -0
  3. package/.agents/agents/designer.md +18 -0
  4. package/.agents/agents/discoverer.md +18 -0
  5. package/.agents/agents/implementer.md +18 -0
  6. package/.agents/agents/mutationist.md +18 -0
  7. package/.agents/agents/planner.md +18 -0
  8. package/.agents/agents/proposer.md +18 -0
  9. package/.agents/agents/specifier.md +18 -0
  10. package/.agents/agents/verifier.md +18 -0
  11. package/.agents/skills/wayfinder-archive/SKILL.md +43 -0
  12. package/.agents/skills/wayfinder-coordinate/SKILL.md +44 -0
  13. package/.agents/skills/wayfinder-design/SKILL.md +42 -0
  14. package/.agents/skills/wayfinder-discover/SKILL.md +42 -0
  15. package/.agents/skills/wayfinder-implement/SKILL.md +53 -0
  16. package/.agents/skills/wayfinder-mutate/SKILL.md +44 -0
  17. package/.agents/skills/wayfinder-plan/SKILL.md +48 -0
  18. package/.agents/skills/wayfinder-propose/SKILL.md +42 -0
  19. package/.agents/skills/wayfinder-specify/SKILL.md +43 -0
  20. package/.agents/skills/wayfinder-test-design/SKILL.md +48 -0
  21. package/.agents/skills/wayfinder-verify/SKILL.md +48 -0
  22. package/LICENSE +21 -0
  23. package/README.md +396 -0
  24. package/assets/agent-communication.svg +1 -0
  25. package/assets/methodology-flow.svg +1 -0
  26. package/dist/coordinator.d.ts +46 -0
  27. package/dist/coordinator.d.ts.map +1 -0
  28. package/dist/coordinator.js +978 -0
  29. package/dist/coordinator.js.map +1 -0
  30. package/dist/index.d.ts +18 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +36 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/mutation.d.ts +67 -0
  35. package/dist/mutation.d.ts.map +1 -0
  36. package/dist/mutation.js +398 -0
  37. package/dist/mutation.js.map +1 -0
  38. package/dist/resources.d.ts +14 -0
  39. package/dist/resources.d.ts.map +1 -0
  40. package/dist/resources.js +142 -0
  41. package/dist/resources.js.map +1 -0
  42. package/dist/storage.d.ts +15 -0
  43. package/dist/storage.d.ts.map +1 -0
  44. package/dist/storage.js +282 -0
  45. package/dist/storage.js.map +1 -0
  46. package/dist/testing-rules.d.ts +3 -0
  47. package/dist/testing-rules.d.ts.map +1 -0
  48. package/dist/testing-rules.js +33 -0
  49. package/dist/testing-rules.js.map +1 -0
  50. package/dist/types.d.ts +214 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +25 -0
  53. package/dist/types.js.map +1 -0
  54. package/dist/validation.d.ts +17 -0
  55. package/dist/validation.d.ts.map +1 -0
  56. package/dist/validation.js +357 -0
  57. package/dist/validation.js.map +1 -0
  58. package/dist/version.d.ts +2 -0
  59. package/dist/version.d.ts.map +1 -0
  60. package/dist/version.js +3 -0
  61. package/dist/version.js.map +1 -0
  62. package/package.json +50 -0
@@ -0,0 +1,978 @@
1
+ import { relative } from "node:path";
2
+ import { assertScopePath, boundedSurvivors, buildMutationPlan, inspectMutationEnvironment, isTestPath, mutationBounds, mutationRemediationLimit, nonEquivalentSurvivors, } from "./mutation.js";
3
+ import { loadRoleInstructions } from "./resources.js";
4
+ import { archiveChange, listChanges, readArtifact, readState, validateChangeName, writeArtifact, writeState, } from "./storage.js";
5
+ import { isTestId, isUiPath } from "./testing-rules.js";
6
+ import { parseChildJson, validateArchive, validateDesign, validateDiscovery, validateImplementation, validateMutation, validatePlan, validateProposal, validateSpecification, validateTargetedVerification, validateVerification, } from "./validation.js";
7
+ const readTools = ["read_file", "list_files", "search_text", "git_status", "git_diff"];
8
+ const optionalMemoryTools = ["memory_search", "memory_get"];
9
+ const delegationTools = ["task", "delegate", "subagent", "sessions_create"];
10
+ const readable = [...readTools, ...optionalMemoryTools];
11
+ const readProfile = (agent, maxTurns, process = false) => ({
12
+ agent,
13
+ readOnly: true,
14
+ permission: { write: "deny", process: process ? "allow" : "deny" },
15
+ tools: { allow: process ? [...readable, "run_process"] : readable, deny: delegationTools },
16
+ maxTurns,
17
+ timeoutMs: process ? 300_000 : 120_000,
18
+ maxOutputTokens: 5_000,
19
+ });
20
+ export const phaseProfiles = {
21
+ discoverer: readProfile("Wayfinder Discoverer", 8),
22
+ proposer: readProfile("Wayfinder Proposer", 8),
23
+ specifier: readProfile("Wayfinder Specifier", 8),
24
+ designer: readProfile("Wayfinder Designer", 10),
25
+ planner: readProfile("Wayfinder Planner", 8),
26
+ implementer: {
27
+ agent: "Wayfinder Implementer",
28
+ readOnly: false,
29
+ permission: { write: "allow", process: "allow" },
30
+ tools: {
31
+ allow: [...readable, "write_file", "edit_file", "run_process"],
32
+ deny: delegationTools,
33
+ },
34
+ maxTurns: 18,
35
+ timeoutMs: 300_000,
36
+ maxOutputTokens: 5_000,
37
+ },
38
+ verifier: readProfile("Wayfinder Verifier", 14, true),
39
+ mutationist: {
40
+ agent: "Wayfinder Mutationist",
41
+ readOnly: true,
42
+ permission: { write: "deny", process: "allow" },
43
+ tools: { allow: [...readable, "run_process"], deny: delegationTools },
44
+ maxTurns: 12,
45
+ timeoutMs: mutationBounds.timeoutMs,
46
+ maxOutputTokens: 5_000,
47
+ },
48
+ archivist: readProfile("Wayfinder Archivist", 8),
49
+ };
50
+ export const phaseRoles = {
51
+ discovery: "discoverer",
52
+ proposal: "proposer",
53
+ specification: "specifier",
54
+ design: "designer",
55
+ plan: "planner",
56
+ implementation: "implementer",
57
+ verification: "verifier",
58
+ archive: "archivist",
59
+ };
60
+ const schemas = {
61
+ discovery: '{ "schemaVersion": 1, "summary": "...", "findings": ["..."], "constraints": ["..."], "criticalQuestions": [] }',
62
+ proposal: '{ "schemaVersion": 1, "outcome": "...", "inScope": ["..."], "outOfScope": [], "assumptions": [], "criticalQuestions": [] }',
63
+ specification: '{ "schemaVersion": 1, "requirements": [{ "id": "REQ-001", "statement": "...", "acceptance": ["..."] }], "criticalQuestions": [] }',
64
+ design: '{ "schemaVersion": 1, "summary": "...", "decisions": [{ "topic": "...", "choice": "...", "rationale": "..." }], "paths": ["relative/path"], "risks": [] }',
65
+ plan: '{ "schemaVersion": 1, "units": [{ "id": "UNIT-001", "title": "...", "goal": "...", "requirements": ["REQ-001"], "paths": ["relative/path"], "checks": ["command or observable check"], "tddExempt": false, "tddExemptReason": "required when tddExempt is true", "requiresTests": false }] }',
66
+ implementation: '{ "schemaVersion": 1, "unitId": "UNIT-001", "summary": "...", "changedPaths": ["relative/path"], "checks": [{ "command": "...", "status": "passed", "summary": "..." }], "testFirst": { "failingCommand": "...", "failingEvidenceRef": "...", "passingCommand": "..." }, "testDesign": [{ "scenario": "...", "happy": ["..."], "unhappy": ["..."] }], "testability": { "testIds": ["feature-element-variant"], "accessibleOnlyReason": "optional when no ids are needed" }, "notes": [] }',
67
+ verification: '{ "schemaVersion": 1, "passed": true, "summary": "...", "requirements": [{ "id": "REQ-001", "status": "passed", "evidence": ["..."] }], "checks": [{ "command": "...", "status": "passed", "summary": "..." }], "blockers": [] }',
68
+ mutation: '{ "schemaVersion": 1, "tool": "stryker", "stack": "javascript", "survivors": [{ "file": "src/a.ts", "line": 12, "description": "...", "equivalent": false, "justification": "" }], "mutationScore": 92.5, "summary": "..." }',
69
+ archive: '{ "schemaVersion": 1, "ready": true, "artifacts": ["intent.md"], "completedUnits": ["UNIT-001"], "requirements": ["REQ-001"], "blockers": [], "summary": "..." }',
70
+ };
71
+ const now = () => new Date().toISOString();
72
+ const bullets = (items) => items.length ? items.map((item) => `- ${item}`).join("\n") : "- None";
73
+ const section = (title, items) => `## ${title}\n\n${bullets(items)}\n`;
74
+ const criticalBlock = (name, questions) => `Blocked by critical questions:\n${bullets(questions)}\nAdd clarification with: /wayfinder:answer ${name} -- <answer>`;
75
+ function renderDiscovery(output) {
76
+ return `# Discovery\n\n${output.summary}\n\n${section("Findings", output.findings)}\n${section("Constraints", output.constraints)}\n${section("Critical questions", output.criticalQuestions)}`;
77
+ }
78
+ function renderProposal(output) {
79
+ return `# Proposal\n\n${output.outcome}\n\n${section("In scope", output.inScope)}\n${section("Out of scope", output.outOfScope)}\n${section("Assumptions", output.assumptions)}\n${section("Critical questions", output.criticalQuestions)}`;
80
+ }
81
+ function renderSpecification(output) {
82
+ const requirements = output.requirements
83
+ .map((item) => `## ${item.id}: ${item.statement}\n\n${bullets(item.acceptance)}`)
84
+ .join("\n\n");
85
+ return `# Specification\n\n${requirements}\n\n${section("Critical questions", output.criticalQuestions)}`;
86
+ }
87
+ function renderDesign(output) {
88
+ const decisions = output.decisions
89
+ .map((item) => `## ${item.topic}\n\n**Choice:** ${item.choice}\n\n**Rationale:** ${item.rationale}`)
90
+ .join("\n\n");
91
+ return `# Design\n\n${output.summary}\n\n${decisions}\n\n${section("Expected paths", output.paths)}\n${section("Risks", output.risks)}`;
92
+ }
93
+ function renderPlan(units) {
94
+ return `# Plan\n\n${units
95
+ .map((unit) => `## ${unit.id}: ${unit.title}\n\n${unit.goal}\n\n${unit.kind === "test-strengthening" ? `- Kind: ${unit.kind}\n` : ""}${unit.tddExempt === true ? `- TDD exempt: ${unit.tddExemptReason}\n` : ""}${unit.requiresTests === true ? "- Requires tests: yes\n" : ""}- Requirements: ${unit.requirements.join(", ")}\n- Paths: ${unit.paths.join(", ")}\n- Checks: ${unit.checks.join("; ")}\n- Status: ${unit.status}`)
96
+ .join("\n\n")}`;
97
+ }
98
+ const nextUnitId = (state) => `UNIT-${String(state.units.length + 1).padStart(3, "0")}`;
99
+ function renderImplementation(output) {
100
+ const testFirst = output.testFirst
101
+ ? `- Test-first: ${output.testFirst.failingCommand} (failing) → ${output.testFirst.passingCommand} (passing)\n`
102
+ : "";
103
+ return `## ${output.unitId}\n\n${output.summary}\n\n- Changed paths: ${output.changedPaths.join(", ")}\n${testFirst}- Checks: ${output.checks.map((item) => `${item.command} — ${item.summary}`).join("; ")}\n- Notes: ${output.notes.join("; ") || "None"}\n`;
104
+ }
105
+ function renderVerification(output) {
106
+ return `# Verification\n\n**Result:** ${output.passed ? "passed" : "failed"}\n\n${output.summary}\n\n${output.requirements
107
+ .map((item) => `## ${item.id}: ${item.status}\n\n${bullets(item.evidence)}`)
108
+ .join("\n\n")}\n\n${section("Checks", output.checks.map((item) => `${item.command} — ${item.summary}`))}\n${section("Blockers", output.blockers)}`;
109
+ }
110
+ function renderArchive(output) {
111
+ return `# Archive readiness\n\n${output.summary}\n\n${section("Artifacts", output.artifacts)}\n${section("Completed units", output.completedUnits)}\n${section("Requirements", output.requirements)}\n${section("Blockers", output.blockers)}`;
112
+ }
113
+ function renderMutation(run) {
114
+ const state = run.unavailableReason
115
+ ? `unavailable — ${run.unavailableReason}`
116
+ : `${run.failingSurvivors} failing, ${run.equivalentSurvivors} equivalent${run.truncated ? " (survivor list truncated)" : ""}`;
117
+ const survivors = run.survivors.map((survivor) => `${survivor.file}${survivor.line ? `:${survivor.line}` : ""} — ${survivor.description}${survivor.equivalent ? ` (equivalent: ${survivor.justification})` : ""}`);
118
+ return `# Mutation testing\n\n**Tool:** ${run.tool}\n**Stack:** ${run.stack}\n**Mode:** ${run.mode}\n**Scope support:** ${run.scopeSupport ?? "unknown"}\n**Concurrency bound:** ${run.concurrencyApplied ? mutationBounds.concurrency : "not applied"}\n**Scope:** ${run.scope.length ? run.scope.join(", ") : "whole repository"}\n**Command:** ${run.command || "(not run)"}\n**Result:** ${state}${run.mutationScore !== undefined ? `\n**Mutation score:** ${run.mutationScore}` : ""}\n\n${run.summary}\n\n${section("Survivors", survivors)}`;
119
+ }
120
+ function parseDelimited(args, usage) {
121
+ const delimiter = args.indexOf("--");
122
+ if (delimiter < 0)
123
+ throw new Error(usage);
124
+ const name = validateChangeName(args.slice(0, delimiter).trim());
125
+ const value = args.slice(delimiter + 2).trim();
126
+ if (!value)
127
+ throw new Error(usage);
128
+ return { name, value };
129
+ }
130
+ async function optionalArtifact(workspace, name, file) {
131
+ try {
132
+ return await readArtifact(workspace, name, file);
133
+ }
134
+ catch (error) {
135
+ if (error.code === "ENOENT")
136
+ return "";
137
+ throw error;
138
+ }
139
+ }
140
+ export class WayfinderCoordinator {
141
+ api;
142
+ constructor(api) {
143
+ this.api = api;
144
+ }
145
+ workspace(sessionId) {
146
+ if (!sessionId)
147
+ throw new Error("Wayfinder commands require an active Alisio session");
148
+ return { parentId: sessionId, workspace: this.api.sessions.workspace(sessionId) };
149
+ }
150
+ async delegate(role, parentId, workspace, title, prompt) {
151
+ const instructions = await loadRoleInstructions(role);
152
+ const child = await this.api.sessions.create({
153
+ parentId,
154
+ workspace,
155
+ title,
156
+ instructions,
157
+ ...phaseProfiles[role],
158
+ });
159
+ this.api.ui.status("phase", `${role}: running`, title);
160
+ try {
161
+ const result = await this.api.sessions.run(child.id, prompt);
162
+ if (result.turnsExceeded)
163
+ throw new Error(`${role} exceeded its turn limit; partial output rejected`);
164
+ if (result.status !== "completed") {
165
+ throw new Error(result.error || `${role} child ended with ${result.status}`);
166
+ }
167
+ return parseChildJson(result.text);
168
+ }
169
+ finally {
170
+ this.api.ui.status("phase", undefined);
171
+ }
172
+ }
173
+ async create(args, sessionId) {
174
+ const { workspace } = this.workspace(sessionId);
175
+ const { name, value: intent } = parseDelimited(args, "Usage: /wayfinder:new <change> -- <explicit intent>");
176
+ try {
177
+ await readState(workspace, name);
178
+ throw new Error(`Change already exists: ${name}`);
179
+ }
180
+ catch (error) {
181
+ if (error.code !== "ENOENT")
182
+ throw error;
183
+ }
184
+ const timestamp = now();
185
+ const state = {
186
+ schemaVersion: 2,
187
+ name,
188
+ intent,
189
+ phase: "discovery",
190
+ createdAt: timestamp,
191
+ updatedAt: timestamp,
192
+ proposalApproved: false,
193
+ planApproved: false,
194
+ requirementIds: [],
195
+ units: [],
196
+ remediationCount: 0,
197
+ mutationRemediationCount: 0,
198
+ };
199
+ await writeArtifact(workspace, name, "intent.md", `# Intent\n\n${intent}\n`);
200
+ await writeState(workspace, state);
201
+ return `Created ${name}. Next: /wayfinder:next ${name}`;
202
+ }
203
+ async answer(args, sessionId) {
204
+ const { workspace } = this.workspace(sessionId);
205
+ const { name, value } = parseDelimited(args, "Usage: /wayfinder:answer <change> -- <clarification>");
206
+ const state = await readState(workspace, name);
207
+ if (!["discovery", "proposal", "specification"].includes(state.phase)) {
208
+ throw new Error(`Clarifications are not accepted while ${name} is in ${state.phase}`);
209
+ }
210
+ const current = await optionalArtifact(workspace, name, "clarifications.md");
211
+ const content = current || "# Clarifications\n";
212
+ await writeArtifact(workspace, name, "clarifications.md", `${content.trimEnd()}\n\n- ${value}\n`);
213
+ return `Clarification recorded for ${name}. Retry: /wayfinder:next ${name}`;
214
+ }
215
+ async status(args, sessionId) {
216
+ const { workspace } = this.workspace(sessionId);
217
+ const name = args.trim();
218
+ if (!name) {
219
+ const changes = await listChanges(workspace);
220
+ if (!changes.length)
221
+ return "No active Wayfinder changes.";
222
+ const states = await Promise.all(changes.map((change) => readState(workspace, change)));
223
+ return states
224
+ .map((state) => `${state.name}: ${state.phase} (${state.units.filter(({ status }) => status === "completed").length}/${state.units.length} units)`)
225
+ .join("\n");
226
+ }
227
+ const state = await readState(workspace, validateChangeName(name));
228
+ const mutation = state.mutation?.decision
229
+ ? `Mutation: ${state.mutation.decision.decision}${state.mutation.targeted ? " (targeted)" : ""} (${state.mutationRemediationCount}/${mutationRemediationLimit})`
230
+ : "Mutation: decision pending";
231
+ const tdd = state.tdd ? `TDD: ${state.tdd.decision}` : "TDD: decision pending";
232
+ return `${state.name}\nPhase: ${state.phase}\nUnits: ${state.units.filter(({ status }) => status === "completed").length}/${state.units.length}\nRemediation: ${state.remediationCount}/2\n${mutation}\n${tdd}\nNext action: ${this.nextAction(state)}`;
233
+ }
234
+ nextAction(state) {
235
+ if (state.phase === "proposal-approval")
236
+ return `/wayfinder:approve ${state.name} proposal`;
237
+ if (state.phase === "plan-approval") {
238
+ return state.tdd
239
+ ? `/wayfinder:approve ${state.name} plan`
240
+ : `/wayfinder:tdd ${state.name} strict|off -- <reason>`;
241
+ }
242
+ if (["discovery", "proposal", "specification", "design", "plan"].includes(state.phase)) {
243
+ return `/wayfinder:next ${state.name}`;
244
+ }
245
+ if (state.phase === "implementation")
246
+ return `/wayfinder:build ${state.name}`;
247
+ if (state.phase === "verification") {
248
+ if (state.verification?.passed && !state.mutation?.decision) {
249
+ return `/wayfinder:mutate ${state.name} run|skip -- <reason>`;
250
+ }
251
+ return `/wayfinder:verify ${state.name}`;
252
+ }
253
+ if (state.phase === "archive")
254
+ return `/wayfinder:close ${state.name}`;
255
+ return "No action; change is closed";
256
+ }
257
+ async requestApproval(state, workspace) {
258
+ const target = state.phase === "proposal-approval" ? "proposal" : "plan";
259
+ if (!this.api.ui.interactive()) {
260
+ return `Approval required for ${target}. Review ${target}.md, then run /wayfinder:approve ${state.name} ${target}`;
261
+ }
262
+ const result = await this.api.ui.askQuestions({
263
+ label: "Wayfinder approval",
264
+ questions: [
265
+ {
266
+ id: "approval",
267
+ header: `${target} approval`,
268
+ question: `Approve the ${target} for ${state.name}?`,
269
+ options: [
270
+ { value: "approve", label: "Approve" },
271
+ { value: "stop", label: "Not yet" },
272
+ ],
273
+ },
274
+ ],
275
+ });
276
+ if (result.approval !== "approve") {
277
+ return `Approval not recorded. Review ${target}.md and resume with /wayfinder:approve ${state.name} ${target}`;
278
+ }
279
+ return this.recordApproval(state, workspace, target);
280
+ }
281
+ async recordApproval(state, workspace, target) {
282
+ if (target === "proposal" && state.phase === "proposal-approval") {
283
+ state.proposalApproved = true;
284
+ state.phase = "specification";
285
+ }
286
+ else if (target === "plan" && state.phase === "plan-approval") {
287
+ if (!state.tdd)
288
+ return this.requestTddDecision(state, workspace);
289
+ state.planApproved = true;
290
+ state.phase = "implementation";
291
+ }
292
+ else {
293
+ throw new Error(`Cannot approve ${target} while ${state.name} is in ${state.phase}`);
294
+ }
295
+ state.updatedAt = now();
296
+ await writeState(workspace, state);
297
+ return `Approved ${target} for ${state.name}. Next: ${this.nextAction(state)}`;
298
+ }
299
+ tddRecommendation(state) {
300
+ const testable = state.units.filter((unit) => unit.tddExempt !== true).length;
301
+ if (testable > 0) {
302
+ return {
303
+ decision: "strict",
304
+ reason: `${testable} non-exempt unit(s) can be driven by tests`,
305
+ };
306
+ }
307
+ return { decision: "off", reason: "every planned unit is exempt from test-first" };
308
+ }
309
+ async requestTddDecision(state, workspace) {
310
+ const recommended = this.tddRecommendation(state);
311
+ const blocked = `TDD decision required for ${state.name} before implementation. Recommendation: ${recommended.decision} — ${recommended.reason}. Record it with /wayfinder:tdd ${state.name} strict -- <reason> or /wayfinder:tdd ${state.name} off -- <reason>`;
312
+ if (!this.api.ui.interactive())
313
+ return blocked;
314
+ const result = await this.api.ui.askQuestions({
315
+ label: "Wayfinder TDD",
316
+ questions: [
317
+ {
318
+ id: "tdd",
319
+ header: "Test-first",
320
+ question: `Adopt strict test-first for ${state.name}?`,
321
+ options: [
322
+ {
323
+ value: "strict",
324
+ label: "Strict test-first",
325
+ description: recommended.reason,
326
+ recommended: recommended.decision === "strict",
327
+ },
328
+ {
329
+ value: "off",
330
+ label: "No test-first requirement",
331
+ description: recommended.reason,
332
+ recommended: recommended.decision === "off",
333
+ },
334
+ ],
335
+ },
336
+ ],
337
+ });
338
+ const choice = result.tdd;
339
+ if (choice !== "strict" && choice !== "off")
340
+ return blocked;
341
+ const reason = choice === recommended.decision
342
+ ? recommended.reason
343
+ : `Developer selected ${choice} over the recommended ${recommended.decision}.`;
344
+ await this.recordTddDecision(state, workspace, {
345
+ decision: choice,
346
+ reason,
347
+ source: "recommended",
348
+ });
349
+ return this.recordApproval(state, workspace, "plan");
350
+ }
351
+ async recordTddDecision(state, workspace, input) {
352
+ if (state.tdd) {
353
+ throw new Error(`TDD decision already recorded for ${state.name}; it is immutable`);
354
+ }
355
+ state.tdd = {
356
+ decision: input.decision,
357
+ reason: input.reason,
358
+ decidedAt: now(),
359
+ source: input.source,
360
+ };
361
+ state.updatedAt = now();
362
+ await writeState(workspace, state);
363
+ const semantics = input.decision === "strict"
364
+ ? "Strict test-first is enabled for non-exempt units."
365
+ : "No test-first evidence will be required for future units.";
366
+ return `Recorded TDD decision (${input.decision}) for ${state.name}. ${semantics} Next: /wayfinder:approve ${state.name} plan`;
367
+ }
368
+ async tdd(args, sessionId) {
369
+ const { workspace } = this.workspace(sessionId);
370
+ const usage = "Usage: /wayfinder:tdd <change> <strict|off> -- <reason>";
371
+ const delimiter = args.indexOf(" -- ");
372
+ if (delimiter < 0)
373
+ throw new Error(usage);
374
+ const head = args.slice(0, delimiter).trim();
375
+ const reason = args.slice(delimiter + 4).trim();
376
+ if (!head || !reason)
377
+ throw new Error(usage);
378
+ const parts = head.split(/\s+/);
379
+ const name = validateChangeName(parts[0] ?? "");
380
+ const action = parts[1];
381
+ if ((action !== "strict" && action !== "off") || parts.length !== 2)
382
+ throw new Error(usage);
383
+ const state = await readState(workspace, name);
384
+ if (state.phase !== "plan-approval") {
385
+ throw new Error(`TDD decision must be recorded while ${name} is in plan-approval (currently ${state.phase}); approve the proposal first`);
386
+ }
387
+ return this.recordTddDecision(state, workspace, { decision: action, reason, source: "manual" });
388
+ }
389
+ async approve(args, sessionId) {
390
+ const { workspace } = this.workspace(sessionId);
391
+ const [rawName, rawTarget, ...extra] = args.trim().split(/\s+/);
392
+ if (!rawName || (rawTarget !== "proposal" && rawTarget !== "plan") || extra.length) {
393
+ throw new Error("Usage: /wayfinder:approve <change> <proposal|plan>");
394
+ }
395
+ const state = await readState(workspace, validateChangeName(rawName));
396
+ return this.recordApproval(state, workspace, rawTarget);
397
+ }
398
+ async next(args, sessionId) {
399
+ const { parentId, workspace } = this.workspace(sessionId);
400
+ const name = validateChangeName(args.trim());
401
+ const state = await readState(workspace, name);
402
+ if (state.phase === "proposal-approval" || state.phase === "plan-approval") {
403
+ return this.requestApproval(state, workspace);
404
+ }
405
+ const clarification = await optionalArtifact(workspace, name, "clarifications.md");
406
+ const context = `Change: ${name}\nIntent: ${state.intent}${clarification ? `\n\nClarifications:\n${clarification}` : ""}`;
407
+ let artifact;
408
+ let critical = [];
409
+ if (state.phase === "discovery") {
410
+ const output = validateDiscovery(await this.delegate("discoverer", parentId, workspace, `Discover ${name}`, `${context}\n\nInspect the current repository. Return only:\n${schemas.discovery}`));
411
+ artifact = "discovery.md";
412
+ critical = output.criticalQuestions;
413
+ await writeArtifact(workspace, name, artifact, renderDiscovery(output));
414
+ if (!critical.length)
415
+ state.phase = "proposal";
416
+ }
417
+ else if (state.phase === "proposal") {
418
+ const discovery = await readArtifact(workspace, name, "discovery.md");
419
+ const output = validateProposal(await this.delegate("proposer", parentId, workspace, `Propose ${name}`, `${context}\n\nDiscovery:\n${discovery}\n\nReturn only:\n${schemas.proposal}`));
420
+ artifact = "proposal.md";
421
+ critical = output.criticalQuestions;
422
+ await writeArtifact(workspace, name, artifact, renderProposal(output));
423
+ if (!critical.length)
424
+ state.phase = "proposal-approval";
425
+ }
426
+ else if (state.phase === "specification") {
427
+ const proposal = await readArtifact(workspace, name, "proposal.md");
428
+ const output = validateSpecification(await this.delegate("specifier", parentId, workspace, `Specify ${name}`, `${context}\n\nApproved proposal:\n${proposal}\n\nReturn only:\n${schemas.specification}`));
429
+ artifact = "specification.md";
430
+ critical = output.criticalQuestions;
431
+ await writeArtifact(workspace, name, artifact, renderSpecification(output));
432
+ if (!critical.length) {
433
+ state.requirementIds = output.requirements.map(({ id }) => id);
434
+ state.phase = "design";
435
+ }
436
+ }
437
+ else if (state.phase === "design") {
438
+ const specification = await readArtifact(workspace, name, "specification.md");
439
+ const output = validateDesign(await this.delegate("designer", parentId, workspace, `Design ${name}`, `${context}\n\nSpecification:\n${specification}\n\nReturn only:\n${schemas.design}`));
440
+ artifact = "design.md";
441
+ await writeArtifact(workspace, name, artifact, renderDesign(output));
442
+ state.phase = "plan";
443
+ }
444
+ else if (state.phase === "plan") {
445
+ const specification = await readArtifact(workspace, name, "specification.md");
446
+ const design = await readArtifact(workspace, name, "design.md");
447
+ const output = validatePlan(await this.delegate("planner", parentId, workspace, `Plan ${name}`, `${context}\n\nSpecification:\n${specification}\n\nDesign:\n${design}\n\nReturn only:\n${schemas.plan}`), new Set(state.requirementIds));
448
+ state.units = output.units.map((unit) => ({ ...unit, status: "pending" }));
449
+ artifact = "plan.md";
450
+ await writeArtifact(workspace, name, artifact, renderPlan(state.units));
451
+ state.phase = "plan-approval";
452
+ }
453
+ else {
454
+ throw new Error(`Cannot run next while ${name} is in phase ${state.phase}`);
455
+ }
456
+ state.updatedAt = now();
457
+ await writeState(workspace, state);
458
+ if (critical.length)
459
+ return `${artifact} recorded without advancement.\n${criticalBlock(name, critical)}`;
460
+ return `Completed ${artifact} for ${name}. Next: ${this.nextAction(state)}`;
461
+ }
462
+ async build(args, sessionId) {
463
+ const { parentId, workspace } = this.workspace(sessionId);
464
+ const name = validateChangeName(args.trim());
465
+ const state = await readState(workspace, name);
466
+ if (state.phase !== "implementation" || !state.proposalApproved || !state.planApproved) {
467
+ throw new Error(`Cannot build while ${name} is in phase ${state.phase}`);
468
+ }
469
+ const unit = state.units.find(({ status }) => status === "pending");
470
+ if (!unit)
471
+ throw new Error("Implementation phase has no pending work unit");
472
+ const specification = await readArtifact(workspace, name, "specification.md");
473
+ const design = await readArtifact(workspace, name, "design.md");
474
+ const priorVerification = await optionalArtifact(workspace, name, "verification.md");
475
+ const output = validateImplementation(await this.delegate("implementer", parentId, workspace, `Implement ${unit.id}: ${unit.title}`, `Change: ${name}\nIntent: ${state.intent}\n\nSpecification:\n${specification}\n\nDesign:\n${design}${priorVerification ? `\n\nPrior verification:\n${priorVerification}` : ""}\n\nImplement exactly this unit:\n${JSON.stringify(unit, null, 2)}\n\nReturn only after changing files and running focused checks:\n${schemas.implementation}`), unit.id);
476
+ if (unit.kind === "test-strengthening") {
477
+ const offending = output.changedPaths.filter((path) => !isTestPath(path));
478
+ if (offending.length) {
479
+ throw new Error(`Test-strengthening unit ${unit.id} may modify test paths only; rejected production edits: ${offending.join(", ")}. Strengthen tests only and do not change production behavior, then retry /wayfinder:build ${name}.`);
480
+ }
481
+ }
482
+ // Grandfather policy: a change already past plan approval without a recorded decision
483
+ // predates the TDD gate, so it is not retroactively gated or deadlocked. Only a change that
484
+ // recorded `strict` (which current code requires at plan approval) is enforced.
485
+ if (state.tdd?.decision === "strict" &&
486
+ unit.tddExempt !== true &&
487
+ unit.kind !== "test-strengthening") {
488
+ const testPaths = output.changedPaths.filter((path) => isTestPath(path));
489
+ const testFirst = output.testFirst;
490
+ const scope = testPaths.length ? testPaths.join(", ") : output.changedPaths.join(", ");
491
+ if (!testPaths.length) {
492
+ throw new Error(`TDD (strict) rejected ${unit.id}: change at least one test file, then provide test-first evidence: a failing test run for ${scope} before the change, then a passing run after. Retry /wayfinder:build ${name}.`);
493
+ }
494
+ if (!testFirst) {
495
+ throw new Error(`TDD (strict) rejected ${unit.id}: provide test-first evidence: a failing test run for ${scope} before the change, then a passing run after. Retry /wayfinder:build ${name}.`);
496
+ }
497
+ if (testFirst.failingCommand !== testFirst.passingCommand) {
498
+ throw new Error(`TDD (strict) rejected ${unit.id}: the failing and passing runs must reference the same test scope; got "${testFirst.failingCommand}" then "${testFirst.passingCommand}". Provide test-first evidence for ${scope} and retry /wayfinder:build ${name}.`);
499
+ }
500
+ }
501
+ const testProblem = this.testEvidenceProblem(unit, output);
502
+ if (testProblem) {
503
+ throw new Error(`${testProblem} Retry /wayfinder:build ${name}.`);
504
+ }
505
+ unit.status = "completed";
506
+ unit.changedPaths = output.changedPaths;
507
+ unit.evidence = output.checks;
508
+ const progress = (await optionalArtifact(workspace, name, "progress.md")) || "# Progress\n";
509
+ await writeArtifact(workspace, name, "progress.md", `${progress.trimEnd()}\n\n${renderImplementation(output)}`);
510
+ await writeArtifact(workspace, name, "plan.md", renderPlan(state.units));
511
+ if (state.units.every(({ status }) => status === "completed"))
512
+ state.phase = "verification";
513
+ state.updatedAt = now();
514
+ await writeState(workspace, state);
515
+ return `Completed ${unit.id} for ${name}. Next: ${this.nextAction(state)}`;
516
+ }
517
+ /**
518
+ * Report-based test-design and UI-testability guardrail. It inspects only what the child reports,
519
+ * so it cannot prove a negative test or a fragile selector is absent; the verifier is the backstop.
520
+ */
521
+ testEvidenceProblem(unit, output) {
522
+ const design = output.testDesign;
523
+ if (unit.requiresTests === true && !design?.length) {
524
+ return `Unit ${unit.id} requires tests: provide testDesign with at least one scenario, each with a happy-path and an unhappy-path test.`;
525
+ }
526
+ const declaresTests = unit.requiresTests === true || (design?.length ?? 0) > 0;
527
+ if (declaresTests && !output.changedPaths.some((path) => isTestPath(path))) {
528
+ return `Unit ${unit.id} declares tests but reports no test file in changedPaths; add the test file(s) covering the scenarios.`;
529
+ }
530
+ if (design) {
531
+ const seen = [];
532
+ for (const entry of design) {
533
+ const scenario = entry.scenario.trim();
534
+ if (seen.includes(scenario)) {
535
+ return `Unit ${unit.id} testDesign repeats the scenario "${scenario}"; scenario names must be unique.`;
536
+ }
537
+ seen.push(scenario);
538
+ if (!entry.happy.length) {
539
+ return `Unit ${unit.id} scenario "${scenario}" has no happy-path test; add at least one positive test.`;
540
+ }
541
+ if (!entry.unhappy.length) {
542
+ return `Unit ${unit.id} scenario "${scenario}" has no unhappy-path test; add at least one negative, error, boundary, or alternative test.`;
543
+ }
544
+ }
545
+ }
546
+ const uiPaths = output.changedPaths.filter((path) => isUiPath(path) && !isTestPath(path));
547
+ if (uiPaths.length) {
548
+ const testability = output.testability;
549
+ if (!testability) {
550
+ return `Unit ${unit.id} changes UI paths (${uiPaths.join(", ")}) without testability; provide testIds or a non-blank accessibleOnlyReason.`;
551
+ }
552
+ const invalid = testability.testIds.filter((id) => !isTestId(id));
553
+ if (invalid.length) {
554
+ return `Unit ${unit.id} declared non-convention test ids: ${invalid.join(", ")}; use feature-element-variant (lowercase segments joined by hyphens).`;
555
+ }
556
+ if (!testability.testIds.length && !testability.accessibleOnlyReason) {
557
+ return `Unit ${unit.id} declares no test ids and no accessibleOnlyReason; add semantic ids or justify accessibility-only queries.`;
558
+ }
559
+ }
560
+ return undefined;
561
+ }
562
+ safeFiles(paths) {
563
+ const scope = [];
564
+ for (const path of paths) {
565
+ try {
566
+ const safe = assertScopePath(path);
567
+ if (!scope.includes(safe))
568
+ scope.push(safe);
569
+ }
570
+ catch {
571
+ // Skip paths that cannot be safely passed as an argument.
572
+ }
573
+ if (scope.length >= mutationBounds.scopeLimit)
574
+ break;
575
+ }
576
+ return scope;
577
+ }
578
+ changeScope(state) {
579
+ const changed = state.units.flatMap((unit) => unit.changedPaths ?? []);
580
+ return this.safeFiles(changed.length ? changed : state.units.flatMap((unit) => unit.paths));
581
+ }
582
+ targetRequirements(state, files) {
583
+ const matched = state.units
584
+ .filter((unit) => unit.paths.some((path) => files.includes(path)))
585
+ .flatMap((unit) => unit.requirements);
586
+ const unique = [...new Set(matched)];
587
+ return unique.length ? unique : [...state.requirementIds];
588
+ }
589
+ mutationPrompt(name, state, plan) {
590
+ return `Change: ${name}\nIntent: ${state.intent}\n\nStack: ${plan.stack}\nTool: ${plan.tool}\nMode: ${plan.mode}\nScope support: ${plan.scopeSupport === "paths" ? "bounded paths" : "whole repository only"}\nScope (${plan.scope.length} file(s)): ${plan.scope.length ? plan.scope.join(", ") : "whole repository (explicit full mode)"}\nBounds: concurrency ${plan.concurrencyApplied ? plan.concurrency : "not applied (tool does not support it)"}, timeout ${plan.timeoutMs} ms, report at most ${plan.survivorLimit} survivors.\n\nRun exactly this argument list with the run_process tool; never install tooling and never build a shell command from file content:\n${JSON.stringify(plan.args)}\n\nTriage every survivor. Mark a survivor equivalent only with a concrete justification.\n\nReturn only:\n${schemas.mutation}`;
591
+ }
592
+ async persistUnavailableRun(state, workspace, name, plan) {
593
+ const reason = plan.reason ?? "Mutation tooling unavailable.";
594
+ const run = {
595
+ tool: plan.tool ?? "none",
596
+ stack: plan.stack,
597
+ mode: plan.mode,
598
+ scopeSupport: plan.scopeSupport,
599
+ concurrencyApplied: plan.concurrencyApplied,
600
+ scope: plan.scope,
601
+ command: "",
602
+ survivors: [],
603
+ failingSurvivors: 0,
604
+ equivalentSurvivors: 0,
605
+ summary: reason,
606
+ ranAt: now(),
607
+ unavailableReason: reason,
608
+ };
609
+ state.mutation = { ...(state.mutation ?? {}), run };
610
+ if (state.mutation)
611
+ delete state.mutation.targeted;
612
+ state.phase = "archive";
613
+ state.updatedAt = now();
614
+ await writeArtifact(workspace, name, "mutation.md", renderMutation(run));
615
+ await writeState(workspace, state);
616
+ return `Mutation testing unavailable for ${name} (${reason}). Advanced to archive without blocking. Next: /wayfinder:close ${name}`;
617
+ }
618
+ async scheduleVerificationRemediation(state, workspace, name, output) {
619
+ if (state.remediationCount >= 2) {
620
+ state.updatedAt = now();
621
+ await writeState(workspace, state);
622
+ return `Verification failed for ${name}; remediation limit reached. Resolve externally or revise the change before retrying verification.`;
623
+ }
624
+ state.remediationCount += 1;
625
+ const failed = output.requirements
626
+ .filter(({ status }) => status === "failed")
627
+ .map(({ id }) => id);
628
+ const paths = [
629
+ ...new Set(state.units
630
+ .filter((unit) => unit.requirements.some((id) => failed.includes(id)))
631
+ .flatMap((unit) => unit.paths)),
632
+ ];
633
+ state.units.push({
634
+ id: nextUnitId(state),
635
+ title: `Remediate verification attempt ${state.remediationCount}`,
636
+ goal: output.blockers.join("; "),
637
+ requirements: failed.length ? failed : [...state.requirementIds],
638
+ paths,
639
+ checks: output.blockers,
640
+ status: "pending",
641
+ });
642
+ state.phase = "implementation";
643
+ await writeArtifact(workspace, name, "plan.md", renderPlan(state.units));
644
+ state.updatedAt = now();
645
+ await writeState(workspace, state);
646
+ return `Verification failed for ${name}. A bounded remediation unit is ready: /wayfinder:build ${name}`;
647
+ }
648
+ async scheduleMutationRemediation(state, workspace, name, input) {
649
+ if (state.mutationRemediationCount >= mutationRemediationLimit) {
650
+ state.updatedAt = now();
651
+ await writeState(workspace, state);
652
+ return `Mutation remediation limit reached for ${name} (${state.mutationRemediationCount}/${mutationRemediationLimit}) with ${input.count} unresolved item(s). Stop for human reassessment; strengthen tests or revise scope manually.`;
653
+ }
654
+ state.mutationRemediationCount += 1;
655
+ const attempt = state.mutationRemediationCount;
656
+ const requirementIds = input.requirementIds.length
657
+ ? input.requirementIds
658
+ : [...state.requirementIds];
659
+ state.mutation = {
660
+ ...(state.mutation ?? {}),
661
+ targeted: { files: input.files, requirementIds, attempt },
662
+ };
663
+ state.units.push({
664
+ id: nextUnitId(state),
665
+ kind: "test-strengthening",
666
+ title: `Strengthen tests (mutation attempt ${attempt})`,
667
+ goal: input.goal,
668
+ requirements: requirementIds,
669
+ paths: input.files.length
670
+ ? input.files
671
+ : [...new Set(state.units.flatMap((unit) => unit.paths))],
672
+ checks: [input.command],
673
+ status: "pending",
674
+ });
675
+ state.phase = "implementation";
676
+ await writeArtifact(workspace, name, "plan.md", renderPlan(state.units));
677
+ state.updatedAt = now();
678
+ await writeState(workspace, state);
679
+ return `Mutation remediation required for ${name} (${input.count} item(s)). A test-strengthening unit is ready: /wayfinder:build ${name}. Strengthen TESTS ONLY; do not change production behavior to satisfy the tool.`;
680
+ }
681
+ recordMutationRun(state, plan, output) {
682
+ const failing = nonEquivalentSurvivors(output.survivors);
683
+ const triaged = boundedSurvivors(output.survivors);
684
+ const run = {
685
+ tool: plan.tool ?? output.tool,
686
+ stack: plan.stack,
687
+ mode: plan.mode,
688
+ scopeSupport: plan.scopeSupport,
689
+ concurrencyApplied: plan.concurrencyApplied,
690
+ scope: plan.scope,
691
+ command: plan.command,
692
+ survivors: triaged.survivors,
693
+ failingSurvivors: failing.length,
694
+ equivalentSurvivors: output.survivors.length - failing.length,
695
+ summary: output.summary,
696
+ ranAt: now(),
697
+ ...(output.mutationScore !== undefined ? { mutationScore: output.mutationScore } : {}),
698
+ ...(triaged.truncated ? { truncated: true } : {}),
699
+ };
700
+ state.mutation = { ...(state.mutation ?? {}), run };
701
+ return run;
702
+ }
703
+ async runMutation(state, workspace, parentId, name) {
704
+ const decision = state.mutation?.decision;
705
+ if (!decision)
706
+ throw new Error(`No mutation decision recorded for ${name}`);
707
+ const environment = await inspectMutationEnvironment(workspace);
708
+ const plan = buildMutationPlan(environment, {
709
+ mode: decision.mode,
710
+ changedPaths: decision.mode === "full" ? [] : this.changeScope(state),
711
+ });
712
+ if (!plan.available)
713
+ return this.persistUnavailableRun(state, workspace, name, plan);
714
+ const output = validateMutation(await this.delegate("mutationist", parentId, workspace, `Mutate ${name}`, this.mutationPrompt(name, state, plan)));
715
+ const run = this.recordMutationRun(state, plan, output);
716
+ await writeArtifact(workspace, name, "mutation.md", renderMutation(run));
717
+ const failing = nonEquivalentSurvivors(output.survivors);
718
+ if (!failing.length) {
719
+ if (state.mutation)
720
+ delete state.mutation.targeted;
721
+ state.phase = "archive";
722
+ state.updatedAt = now();
723
+ await writeState(workspace, state);
724
+ return `Mutation testing passed for ${name} (${run.equivalentSurvivors} equivalent survivor(s) excluded). Next: /wayfinder:close ${name}`;
725
+ }
726
+ const files = this.safeFiles(failing.map(({ file }) => file));
727
+ return this.scheduleMutationRemediation(state, workspace, name, {
728
+ files,
729
+ requirementIds: this.targetRequirements(state, files),
730
+ count: failing.length,
731
+ goal: `Kill ${failing.length} non-equivalent mutant(s) by strengthening tests only; do not change production behavior to satisfy the mutation tool.`,
732
+ command: plan.command,
733
+ });
734
+ }
735
+ async targetedReVerification(state, workspace, parentId, name) {
736
+ const target = state.mutation?.targeted;
737
+ if (!target)
738
+ throw new Error(`No targeted mutation scope recorded for ${name}`);
739
+ const environment = await inspectMutationEnvironment(workspace);
740
+ const plan = buildMutationPlan(environment, { mode: "changed", changedPaths: target.files });
741
+ if (!plan.available)
742
+ return this.persistUnavailableRun(state, workspace, name, plan);
743
+ const output = validateMutation(await this.delegate("mutationist", parentId, workspace, `Re-mutate ${name}`, this.mutationPrompt(name, state, plan)));
744
+ const run = this.recordMutationRun(state, plan, output);
745
+ await writeArtifact(workspace, name, "mutation.md", renderMutation(run));
746
+ const failing = nonEquivalentSurvivors(output.survivors);
747
+ if (failing.length) {
748
+ const files = this.safeFiles(failing.map(({ file }) => file));
749
+ return this.scheduleMutationRemediation(state, workspace, name, {
750
+ files,
751
+ requirementIds: this.targetRequirements(state, files),
752
+ count: failing.length,
753
+ goal: `Kill ${failing.length} remaining non-equivalent mutant(s) in the targeted scope by strengthening tests only.`,
754
+ command: plan.command,
755
+ });
756
+ }
757
+ const specification = await readArtifact(workspace, name, "specification.md");
758
+ const progress = await optionalArtifact(workspace, name, "progress.md");
759
+ const recheck = validateTargetedVerification(await this.delegate("verifier", parentId, workspace, `Re-verify ${name} (mutation remediation)`, `Change: ${name}\nIntent: ${state.intent}\n\nSpecification:\n${specification}${progress ? `\n\nImplementation self-report (context only; inspect current files):\n${progress}` : ""}\n\nAfter test strengthening, verify ONLY these requirement ids: ${target.requirementIds.join(", ")}. Cover exactly those ids and no others.\n\nReturn only:\n${schemas.verification}`), new Set(target.requirementIds));
760
+ await writeArtifact(workspace, name, "verification-targeted.md", renderVerification(recheck));
761
+ if (!recheck.passed) {
762
+ const failed = recheck.requirements
763
+ .filter(({ status }) => status === "failed")
764
+ .map(({ id }) => id);
765
+ return this.scheduleMutationRemediation(state, workspace, name, {
766
+ files: target.files,
767
+ requirementIds: failed.length ? failed : target.requirementIds,
768
+ count: failed.length || 1,
769
+ goal: `Address failed targeted verification (${failed.join(", ") || "remaining"}) by strengthening tests only.`,
770
+ command: plan.command,
771
+ });
772
+ }
773
+ state.verification = {
774
+ passed: true,
775
+ summary: recheck.summary,
776
+ blockers: [],
777
+ checkedAt: now(),
778
+ };
779
+ if (state.mutation)
780
+ delete state.mutation.targeted;
781
+ state.phase = "archive";
782
+ state.updatedAt = now();
783
+ await writeState(workspace, state);
784
+ return `Targeted mutation and verification passed for ${name}. Next: /wayfinder:close ${name}`;
785
+ }
786
+ async requestMutationDecision(state, workspace, parentId, name) {
787
+ const environment = await inspectMutationEnvironment(workspace);
788
+ const plan = buildMutationPlan(environment, {
789
+ mode: "changed",
790
+ changedPaths: this.changeScope(state),
791
+ });
792
+ const concurrency = plan.concurrencyApplied
793
+ ? String(plan.concurrency)
794
+ : "not applied (tool does not support it)";
795
+ const recommendation = plan.available
796
+ ? `Run ${plan.tool} (${plan.stack}, mode ${plan.mode}, concurrency ${concurrency}, at most ${plan.survivorLimit} survivors reported)`
797
+ : `No tooling available: ${plan.reason}`;
798
+ if (!this.api.ui.interactive()) {
799
+ return `Mutation testing decision required for ${name} before archive. Recommendation: ${recommendation}. Record it with /wayfinder:mutate ${name} run -- <reason> or /wayfinder:mutate ${name} skip -- <reason>`;
800
+ }
801
+ const result = await this.api.ui.askQuestions({
802
+ label: "Wayfinder mutation testing",
803
+ questions: [
804
+ {
805
+ id: "mutation",
806
+ header: "Mutation testing",
807
+ question: `Run mutation testing for ${name} before archive?`,
808
+ options: [
809
+ {
810
+ value: "run",
811
+ label: "Run mutation testing",
812
+ description: recommendation,
813
+ recommended: plan.available,
814
+ },
815
+ { value: "skip", label: "Skip for this change" },
816
+ ],
817
+ },
818
+ ],
819
+ });
820
+ const choice = result.mutation;
821
+ if (choice !== "run" && choice !== "skip") {
822
+ return `Mutation testing decision required for ${name}. Record it with /wayfinder:mutate ${name} run -- <reason> or /wayfinder:mutate ${name} skip -- <reason>`;
823
+ }
824
+ return this.recordMutationDecision(state, workspace, parentId, name, {
825
+ decision: choice,
826
+ mode: "changed",
827
+ reason: plan.available
828
+ ? `Recommended ${plan.tool} for ${plan.stack}`
829
+ : `No tooling available: ${plan.reason}`,
830
+ source: "recommended",
831
+ execute: true,
832
+ });
833
+ }
834
+ async recordMutationDecision(state, workspace, parentId, name, input) {
835
+ if (state.mutation?.decision) {
836
+ throw new Error(`Mutation decision already recorded for ${name}; it is immutable`);
837
+ }
838
+ const environment = await inspectMutationEnvironment(workspace);
839
+ state.mutation = {
840
+ ...(state.mutation ?? {}),
841
+ decision: {
842
+ decision: input.decision,
843
+ mode: input.mode,
844
+ reason: input.reason,
845
+ decidedAt: now(),
846
+ stack: environment.stack,
847
+ source: input.source,
848
+ ...(environment.tool ? { tool: environment.tool.name } : {}),
849
+ },
850
+ };
851
+ state.updatedAt = now();
852
+ await writeState(workspace, state);
853
+ if (input.decision === "skip") {
854
+ if (state.verification?.passed) {
855
+ state.phase = "archive";
856
+ state.updatedAt = now();
857
+ await writeState(workspace, state);
858
+ return `Recorded skip decision for ${name}. Verification already passed; advanced to archive. Next: /wayfinder:close ${name}`;
859
+ }
860
+ return `Recorded skip decision for ${name}. Complete verification with /wayfinder:verify ${name}`;
861
+ }
862
+ if (input.execute)
863
+ return this.runMutation(state, workspace, parentId, name);
864
+ return `Recorded run decision (mode ${input.mode}) for ${name}. Next: /wayfinder:verify ${name}`;
865
+ }
866
+ async mutate(args, sessionId) {
867
+ const { parentId, workspace } = this.workspace(sessionId);
868
+ const usage = "Usage: /wayfinder:mutate <change> <run|skip> [--mode changed|full] -- <reason>";
869
+ const delimiter = args.indexOf(" -- ");
870
+ if (delimiter < 0)
871
+ throw new Error(usage);
872
+ const head = args.slice(0, delimiter).trim();
873
+ const reason = args.slice(delimiter + 4).trim();
874
+ if (!head || !reason)
875
+ throw new Error(usage);
876
+ const parts = head.split(/\s+/);
877
+ const name = validateChangeName(parts[0] ?? "");
878
+ const action = parts[1];
879
+ if (action !== "run" && action !== "skip")
880
+ throw new Error(usage);
881
+ let mode = "changed";
882
+ for (let index = 2; index < parts.length; index += 1) {
883
+ const candidate = parts[index + 1];
884
+ if (parts[index] === "--mode" && (candidate === "changed" || candidate === "full")) {
885
+ mode = candidate;
886
+ index += 1;
887
+ }
888
+ else {
889
+ throw new Error(usage);
890
+ }
891
+ }
892
+ const state = await readState(workspace, name);
893
+ return this.recordMutationDecision(state, workspace, parentId, name, {
894
+ decision: action,
895
+ mode,
896
+ reason,
897
+ source: "manual",
898
+ execute: false,
899
+ });
900
+ }
901
+ async verify(args, sessionId) {
902
+ const { parentId, workspace } = this.workspace(sessionId);
903
+ const name = validateChangeName(args.trim());
904
+ const state = await readState(workspace, name);
905
+ if (state.phase !== "verification") {
906
+ throw new Error(`Cannot verify while ${name} is in phase ${state.phase}`);
907
+ }
908
+ if (!state.units.length || state.units.some(({ status }) => status !== "completed")) {
909
+ throw new Error("Verification requires every work unit to be completed");
910
+ }
911
+ if (state.mutation?.targeted) {
912
+ return this.targetedReVerification(state, workspace, parentId, name);
913
+ }
914
+ if (state.verification?.passed !== true) {
915
+ const specification = await readArtifact(workspace, name, "specification.md");
916
+ const design = await readArtifact(workspace, name, "design.md");
917
+ const progress = await readArtifact(workspace, name, "progress.md");
918
+ const output = validateVerification(await this.delegate("verifier", parentId, workspace, `Verify ${name}`, `Change: ${name}\nIntent: ${state.intent}\n\nSpecification:\n${specification}\n\nDesign:\n${design}\n\nImplementation self-report (context only; independently inspect files and run checks):\n${progress}\n\nReturn only:\n${schemas.verification}`), new Set(state.requirementIds));
919
+ await writeArtifact(workspace, name, "verification.md", renderVerification(output));
920
+ state.verification = {
921
+ passed: output.passed,
922
+ summary: output.summary,
923
+ blockers: output.blockers,
924
+ checkedAt: now(),
925
+ };
926
+ if (!output.passed) {
927
+ return this.scheduleVerificationRemediation(state, workspace, name, output);
928
+ }
929
+ state.updatedAt = now();
930
+ await writeState(workspace, state);
931
+ }
932
+ const decision = state.mutation?.decision;
933
+ if (!decision)
934
+ return this.requestMutationDecision(state, workspace, parentId, name);
935
+ if (decision.decision === "skip") {
936
+ state.phase = "archive";
937
+ state.updatedAt = now();
938
+ await writeState(workspace, state);
939
+ return `Verification passed and mutation testing was skipped for ${name} (${decision.reason}). Next: /wayfinder:close ${name}`;
940
+ }
941
+ return this.runMutation(state, workspace, parentId, name);
942
+ }
943
+ async close(args, sessionId) {
944
+ const { parentId, workspace } = this.workspace(sessionId);
945
+ const name = validateChangeName(args.trim());
946
+ const state = await readState(workspace, name);
947
+ if (state.phase !== "archive" ||
948
+ !state.verification?.passed ||
949
+ state.units.some(({ status }) => status !== "completed")) {
950
+ throw new Error(`Cannot close ${name}: verification or implementation is incomplete`);
951
+ }
952
+ if (!state.mutation?.decision) {
953
+ throw new Error(`Cannot close ${name}: record a mutation decision with /wayfinder:mutate ${name} run|skip -- <reason>`);
954
+ }
955
+ const artifacts = [
956
+ "intent.md",
957
+ "discovery.md",
958
+ "proposal.md",
959
+ "specification.md",
960
+ "design.md",
961
+ "plan.md",
962
+ "progress.md",
963
+ "verification.md",
964
+ ];
965
+ const contents = await Promise.all(artifacts.map(async (file) => `${file}: ${(await readArtifact(workspace, name, file)).length} bytes`));
966
+ const output = validateArchive(await this.delegate("archivist", parentId, workspace, `Check archive readiness for ${name}`, `Change: ${name}\nExpected artifacts:\n${contents.join("\n")}\nCompleted units: ${state.units.map(({ id }) => id).join(", ")}\nRequirements: ${state.requirementIds.join(", ")}\nVerification: ${state.verification.summary}\n\nReturn only:\n${schemas.archive}`), {
967
+ artifacts: new Set(artifacts),
968
+ units: new Set(state.units.map(({ id }) => id)),
969
+ requirements: new Set(state.requirementIds),
970
+ });
971
+ await writeArtifact(workspace, name, "archive-readiness.md", renderArchive(output));
972
+ state.phase = "closed";
973
+ state.updatedAt = now();
974
+ const destination = await archiveChange(workspace, state);
975
+ return `Closed ${name} and atomically archived it at ${relative(workspace, destination)}.`;
976
+ }
977
+ }
978
+ //# sourceMappingURL=coordinator.js.map