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 CHANGED
@@ -1,32 +1,62 @@
1
1
  # opencode-missions
2
2
 
3
- OpenCode plugin for project-local 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.
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
- Add the npm plugin to `opencode.json`:
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
- OpenCode installs npm plugins automatically with Bun at startup.
33
+ For a different project directory:
17
34
 
18
- Optional agent, command, and skill assets are included under `opencode/`. To install them into the current project:
19
-
20
- ```bash
21
- bunx opencode-missions install
35
+ ```sh
36
+ opencode-missions install /path/to/project
22
37
  ```
23
38
 
24
- That copies:
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
- .opencode/droid-missions/missions/
73
+ ~/.factory/missions/
44
74
  ```
45
75
 
46
- The storage path intentionally remains stable for compatibility with the recovered mission lifecycle.
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 STORAGE_DIR: string;
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 missionStorageRoot(projectRoot: string): string;
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: ${missionDir}
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.workerSessionId = undefined;
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.workerSessionId = undefined;
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.workerSessionId = workerSessionId;
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.workerSessionId = undefined;
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.workerSessionId = args.workerSessionId ?? feature.workerSessionId;
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: readString(input, "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
- return readJson(path2.join(this.missionDir(missionId), "state.json"));
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
- return readJson(path2.join(this.missionDir(missionId), "features.json"));
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"), features);
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 handoffFile = path2.join(this.missionDir(missionId), "handoffs", `${id}.json`);
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: await this.readFeatures(missionId),
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 = path3.resolve(filePath);
1160
- const missionRoot = path3.join(path3.resolve(projectRoot), ".opencode", "droid-missions", "missions");
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 project-local OpenCode missions.",
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(".opencode/droid-missions/missions/") && /state\.json|features\.json|progress_log\.jsonl|handoffs\.jsonl/.test(serializedArgs)) {
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 = path3.join(root, ".opencode", "droid-missions");
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();
@@ -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 `.opencode/droid-missions/missions/*` system files directly.
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,5 +1,5 @@
1
1
  ---
2
- description: Plans and controls Droid-style missions through project-local OpenCode mission tools.
2
+ description: Plans and controls Droid-style missions through global OpenCode mission tools.
3
3
  mode: primary
4
4
  permission:
5
5
  read: allow
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Create or continue a project-local OpenCode mission.
2
+ description: Create or continue a global OpenCode mission.
3
3
  agent: droid-mission-orchestrator
4
4
  ---
5
5
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: droid-mission-orchestrator
3
- description: Create and run project-local Droid-style missions in OpenCode without any external mission runtime.
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.0",
4
- "description": "OpenCode plugin for project-local mission planning, worker delegation, handoffs, and validation.",
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",