opencode-missions 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.
package/dist/index.js ADDED
@@ -0,0 +1,1414 @@
1
+ // @bun
2
+ // src/index.ts
3
+ import { tool } from "@opencode-ai/plugin";
4
+ import path3 from "path";
5
+
6
+ // src/droid-missions/prompts.ts
7
+ function buildWorkerPrompt(input) {
8
+ const { state, feature, missionDir, message } = input;
9
+ const extraMessage = message ? `
10
+ ## Orchestrator Message
11
+
12
+ ${message}
13
+ ` : "";
14
+ return `You are executing one OpenCode mission feature.
15
+
16
+ ## Mission
17
+
18
+ - Mission ID: ${state.missionId}
19
+ - Title: ${state.title}
20
+ - Goal: ${state.goal}
21
+ - Mission directory: ${missionDir}
22
+ - Working directory: ${state.workingDirectory}
23
+
24
+ ## Assigned Feature
25
+
26
+ - Feature ID: ${feature.id}
27
+ - Skill: ${feature.skillName}
28
+ - Milestone: ${feature.milestone}
29
+ - Description: ${feature.description}
30
+ - Expected behavior: ${feature.expectedBehavior}
31
+ - Preconditions: ${feature.preconditions.join("; ") || "none"}
32
+ - Verification steps: ${feature.verificationSteps.join("; ") || "none"}
33
+ - Fulfills: ${feature.fulfills.join("; ") || "none"}
34
+ ${extraMessage}
35
+ ## Required Completion Contract
36
+
37
+ 1. Inspect the mission artifacts before editing.
38
+ 2. Implement only this feature unless verification requires a tiny local adjustment.
39
+ 3. Run the verification steps or the closest available checks.
40
+ 4. Finish by calling droid_mission_end_feature with:
41
+ - missionId: "${state.missionId}"
42
+ - featureId: "${feature.id}"
43
+ - successState: "success", "partial", or "failure"
44
+ - a structured handoff containing salientSummary, whatWasImplemented, whatWasLeftUndone, verification.commandsRun, and discoveredIssues.
45
+
46
+ Do not mark unrelated features complete.`;
47
+ }
48
+ function buildCompactionContext(input) {
49
+ const current = input.features.find((feature) => feature.id === input.state.activeFeatureId);
50
+ const counts = input.features.reduce((acc, feature) => {
51
+ acc[feature.status] += 1;
52
+ return acc;
53
+ }, { pending: 0, in_progress: 0, completed: 0, cancelled: 0 });
54
+ return `OpenCode mission ${input.state.missionId} is ${input.state.status}. Feature counts: ${JSON.stringify(counts)}. Active feature: ${current?.id ?? "none"}. Use mission tools for state changes.`;
55
+ }
56
+ function buildResumePrompt(input) {
57
+ return `Continue where you left off on OpenCode mission feature "${input.feature.id}".
58
+
59
+ Mission directory: ${input.missionDir}
60
+ Working directory: ${input.state.workingDirectory}
61
+
62
+ Before continuing:
63
+ 1. Check current repo state and modified files.
64
+ 2. Review existing work for this feature.
65
+ 3. Continue the same feature; do not start from scratch.
66
+ 4. Finish by calling droid_mission_end_feature with missionId "${input.state.missionId}" and featureId "${input.feature.id}".`;
67
+ }
68
+
69
+ // src/droid-missions/runner.ts
70
+ function nowIso(now) {
71
+ return now ?? new Date().toISOString();
72
+ }
73
+ function extractSessionId(response) {
74
+ if (response && typeof response === "object") {
75
+ const record = response;
76
+ if (typeof record.id === "string")
77
+ return record.id;
78
+ if (record.data && typeof record.data === "object") {
79
+ const data = record.data;
80
+ if (typeof data.id === "string")
81
+ return data.id;
82
+ }
83
+ if (record.session && typeof record.session === "object") {
84
+ const session = record.session;
85
+ if (typeof session.id === "string")
86
+ return session.id;
87
+ }
88
+ }
89
+ throw new Error("OpenCode did not return a worker session id");
90
+ }
91
+ function allFeaturesCompleted(features) {
92
+ return features.length > 0 && features.every((feature) => feature.status === "completed" || feature.status === "cancelled");
93
+ }
94
+ function progressType(successState) {
95
+ return successState === "success" ? "worker_completed" : "worker_failed";
96
+ }
97
+ function validationFeature(milestone, kind) {
98
+ const id = kind === "scrutiny" ? `scrutiny-validator-${milestone}` : `user-testing-validator-${milestone}`;
99
+ return {
100
+ id,
101
+ description: kind === "scrutiny" ? `Validate milestone ${milestone} for correctness, regressions, and test adequacy.` : `Validate milestone ${milestone} through user-facing workflow checks.`,
102
+ skillName: kind === "scrutiny" ? "scrutiny-validator" : "user-testing-validator",
103
+ milestone,
104
+ preconditions: [`implementation milestone ${milestone} completed`],
105
+ expectedBehavior: kind === "scrutiny" ? "The completed milestone satisfies its contract and does not regress existing behavior." : "The completed milestone works from the user workflow perspective.",
106
+ verificationSteps: kind === "scrutiny" ? ["review feature handoffs", "run relevant automated checks"] : ["exercise the milestone workflow", "record manual observations"],
107
+ fulfills: [`milestone:${milestone}:validation`],
108
+ status: "pending",
109
+ isValidation: true,
110
+ validatesMilestone: milestone,
111
+ validationKind: kind
112
+ };
113
+ }
114
+ function agentForFeature(feature) {
115
+ if (feature.skillName === "scrutiny-validator") {
116
+ return "droid-mission-scrutiny-validator";
117
+ }
118
+ if (feature.skillName === "user-testing-validator") {
119
+ return "droid-mission-user-testing-validator";
120
+ }
121
+ return "droid-mission-worker";
122
+ }
123
+ function hasUnfinishedWork(value) {
124
+ const trimmed = value.trim();
125
+ return trimmed !== "" && trimmed.toLowerCase() !== "none";
126
+ }
127
+ function isValidationFeature(feature) {
128
+ return feature.isValidation === true || feature.skillName === "scrutiny-validator" || feature.skillName === "user-testing-validator";
129
+ }
130
+ function countSentences(text) {
131
+ const normalized = text.replace(/\s+/g, " ").trim().replace(/[.!?]+\s*$/, "");
132
+ if (!normalized)
133
+ return 0;
134
+ return normalized.split(/[.!?]+\s+/).filter(Boolean).length;
135
+ }
136
+ function assertHandoffContract(args) {
137
+ const summary = args.handoff.salientSummary.trim();
138
+ if (summary.length < 20 || summary.length > 750 || summary.includes(`
139
+ `) || countSentences(summary) < 1 || countSentences(summary) > 6) {
140
+ throw new Error("handoff.salientSummary must be 20-750 characters and 1-6 sentences with no newlines");
141
+ }
142
+ if (args.handoff.whatWasImplemented.trim().length < 50) {
143
+ throw new Error("handoff.whatWasImplemented must be at least 50 characters");
144
+ }
145
+ for (const command of args.handoff.verification.commandsRun) {
146
+ if (!command.command.trim())
147
+ throw new Error("verification command is required");
148
+ if (!Number.isFinite(command.exitCode))
149
+ throw new Error("verification exitCode must be numeric");
150
+ if (!command.observation.trim())
151
+ throw new Error("verification observation is required");
152
+ }
153
+ if (!Array.isArray(args.handoff.tests.added)) {
154
+ throw new Error("handoff.tests.added must be an array");
155
+ }
156
+ if (!args.handoff.tests.coverage.trim()) {
157
+ throw new Error("handoff.tests.coverage is required");
158
+ }
159
+ for (const issue of args.handoff.discoveredIssues) {
160
+ if (!issue.description.trim())
161
+ throw new Error("discovered issue description is required");
162
+ if (!["blocking", "non_blocking", "suggestion"].includes(issue.severity)) {
163
+ throw new Error("discovered issue severity is invalid");
164
+ }
165
+ }
166
+ }
167
+
168
+ class MissionRunner {
169
+ store;
170
+ client;
171
+ constructor(store, client) {
172
+ this.store = store;
173
+ this.client = client;
174
+ }
175
+ async startMission(args) {
176
+ const firstStarted = {};
177
+ const maxIterations = 100;
178
+ for (let iteration = 0;iteration < maxIterations; iteration++) {
179
+ const at = nowIso(args.now);
180
+ const state = await this.store.readState(args.missionId);
181
+ const features = await this.store.readFeatures(args.missionId);
182
+ if (state.status === "paused") {
183
+ const pausedFeature = features.find((candidate) => candidate.status === "in_progress");
184
+ const pausedIndex = pausedFeature ? features.findIndex((feature2) => feature2.id === pausedFeature.id) : -1;
185
+ const firstPendingIndex = features.findIndex((feature2) => feature2.status === "pending");
186
+ if (pausedFeature && firstPendingIndex !== -1 && pausedIndex !== -1 && firstPendingIndex < pausedIndex) {
187
+ pausedFeature.status = "pending";
188
+ pausedFeature.workerSessionId = undefined;
189
+ pausedFeature.startedAt = undefined;
190
+ state.status = "running";
191
+ state.pauseReason = undefined;
192
+ state.activeFeatureId = undefined;
193
+ state.activeWorkerSessionId = undefined;
194
+ state.updatedAt = at;
195
+ await this.store.writeFeatureRecords(args.missionId, features);
196
+ await this.store.writeState(state);
197
+ continue;
198
+ }
199
+ if (pausedFeature && args.restartFeature) {
200
+ pausedFeature.status = "pending";
201
+ pausedFeature.workerSessionId = undefined;
202
+ pausedFeature.startedAt = undefined;
203
+ state.status = "running";
204
+ state.pauseReason = undefined;
205
+ state.activeFeatureId = undefined;
206
+ state.activeWorkerSessionId = undefined;
207
+ state.updatedAt = at;
208
+ await this.store.writeFeatureRecords(args.missionId, features);
209
+ await this.store.writeState(state);
210
+ continue;
211
+ }
212
+ if (pausedFeature && (args.resumeWorkerSessionId === undefined || pausedFeature.workerSessionId === args.resumeWorkerSessionId)) {
213
+ await this.resumeWorker({
214
+ missionId: args.missionId,
215
+ state,
216
+ feature: pausedFeature,
217
+ workerSessionId: pausedFeature.workerSessionId,
218
+ now: at
219
+ });
220
+ if (!firstStarted.featureId) {
221
+ firstStarted.featureId = pausedFeature.id;
222
+ firstStarted.workerSessionId = pausedFeature.workerSessionId;
223
+ }
224
+ continue;
225
+ }
226
+ return {
227
+ started: false,
228
+ completed: false,
229
+ missionId: args.missionId,
230
+ blockedReason: "paused",
231
+ systemMessage: state.pauseReason ?? "Mission is paused."
232
+ };
233
+ }
234
+ const unresolved = await this.getUnresolvedHandoffItems(args.missionId);
235
+ if (unresolved.length > 0) {
236
+ state.status = "orchestrator_turn";
237
+ state.updatedAt = at;
238
+ await this.store.writeState(state);
239
+ return {
240
+ started: false,
241
+ completed: false,
242
+ missionId: args.missionId,
243
+ blockedReason: "unresolved_handoff",
244
+ systemMessage: `Mission has ${unresolved.length} unresolved handoff item(s). Update mission artifacts or dismiss them before continuing.`
245
+ };
246
+ }
247
+ const validationInserted = this.injectEligibleMilestoneValidators(features);
248
+ if (validationInserted.length > 0) {
249
+ await this.store.writeFeatureRecords(args.missionId, features);
250
+ await this.store.appendProgress(args.missionId, {
251
+ type: "milestone_validation_triggered",
252
+ at,
253
+ missionId: args.missionId,
254
+ summary: "Validation queued for terminal milestone",
255
+ details: { validationInserted }
256
+ });
257
+ continue;
258
+ }
259
+ if (allFeaturesCompleted(features)) {
260
+ const validationBlockers = await this.getValidationStateBlockers(args.missionId);
261
+ if (validationBlockers.length > 0) {
262
+ state.status = "orchestrator_turn";
263
+ state.updatedAt = at;
264
+ await this.store.writeState(state);
265
+ return {
266
+ started: false,
267
+ completed: false,
268
+ missionId: args.missionId,
269
+ blockedReason: "validation_state",
270
+ systemMessage: `Mission validation-state has ${validationBlockers.length} unresolved assertion(s).`
271
+ };
272
+ }
273
+ state.status = "completed";
274
+ state.completedAt = state.completedAt ?? at;
275
+ state.updatedAt = at;
276
+ state.activeFeatureId = undefined;
277
+ state.activeWorkerSessionId = undefined;
278
+ await this.store.writeState(state);
279
+ await this.store.appendProgress(args.missionId, {
280
+ type: "mission_completed",
281
+ at,
282
+ missionId: args.missionId,
283
+ summary: "All mission features are complete"
284
+ });
285
+ return {
286
+ started: false,
287
+ completed: true,
288
+ missionId: args.missionId,
289
+ systemMessage: "Mission completed."
290
+ };
291
+ }
292
+ const inProgress = features.find((candidate) => candidate.status === "in_progress");
293
+ if (inProgress) {
294
+ return {
295
+ started: firstStarted.featureId !== undefined,
296
+ completed: false,
297
+ missionId: args.missionId,
298
+ featureId: inProgress.id,
299
+ workerSessionId: inProgress.workerSessionId,
300
+ blockedReason: "worker_in_progress",
301
+ systemMessage: `Feature "${inProgress.id}" is already in progress in worker session "${inProgress.workerSessionId ?? "unknown"}".`
302
+ };
303
+ }
304
+ const feature = features.find((candidate) => candidate.status === "pending");
305
+ if (!feature) {
306
+ state.status = features.length === 0 ? "awaiting_input" : "orchestrator_turn";
307
+ state.updatedAt = at;
308
+ await this.store.writeState(state);
309
+ return {
310
+ started: false,
311
+ completed: false,
312
+ missionId: args.missionId,
313
+ systemMessage: features.length === 0 ? "Mission has no features yet." : "No pending features are available."
314
+ };
315
+ }
316
+ if (!this.client.session?.create || !this.client.session.prompt) {
317
+ throw new Error("OpenCode session client is unavailable");
318
+ }
319
+ const createResponse = await this.client.session.create({
320
+ body: {
321
+ parentID: args.parentSessionId,
322
+ title: `${state.title}: ${feature.id}`
323
+ },
324
+ query: {
325
+ directory: state.workingDirectory
326
+ }
327
+ });
328
+ const workerSessionId = extractSessionId(createResponse);
329
+ if (!firstStarted.featureId) {
330
+ firstStarted.featureId = feature.id;
331
+ firstStarted.workerSessionId = workerSessionId;
332
+ }
333
+ feature.status = "in_progress";
334
+ feature.workerSessionId = workerSessionId;
335
+ feature.startedAt = at;
336
+ state.status = "running";
337
+ state.updatedAt = at;
338
+ state.activeFeatureId = feature.id;
339
+ state.activeWorkerSessionId = workerSessionId;
340
+ state.lastMessage = args.message;
341
+ await this.store.writeFeatureRecords(args.missionId, features);
342
+ await this.store.writeState(state);
343
+ await this.store.appendProgress(args.missionId, {
344
+ type: "worker_selected_feature",
345
+ at,
346
+ missionId: args.missionId,
347
+ featureId: feature.id,
348
+ workerSessionId,
349
+ summary: feature.description
350
+ });
351
+ await this.store.appendProgress(args.missionId, {
352
+ type: "worker_started",
353
+ at,
354
+ missionId: args.missionId,
355
+ featureId: feature.id,
356
+ workerSessionId,
357
+ summary: feature.description
358
+ });
359
+ try {
360
+ await this.client.session.prompt({
361
+ path: { id: workerSessionId },
362
+ query: { directory: state.workingDirectory },
363
+ body: {
364
+ agent: agentForFeature(feature),
365
+ parts: [
366
+ {
367
+ type: "text",
368
+ text: buildWorkerPrompt({
369
+ state,
370
+ feature,
371
+ missionDir: this.store.missionDir(args.missionId),
372
+ message: args.message
373
+ })
374
+ }
375
+ ],
376
+ tools: {
377
+ droid_mission_end_feature: true,
378
+ droid_mission_status: true,
379
+ droid_mission_write_artifact: true
380
+ }
381
+ }
382
+ });
383
+ } catch (error) {
384
+ feature.status = "pending";
385
+ feature.workerSessionId = undefined;
386
+ feature.startedAt = undefined;
387
+ state.status = "orchestrator_turn";
388
+ state.activeFeatureId = undefined;
389
+ state.activeWorkerSessionId = undefined;
390
+ state.updatedAt = nowIso();
391
+ await this.store.writeFeatureRecords(args.missionId, features);
392
+ await this.store.writeState(state);
393
+ await this.store.appendProgress(args.missionId, {
394
+ type: "worker_failed",
395
+ at: state.updatedAt,
396
+ missionId: args.missionId,
397
+ featureId: feature.id,
398
+ workerSessionId,
399
+ summary: error instanceof Error ? error.message : String(error)
400
+ });
401
+ throw error;
402
+ }
403
+ }
404
+ throw new Error(`Mission run loop exceeded ${maxIterations} iterations`);
405
+ }
406
+ async endFeatureRun(args) {
407
+ const at = nowIso(args.now);
408
+ const state = await this.store.readState(args.missionId);
409
+ const features = await this.store.readFeatures(args.missionId);
410
+ const feature = features.find((candidate) => candidate.id === args.featureId);
411
+ if (!feature) {
412
+ throw new Error(`Unknown feature: ${args.featureId}`);
413
+ }
414
+ if (feature.status !== "in_progress") {
415
+ throw new Error(`Feature "${feature.id}" is not in progress`);
416
+ }
417
+ if (feature.workerSessionId && args.workerSessionId && feature.workerSessionId !== args.workerSessionId) {
418
+ throw new Error(`Worker "${args.workerSessionId}" does not own feature "${feature.id}"`);
419
+ }
420
+ if (state.activeFeatureId && state.activeFeatureId !== feature.id && state.activeWorkerSessionId === args.workerSessionId) {
421
+ throw new Error(`Worker "${args.workerSessionId}" is not assigned to feature "${feature.id}"`);
422
+ }
423
+ if (args.successState === "success" && args.validatorsPassed !== true) {
424
+ throw new Error('validatorsPassed must be true when successState is "success"');
425
+ }
426
+ if (args.commitId && !args.repoPath) {
427
+ throw new Error("repoPath is required when commitId is provided");
428
+ }
429
+ if (args.codeChanged && (!args.commitId || !args.repoPath)) {
430
+ throw new Error("commitId and repoPath are required when codeChanged is true");
431
+ }
432
+ assertHandoffContract(args);
433
+ feature.workerSessionId = args.workerSessionId ?? feature.workerSessionId;
434
+ feature.completedAt = at;
435
+ feature.status = args.successState === "success" ? "completed" : "pending";
436
+ const hasHandoffIssues = args.handoff.discoveredIssues.length > 0 || hasUnfinishedWork(args.handoff.whatWasLeftUndone);
437
+ const forceReturnToOrchestrator = args.returnToOrchestrator === true || isValidationFeature(feature);
438
+ state.status = args.successState === "success" && !hasHandoffIssues && !forceReturnToOrchestrator ? "running" : "orchestrator_turn";
439
+ state.updatedAt = at;
440
+ state.activeFeatureId = state.activeFeatureId === feature.id ? undefined : state.activeFeatureId;
441
+ state.activeWorkerSessionId = state.activeWorkerSessionId === feature.workerSessionId ? undefined : state.activeWorkerSessionId;
442
+ if (args.successState === "success") {
443
+ const currentIndex = features.findIndex((candidate) => candidate.id === feature.id);
444
+ if (currentIndex >= 0) {
445
+ const [completedFeature] = features.splice(currentIndex, 1);
446
+ if (completedFeature)
447
+ features.push(completedFeature);
448
+ }
449
+ }
450
+ const storedHandoff = await this.store.appendHandoff(args.missionId, {
451
+ featureId: feature.id,
452
+ workerSessionId: feature.workerSessionId,
453
+ successState: args.successState,
454
+ returnToOrchestrator: forceReturnToOrchestrator || hasHandoffIssues,
455
+ validatorsPassed: args.validatorsPassed,
456
+ commitId: args.commitId,
457
+ repoPath: args.repoPath,
458
+ handoff: args.handoff,
459
+ now: at
460
+ });
461
+ const validationInserted = args.successState === "success" ? this.injectMilestoneValidators(features, feature) : [];
462
+ if (validationInserted.length > 0) {
463
+ await this.store.appendProgress(args.missionId, {
464
+ type: "milestone_validation_triggered",
465
+ at,
466
+ missionId: args.missionId,
467
+ featureId: feature.id,
468
+ summary: `Validation queued for milestone ${feature.milestone}`,
469
+ details: { validationInserted }
470
+ });
471
+ }
472
+ await this.store.writeFeatureRecords(args.missionId, features);
473
+ await this.store.writeState(state);
474
+ await this.store.appendProgress(args.missionId, {
475
+ type: progressType(args.successState),
476
+ at,
477
+ missionId: args.missionId,
478
+ featureId: feature.id,
479
+ workerSessionId: feature.workerSessionId,
480
+ summary: args.handoff.salientSummary,
481
+ details: {
482
+ returnToOrchestrator: args.returnToOrchestrator ?? hasHandoffIssues,
483
+ validationFeature: isValidationFeature(feature),
484
+ validatorsPassed: args.validatorsPassed,
485
+ commitId: args.commitId,
486
+ repoPath: args.repoPath,
487
+ handoffFile: storedHandoff.handoffFile
488
+ }
489
+ });
490
+ return { feature, validationInserted };
491
+ }
492
+ async pauseMission(missionId, reason, now) {
493
+ const at = nowIso(now);
494
+ const state = await this.store.readState(missionId);
495
+ state.status = "paused";
496
+ state.pauseReason = reason ?? "Paused by mission tool";
497
+ state.updatedAt = at;
498
+ await this.store.writeState(state);
499
+ await this.store.appendProgress(missionId, {
500
+ type: "mission_paused",
501
+ at,
502
+ missionId,
503
+ summary: state.pauseReason
504
+ });
505
+ if (state.activeWorkerSessionId && this.client.session?.abort) {
506
+ await this.client.session.abort({ path: { id: state.activeWorkerSessionId } });
507
+ }
508
+ return state;
509
+ }
510
+ async resumeWorker(args) {
511
+ if (!this.client.session?.prompt) {
512
+ throw new Error("OpenCode session client is unavailable");
513
+ }
514
+ if (!args.workerSessionId) {
515
+ throw new Error(`Cannot resume feature "${args.feature.id}" without a worker session id`);
516
+ }
517
+ args.state.status = "running";
518
+ args.state.pauseReason = undefined;
519
+ args.state.activeFeatureId = args.feature.id;
520
+ args.state.activeWorkerSessionId = args.workerSessionId;
521
+ args.state.updatedAt = args.now;
522
+ await this.store.writeState(args.state);
523
+ await this.store.appendProgress(args.missionId, {
524
+ type: "mission_resumed",
525
+ at: args.now,
526
+ missionId: args.missionId,
527
+ featureId: args.feature.id,
528
+ workerSessionId: args.workerSessionId,
529
+ summary: "Resumed paused worker session"
530
+ });
531
+ await this.client.session.prompt({
532
+ path: { id: args.workerSessionId },
533
+ query: { directory: args.state.workingDirectory },
534
+ body: {
535
+ agent: agentForFeature(args.feature),
536
+ parts: [
537
+ {
538
+ type: "text",
539
+ text: buildResumePrompt({
540
+ state: args.state,
541
+ feature: args.feature,
542
+ missionDir: this.store.missionDir(args.missionId)
543
+ })
544
+ }
545
+ ],
546
+ tools: {
547
+ droid_mission_end_feature: true,
548
+ droid_mission_status: true,
549
+ droid_mission_write_artifact: true
550
+ }
551
+ }
552
+ });
553
+ }
554
+ async completeMission(missionId, now) {
555
+ const at = nowIso(now);
556
+ const features = await this.store.readFeatures(missionId);
557
+ const validationInserted = this.injectEligibleMilestoneValidators(features);
558
+ if (validationInserted.length > 0) {
559
+ await this.store.writeFeatureRecords(missionId, features);
560
+ await this.store.appendProgress(missionId, {
561
+ type: "milestone_validation_triggered",
562
+ at,
563
+ missionId,
564
+ summary: "Validation queued before manual mission completion",
565
+ details: { validationInserted }
566
+ });
567
+ }
568
+ const incomplete = features.filter((feature) => feature.status !== "completed" && feature.status !== "cancelled");
569
+ if (incomplete.length > 0) {
570
+ throw new Error(`Cannot complete mission with incomplete features: ${incomplete.map((feature) => feature.id).join(", ")}`);
571
+ }
572
+ const unresolved = await this.getUnresolvedHandoffItems(missionId);
573
+ if (unresolved.length > 0) {
574
+ throw new Error(`Cannot complete mission with unresolved handoff items: ${unresolved.join("; ")}`);
575
+ }
576
+ const validationBlockers = await this.getValidationStateBlockers(missionId);
577
+ if (validationBlockers.length > 0) {
578
+ throw new Error(`Cannot complete mission with unresolved validation-state assertions: ${validationBlockers.join("; ")}`);
579
+ }
580
+ const state = await this.store.readState(missionId);
581
+ state.status = "completed";
582
+ state.completedAt = at;
583
+ state.updatedAt = at;
584
+ state.activeFeatureId = undefined;
585
+ state.activeWorkerSessionId = undefined;
586
+ await this.store.writeState(state);
587
+ await this.store.appendProgress(missionId, {
588
+ type: "mission_completed",
589
+ at,
590
+ missionId,
591
+ summary: "Mission manually completed"
592
+ });
593
+ return state;
594
+ }
595
+ injectMilestoneValidators(features, completedFeature) {
596
+ if (isValidationFeature(completedFeature))
597
+ return [];
598
+ return this.injectValidatorsForMilestone(features, completedFeature.milestone);
599
+ }
600
+ injectEligibleMilestoneValidators(features) {
601
+ const inserted = [];
602
+ const milestones = new Set(features.filter((feature) => !isValidationFeature(feature)).map((feature) => feature.milestone).filter(Boolean));
603
+ for (const milestone of milestones) {
604
+ inserted.push(...this.injectValidatorsForMilestone(features, milestone));
605
+ }
606
+ return inserted;
607
+ }
608
+ injectValidatorsForMilestone(features, milestone) {
609
+ if (features.some((feature) => isValidationFeature(feature) && feature.validatesMilestone === milestone)) {
610
+ return [];
611
+ }
612
+ const implementationFeatures = features.filter((feature) => feature.milestone === milestone && !isValidationFeature(feature));
613
+ const shouldValidate = implementationFeatures.length > 0 && implementationFeatures.every((feature) => feature.status === "completed" || feature.status === "cancelled") && implementationFeatures.some((feature) => feature.status === "completed");
614
+ if (!shouldValidate)
615
+ return [];
616
+ const validators = [
617
+ validationFeature(milestone, "scrutiny"),
618
+ validationFeature(milestone, "user-testing")
619
+ ];
620
+ features.splice(0, 0, ...validators);
621
+ return validators.map((feature) => feature.id);
622
+ }
623
+ async getUnresolvedHandoffItems(missionId) {
624
+ const handoffs = await this.store.readHandoffs(missionId);
625
+ const progress = await this.store.readProgress(missionId);
626
+ return handoffs.filter((handoff) => {
627
+ if (handoff.dismissedAt)
628
+ return false;
629
+ const handoffTime = new Date(handoff.createdAt).getTime();
630
+ return !progress.some((entry) => {
631
+ const clearsHandoff = entry.type === "features_written" || entry.type === "artifact_written" && entry.summary === "mission.md";
632
+ if (!clearsHandoff) {
633
+ return false;
634
+ }
635
+ return new Date(entry.at).getTime() > handoffTime;
636
+ });
637
+ }).flatMap((handoff) => [
638
+ ...handoff.discoveredIssues.map((issue) => `${handoff.id}: discovered issue: ${issue.description}`),
639
+ ...hasUnfinishedWork(handoff.whatWasLeftUndone) ? [`${handoff.id}: unfinished work: ${handoff.whatWasLeftUndone}`] : []
640
+ ]);
641
+ }
642
+ async getValidationStateBlockers(missionId) {
643
+ const validationState = await this.store.readValidationState(missionId);
644
+ const blockers = [];
645
+ const visit = (value, trail, assertionContext) => {
646
+ if (!value || typeof value !== "object")
647
+ return;
648
+ for (const [key, child] of Object.entries(value)) {
649
+ const nextTrail = [...trail, key];
650
+ const inAssertionContext = assertionContext || key === "assertions" || key === "validationAssertions";
651
+ if (typeof child === "string") {
652
+ const normalized = child.toLowerCase();
653
+ const isStatusKey = key === "status" || key === "state";
654
+ if ((inAssertionContext || isStatusKey) && normalized !== "passed") {
655
+ blockers.push(`${nextTrail.join(".")}: ${child}`);
656
+ }
657
+ continue;
658
+ }
659
+ visit(child, nextTrail, inAssertionContext);
660
+ }
661
+ };
662
+ visit(validationState, [], false);
663
+ return blockers;
664
+ }
665
+ }
666
+
667
+ // src/droid-missions/store.ts
668
+ import {
669
+ mkdir,
670
+ readdir,
671
+ readFile,
672
+ rename,
673
+ rm,
674
+ stat,
675
+ writeFile
676
+ } from "fs/promises";
677
+ import path2 from "path";
678
+
679
+ // src/droid-missions/paths.ts
680
+ import path from "path";
681
+ var STORAGE_DIR = path.join(".opencode", "droid-missions");
682
+ var MISSIONS_DIR = "missions";
683
+ function missionStorageRoot(projectRoot) {
684
+ return path.join(projectRoot, STORAGE_DIR);
685
+ }
686
+ function missionsRoot(projectRoot) {
687
+ return path.join(missionStorageRoot(projectRoot), MISSIONS_DIR);
688
+ }
689
+ function sanitizeMissionId(input) {
690
+ const value = input.trim();
691
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value)) {
692
+ throw new Error("Invalid mission id. Use 1-128 letters, numbers, dots, underscores, or hyphens.");
693
+ }
694
+ return value;
695
+ }
696
+ function createMissionId(title, now) {
697
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "mission";
698
+ const stamp = new Date(now).getTime().toString(36);
699
+ return sanitizeMissionId(`${slug}-${stamp}`);
700
+ }
701
+ function missionDir(projectRoot, missionId) {
702
+ return path.join(missionsRoot(projectRoot), sanitizeMissionId(missionId));
703
+ }
704
+ function toMissionRelativePath(relativePath) {
705
+ const normalized = relativePath.replaceAll("\\", "/").replace(/^\/+/, "");
706
+ if (normalized === "" || normalized.startsWith("../") || normalized.includes("/../") || normalized === "..") {
707
+ throw new Error(`Invalid mission artifact path: ${relativePath}`);
708
+ }
709
+ return path.posix.normalize(normalized);
710
+ }
711
+ function assertInside(parent, child) {
712
+ const resolvedParent = path.resolve(parent);
713
+ const resolvedChild = path.resolve(child);
714
+ if (resolvedChild !== resolvedParent && !resolvedChild.startsWith(resolvedParent + path.sep)) {
715
+ throw new Error(`Path escapes mission directory: ${child}`);
716
+ }
717
+ }
718
+ function isMissionSystemFile(relativePath) {
719
+ const systemFiles = new Set([
720
+ "state.json",
721
+ "features.json",
722
+ "progress_log.jsonl",
723
+ "handoffs.jsonl",
724
+ "worker-transcripts.jsonl",
725
+ "working_directory.txt"
726
+ ]);
727
+ return systemFiles.has(toMissionRelativePath(relativePath));
728
+ }
729
+
730
+ // src/droid-missions/validation.ts
731
+ var systemManagedFeatureFields = new Set([
732
+ "status",
733
+ "workerSessionId",
734
+ "startedAt",
735
+ "completedAt",
736
+ "isValidation",
737
+ "validatesMilestone",
738
+ "validationKind"
739
+ ]);
740
+ var allowedArtifactFiles = new Set([
741
+ "mission.md",
742
+ "AGENTS.md",
743
+ "validation-contract.md",
744
+ "validation-state.json",
745
+ "services.yaml",
746
+ "init.sh",
747
+ "model-settings.json",
748
+ "runtime-custom-models.json"
749
+ ]);
750
+ function assertRecord(value, label) {
751
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
752
+ throw new Error(`${label} must be an object`);
753
+ }
754
+ }
755
+ function readString(record, key) {
756
+ const value = record[key];
757
+ if (typeof value !== "string" || value.trim() === "") {
758
+ throw new Error(`Feature field "${key}" must be a non-empty string`);
759
+ }
760
+ return value.trim();
761
+ }
762
+ function readStringArray(record, key) {
763
+ const value = record[key];
764
+ if (value === undefined)
765
+ return [];
766
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
767
+ throw new Error(`Feature field "${key}" must be an array of strings`);
768
+ }
769
+ return value.map((item) => item.trim()).filter(Boolean);
770
+ }
771
+ function validateFeaturePlans(inputs, skillNames) {
772
+ const ids = new Set;
773
+ return inputs.map((input, index) => {
774
+ assertRecord(input, `Feature ${index + 1}`);
775
+ for (const key of Object.keys(input)) {
776
+ if (systemManagedFeatureFields.has(key)) {
777
+ throw new Error(`Feature "${String(input.id ?? index + 1)}" includes system-managed field "${key}"`);
778
+ }
779
+ }
780
+ const id = readString(input, "id");
781
+ if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(id)) {
782
+ throw new Error(`Invalid feature id: ${id}`);
783
+ }
784
+ if (ids.has(id)) {
785
+ throw new Error(`Duplicate feature id: ${id}`);
786
+ }
787
+ ids.add(id);
788
+ const skillName = readString(input, "skillName");
789
+ if (!skillNames.has(skillName)) {
790
+ throw new Error(`Missing mission skill "${skillName}" for feature "${id}"`);
791
+ }
792
+ const feature = {
793
+ id,
794
+ description: readString(input, "description"),
795
+ skillName,
796
+ milestone: readString(input, "milestone"),
797
+ preconditions: readStringArray(input, "preconditions"),
798
+ expectedBehavior: readString(input, "expectedBehavior"),
799
+ verificationSteps: readStringArray(input, "verificationSteps"),
800
+ fulfills: readStringArray(input, "fulfills")
801
+ };
802
+ return {
803
+ ...feature,
804
+ preconditions: feature.preconditions ?? [],
805
+ verificationSteps: feature.verificationSteps ?? [],
806
+ fulfills: feature.fulfills ?? [],
807
+ status: "pending"
808
+ };
809
+ });
810
+ }
811
+ function validateArtifactPath(relativePath) {
812
+ const normalized = toMissionRelativePath(relativePath);
813
+ if (isMissionSystemFile(normalized)) {
814
+ throw new Error(`Cannot write system-managed mission file: ${normalized}`);
815
+ }
816
+ if (allowedArtifactFiles.has(normalized)) {
817
+ return normalized;
818
+ }
819
+ if (normalized.startsWith("skills/") && normalized.endsWith("/SKILL.md") && normalized.split("/").length === 3) {
820
+ return normalized;
821
+ }
822
+ if (normalized.startsWith("library/") && normalized.length > "library/".length) {
823
+ return normalized;
824
+ }
825
+ if (normalized.startsWith("scripts/") && normalized.length > "scripts/".length) {
826
+ return normalized;
827
+ }
828
+ throw new Error(`Invalid mission artifact path: ${relativePath}`);
829
+ }
830
+
831
+ // src/droid-missions/store.ts
832
+ var DEFAULT_IMPLEMENTATION_SKILL = `---
833
+ name: implementation
834
+ description: Implement one mission feature, verify it, and report the result through the mission completion tool.
835
+ ---
836
+
837
+ Work only on the assigned feature. Use the mission files as the source of truth. When finished, call the mission completion tool with a structured handoff.
838
+ `;
839
+ var DEFAULT_SCRUTINY_SKILL = `---
840
+ name: scrutiny-validator
841
+ description: Validate a completed mission milestone for correctness, regressions, and missing tests.
842
+ ---
843
+
844
+ Review the milestone implementation against the validation contract and feature handoffs. Record verification commands and call the mission completion tool.
845
+ `;
846
+ var DEFAULT_USER_TESTING_SKILL = `---
847
+ name: user-testing-validator
848
+ description: Validate a completed mission milestone from the expected user workflow.
849
+ ---
850
+
851
+ Exercise the milestone as a user would. Capture manual checks, issues, and confidence in the mission completion tool.
852
+ `;
853
+ function nowIso2(now) {
854
+ return now ?? new Date().toISOString();
855
+ }
856
+ async function pathExists(filePath) {
857
+ try {
858
+ await stat(filePath);
859
+ return true;
860
+ } catch {
861
+ return false;
862
+ }
863
+ }
864
+ async function writeJson(filePath, value) {
865
+ await mkdir(path2.dirname(filePath), { recursive: true });
866
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
867
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
868
+ `, "utf8");
869
+ await rename(tempPath, filePath);
870
+ }
871
+ async function readJson(filePath) {
872
+ return JSON.parse(await readFile(filePath, "utf8"));
873
+ }
874
+ async function readJsonl(filePath) {
875
+ if (!await pathExists(filePath))
876
+ return [];
877
+ const content = await readFile(filePath, "utf8");
878
+ return content.split(`
879
+ `).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
880
+ }
881
+ async function appendJsonl(filePath, value) {
882
+ await mkdir(path2.dirname(filePath), { recursive: true });
883
+ await writeFile(filePath, `${JSON.stringify(value)}
884
+ `, {
885
+ encoding: "utf8",
886
+ flag: "a"
887
+ });
888
+ }
889
+ function missionAgentsFile(title) {
890
+ return `# OpenCode mission mode: ${title}
891
+
892
+ This directory is managed by the OpenCode mission plugin.
893
+
894
+ - The mission state files are system-managed. Use the mission tools instead of direct edits.
895
+ - The orchestrator owns decomposition, artifacts, ordering, and validation.
896
+ - Workers own exactly one feature at a time and must finish with a structured handoff.
897
+ - Validation features are created automatically when implementation for a milestone is complete.
898
+ `;
899
+ }
900
+ function missionMarkdown(args, missionId, createdAt) {
901
+ return `# ${args.title}
902
+
903
+ Mission ID: ${missionId}
904
+ Created: ${createdAt}
905
+
906
+ ## Goal
907
+
908
+ ${args.goal}
909
+
910
+ ## Operating Model
911
+
912
+ Plan artifacts, write feature slices, run one worker per feature, then complete milestone validation before declaring the mission done.
913
+ `;
914
+ }
915
+ function handoffId(featureId, at) {
916
+ return `${featureId}-${at.replace(/[^a-z0-9]/gi, "").slice(0, 20)}`;
917
+ }
918
+
919
+ class MissionStore {
920
+ projectRoot;
921
+ constructor(projectRoot) {
922
+ this.projectRoot = projectRoot;
923
+ }
924
+ storageRoot() {
925
+ return missionStorageRoot(this.projectRoot);
926
+ }
927
+ missionsRoot() {
928
+ return missionsRoot(this.projectRoot);
929
+ }
930
+ missionDir(missionId) {
931
+ return missionDir(this.projectRoot, missionId);
932
+ }
933
+ async createMission(args) {
934
+ const createdAt = nowIso2(args.now);
935
+ const missionId = args.missionId ? sanitizeMissionId(args.missionId) : createMissionId(args.title, createdAt);
936
+ const dir = this.missionDir(missionId);
937
+ if (await pathExists(dir)) {
938
+ throw new Error(`Mission already exists: ${missionId}`);
939
+ }
940
+ await mkdir(path2.join(dir, "skills", "implementation"), { recursive: true });
941
+ await mkdir(path2.join(dir, "skills", "scrutiny-validator"), {
942
+ recursive: true
943
+ });
944
+ await mkdir(path2.join(dir, "skills", "user-testing-validator"), {
945
+ recursive: true
946
+ });
947
+ await mkdir(path2.join(dir, "library"), { recursive: true });
948
+ await mkdir(path2.join(dir, "scripts"), { recursive: true });
949
+ await mkdir(path2.join(dir, "handoffs"), { recursive: true });
950
+ const state = {
951
+ missionId,
952
+ title: args.title,
953
+ goal: args.goal,
954
+ status: "awaiting_input",
955
+ workingDirectory: args.workingDirectory ?? this.projectRoot,
956
+ createdAt,
957
+ updatedAt: createdAt
958
+ };
959
+ await writeFile(path2.join(dir, "mission.md"), missionMarkdown(args, missionId, createdAt));
960
+ await writeFile(path2.join(dir, "working_directory.txt"), `${state.workingDirectory}
961
+ `);
962
+ await writeFile(path2.join(dir, "AGENTS.md"), missionAgentsFile(args.title));
963
+ await writeFile(path2.join(dir, "skills", "implementation", "SKILL.md"), DEFAULT_IMPLEMENTATION_SKILL);
964
+ await writeFile(path2.join(dir, "skills", "scrutiny-validator", "SKILL.md"), DEFAULT_SCRUTINY_SKILL);
965
+ await writeFile(path2.join(dir, "skills", "user-testing-validator", "SKILL.md"), DEFAULT_USER_TESTING_SKILL);
966
+ await writeJson(path2.join(dir, "state.json"), state);
967
+ await writeJson(path2.join(dir, "features.json"), []);
968
+ await writeFile(path2.join(dir, "progress_log.jsonl"), "");
969
+ await writeFile(path2.join(dir, "handoffs.jsonl"), "");
970
+ await writeFile(path2.join(dir, "worker-transcripts.jsonl"), "");
971
+ await writeJson(path2.join(dir, "validation-state.json"), {
972
+ missionId,
973
+ milestones: {}
974
+ });
975
+ await this.appendProgress(missionId, {
976
+ type: "mission_accepted",
977
+ at: createdAt,
978
+ missionId,
979
+ summary: args.title
980
+ });
981
+ return { dir, state, features: [] };
982
+ }
983
+ async removeMission(missionId) {
984
+ await rm(this.missionDir(missionId), { recursive: true, force: true });
985
+ }
986
+ async listMissions() {
987
+ if (!await pathExists(this.missionsRoot()))
988
+ return [];
989
+ const entries = await readdir(this.missionsRoot(), { withFileTypes: true });
990
+ const states = [];
991
+ for (const entry of entries) {
992
+ if (!entry.isDirectory())
993
+ continue;
994
+ try {
995
+ states.push(await this.readState(entry.name));
996
+ } catch {}
997
+ }
998
+ return states.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
999
+ }
1000
+ async readState(missionId) {
1001
+ return readJson(path2.join(this.missionDir(missionId), "state.json"));
1002
+ }
1003
+ async writeState(state) {
1004
+ await writeJson(path2.join(this.missionDir(state.missionId), "state.json"), state);
1005
+ }
1006
+ async readFeatures(missionId) {
1007
+ return readJson(path2.join(this.missionDir(missionId), "features.json"));
1008
+ }
1009
+ async writeFeatureRecords(missionId, features) {
1010
+ await writeJson(path2.join(this.missionDir(missionId), "features.json"), features);
1011
+ }
1012
+ async writeFeatures(missionId, inputs) {
1013
+ const skillNames = await this.listMissionSkills(missionId);
1014
+ const features = validateFeaturePlans(inputs, skillNames);
1015
+ await this.writeFeatureRecords(missionId, features);
1016
+ const state = await this.readState(missionId);
1017
+ state.status = "orchestrator_turn";
1018
+ state.updatedAt = nowIso2();
1019
+ await this.writeState(state);
1020
+ await this.appendProgress(missionId, {
1021
+ type: "features_written",
1022
+ at: state.updatedAt,
1023
+ missionId,
1024
+ summary: `${features.length} features written`
1025
+ });
1026
+ return features;
1027
+ }
1028
+ async listMissionSkills(missionId) {
1029
+ const skillsDir = path2.join(this.missionDir(missionId), "skills");
1030
+ const skills = new Set;
1031
+ if (!await pathExists(skillsDir))
1032
+ return skills;
1033
+ for (const entry of await readdir(skillsDir, { withFileTypes: true })) {
1034
+ if (!entry.isDirectory())
1035
+ continue;
1036
+ if (await pathExists(path2.join(skillsDir, entry.name, "SKILL.md"))) {
1037
+ skills.add(entry.name);
1038
+ }
1039
+ }
1040
+ return skills;
1041
+ }
1042
+ async writeArtifact(missionId, args) {
1043
+ const relativePath = validateArtifactPath(args.relativePath);
1044
+ const dir = this.missionDir(missionId);
1045
+ const target = path2.join(dir, relativePath);
1046
+ assertInside(dir, target);
1047
+ await mkdir(path2.dirname(target), { recursive: true });
1048
+ await writeFile(target, args.content, "utf8");
1049
+ await this.appendProgress(missionId, {
1050
+ type: "artifact_written",
1051
+ at: nowIso2(),
1052
+ missionId,
1053
+ summary: relativePath
1054
+ });
1055
+ return target;
1056
+ }
1057
+ async readProgress(missionId) {
1058
+ return readJsonl(path2.join(this.missionDir(missionId), "progress_log.jsonl"));
1059
+ }
1060
+ async appendProgress(missionId, entry) {
1061
+ await appendJsonl(path2.join(this.missionDir(missionId), "progress_log.jsonl"), entry);
1062
+ }
1063
+ async readHandoffs(missionId) {
1064
+ return readJsonl(path2.join(this.missionDir(missionId), "handoffs.jsonl"));
1065
+ }
1066
+ async writeHandoffs(missionId, handoffs) {
1067
+ await writeFile(path2.join(this.missionDir(missionId), "handoffs.jsonl"), handoffs.map((handoff) => JSON.stringify(handoff)).join(`
1068
+ `) + (handoffs.length ? `
1069
+ ` : ""), "utf8");
1070
+ }
1071
+ async appendHandoff(missionId, args) {
1072
+ const createdAt = nowIso2(args.now);
1073
+ const id = handoffId(args.featureId, createdAt);
1074
+ const handoffFile = path2.join(this.missionDir(missionId), "handoffs", `${id}.json`);
1075
+ const stored = {
1076
+ id,
1077
+ missionId,
1078
+ featureId: args.featureId,
1079
+ workerSessionId: args.workerSessionId,
1080
+ successState: args.successState,
1081
+ returnToOrchestrator: args.returnToOrchestrator,
1082
+ validatorsPassed: args.validatorsPassed,
1083
+ commitId: args.commitId,
1084
+ repoPath: args.repoPath,
1085
+ handoffFile,
1086
+ createdAt,
1087
+ ...args.handoff
1088
+ };
1089
+ await writeJson(handoffFile, stored);
1090
+ await this.appendTranscriptSkeleton(missionId, {
1091
+ workerSessionId: args.workerSessionId,
1092
+ featureId: args.featureId,
1093
+ milestone: undefined,
1094
+ skeleton: `Feature ${args.featureId} completed via OpenCode worker ${args.workerSessionId ?? "unknown"}.
1095
+
1096
+ Summary: ${args.handoff.salientSummary}
1097
+
1098
+ Commands:
1099
+ ${args.handoff.verification.commandsRun.map((command) => `- ${command.command} (exit ${command.exitCode}): ${command.observation}`).join(`
1100
+ `)}`,
1101
+ createdAt
1102
+ });
1103
+ await appendJsonl(path2.join(this.missionDir(missionId), "handoffs.jsonl"), stored);
1104
+ return stored;
1105
+ }
1106
+ async appendTranscriptSkeleton(missionId, args) {
1107
+ await appendJsonl(path2.join(this.missionDir(missionId), "worker-transcripts.jsonl"), {
1108
+ timestamp: args.createdAt ?? nowIso2(),
1109
+ workerSessionId: args.workerSessionId,
1110
+ featureId: args.featureId,
1111
+ milestone: args.milestone,
1112
+ skeleton: args.skeleton
1113
+ });
1114
+ }
1115
+ async readValidationState(missionId) {
1116
+ return readJson(path2.join(this.missionDir(missionId), "validation-state.json"));
1117
+ }
1118
+ async dismissHandoffItems(missionId, dismissals, now) {
1119
+ const at = nowIso2(now);
1120
+ const reasons = new Map(dismissals.map((item) => [item.id, item.reason]));
1121
+ const handoffs = (await this.readHandoffs(missionId)).map((handoff) => {
1122
+ const reason = reasons.get(handoff.id);
1123
+ if (!reason)
1124
+ return handoff;
1125
+ return {
1126
+ ...handoff,
1127
+ dismissedAt: at,
1128
+ dismissalReason: reason
1129
+ };
1130
+ });
1131
+ await this.writeHandoffs(missionId, handoffs);
1132
+ await this.appendProgress(missionId, {
1133
+ type: "handoff_items_dismissed",
1134
+ at,
1135
+ missionId,
1136
+ summary: `${dismissals.length} handoff items dismissed`
1137
+ });
1138
+ return handoffs;
1139
+ }
1140
+ async snapshot(missionId) {
1141
+ return {
1142
+ dir: this.missionDir(missionId),
1143
+ state: await this.readState(missionId),
1144
+ features: await this.readFeatures(missionId),
1145
+ handoffs: await this.readHandoffs(missionId),
1146
+ progress: await this.readProgress(missionId)
1147
+ };
1148
+ }
1149
+ }
1150
+
1151
+ // src/index.ts
1152
+ function jsonResult(value) {
1153
+ return JSON.stringify(value, null, 2);
1154
+ }
1155
+ function projectRootFrom(input) {
1156
+ return input.worktree || input.directory;
1157
+ }
1158
+ function isProtectedMissionPath(projectRoot, filePath) {
1159
+ const resolved = path3.resolve(filePath);
1160
+ const missionRoot = path3.join(path3.resolve(projectRoot), ".opencode", "droid-missions", "missions");
1161
+ if (!resolved.startsWith(missionRoot + path3.sep))
1162
+ return false;
1163
+ return [
1164
+ "state.json",
1165
+ "features.json",
1166
+ "progress_log.jsonl",
1167
+ "handoffs.jsonl",
1168
+ "worker-transcripts.jsonl",
1169
+ "working_directory.txt"
1170
+ ].includes(path3.basename(resolved));
1171
+ }
1172
+ function normalizeToolPath(args) {
1173
+ const candidate = args.filePath ?? args.path ?? args.file ?? args.target;
1174
+ return typeof candidate === "string" ? candidate : undefined;
1175
+ }
1176
+ function normalizeSuccessState(value) {
1177
+ if (value === "success" || value === "partial" || value === "failure") {
1178
+ return value;
1179
+ }
1180
+ throw new Error(`Invalid success state: ${value}`);
1181
+ }
1182
+ var z = tool.schema;
1183
+ var verificationCommandSchema = z.object({
1184
+ command: z.string(),
1185
+ exitCode: z.number(),
1186
+ observation: z.string()
1187
+ });
1188
+ var interactiveCheckSchema = z.object({
1189
+ action: z.string(),
1190
+ observed: z.string()
1191
+ });
1192
+ var testCaseSchema = z.object({
1193
+ name: z.string(),
1194
+ verifies: z.string()
1195
+ });
1196
+ var testFileSchema = z.object({
1197
+ file: z.string(),
1198
+ cases: z.array(testCaseSchema)
1199
+ });
1200
+ var discoveredIssueSchema = z.object({
1201
+ severity: z.enum(["blocking", "non_blocking", "suggestion"]),
1202
+ description: z.string(),
1203
+ suggestedFix: z.string().optional()
1204
+ });
1205
+ var skillDeviationSchema = z.object({
1206
+ step: z.string(),
1207
+ whatIDidInstead: z.string(),
1208
+ why: z.string()
1209
+ });
1210
+ var skillFeedbackSchema = z.object({
1211
+ followedProcedure: z.boolean(),
1212
+ deviations: z.array(skillDeviationSchema),
1213
+ suggestedChanges: z.array(z.string()).optional()
1214
+ });
1215
+ var handoffSchema = z.object({
1216
+ salientSummary: z.string(),
1217
+ whatWasImplemented: z.string(),
1218
+ whatWasLeftUndone: z.string(),
1219
+ verification: z.object({
1220
+ commandsRun: z.array(verificationCommandSchema),
1221
+ interactiveChecks: z.array(interactiveCheckSchema).optional()
1222
+ }),
1223
+ tests: z.object({
1224
+ added: z.array(testFileSchema),
1225
+ updated: z.array(z.string()).optional(),
1226
+ coverage: z.string()
1227
+ }),
1228
+ discoveredIssues: z.array(discoveredIssueSchema),
1229
+ skillFeedback: skillFeedbackSchema.optional()
1230
+ });
1231
+ function normalizeHandoff(value) {
1232
+ const parsed = handoffSchema.safeParse(value);
1233
+ if (!parsed.success) {
1234
+ throw new Error(`Invalid handoff schema: ${parsed.error.message}`);
1235
+ }
1236
+ return parsed.data;
1237
+ }
1238
+ var DroidMissionsPlugin = async (ctx) => {
1239
+ const projectRoot = projectRootFrom(ctx);
1240
+ const store = new MissionStore(projectRoot);
1241
+ const runner = new MissionRunner(store, ctx.client);
1242
+ return {
1243
+ tool: {
1244
+ droid_mission_create: tool({
1245
+ description: "Create a self-contained OpenCode mission workspace and initialize mission artifacts.",
1246
+ args: {
1247
+ missionId: tool.schema.string().optional(),
1248
+ title: tool.schema.string(),
1249
+ goal: tool.schema.string(),
1250
+ workingDirectory: tool.schema.string().optional()
1251
+ },
1252
+ async execute(args, context) {
1253
+ const mission = await store.createMission({
1254
+ missionId: args.missionId,
1255
+ title: args.title,
1256
+ goal: args.goal,
1257
+ workingDirectory: args.workingDirectory ?? context.directory
1258
+ });
1259
+ return jsonResult(mission);
1260
+ }
1261
+ }),
1262
+ droid_mission_write_artifact: tool({
1263
+ description: "Write an allowed mission artifact such as mission.md, AGENTS.md, validation contract, service file, script, library doc, or skill.",
1264
+ args: {
1265
+ missionId: tool.schema.string(),
1266
+ relativePath: tool.schema.string(),
1267
+ content: tool.schema.string()
1268
+ },
1269
+ async execute(args) {
1270
+ const filePath = await store.writeArtifact(args.missionId, {
1271
+ relativePath: args.relativePath,
1272
+ content: args.content
1273
+ });
1274
+ return jsonResult({ filePath });
1275
+ }
1276
+ }),
1277
+ droid_mission_write_features: tool({
1278
+ description: "Replace the mission feature plan. Feature plans may not include system-managed runtime fields.",
1279
+ args: {
1280
+ missionId: tool.schema.string(),
1281
+ features: tool.schema.array(tool.schema.any())
1282
+ },
1283
+ async execute(args) {
1284
+ const features = await store.writeFeatures(args.missionId, args.features);
1285
+ return jsonResult({ features });
1286
+ }
1287
+ }),
1288
+ droid_mission_start: tool({
1289
+ description: "Run the next pending mission feature in an OpenCode child session and wait for the worker prompt to return.",
1290
+ args: {
1291
+ missionId: tool.schema.string(),
1292
+ message: tool.schema.string().optional(),
1293
+ resumeWorkerSessionId: tool.schema.string().optional(),
1294
+ restartFeature: tool.schema.boolean().optional()
1295
+ },
1296
+ async execute(args, context) {
1297
+ return jsonResult(await runner.startMission({
1298
+ missionId: args.missionId,
1299
+ parentSessionId: context.sessionID,
1300
+ message: args.message,
1301
+ resumeWorkerSessionId: args.resumeWorkerSessionId,
1302
+ restartFeature: args.restartFeature
1303
+ }));
1304
+ }
1305
+ }),
1306
+ droid_mission_end_feature: tool({
1307
+ description: "Finish the active mission feature with a structured handoff and success, partial, or failure state.",
1308
+ args: {
1309
+ missionId: tool.schema.string(),
1310
+ featureId: tool.schema.string(),
1311
+ successState: tool.schema.enum(["success", "partial", "failure"]),
1312
+ returnToOrchestrator: tool.schema.boolean().optional(),
1313
+ validatorsPassed: tool.schema.boolean().optional(),
1314
+ codeChanged: tool.schema.boolean().optional(),
1315
+ commitId: tool.schema.string().optional(),
1316
+ repoPath: tool.schema.string().optional(),
1317
+ handoff: handoffSchema
1318
+ },
1319
+ async execute(args, context) {
1320
+ return jsonResult(await runner.endFeatureRun({
1321
+ missionId: args.missionId,
1322
+ featureId: args.featureId,
1323
+ workerSessionId: context.sessionID,
1324
+ successState: normalizeSuccessState(args.successState),
1325
+ returnToOrchestrator: args.returnToOrchestrator,
1326
+ validatorsPassed: args.validatorsPassed,
1327
+ codeChanged: args.codeChanged,
1328
+ commitId: args.commitId,
1329
+ repoPath: args.repoPath,
1330
+ handoff: normalizeHandoff(args.handoff)
1331
+ }));
1332
+ }
1333
+ }),
1334
+ droid_mission_pause: tool({
1335
+ description: "Pause a mission and abort the active OpenCode worker session if present.",
1336
+ args: {
1337
+ missionId: tool.schema.string(),
1338
+ reason: tool.schema.string().optional()
1339
+ },
1340
+ async execute(args) {
1341
+ return jsonResult(await runner.pauseMission(args.missionId, args.reason));
1342
+ }
1343
+ }),
1344
+ droid_mission_status: tool({
1345
+ description: "Read a complete mission snapshot: state, features, handoffs, and progress.",
1346
+ args: {
1347
+ missionId: tool.schema.string()
1348
+ },
1349
+ async execute(args) {
1350
+ return jsonResult(await store.snapshot(args.missionId));
1351
+ }
1352
+ }),
1353
+ droid_mission_complete: tool({
1354
+ description: "Mark a mission completed after every feature and validation feature is complete.",
1355
+ args: {
1356
+ missionId: tool.schema.string()
1357
+ },
1358
+ async execute(args) {
1359
+ return jsonResult(await runner.completeMission(args.missionId));
1360
+ }
1361
+ }),
1362
+ droid_mission_dismiss_handoff_items: tool({
1363
+ description: "Dismiss handoff items by stored handoff id with explicit reasons.",
1364
+ args: {
1365
+ missionId: tool.schema.string(),
1366
+ dismissals: tool.schema.array(tool.schema.object({
1367
+ id: tool.schema.string(),
1368
+ reason: tool.schema.string()
1369
+ }))
1370
+ },
1371
+ async execute(args) {
1372
+ return jsonResult(await store.dismissHandoffItems(args.missionId, args.dismissals));
1373
+ }
1374
+ }),
1375
+ droid_mission_list: tool({
1376
+ description: "List all project-local OpenCode missions.",
1377
+ args: {},
1378
+ async execute() {
1379
+ return jsonResult({ missions: await store.listMissions() });
1380
+ }
1381
+ })
1382
+ },
1383
+ async "tool.execute.before"(input, output) {
1384
+ if (!["write", "edit", "apply_patch", "patch", "multiedit"].includes(input.tool) || !output.args || typeof output.args !== "object") {
1385
+ return;
1386
+ }
1387
+ const filePath = normalizeToolPath(output.args);
1388
+ if (filePath && isProtectedMissionPath(projectRoot, filePath)) {
1389
+ throw new Error("Use droid_mission tools instead of direct edits to mission system files.");
1390
+ }
1391
+ const serializedArgs = JSON.stringify(output.args);
1392
+ if (serializedArgs.includes(".opencode/droid-missions/missions/") && /state\.json|features\.json|progress_log\.jsonl|handoffs\.jsonl/.test(serializedArgs)) {
1393
+ throw new Error("Use droid_mission tools instead of patching mission system files.");
1394
+ }
1395
+ },
1396
+ async "shell.env"(input, output) {
1397
+ const root = input.cwd.startsWith(projectRoot) ? projectRoot : input.cwd;
1398
+ output.env.OPENCODE_DROID_MISSIONS_ROOT = path3.join(root, ".opencode", "droid-missions");
1399
+ },
1400
+ async "experimental.session.compacting"(_input, output) {
1401
+ const missions = await store.listMissions();
1402
+ const active = missions.find((mission) => mission.status !== "completed" && mission.status !== "paused");
1403
+ if (!active)
1404
+ return;
1405
+ const features = await store.readFeatures(active.missionId);
1406
+ output.context.push(buildCompactionContext({ state: active, features }));
1407
+ }
1408
+ };
1409
+ };
1410
+ var src_default = DroidMissionsPlugin;
1411
+ export {
1412
+ src_default as default,
1413
+ DroidMissionsPlugin
1414
+ };