opencode-missions 0.1.0 → 0.1.1
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/README.md +78 -11
- package/dist/droid-missions/paths.d.ts +8 -2
- package/dist/droid-missions/store.d.ts +3 -0
- package/dist/droid-missions/types.d.ts +12 -1
- package/dist/index.js +366 -81
- package/opencode/AGENTS.md +1 -1
- package/opencode/agents/droid-mission-orchestrator.md +1 -1
- package/opencode/commands/mission.md +1 -1
- package/opencode/skills/droid-mission-orchestrator/SKILL.md +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,32 +1,62 @@
|
|
|
1
1
|
# opencode-missions
|
|
2
2
|
|
|
3
|
-
OpenCode plugin for
|
|
3
|
+
OpenCode plugin for mission planning, delegated feature execution, structured worker handoffs, and milestone validation. It re-implements the mission lifecycle locally and does not require Droid CLI, Factory runtime files, or any external mission service.
|
|
4
|
+
|
|
5
|
+
## What it provides
|
|
6
|
+
|
|
7
|
+
- OpenCode mission tools for creating, planning, running, pausing, resuming, and completing missions
|
|
8
|
+
- One-worker-at-a-time feature delegation through OpenCode child sessions
|
|
9
|
+
- Structured worker handoffs with verification evidence, test coverage notes, and discovered issues
|
|
10
|
+
- Automatic scrutiny and user-testing validator features after each completed milestone
|
|
11
|
+
- Completion gates that block unresolved handoff items, failed validation assertions, and missing milestone validators
|
|
12
|
+
- Optional `/mission` command, orchestrator agent, worker agent, validator agents, and mission orchestration skill
|
|
13
|
+
- Global mission state under Factory-compatible storage when Droid is installed, or OpenCode storage otherwise
|
|
4
14
|
|
|
5
15
|
## Install
|
|
6
16
|
|
|
7
|
-
|
|
17
|
+
For the fastest setup in the current project:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install -g opencode-missions
|
|
21
|
+
opencode-missions install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then add the npm plugin to `opencode.json`:
|
|
8
25
|
|
|
9
26
|
```json
|
|
10
27
|
{
|
|
11
28
|
"$schema": "https://opencode.ai/config.json",
|
|
12
|
-
"plugin": ["opencode-missions"]
|
|
29
|
+
"plugin": ["opencode-missions@latest"]
|
|
13
30
|
}
|
|
14
31
|
```
|
|
15
32
|
|
|
16
|
-
|
|
33
|
+
For a different project directory:
|
|
17
34
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
```bash
|
|
21
|
-
bunx opencode-missions install
|
|
35
|
+
```sh
|
|
36
|
+
opencode-missions install /path/to/project
|
|
22
37
|
```
|
|
23
38
|
|
|
24
|
-
|
|
39
|
+
The install command is idempotent. It copies optional OpenCode assets into the project:
|
|
25
40
|
|
|
26
41
|
- `opencode/agents/*` to `.opencode/agents/`
|
|
27
42
|
- `opencode/commands/*` to `.opencode/commands/`
|
|
28
43
|
- `opencode/skills/*` to `.opencode/skills/`
|
|
29
44
|
|
|
45
|
+
OpenCode installs npm plugins automatically with Bun at startup after the package is listed in config.
|
|
46
|
+
|
|
47
|
+
## Manual config
|
|
48
|
+
|
|
49
|
+
If you do not want the optional assets, only add the plugin entry:
|
|
50
|
+
|
|
51
|
+
```jsonc
|
|
52
|
+
{
|
|
53
|
+
"$schema": "https://opencode.ai/config.json",
|
|
54
|
+
"plugin": ["opencode-missions@latest"]
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
If you want the included `/mission` command and agent presets without using the installer, copy the package `opencode/agents`, `opencode/commands`, and `opencode/skills` directories into your project's `.opencode/` directory.
|
|
59
|
+
|
|
30
60
|
## Usage
|
|
31
61
|
|
|
32
62
|
After enabling the plugin, use the mission tools directly or install the included `/mission` command and mission agents:
|
|
@@ -40,10 +70,39 @@ The orchestrator creates a mission workspace, writes mission artifacts and featu
|
|
|
40
70
|
Mission state is stored under:
|
|
41
71
|
|
|
42
72
|
```text
|
|
43
|
-
|
|
73
|
+
~/.factory/missions/
|
|
44
74
|
```
|
|
45
75
|
|
|
46
|
-
|
|
76
|
+
when a `droid` executable is available on `PATH`. If Droid is not installed, the plugin falls back to:
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
~/.opencode/missions/
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`FACTORY_HOME_OVERRIDE` is respected for isolated development and tests.
|
|
83
|
+
When Factory environment variables indicate a non-production environment and `droid` is installed, the Factory-compatible root is `~/.factory-dev/missions/`.
|
|
84
|
+
|
|
85
|
+
Missions created by earlier versions under project-local `.opencode/droid-missions/missions/` are not moved automatically. Move or recreate those missions explicitly if you need them in global storage.
|
|
86
|
+
|
|
87
|
+
## Local development install
|
|
88
|
+
|
|
89
|
+
From this repo:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
bun install
|
|
93
|
+
bun run build
|
|
94
|
+
npm pack
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Then point OpenCode at the packed tarball through the plugin array:
|
|
98
|
+
|
|
99
|
+
```jsonc
|
|
100
|
+
{
|
|
101
|
+
"plugin": ["opencode-missions@file:/absolute/path/opencode-missions-0.1.0.tgz"]
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Direct local source installs are useful for debugging, but npm/tarball installs better match how OpenCode resolves plugin dependencies.
|
|
47
106
|
|
|
48
107
|
## Tools
|
|
49
108
|
|
|
@@ -76,3 +135,11 @@ npm version patch
|
|
|
76
135
|
git push --follow-tags
|
|
77
136
|
npm publish --access public
|
|
78
137
|
```
|
|
138
|
+
|
|
139
|
+
## Troubleshooting
|
|
140
|
+
|
|
141
|
+
- **Mission tools are not visible:** confirm `opencode.json` contains `"plugin": ["opencode-missions@latest"]`, then restart OpenCode.
|
|
142
|
+
- **`/mission` is not visible:** run `opencode-missions install` in the project root or copy the optional assets manually.
|
|
143
|
+
- **A mission will not complete:** run `droid_mission_status` and resolve pending features, validation features, handoff issues, or validation-state assertions.
|
|
144
|
+
- **Direct edits to mission state fail:** use the mission tools instead of editing global mission system files such as `state.json`, `features.json`, or `progress_log.jsonl`.
|
|
145
|
+
- **Local tarball install cannot find dependencies:** use `npm pack` and reference the packed `.tgz`, or install from npm with `opencode-missions@latest`.
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
export declare const
|
|
1
|
+
export declare const OPENCODE_STORAGE_DIR = ".opencode";
|
|
2
|
+
export declare const FACTORY_STORAGE_DIR = ".factory";
|
|
3
|
+
export declare const FACTORY_DEV_STORAGE_DIR = ".factory-dev";
|
|
2
4
|
export declare const MISSIONS_DIR = "missions";
|
|
3
|
-
export declare function
|
|
5
|
+
export declare function hasDroidOnPath(): boolean;
|
|
6
|
+
export declare function factoryMissionStorageRoot(): string;
|
|
7
|
+
export declare function opencodeMissionStorageRoot(): string;
|
|
8
|
+
export declare function missionStorageKind(): "factory" | "opencode";
|
|
9
|
+
export declare function missionStorageRoot(_projectRoot: string): string;
|
|
4
10
|
export declare function missionsRoot(projectRoot: string): string;
|
|
5
11
|
export declare function sanitizeMissionId(input: string): string;
|
|
6
12
|
export declare function createMissionId(title: string, now: string): string;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { FeatureHandoff, MissionCreateArgs, MissionFeature, MissionSnapshot, MissionState, ProgressEntry, StoredHandoff } from "./types";
|
|
2
|
+
export declare class MissionNotFoundError extends Error {
|
|
3
|
+
constructor(missionId: string, missionsRootPath: string);
|
|
4
|
+
}
|
|
2
5
|
export declare class MissionStore {
|
|
3
6
|
readonly projectRoot: string;
|
|
4
7
|
constructor(projectRoot: string);
|
|
@@ -7,6 +7,7 @@ export type MissionState = {
|
|
|
7
7
|
title: string;
|
|
8
8
|
goal: string;
|
|
9
9
|
status: MissionStatus;
|
|
10
|
+
state?: MissionStatus;
|
|
10
11
|
workingDirectory: string;
|
|
11
12
|
createdAt: string;
|
|
12
13
|
updatedAt: string;
|
|
@@ -15,6 +16,7 @@ export type MissionState = {
|
|
|
15
16
|
activeWorkerSessionId?: string;
|
|
16
17
|
pauseReason?: string;
|
|
17
18
|
lastMessage?: string;
|
|
19
|
+
artifactLayoutVersion?: number;
|
|
18
20
|
};
|
|
19
21
|
export type FeaturePlanInput = {
|
|
20
22
|
id: string;
|
|
@@ -22,7 +24,7 @@ export type FeaturePlanInput = {
|
|
|
22
24
|
skillName: string;
|
|
23
25
|
milestone: string;
|
|
24
26
|
preconditions?: string[];
|
|
25
|
-
expectedBehavior: string;
|
|
27
|
+
expectedBehavior: string | string[];
|
|
26
28
|
verificationSteps?: string[];
|
|
27
29
|
fulfills?: string[];
|
|
28
30
|
};
|
|
@@ -32,6 +34,7 @@ export type MissionFeature = FeaturePlanInput & {
|
|
|
32
34
|
fulfills: string[];
|
|
33
35
|
status: FeatureStatus;
|
|
34
36
|
workerSessionId?: string;
|
|
37
|
+
workerSessionIds?: string[];
|
|
35
38
|
startedAt?: string;
|
|
36
39
|
completedAt?: string;
|
|
37
40
|
isValidation?: boolean;
|
|
@@ -102,10 +105,13 @@ export type StoredHandoff = FeatureHandoff & {
|
|
|
102
105
|
createdAt: string;
|
|
103
106
|
dismissedAt?: string;
|
|
104
107
|
dismissalReason?: string;
|
|
108
|
+
timestamp?: string;
|
|
109
|
+
handoff?: FeatureHandoff;
|
|
105
110
|
};
|
|
106
111
|
export type ProgressEntry = {
|
|
107
112
|
type: "mission_accepted" | "mission_paused" | "mission_resumed" | "mission_run_started" | "worker_started" | "worker_selected_feature" | "worker_completed" | "worker_failed" | "worker_paused" | "handoff_items_dismissed" | "milestone_validation_triggered" | "mission_completed" | "features_written" | "artifact_written";
|
|
108
113
|
at: string;
|
|
114
|
+
timestamp?: string;
|
|
109
115
|
missionId: string;
|
|
110
116
|
featureId?: string;
|
|
111
117
|
workerSessionId?: string;
|
|
@@ -118,6 +124,11 @@ export type MissionSnapshot = {
|
|
|
118
124
|
features: MissionFeature[];
|
|
119
125
|
handoffs: StoredHandoff[];
|
|
120
126
|
progress: ProgressEntry[];
|
|
127
|
+
metadata?: {
|
|
128
|
+
storageKind: "factory" | "opencode";
|
|
129
|
+
featureCounts: Record<FeatureStatus, number>;
|
|
130
|
+
resumable: boolean;
|
|
131
|
+
};
|
|
121
132
|
};
|
|
122
133
|
export type MissionCreateArgs = {
|
|
123
134
|
missionId?: string;
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,108 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/index.ts
|
|
3
3
|
import { tool } from "@opencode-ai/plugin";
|
|
4
|
+
import { realpathSync } from "fs";
|
|
4
5
|
import path3 from "path";
|
|
5
6
|
|
|
7
|
+
// src/droid-missions/paths.ts
|
|
8
|
+
import { accessSync, constants } from "fs";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import path from "path";
|
|
11
|
+
var OPENCODE_STORAGE_DIR = ".opencode";
|
|
12
|
+
var FACTORY_STORAGE_DIR = ".factory";
|
|
13
|
+
var FACTORY_DEV_STORAGE_DIR = ".factory-dev";
|
|
14
|
+
var MISSIONS_DIR = "missions";
|
|
15
|
+
function factoryHome() {
|
|
16
|
+
return process.env.FACTORY_HOME_OVERRIDE || homedir();
|
|
17
|
+
}
|
|
18
|
+
function factoryDirName() {
|
|
19
|
+
const env = process.env.FACTORY_ENV ?? process.env.NEXT_PUBLIC_ENV;
|
|
20
|
+
if (env && env.toLowerCase().trim() !== "production") {
|
|
21
|
+
return FACTORY_DEV_STORAGE_DIR;
|
|
22
|
+
}
|
|
23
|
+
return FACTORY_STORAGE_DIR;
|
|
24
|
+
}
|
|
25
|
+
function executableNames(command) {
|
|
26
|
+
if (process.platform !== "win32")
|
|
27
|
+
return [command];
|
|
28
|
+
const extensions = (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
|
|
29
|
+
return [command, ...extensions.map((extension) => `${command}${extension}`)];
|
|
30
|
+
}
|
|
31
|
+
function hasDroidOnPath() {
|
|
32
|
+
const pathEnv = process.env.PATH;
|
|
33
|
+
if (!pathEnv)
|
|
34
|
+
return false;
|
|
35
|
+
for (const entry of pathEnv.split(path.delimiter)) {
|
|
36
|
+
if (!entry)
|
|
37
|
+
continue;
|
|
38
|
+
for (const executable of executableNames("droid")) {
|
|
39
|
+
try {
|
|
40
|
+
accessSync(path.join(entry, executable), constants.X_OK);
|
|
41
|
+
return true;
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
function factoryMissionStorageRoot() {
|
|
48
|
+
return path.join(factoryHome(), factoryDirName());
|
|
49
|
+
}
|
|
50
|
+
function opencodeMissionStorageRoot() {
|
|
51
|
+
return path.join(factoryHome(), OPENCODE_STORAGE_DIR);
|
|
52
|
+
}
|
|
53
|
+
function missionStorageKind() {
|
|
54
|
+
return hasDroidOnPath() ? "factory" : "opencode";
|
|
55
|
+
}
|
|
56
|
+
function missionStorageRoot(_projectRoot) {
|
|
57
|
+
return missionStorageKind() === "factory" ? factoryMissionStorageRoot() : opencodeMissionStorageRoot();
|
|
58
|
+
}
|
|
59
|
+
function missionsRoot(projectRoot) {
|
|
60
|
+
return path.join(missionStorageRoot(projectRoot), MISSIONS_DIR);
|
|
61
|
+
}
|
|
62
|
+
function sanitizeMissionId(input) {
|
|
63
|
+
const value = input.trim();
|
|
64
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value)) {
|
|
65
|
+
throw new Error("Invalid mission id. Use 1-128 letters, numbers, dots, underscores, or hyphens.");
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function createMissionId(title, now) {
|
|
70
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "mission";
|
|
71
|
+
const stamp = new Date(now).getTime().toString(36);
|
|
72
|
+
return sanitizeMissionId(`${slug}-${stamp}`);
|
|
73
|
+
}
|
|
74
|
+
function missionDir(projectRoot, missionId) {
|
|
75
|
+
return path.join(missionsRoot(projectRoot), sanitizeMissionId(missionId));
|
|
76
|
+
}
|
|
77
|
+
function toMissionRelativePath(relativePath) {
|
|
78
|
+
const normalized = relativePath.replaceAll("\\", "/").replace(/^\/+/, "");
|
|
79
|
+
if (normalized === "" || normalized.startsWith("../") || normalized.includes("/../") || normalized === "..") {
|
|
80
|
+
throw new Error(`Invalid mission artifact path: ${relativePath}`);
|
|
81
|
+
}
|
|
82
|
+
return path.posix.normalize(normalized);
|
|
83
|
+
}
|
|
84
|
+
function assertInside(parent, child) {
|
|
85
|
+
const resolvedParent = path.resolve(parent);
|
|
86
|
+
const resolvedChild = path.resolve(child);
|
|
87
|
+
if (resolvedChild !== resolvedParent && !resolvedChild.startsWith(resolvedParent + path.sep)) {
|
|
88
|
+
throw new Error(`Path escapes mission directory: ${child}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function isMissionSystemFile(relativePath) {
|
|
92
|
+
const systemFiles = new Set([
|
|
93
|
+
"state.json",
|
|
94
|
+
"features.json",
|
|
95
|
+
"progress_log.jsonl",
|
|
96
|
+
"handoffs.jsonl",
|
|
97
|
+
"worker-transcripts.jsonl",
|
|
98
|
+
"working_directory.txt"
|
|
99
|
+
]);
|
|
100
|
+
return systemFiles.has(toMissionRelativePath(relativePath));
|
|
101
|
+
}
|
|
102
|
+
|
|
6
103
|
// src/droid-missions/prompts.ts
|
|
7
104
|
function buildWorkerPrompt(input) {
|
|
8
|
-
const { state, feature, missionDir, message } = input;
|
|
105
|
+
const { state, feature, missionDir: missionDir2, message } = input;
|
|
9
106
|
const extraMessage = message ? `
|
|
10
107
|
## Orchestrator Message
|
|
11
108
|
|
|
@@ -18,7 +115,7 @@ ${message}
|
|
|
18
115
|
- Mission ID: ${state.missionId}
|
|
19
116
|
- Title: ${state.title}
|
|
20
117
|
- Goal: ${state.goal}
|
|
21
|
-
- Mission directory: ${
|
|
118
|
+
- Mission directory: ${missionDir2}
|
|
22
119
|
- Working directory: ${state.workingDirectory}
|
|
23
120
|
|
|
24
121
|
## Assigned Feature
|
|
@@ -94,6 +191,19 @@ function allFeaturesCompleted(features) {
|
|
|
94
191
|
function progressType(successState) {
|
|
95
192
|
return successState === "success" ? "worker_completed" : "worker_failed";
|
|
96
193
|
}
|
|
194
|
+
function clearWorkerSession(feature) {
|
|
195
|
+
feature.workerSessionId = undefined;
|
|
196
|
+
feature.workerSessionIds = [];
|
|
197
|
+
}
|
|
198
|
+
function setWorkerSession(feature, workerSessionId) {
|
|
199
|
+
feature.workerSessionId = workerSessionId;
|
|
200
|
+
if (!workerSessionId)
|
|
201
|
+
return;
|
|
202
|
+
feature.workerSessionIds = [
|
|
203
|
+
...feature.workerSessionIds ?? [],
|
|
204
|
+
workerSessionId
|
|
205
|
+
].filter((value, index, array) => array.indexOf(value) === index);
|
|
206
|
+
}
|
|
97
207
|
function validationFeature(milestone, kind) {
|
|
98
208
|
const id = kind === "scrutiny" ? `scrutiny-validator-${milestone}` : `user-testing-validator-${milestone}`;
|
|
99
209
|
return {
|
|
@@ -185,7 +295,7 @@ class MissionRunner {
|
|
|
185
295
|
const firstPendingIndex = features.findIndex((feature2) => feature2.status === "pending");
|
|
186
296
|
if (pausedFeature && firstPendingIndex !== -1 && pausedIndex !== -1 && firstPendingIndex < pausedIndex) {
|
|
187
297
|
pausedFeature.status = "pending";
|
|
188
|
-
pausedFeature
|
|
298
|
+
clearWorkerSession(pausedFeature);
|
|
189
299
|
pausedFeature.startedAt = undefined;
|
|
190
300
|
state.status = "running";
|
|
191
301
|
state.pauseReason = undefined;
|
|
@@ -198,7 +308,7 @@ class MissionRunner {
|
|
|
198
308
|
}
|
|
199
309
|
if (pausedFeature && args.restartFeature) {
|
|
200
310
|
pausedFeature.status = "pending";
|
|
201
|
-
pausedFeature
|
|
311
|
+
clearWorkerSession(pausedFeature);
|
|
202
312
|
pausedFeature.startedAt = undefined;
|
|
203
313
|
state.status = "running";
|
|
204
314
|
state.pauseReason = undefined;
|
|
@@ -331,7 +441,7 @@ class MissionRunner {
|
|
|
331
441
|
firstStarted.workerSessionId = workerSessionId;
|
|
332
442
|
}
|
|
333
443
|
feature.status = "in_progress";
|
|
334
|
-
feature
|
|
444
|
+
setWorkerSession(feature, workerSessionId);
|
|
335
445
|
feature.startedAt = at;
|
|
336
446
|
state.status = "running";
|
|
337
447
|
state.updatedAt = at;
|
|
@@ -382,7 +492,7 @@ class MissionRunner {
|
|
|
382
492
|
});
|
|
383
493
|
} catch (error) {
|
|
384
494
|
feature.status = "pending";
|
|
385
|
-
feature
|
|
495
|
+
clearWorkerSession(feature);
|
|
386
496
|
feature.startedAt = undefined;
|
|
387
497
|
state.status = "orchestrator_turn";
|
|
388
498
|
state.activeFeatureId = undefined;
|
|
@@ -430,7 +540,7 @@ class MissionRunner {
|
|
|
430
540
|
throw new Error("commitId and repoPath are required when codeChanged is true");
|
|
431
541
|
}
|
|
432
542
|
assertHandoffContract(args);
|
|
433
|
-
feature
|
|
543
|
+
setWorkerSession(feature, args.workerSessionId ?? feature.workerSessionId);
|
|
434
544
|
feature.completedAt = at;
|
|
435
545
|
feature.status = args.successState === "success" ? "completed" : "pending";
|
|
436
546
|
const hasHandoffIssues = args.handoff.discoveredIssues.length > 0 || hasUnfinishedWork(args.handoff.whatWasLeftUndone);
|
|
@@ -676,61 +786,11 @@ import {
|
|
|
676
786
|
} from "fs/promises";
|
|
677
787
|
import path2 from "path";
|
|
678
788
|
|
|
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
789
|
// src/droid-missions/validation.ts
|
|
731
790
|
var systemManagedFeatureFields = new Set([
|
|
732
791
|
"status",
|
|
733
792
|
"workerSessionId",
|
|
793
|
+
"workerSessionIds",
|
|
734
794
|
"startedAt",
|
|
735
795
|
"completedAt",
|
|
736
796
|
"isValidation",
|
|
@@ -768,6 +828,15 @@ function readStringArray(record, key) {
|
|
|
768
828
|
}
|
|
769
829
|
return value.map((item) => item.trim()).filter(Boolean);
|
|
770
830
|
}
|
|
831
|
+
function readStringOrStringArray(record, key) {
|
|
832
|
+
const value = record[key];
|
|
833
|
+
if (typeof value === "string" && value.trim() !== "")
|
|
834
|
+
return value.trim();
|
|
835
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
|
|
836
|
+
return value.map((item) => item.trim()).filter(Boolean);
|
|
837
|
+
}
|
|
838
|
+
throw new Error(`Feature field "${key}" must be a non-empty string or array of strings`);
|
|
839
|
+
}
|
|
771
840
|
function validateFeaturePlans(inputs, skillNames) {
|
|
772
841
|
const ids = new Set;
|
|
773
842
|
return inputs.map((input, index) => {
|
|
@@ -795,7 +864,7 @@ function validateFeaturePlans(inputs, skillNames) {
|
|
|
795
864
|
skillName,
|
|
796
865
|
milestone: readString(input, "milestone"),
|
|
797
866
|
preconditions: readStringArray(input, "preconditions"),
|
|
798
|
-
expectedBehavior:
|
|
867
|
+
expectedBehavior: readStringOrStringArray(input, "expectedBehavior"),
|
|
799
868
|
verificationSteps: readStringArray(input, "verificationSteps"),
|
|
800
869
|
fulfills: readStringArray(input, "fulfills")
|
|
801
870
|
};
|
|
@@ -915,6 +984,153 @@ Plan artifacts, write feature slices, run one worker per feature, then complete
|
|
|
915
984
|
function handoffId(featureId, at) {
|
|
916
985
|
return `${featureId}-${at.replace(/[^a-z0-9]/gi, "").slice(0, 20)}`;
|
|
917
986
|
}
|
|
987
|
+
function asRecord(value) {
|
|
988
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
989
|
+
}
|
|
990
|
+
function normalizeStringArray(value) {
|
|
991
|
+
if (Array.isArray(value)) {
|
|
992
|
+
return value.filter((item) => typeof item === "string");
|
|
993
|
+
}
|
|
994
|
+
if (typeof value === "string" && value.trim())
|
|
995
|
+
return [value.trim()];
|
|
996
|
+
return [];
|
|
997
|
+
}
|
|
998
|
+
function normalizeState(raw, missionId) {
|
|
999
|
+
const record = asRecord(raw);
|
|
1000
|
+
const status = record.status ?? record.state;
|
|
1001
|
+
return {
|
|
1002
|
+
...record,
|
|
1003
|
+
missionId: typeof record.missionId === "string" ? record.missionId : missionId,
|
|
1004
|
+
title: typeof record.title === "string" ? record.title : missionId,
|
|
1005
|
+
goal: typeof record.goal === "string" ? record.goal : "",
|
|
1006
|
+
status: typeof status === "string" ? status : "awaiting_input",
|
|
1007
|
+
state: typeof status === "string" ? status : "awaiting_input",
|
|
1008
|
+
workingDirectory: typeof record.workingDirectory === "string" ? record.workingDirectory : process.cwd(),
|
|
1009
|
+
createdAt: typeof record.createdAt === "string" ? record.createdAt : new Date(0).toISOString(),
|
|
1010
|
+
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : new Date(0).toISOString()
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
function stateForDisk(state) {
|
|
1014
|
+
return {
|
|
1015
|
+
...state,
|
|
1016
|
+
state: state.status
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
function normalizeFeature(raw) {
|
|
1020
|
+
const record = asRecord(raw);
|
|
1021
|
+
const workerSessionIds = normalizeStringArray(record.workerSessionIds);
|
|
1022
|
+
if (typeof record.workerSessionId === "string" && !workerSessionIds.includes(record.workerSessionId)) {
|
|
1023
|
+
workerSessionIds.push(record.workerSessionId);
|
|
1024
|
+
}
|
|
1025
|
+
const workerSessionId = typeof record.workerSessionId === "string" ? record.workerSessionId : workerSessionIds.at(-1);
|
|
1026
|
+
return {
|
|
1027
|
+
...record,
|
|
1028
|
+
id: String(record.id ?? ""),
|
|
1029
|
+
description: String(record.description ?? ""),
|
|
1030
|
+
skillName: String(record.skillName ?? "implementation"),
|
|
1031
|
+
milestone: String(record.milestone ?? "default"),
|
|
1032
|
+
preconditions: normalizeStringArray(record.preconditions),
|
|
1033
|
+
expectedBehavior: typeof record.expectedBehavior === "string" || Array.isArray(record.expectedBehavior) ? record.expectedBehavior : "",
|
|
1034
|
+
verificationSteps: normalizeStringArray(record.verificationSteps),
|
|
1035
|
+
fulfills: normalizeStringArray(record.fulfills),
|
|
1036
|
+
status: record.status === "in_progress" || record.status === "completed" || record.status === "cancelled" ? record.status : "pending",
|
|
1037
|
+
workerSessionId,
|
|
1038
|
+
workerSessionIds
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
function featureForDisk(feature) {
|
|
1042
|
+
const workerSessionIds = [
|
|
1043
|
+
...feature.workerSessionIds ?? [],
|
|
1044
|
+
...feature.workerSessionId ? [feature.workerSessionId] : []
|
|
1045
|
+
].filter((value, index, array) => array.indexOf(value) === index);
|
|
1046
|
+
const { workerSessionId: _workerSessionId, ...rest } = feature;
|
|
1047
|
+
return {
|
|
1048
|
+
...rest,
|
|
1049
|
+
workerSessionIds
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
function normalizeProgressEntry(raw) {
|
|
1053
|
+
const record = asRecord(raw);
|
|
1054
|
+
const at = record.at ?? record.timestamp;
|
|
1055
|
+
return {
|
|
1056
|
+
...record,
|
|
1057
|
+
type: String(record.type ?? "artifact_written"),
|
|
1058
|
+
at: typeof at === "string" ? at : new Date(0).toISOString(),
|
|
1059
|
+
timestamp: typeof at === "string" ? at : new Date(0).toISOString(),
|
|
1060
|
+
missionId: String(record.missionId ?? "")
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
function progressForDisk(entry) {
|
|
1064
|
+
return {
|
|
1065
|
+
...entry,
|
|
1066
|
+
timestamp: entry.timestamp ?? entry.at
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
function normalizeStoredHandoff(raw) {
|
|
1070
|
+
const record = asRecord(raw);
|
|
1071
|
+
const handoff = asRecord(record.handoff);
|
|
1072
|
+
const flattened = Object.keys(handoff).length > 0 ? handoff : record;
|
|
1073
|
+
const createdAt = record.createdAt ?? record.timestamp;
|
|
1074
|
+
const featureId = String(record.featureId ?? "unknown-feature");
|
|
1075
|
+
const timestamp = typeof createdAt === "string" ? createdAt : new Date(0).toISOString();
|
|
1076
|
+
return {
|
|
1077
|
+
...flattened,
|
|
1078
|
+
id: typeof record.id === "string" ? record.id : handoffId(featureId, timestamp),
|
|
1079
|
+
missionId: String(record.missionId ?? ""),
|
|
1080
|
+
featureId,
|
|
1081
|
+
workerSessionId: typeof record.workerSessionId === "string" ? record.workerSessionId : undefined,
|
|
1082
|
+
successState: record.successState === "partial" || record.successState === "failure" ? record.successState : "success",
|
|
1083
|
+
returnToOrchestrator: record.returnToOrchestrator === true,
|
|
1084
|
+
validatorsPassed: typeof record.validatorsPassed === "boolean" ? record.validatorsPassed : undefined,
|
|
1085
|
+
commitId: typeof record.commitId === "string" ? record.commitId : undefined,
|
|
1086
|
+
repoPath: typeof record.repoPath === "string" ? record.repoPath : undefined,
|
|
1087
|
+
handoffFile: typeof record.handoffFile === "string" ? record.handoffFile : "",
|
|
1088
|
+
createdAt: timestamp,
|
|
1089
|
+
dismissedAt: typeof record.dismissedAt === "string" ? record.dismissedAt : undefined,
|
|
1090
|
+
dismissalReason: typeof record.dismissalReason === "string" ? record.dismissalReason : undefined,
|
|
1091
|
+
timestamp,
|
|
1092
|
+
handoff: flattened
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
function handoffForDisk(handoff) {
|
|
1096
|
+
const nested = handoff.handoff ?? {
|
|
1097
|
+
salientSummary: handoff.salientSummary,
|
|
1098
|
+
whatWasImplemented: handoff.whatWasImplemented,
|
|
1099
|
+
whatWasLeftUndone: handoff.whatWasLeftUndone,
|
|
1100
|
+
verification: handoff.verification,
|
|
1101
|
+
tests: handoff.tests,
|
|
1102
|
+
discoveredIssues: handoff.discoveredIssues,
|
|
1103
|
+
skillFeedback: handoff.skillFeedback
|
|
1104
|
+
};
|
|
1105
|
+
return {
|
|
1106
|
+
id: handoff.id,
|
|
1107
|
+
missionId: handoff.missionId,
|
|
1108
|
+
timestamp: handoff.timestamp ?? handoff.createdAt,
|
|
1109
|
+
createdAt: handoff.createdAt,
|
|
1110
|
+
workerSessionId: handoff.workerSessionId,
|
|
1111
|
+
featureId: handoff.featureId,
|
|
1112
|
+
successState: handoff.successState,
|
|
1113
|
+
returnToOrchestrator: handoff.returnToOrchestrator,
|
|
1114
|
+
validatorsPassed: handoff.validatorsPassed,
|
|
1115
|
+
commitId: handoff.commitId,
|
|
1116
|
+
repoPath: handoff.repoPath,
|
|
1117
|
+
handoffFile: handoff.handoffFile,
|
|
1118
|
+
dismissedAt: handoff.dismissedAt,
|
|
1119
|
+
dismissalReason: handoff.dismissalReason,
|
|
1120
|
+
handoff: nested
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
function isEnoent(error) {
|
|
1124
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
class MissionNotFoundError extends Error {
|
|
1128
|
+
constructor(missionId, missionsRootPath) {
|
|
1129
|
+
const storage = missionStorageKind() === "factory" ? "Factory global mission storage because droid is installed on PATH" : "OpenCode global mission storage because droid is not installed on PATH";
|
|
1130
|
+
super(`Mission not found: ${missionId}. Checked ${storage} at ${missionsRootPath}. Run droid_mission_list to see available mission IDs, or create a mission with droid_mission_create.`);
|
|
1131
|
+
this.name = "MissionNotFoundError";
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
918
1134
|
|
|
919
1135
|
class MissionStore {
|
|
920
1136
|
projectRoot;
|
|
@@ -947,14 +1163,17 @@ class MissionStore {
|
|
|
947
1163
|
await mkdir(path2.join(dir, "library"), { recursive: true });
|
|
948
1164
|
await mkdir(path2.join(dir, "scripts"), { recursive: true });
|
|
949
1165
|
await mkdir(path2.join(dir, "handoffs"), { recursive: true });
|
|
1166
|
+
await mkdir(path2.join(dir, "validation"), { recursive: true });
|
|
950
1167
|
const state = {
|
|
951
1168
|
missionId,
|
|
952
1169
|
title: args.title,
|
|
953
1170
|
goal: args.goal,
|
|
954
1171
|
status: "awaiting_input",
|
|
1172
|
+
state: "awaiting_input",
|
|
955
1173
|
workingDirectory: args.workingDirectory ?? this.projectRoot,
|
|
956
1174
|
createdAt,
|
|
957
|
-
updatedAt: createdAt
|
|
1175
|
+
updatedAt: createdAt,
|
|
1176
|
+
artifactLayoutVersion: 2
|
|
958
1177
|
};
|
|
959
1178
|
await writeFile(path2.join(dir, "mission.md"), missionMarkdown(args, missionId, createdAt));
|
|
960
1179
|
await writeFile(path2.join(dir, "working_directory.txt"), `${state.workingDirectory}
|
|
@@ -963,8 +1182,8 @@ class MissionStore {
|
|
|
963
1182
|
await writeFile(path2.join(dir, "skills", "implementation", "SKILL.md"), DEFAULT_IMPLEMENTATION_SKILL);
|
|
964
1183
|
await writeFile(path2.join(dir, "skills", "scrutiny-validator", "SKILL.md"), DEFAULT_SCRUTINY_SKILL);
|
|
965
1184
|
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"), []);
|
|
1185
|
+
await writeJson(path2.join(dir, "state.json"), stateForDisk(state));
|
|
1186
|
+
await writeJson(path2.join(dir, "features.json"), { features: [] });
|
|
968
1187
|
await writeFile(path2.join(dir, "progress_log.jsonl"), "");
|
|
969
1188
|
await writeFile(path2.join(dir, "handoffs.jsonl"), "");
|
|
970
1189
|
await writeFile(path2.join(dir, "worker-transcripts.jsonl"), "");
|
|
@@ -972,6 +1191,12 @@ class MissionStore {
|
|
|
972
1191
|
missionId,
|
|
973
1192
|
milestones: {}
|
|
974
1193
|
});
|
|
1194
|
+
await writeJson(path2.join(dir, "canonical-artifact-layout.json"), {
|
|
1195
|
+
version: 2,
|
|
1196
|
+
markedCanonicalAt: createdAt,
|
|
1197
|
+
importedPaths: [],
|
|
1198
|
+
ambiguousSkillNames: []
|
|
1199
|
+
});
|
|
975
1200
|
await this.appendProgress(missionId, {
|
|
976
1201
|
type: "mission_accepted",
|
|
977
1202
|
at: createdAt,
|
|
@@ -998,16 +1223,35 @@ class MissionStore {
|
|
|
998
1223
|
return states.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
999
1224
|
}
|
|
1000
1225
|
async readState(missionId) {
|
|
1001
|
-
|
|
1226
|
+
try {
|
|
1227
|
+
return normalizeState(await readJson(path2.join(this.missionDir(missionId), "state.json")), missionId);
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
if (isEnoent(error)) {
|
|
1230
|
+
throw new MissionNotFoundError(missionId, this.missionsRoot());
|
|
1231
|
+
}
|
|
1232
|
+
throw error;
|
|
1233
|
+
}
|
|
1002
1234
|
}
|
|
1003
1235
|
async writeState(state) {
|
|
1004
|
-
await writeJson(path2.join(this.missionDir(state.missionId), "state.json"), state);
|
|
1236
|
+
await writeJson(path2.join(this.missionDir(state.missionId), "state.json"), stateForDisk(state));
|
|
1005
1237
|
}
|
|
1006
1238
|
async readFeatures(missionId) {
|
|
1007
|
-
|
|
1239
|
+
let raw;
|
|
1240
|
+
try {
|
|
1241
|
+
raw = await readJson(path2.join(this.missionDir(missionId), "features.json"));
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
if (isEnoent(error)) {
|
|
1244
|
+
throw new MissionNotFoundError(missionId, this.missionsRoot());
|
|
1245
|
+
}
|
|
1246
|
+
throw error;
|
|
1247
|
+
}
|
|
1248
|
+
const features = Array.isArray(raw) ? raw : asRecord(raw).features;
|
|
1249
|
+
return Array.isArray(features) ? features.map(normalizeFeature) : [];
|
|
1008
1250
|
}
|
|
1009
1251
|
async writeFeatureRecords(missionId, features) {
|
|
1010
|
-
await writeJson(path2.join(this.missionDir(missionId), "features.json"),
|
|
1252
|
+
await writeJson(path2.join(this.missionDir(missionId), "features.json"), {
|
|
1253
|
+
features: features.map(featureForDisk)
|
|
1254
|
+
});
|
|
1011
1255
|
}
|
|
1012
1256
|
async writeFeatures(missionId, inputs) {
|
|
1013
1257
|
const skillNames = await this.listMissionSkills(missionId);
|
|
@@ -1055,23 +1299,27 @@ class MissionStore {
|
|
|
1055
1299
|
return target;
|
|
1056
1300
|
}
|
|
1057
1301
|
async readProgress(missionId) {
|
|
1058
|
-
return readJsonl(path2.join(this.missionDir(missionId), "progress_log.jsonl"));
|
|
1302
|
+
return (await readJsonl(path2.join(this.missionDir(missionId), "progress_log.jsonl"))).map(normalizeProgressEntry);
|
|
1059
1303
|
}
|
|
1060
1304
|
async appendProgress(missionId, entry) {
|
|
1061
|
-
await appendJsonl(path2.join(this.missionDir(missionId), "progress_log.jsonl"), entry);
|
|
1305
|
+
await appendJsonl(path2.join(this.missionDir(missionId), "progress_log.jsonl"), progressForDisk(entry));
|
|
1062
1306
|
}
|
|
1063
1307
|
async readHandoffs(missionId) {
|
|
1064
|
-
return readJsonl(path2.join(this.missionDir(missionId), "handoffs.jsonl"))
|
|
1308
|
+
return (await readJsonl(path2.join(this.missionDir(missionId), "handoffs.jsonl"))).map((entry) => {
|
|
1309
|
+
const normalized = normalizeStoredHandoff(entry);
|
|
1310
|
+
return { ...normalized, missionId };
|
|
1311
|
+
});
|
|
1065
1312
|
}
|
|
1066
1313
|
async writeHandoffs(missionId, handoffs) {
|
|
1067
|
-
await writeFile(path2.join(this.missionDir(missionId), "handoffs.jsonl"), handoffs.map((handoff) => JSON.stringify(handoff)).join(`
|
|
1314
|
+
await writeFile(path2.join(this.missionDir(missionId), "handoffs.jsonl"), handoffs.map((handoff) => JSON.stringify(handoffForDisk(handoff))).join(`
|
|
1068
1315
|
`) + (handoffs.length ? `
|
|
1069
1316
|
` : ""), "utf8");
|
|
1070
1317
|
}
|
|
1071
1318
|
async appendHandoff(missionId, args) {
|
|
1072
1319
|
const createdAt = nowIso2(args.now);
|
|
1073
1320
|
const id = handoffId(args.featureId, createdAt);
|
|
1074
|
-
const
|
|
1321
|
+
const workerId = args.workerSessionId ?? "unknown-worker";
|
|
1322
|
+
const handoffFile = path2.join(this.missionDir(missionId), "handoffs", `${createdAt.replace(/[:.]/g, "-")}__${args.featureId}__${workerId}.json`);
|
|
1075
1323
|
const stored = {
|
|
1076
1324
|
id,
|
|
1077
1325
|
missionId,
|
|
@@ -1084,9 +1332,11 @@ class MissionStore {
|
|
|
1084
1332
|
repoPath: args.repoPath,
|
|
1085
1333
|
handoffFile,
|
|
1086
1334
|
createdAt,
|
|
1335
|
+
timestamp: createdAt,
|
|
1336
|
+
handoff: args.handoff,
|
|
1087
1337
|
...args.handoff
|
|
1088
1338
|
};
|
|
1089
|
-
await writeJson(handoffFile, stored);
|
|
1339
|
+
await writeJson(handoffFile, handoffForDisk(stored));
|
|
1090
1340
|
await this.appendTranscriptSkeleton(missionId, {
|
|
1091
1341
|
workerSessionId: args.workerSessionId,
|
|
1092
1342
|
featureId: args.featureId,
|
|
@@ -1100,7 +1350,7 @@ ${args.handoff.verification.commandsRun.map((command) => `- ${command.command} (
|
|
|
1100
1350
|
`)}`,
|
|
1101
1351
|
createdAt
|
|
1102
1352
|
});
|
|
1103
|
-
await appendJsonl(path2.join(this.missionDir(missionId), "handoffs.jsonl"), stored);
|
|
1353
|
+
await appendJsonl(path2.join(this.missionDir(missionId), "handoffs.jsonl"), handoffForDisk(stored));
|
|
1104
1354
|
return stored;
|
|
1105
1355
|
}
|
|
1106
1356
|
async appendTranscriptSkeleton(missionId, args) {
|
|
@@ -1118,6 +1368,7 @@ ${args.handoff.verification.commandsRun.map((command) => `- ${command.command} (
|
|
|
1118
1368
|
async dismissHandoffItems(missionId, dismissals, now) {
|
|
1119
1369
|
const at = nowIso2(now);
|
|
1120
1370
|
const reasons = new Map(dismissals.map((item) => [item.id, item.reason]));
|
|
1371
|
+
const originalHandoffs = await this.readHandoffs(missionId);
|
|
1121
1372
|
const handoffs = (await this.readHandoffs(missionId)).map((handoff) => {
|
|
1122
1373
|
const reason = reasons.get(handoff.id);
|
|
1123
1374
|
if (!reason)
|
|
@@ -1133,17 +1384,37 @@ ${args.handoff.verification.commandsRun.map((command) => `- ${command.command} (
|
|
|
1133
1384
|
type: "handoff_items_dismissed",
|
|
1134
1385
|
at,
|
|
1135
1386
|
missionId,
|
|
1136
|
-
summary: `${dismissals.length} handoff items dismissed
|
|
1387
|
+
summary: `${dismissals.length} handoff items dismissed`,
|
|
1388
|
+
details: {
|
|
1389
|
+
dismissals: dismissals.map((dismissal) => {
|
|
1390
|
+
const handoff = originalHandoffs.find((item) => item.id === dismissal.id);
|
|
1391
|
+
return {
|
|
1392
|
+
type: "handoff",
|
|
1393
|
+
sourceFeatureId: handoff?.featureId ?? dismissal.id,
|
|
1394
|
+
summary: handoff?.salientSummary ?? dismissal.id,
|
|
1395
|
+
justification: dismissal.reason
|
|
1396
|
+
};
|
|
1397
|
+
})
|
|
1398
|
+
}
|
|
1137
1399
|
});
|
|
1138
1400
|
return handoffs;
|
|
1139
1401
|
}
|
|
1140
1402
|
async snapshot(missionId) {
|
|
1403
|
+
const features = await this.readFeatures(missionId);
|
|
1141
1404
|
return {
|
|
1142
1405
|
dir: this.missionDir(missionId),
|
|
1143
1406
|
state: await this.readState(missionId),
|
|
1144
|
-
features
|
|
1407
|
+
features,
|
|
1145
1408
|
handoffs: await this.readHandoffs(missionId),
|
|
1146
|
-
progress: await this.readProgress(missionId)
|
|
1409
|
+
progress: await this.readProgress(missionId),
|
|
1410
|
+
metadata: {
|
|
1411
|
+
storageKind: missionStorageKind(),
|
|
1412
|
+
featureCounts: features.reduce((counts, feature) => {
|
|
1413
|
+
counts[feature.status] += 1;
|
|
1414
|
+
return counts;
|
|
1415
|
+
}, { pending: 0, in_progress: 0, completed: 0, cancelled: 0 }),
|
|
1416
|
+
resumable: missionStorageKind() !== "factory"
|
|
1417
|
+
}
|
|
1147
1418
|
};
|
|
1148
1419
|
}
|
|
1149
1420
|
}
|
|
@@ -1156,8 +1427,8 @@ function projectRootFrom(input) {
|
|
|
1156
1427
|
return input.worktree || input.directory;
|
|
1157
1428
|
}
|
|
1158
1429
|
function isProtectedMissionPath(projectRoot, filePath) {
|
|
1159
|
-
const resolved =
|
|
1160
|
-
const missionRoot =
|
|
1430
|
+
const resolved = resolveExistingOrParent(filePath);
|
|
1431
|
+
const missionRoot = resolveExistingOrParent(missionsRoot(projectRoot));
|
|
1161
1432
|
if (!resolved.startsWith(missionRoot + path3.sep))
|
|
1162
1433
|
return false;
|
|
1163
1434
|
return [
|
|
@@ -1169,6 +1440,19 @@ function isProtectedMissionPath(projectRoot, filePath) {
|
|
|
1169
1440
|
"working_directory.txt"
|
|
1170
1441
|
].includes(path3.basename(resolved));
|
|
1171
1442
|
}
|
|
1443
|
+
function resolveExistingOrParent(filePath) {
|
|
1444
|
+
try {
|
|
1445
|
+
return realpathSync(filePath);
|
|
1446
|
+
} catch {
|
|
1447
|
+
const parent = path3.dirname(filePath);
|
|
1448
|
+
const base = path3.basename(filePath);
|
|
1449
|
+
try {
|
|
1450
|
+
return path3.join(realpathSync(parent), base);
|
|
1451
|
+
} catch {
|
|
1452
|
+
return path3.resolve(filePath);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1172
1456
|
function normalizeToolPath(args) {
|
|
1173
1457
|
const candidate = args.filePath ?? args.path ?? args.file ?? args.target;
|
|
1174
1458
|
return typeof candidate === "string" ? candidate : undefined;
|
|
@@ -1373,7 +1657,7 @@ var DroidMissionsPlugin = async (ctx) => {
|
|
|
1373
1657
|
}
|
|
1374
1658
|
}),
|
|
1375
1659
|
droid_mission_list: tool({
|
|
1376
|
-
description: "List all
|
|
1660
|
+
description: "List all missions in the selected global mission storage root.",
|
|
1377
1661
|
args: {},
|
|
1378
1662
|
async execute() {
|
|
1379
1663
|
return jsonResult({ missions: await store.listMissions() });
|
|
@@ -1388,14 +1672,15 @@ var DroidMissionsPlugin = async (ctx) => {
|
|
|
1388
1672
|
if (filePath && isProtectedMissionPath(projectRoot, filePath)) {
|
|
1389
1673
|
throw new Error("Use droid_mission tools instead of direct edits to mission system files.");
|
|
1390
1674
|
}
|
|
1675
|
+
const missionRoot = path3.resolve(missionsRoot(projectRoot));
|
|
1391
1676
|
const serializedArgs = JSON.stringify(output.args);
|
|
1392
|
-
if (serializedArgs.includes(
|
|
1677
|
+
if (serializedArgs.includes(missionRoot) && /state\.json|features\.json|progress_log\.jsonl|handoffs\.jsonl/.test(serializedArgs)) {
|
|
1393
1678
|
throw new Error("Use droid_mission tools instead of patching mission system files.");
|
|
1394
1679
|
}
|
|
1395
1680
|
},
|
|
1396
1681
|
async "shell.env"(input, output) {
|
|
1397
1682
|
const root = input.cwd.startsWith(projectRoot) ? projectRoot : input.cwd;
|
|
1398
|
-
output.env.OPENCODE_DROID_MISSIONS_ROOT =
|
|
1683
|
+
output.env.OPENCODE_DROID_MISSIONS_ROOT = missionsRoot(root);
|
|
1399
1684
|
},
|
|
1400
1685
|
async "experimental.session.compacting"(_input, output) {
|
|
1401
1686
|
const missions = await store.listMissions();
|
package/opencode/AGENTS.md
CHANGED
|
@@ -15,7 +15,7 @@ skills/ OpenCode-compatible mission orchestration skill
|
|
|
15
15
|
## Safe-Change Rules
|
|
16
16
|
|
|
17
17
|
- Keep tool permissions aligned with the plugin tool names.
|
|
18
|
-
- Do not instruct agents to edit
|
|
18
|
+
- Do not instruct agents to edit global mission system files directly.
|
|
19
19
|
- Validation agents must return control to the orchestrator through `droid_mission_end_feature`.
|
|
20
20
|
- Do not mention Droid CLI installation or Factory runtime setup in these assets.
|
|
21
21
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: droid-mission-orchestrator
|
|
3
|
-
description: Create and run
|
|
3
|
+
description: Create and run global Droid-style missions in OpenCode without any external mission runtime.
|
|
4
4
|
compatibility: opencode
|
|
5
5
|
---
|
|
6
6
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-missions",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "OpenCode plugin for
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenCode plugin for global mission planning, worker delegation, handoffs, and validation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|