owflow 0.1.9 → 0.2.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/README.md CHANGED
@@ -18,6 +18,26 @@ Started as a simple OpenCode fork of [Maister Claude Code plugin](https://github
18
18
 
19
19
  ## Getting Started
20
20
 
21
+ ### Directory Structure
22
+
23
+ ```
24
+ owflow/
25
+ ├── docs/ # Plugin documentation (commands, workflows)
26
+ ├── scripts/ # Build and utility scripts
27
+ └── src/ # Source code
28
+ ├── __tests__/ # Test suites
29
+ ├── agents/ # Agent definitions and prompts (.md)
30
+ ├── commands/ # OpenCode slash commands (.md)
31
+ ├── configuration/ # Handlers for agent, command, and skill configurations
32
+ ├── hooks/ # OpenCode lifecycle hooks (tool execution, compaction)
33
+ ├── skills/ # Workflows, orchestrators, and reusable skills
34
+ ├── templates/ # Orchestrator state and task YAML templates
35
+ ├── tools/ # Custom OpenCode tool implementations
36
+ ├── types/ # TypeScript type definitions
37
+ ├── utils/ # Internal utility functions
38
+ └── index.ts # Main plugin entry point
39
+ ```
40
+
21
41
  ### Prerequisites
22
42
 
23
43
  - [Opencode](https://opencode.ai/) CLI installed and configured
@@ -0,0 +1,66 @@
1
+ import { loadMarkdownDir } from "../utils/file-util.js";
2
+ import matter from "gray-matter";
3
+ /**
4
+ * Resolves model aliases to the configured small_model value.
5
+ * Maps common small/fast model aliases to OpenCode's small_model config.
6
+ *
7
+ * @param {string} modelValue - The model value from agent frontmatter
8
+ * @param {string} smallModel - The small_model from OpenCode config
9
+ * @returns {string|undefined} - Resolved model value or undefined
10
+ */
11
+ const resolveModelAlias = (modelValue, smallModel) => {
12
+ // Aliases that should map to small_model
13
+ const SMALL_MODEL_ALIASES = [
14
+ "haiku", // Claude Code alias
15
+ "gpt-4o-mini", // OpenAI small model
16
+ "small", // Generic alias
17
+ "fast", // Generic alias
18
+ ];
19
+ // If no model specified or it's 'inherit', return undefined (let OpenCode decide)
20
+ if (!modelValue || modelValue === "inherit") {
21
+ return undefined;
22
+ }
23
+ // If it's a small model alias and we have a configured small_model, use it
24
+ if (SMALL_MODEL_ALIASES.includes(modelValue.toLowerCase())) {
25
+ if (smallModel) {
26
+ return smallModel;
27
+ }
28
+ // If no small_model configured, return undefined (inherit)
29
+ console.warn(`[owflow] Agent uses '${modelValue}' but no small_model configured in opencode.json - falling back to inherit`);
30
+ return undefined;
31
+ }
32
+ // Otherwise, pass through the original value
33
+ return modelValue;
34
+ };
35
+ const prepareAgent = ({ data, content }, smallModel) => {
36
+ return {
37
+ prompt: content,
38
+ ...(data.description && { description: data.description }),
39
+ // Resolve model aliases (haiku, gpt-4o-mini, etc.) to small_model
40
+ ...(() => {
41
+ const resolvedModel = resolveModelAlias(data.model, smallModel);
42
+ return resolvedModel ? { model: resolvedModel } : {};
43
+ })(),
44
+ ...(data.mode && { mode: data.mode }),
45
+ // Add skills array if present
46
+ ...(data.skills && Array.isArray(data.skills) && { skills: data.skills }),
47
+ ...(data.hidden === "true" && { hidden: true }),
48
+ };
49
+ };
50
+ export const configureAgents = (config, smallModel) => {
51
+ config.agent = config.agent || {};
52
+ for (const markdown of loadMarkdownDir("agents")) {
53
+ const name = markdown.data.name;
54
+ if (!name)
55
+ continue;
56
+ // Don't overwrite agents the user has explicitly configured
57
+ if (config.agent[name])
58
+ continue;
59
+ try {
60
+ config.agent[name] = prepareAgent(markdown, smallModel);
61
+ }
62
+ catch (error) {
63
+ console.warn(`[owflow] Failed to register agent '${name}': ${error.message}`);
64
+ }
65
+ }
66
+ };
@@ -0,0 +1,21 @@
1
+ import { loadMarkdownDir } from "../utils/file-util.js";
2
+ export const configureCommands = (config) => {
3
+ config.command = config.command || {};
4
+ for (const { data, content } of loadMarkdownDir("commands")) {
5
+ const name = data.name;
6
+ if (!name)
7
+ continue;
8
+ // Don't overwrite commands the user has explicitly configured
9
+ if (config.command[name])
10
+ continue;
11
+ config.command[name] = {
12
+ template: content,
13
+ ...(data.description && { description: data.description }),
14
+ ...(data.agent && { agent: data.agent }),
15
+ ...(data.model && { model: data.model }),
16
+ ...(data.subtask !== undefined && {
17
+ subtask: data.subtask === "true",
18
+ }),
19
+ };
20
+ }
21
+ };
@@ -0,0 +1,10 @@
1
+ import path from "node:path";
2
+ import { PLUGIN_ROOT } from "../constants.js";
3
+ export const configureSkills = (config) => {
4
+ config.skills = config.skills || {};
5
+ config.skills.paths = config.skills.paths || [];
6
+ const skillsDir = path.join(PLUGIN_ROOT, "skills");
7
+ if (!config.skills.paths.includes(skillsDir)) {
8
+ config.skills.paths.push(skillsDir);
9
+ }
10
+ };
@@ -0,0 +1,4 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
4
+ export const PLUGIN_ROOT = __dirname; // Points to dist/ where skills/commands/agents are copied
@@ -0,0 +1,19 @@
1
+ export const guardAgainstDestructiveActions = async (input, output, agentBySessionMap) => {
2
+ if (input.tool !== "bash")
3
+ return;
4
+ const agentName = agentBySessionMap.get(input.sessionID) ?? "main";
5
+ const WHITELIST = [
6
+ "task-group-implementer",
7
+ "test-suite-runner",
8
+ "e2e-test-verifier",
9
+ "user-docs-generator",
10
+ "docs-operator",
11
+ ];
12
+ if (WHITELIST.includes(agentName))
13
+ return;
14
+ const cmd = output?.args?.command ?? "";
15
+ const DESTRUCTIVE = /git\s+stash|git\s+reset\s+--hard|git\s+checkout\s+--\s+\.|git\s+checkout\s+\.\s*(?:$|\s)|git\s+clean|git\s+push\s+(?:--force|-f)|rm\s+-[rf]{2}/i;
16
+ if (DESTRUCTIVE.test(cmd)) {
17
+ throw new Error(`Blocked: destructive command not permitted for agent "${agentName}"`);
18
+ }
19
+ };
@@ -0,0 +1,19 @@
1
+ import { $ } from "bun";
2
+ /**
3
+ * Post-compaction reminder.
4
+ * Injects orchestrator state reminder into the compaction context so the
5
+ * AI knows to re-read orchestrator-state.yml after the context is rebuilt.
6
+ */
7
+ export const rereadOrchestratorState = async (directory, output) => {
8
+ const tasksDir = `${directory}/.owflow/tasks`;
9
+ try {
10
+ await $ `test -d ${tasksDir}`;
11
+ output.context.push(`## owflow Workflow State
12
+ If an orchestrator workflow was active before compaction, you MUST re-read
13
+ orchestrator-state.yml in that task's directory to verify completed_phases
14
+ and determine the next phase. Use the question tool at Phase Gates.`);
15
+ }
16
+ catch {
17
+ // No .owflow/tasks directory — not a owflow project, skip injection
18
+ }
19
+ };
package/dist/index.js CHANGED
@@ -4,72 +4,13 @@
4
4
  */
5
5
  import path from "node:path";
6
6
  import fs from "node:fs";
7
- import { fileURLToPath } from "url";
8
- import matter from "gray-matter";
9
7
  import { verify_template } from "./tools/verify_template.js";
10
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
- const PLUGIN_ROOT = __dirname; // Points to dist/ where skills/commands/agents are copied
12
- /**
13
- * Resolves model aliases to the configured small_model value.
14
- * Maps common small/fast model aliases to OpenCode's small_model config.
15
- *
16
- * @param {string} modelValue - The model value from agent frontmatter
17
- * @param {string} smallModel - The small_model from OpenCode config
18
- * @returns {string|undefined} - Resolved model value or undefined
19
- */
20
- const resolveModelAlias = (modelValue, smallModel) => {
21
- // Aliases that should map to small_model
22
- const SMALL_MODEL_ALIASES = [
23
- "haiku", // Claude Code alias
24
- "gpt-4o-mini", // OpenAI small model
25
- "small", // Generic alias
26
- "fast", // Generic alias
27
- ];
28
- // If no model specified or it's 'inherit', return undefined (let OpenCode decide)
29
- if (!modelValue || modelValue === "inherit") {
30
- return undefined;
31
- }
32
- // If it's a small model alias and we have a configured small_model, use it
33
- if (SMALL_MODEL_ALIASES.includes(modelValue.toLowerCase())) {
34
- if (smallModel) {
35
- return smallModel;
36
- }
37
- // If no small_model configured, return undefined (inherit)
38
- console.warn(`[owflow] Agent uses '${modelValue}' but no small_model configured in opencode.json - falling back to inherit`);
39
- return undefined;
40
- }
41
- // Otherwise, pass through the original value
42
- return modelValue;
43
- };
44
- const loadMarkdownDir = (dirPath) => {
45
- try {
46
- return fs
47
- .readdirSync(dirPath)
48
- .filter((f) => f.endsWith(".md"))
49
- .map((f) => {
50
- const raw = fs.readFileSync(path.join(dirPath, f), "utf8");
51
- return matter(raw);
52
- });
53
- }
54
- catch {
55
- return [];
56
- }
57
- };
58
- const prepareAgent = ({ data, content }, smallModel) => {
59
- return {
60
- prompt: content,
61
- ...(data.description && { description: data.description }),
62
- // Resolve model aliases (haiku, gpt-4o-mini, etc.) to small_model
63
- ...(() => {
64
- const resolvedModel = resolveModelAlias(data.model, smallModel);
65
- return resolvedModel ? { model: resolvedModel } : {};
66
- })(),
67
- ...(data.mode && { mode: data.mode }),
68
- // Add skills array if present
69
- ...(data.skills && Array.isArray(data.skills) && { skills: data.skills }),
70
- ...(data.hidden === "true" && { hidden: true }),
71
- };
72
- };
8
+ import { rereadOrchestratorState } from "./hooks/session-compaction.js";
9
+ import { guardAgainstDestructiveActions } from "./hooks/before-tool.js";
10
+ import { configureSkills } from "./configuration/skills-config.js";
11
+ import { configureCommands } from "./configuration/commands-config.js";
12
+ import { configureAgents } from "./configuration/agents-config.js";
13
+ // Only the plugin factory method should be exported from this file due to OpenCode plugin handling
73
14
  const OwflowPlugin = async ({ $, directory }) => {
74
15
  const agentBySession = new Map();
75
16
  return {
@@ -81,6 +22,7 @@ const OwflowPlugin = async ({ $, directory }) => {
81
22
  * them without requiring manual config file edits.
82
23
  */
83
24
  config: async (config) => {
25
+ console.log("[owflow] Starting plugin installation...");
84
26
  let typedConfig = config;
85
27
  // Get small_model config for alias resolution
86
28
  // Try from config parameter first, fallback to reading opencode.json
@@ -99,69 +41,14 @@ const OwflowPlugin = async ({ $, directory }) => {
99
41
  console.warn(`Couldn't read or parse opencode.json config - small_model remains undefined`);
100
42
  }
101
43
  }
102
- // --- Skills ---
103
- typedConfig.skills = typedConfig.skills || {};
104
- typedConfig.skills.paths = typedConfig.skills.paths || [];
105
- const skillsDir = path.join(PLUGIN_ROOT, "skills");
106
- if (!typedConfig.skills.paths.includes(skillsDir)) {
107
- typedConfig.skills.paths.push(skillsDir);
108
- }
109
- // --- Commands ---
110
- typedConfig.command = typedConfig.command || {};
111
- const commandsDir = path.join(PLUGIN_ROOT, "commands");
112
- for (const { data, content } of loadMarkdownDir(commandsDir)) {
113
- const name = data.name;
114
- if (!name)
115
- continue;
116
- // Don't overwrite commands the user has explicitly configured
117
- if (typedConfig.command[name])
118
- continue;
119
- typedConfig.command[name] = {
120
- template: content,
121
- ...(data.description && { description: data.description }),
122
- ...(data.agent && { agent: data.agent }),
123
- ...(data.model && { model: data.model }),
124
- ...(data.subtask !== undefined && {
125
- subtask: data.subtask === "true",
126
- }),
127
- };
128
- }
129
- // --- Agents ---
130
- typedConfig.agent = typedConfig.agent || {};
131
- const agentsDir = path.join(PLUGIN_ROOT, "agents");
132
- for (const markdown of loadMarkdownDir(agentsDir)) {
133
- const name = markdown.data.name;
134
- if (!name)
135
- continue;
136
- // Don't overwrite agents the user has explicitly configured
137
- if (typedConfig.agent[name])
138
- continue;
139
- try {
140
- typedConfig.agent[name] = prepareAgent(markdown, smallModel);
141
- }
142
- catch (error) {
143
- console.warn(`[owflow] Failed to register agent '${name}': ${error.message}`);
144
- }
145
- }
44
+ configureSkills(typedConfig);
45
+ configureCommands(typedConfig);
46
+ configureAgents(typedConfig, smallModel);
146
47
  config = typedConfig;
48
+ console.log("[owflow] Plugin installation completed successfully.");
147
49
  },
148
- /**
149
- * Post-compaction reminder.
150
- * Injects orchestrator state reminder into the compaction context so the
151
- * AI knows to re-read orchestrator-state.yml after the context is rebuilt.
152
- */
153
50
  "experimental.session.compacting": async (input, output) => {
154
- const tasksDir = `${directory}/.owflow/tasks`;
155
- try {
156
- await $ `test -d ${tasksDir}`;
157
- output.context.push(`## owflow Workflow State
158
- If an orchestrator workflow was active before compaction, you MUST re-read
159
- orchestrator-state.yml in that task's directory to verify completed_phases
160
- and determine the next phase. Use the question tool at Phase Gates.`);
161
- }
162
- catch {
163
- // No .owflow/tasks directory — not a owflow project, skip injection
164
- }
51
+ await rereadOrchestratorState(directory, output);
165
52
  },
166
53
  "chat.message": async (input, _output) => {
167
54
  try {
@@ -173,23 +60,7 @@ and determine the next phase. Use the question tool at Phase Gates.`);
173
60
  },
174
61
  "tool.execute.before": async (input, output) => {
175
62
  try {
176
- if (input.tool !== "bash")
177
- return;
178
- const agentName = agentBySession.get(input.sessionID) ?? "main";
179
- const WHITELIST = [
180
- "task-group-implementer",
181
- "test-suite-runner",
182
- "e2e-test-verifier",
183
- "user-docs-generator",
184
- "docs-operator",
185
- ];
186
- if (WHITELIST.includes(agentName))
187
- return;
188
- const cmd = output?.args?.command ?? "";
189
- const DESTRUCTIVE = /git\s+stash|git\s+reset\s+--hard|git\s+checkout\s+--\s+\.|git\s+checkout\s+\.\s*(?:$|\s)|git\s+clean|git\s+push\s+(?:--force|-f)|rm\s+-[rf]{2}/i;
190
- if (DESTRUCTIVE.test(cmd)) {
191
- throw new Error(`Blocked: destructive command not permitted for agent "${agentName}"`);
192
- }
63
+ await guardAgainstDestructiveActions(input, output, agentBySession);
193
64
  }
194
65
  catch (e) {
195
66
  // Re-throw only intentional blocks; swallow unexpected errors
@@ -0,0 +1,19 @@
1
+ import matter from "gray-matter";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { PLUGIN_ROOT } from "../constants.js";
5
+ export const loadMarkdownDir = (dirPath) => {
6
+ const fullPath = path.join(PLUGIN_ROOT, dirPath);
7
+ try {
8
+ return fs
9
+ .readdirSync(fullPath)
10
+ .filter((f) => f.endsWith(".md"))
11
+ .map((f) => {
12
+ const raw = fs.readFileSync(path.join(fullPath, f), "utf8");
13
+ return matter(raw);
14
+ });
15
+ }
16
+ catch {
17
+ return [];
18
+ }
19
+ };
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "owflow",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "Spec-driven development AI workflow for OpenCode",
5
5
  "type": "module",
6
- "module": "index.ts",
7
6
  "main": "dist/index.js",
8
7
  "exports": "./dist/index.js",
9
8
  "types": "dist/index.d.ts",
@@ -13,10 +12,12 @@
13
12
  "LICENSE"
14
13
  ],
15
14
  "scripts": {
16
- "build": "rm -rf dist && tsc && bun run copy-markdowns",
17
- "copy-markdowns": "node ./scripts/copy-markdowns.js",
15
+ "build": "rm -rf dist && bun run type-check && bun run test && bun run copy-markdowns",
16
+ "copy-markdowns": "bun run ./scripts/copy-markdowns.js",
17
+ "local-install": "bun run build && opencode plugin \"$(pwd)\" --global --force",
18
18
  "prepack": "bun run build",
19
- "test": "bun test"
19
+ "test": "bun test src",
20
+ "type-check": "bun tsc"
20
21
  },
21
22
  "keywords": [
22
23
  "opencode",
@@ -31,11 +32,11 @@
31
32
  },
32
33
  "license": "MIT",
33
34
  "dependencies": {
35
+ "@opencode-ai/plugin": "^1.18.4",
34
36
  "gray-matter": "^4.0.3",
35
37
  "yaml": "^2.4.1"
36
38
  },
37
39
  "devDependencies": {
38
- "@opencode-ai/plugin": "^1.18.4",
39
40
  "@types/bun": "^1.3.13"
40
41
  },
41
42
  "peerDependencies": {
@@ -1,102 +0,0 @@
1
- import { expect, test, describe, beforeAll, afterAll } from "bun:test";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { verify_template } from "../tools/verify_template.js";
6
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
- describe("verify_template tool", () => {
8
- const templatesDir = path.join(__dirname, "../templates");
9
- const testDir = path.join(__dirname, "test_tmp_dir");
10
- const testTemplateName = "test-template.yml";
11
- const testTemplatePath = path.join(templatesDir, testTemplateName);
12
- beforeAll(() => {
13
- // Ensure templates dir exists
14
- if (!fs.existsSync(templatesDir)) {
15
- fs.mkdirSync(templatesDir, { recursive: true });
16
- }
17
- // Create a dummy template
18
- fs.writeFileSync(testTemplatePath, `
19
- key1: value1
20
- key2:
21
- subKey1: subValue1
22
- `);
23
- // Ensure test dir exists
24
- if (!fs.existsSync(testDir)) {
25
- fs.mkdirSync(testDir, { recursive: true });
26
- }
27
- });
28
- afterAll(() => {
29
- // Cleanup
30
- if (fs.existsSync(testTemplatePath)) {
31
- fs.rmSync(testTemplatePath);
32
- }
33
- if (fs.existsSync(testDir)) {
34
- fs.rmSync(testDir, { recursive: true, force: true });
35
- }
36
- });
37
- test("should return error if file not found", async () => {
38
- const result = await verify_template.execute({ filePath: "nonexistent.yml", templateName: testTemplateName }, { directory: testDir });
39
- expect(result.output).toContain("File not found at nonexistent.yml");
40
- });
41
- test("should return error if template not found", async () => {
42
- const filePath = "target.yml";
43
- fs.writeFileSync(path.join(testDir, filePath), "key1: value");
44
- const result = await verify_template.execute({ filePath, templateName: "nonexistent-template.yml" }, { directory: testDir });
45
- expect(result.output).toContain("Template 'nonexistent-template.yml' not found");
46
- });
47
- test("should return error if target file has invalid YAML syntax", async () => {
48
- const filePath = "invalid.yml";
49
- fs.writeFileSync(path.join(testDir, filePath), "key1: : value\n invalid");
50
- const result = await verify_template.execute({ filePath, templateName: testTemplateName }, { directory: testDir });
51
- expect(result.output).toContain("YAML Syntax Error in invalid.yml");
52
- });
53
- test("should return error if target file is missing keys from template", async () => {
54
- const filePath = "missing-keys.yml";
55
- // Missing key2
56
- fs.writeFileSync(path.join(testDir, filePath), "key1: some-value");
57
- const result = await verify_template.execute({ filePath, templateName: testTemplateName }, { directory: testDir });
58
- expect(result.output).toContain("YAML Structure Validation Failed");
59
- expect(result.output).toContain("- Missing key: key2");
60
- });
61
- test("should pass if target file matches template structure", async () => {
62
- const filePath = "valid.yml";
63
- fs.writeFileSync(path.join(testDir, filePath), `
64
- key1: different_value
65
- key2:
66
- subKey1: different_sub_value
67
- extraKey: this is fine
68
- `);
69
- const result = await verify_template.execute({ filePath, templateName: testTemplateName }, { directory: testDir });
70
- expect(result.output).toBe("File exists and follows the correct YAML structure.");
71
- });
72
- test("should pass if template has null or non-object values and target has them too", async () => {
73
- const nullTemplateName = "null-template.yml";
74
- const nullTemplatePath = path.join(templatesDir, nullTemplateName);
75
- fs.writeFileSync(nullTemplatePath, "key: null\nother: 123");
76
- const filePath = "valid-null.yml";
77
- fs.writeFileSync(path.join(testDir, filePath), "key: something\nother: 456");
78
- const result = await verify_template.execute({ filePath, templateName: nullTemplateName }, { directory: testDir });
79
- expect(result.output).toBe("File exists and follows the correct YAML structure.");
80
- fs.rmSync(nullTemplatePath);
81
- });
82
- describe("real templates validation", () => {
83
- let templateFiles = [];
84
- try {
85
- const files = fs.readdirSync(templatesDir);
86
- templateFiles = files.filter(f => f.endsWith('.yml') || f.endsWith('.yaml') || f.endsWith('.md'));
87
- // Filter out our dummy test template
88
- templateFiles = templateFiles.filter(f => f !== "test-template.yml" && f !== "null-template.yml");
89
- }
90
- catch (e) {
91
- // templates dir might not exist or be readable in some contexts, though beforeAll ensures it exists
92
- }
93
- for (const templateName of templateFiles) {
94
- test(`should successfully validate a valid instance of ${templateName}`, async () => {
95
- // We use the templates directory as the working directory
96
- // and the template name as the filePath, effectively comparing the template to itself to ensure it's structurally valid.
97
- const result = await verify_template.execute({ filePath: templateName, templateName }, { directory: templatesDir });
98
- expect(result.output).toBe("File exists and follows the correct YAML structure.");
99
- });
100
- }
101
- });
102
- });