owflow 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/index.js +197 -0
- package/dist/skills/product-design/server/index.js +302 -0
- package/dist/types/opencode-types.js +1 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dawid Paluchowski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# Owflow
|
|
4
|
+
|
|
5
|
+
**Structured, standards-aware development workflows for OpenCode**
|
|
6
|
+
|
|
7
|
+
Describe what you want to build, and the plugin handles the rest - from specification through implementation to verification - while enforcing your project's coding standards at every step.
|
|
8
|
+
|
|
9
|
+
Started as a simple OpenCode fork of [Maister Claude Code plugin](https://github.com/SkillPanel/maister), Owflow aims to deliver production ready workflows using the full [Opencode](https://opencode.ai/) plugin support.
|
|
10
|
+
|
|
11
|
+
## What You Get
|
|
12
|
+
|
|
13
|
+
- **Guided workflows** for features, bug fixes, enhancements, performance, migrations, research, and product design
|
|
14
|
+
- **Auto-discovered standards** from your codebase - config files, source patterns, and documentation are analyzed and enforced throughout every workflow
|
|
15
|
+
- **Test-driven implementation** with automated planning, incremental verification, and full test suite runs before completion
|
|
16
|
+
- **Pause and resume** any workflow - state is preserved across sessions
|
|
17
|
+
- **Production readiness checks** including code review, reality assessment, and pragmatic over-engineering detection
|
|
18
|
+
|
|
19
|
+
## Getting Started
|
|
20
|
+
|
|
21
|
+
### Prerequisites
|
|
22
|
+
|
|
23
|
+
- [Opencode](https://opencode.ai/) CLI installed and configured
|
|
24
|
+
|
|
25
|
+
### Installation
|
|
26
|
+
|
|
27
|
+
Add owflow to the `plugin` array in your `opencode.json` ([global or project-level](https://opencode.ai/docs/plugins/#load-order)):
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"plugin": ["owflow"]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Initial project setup
|
|
36
|
+
|
|
37
|
+
Initialize your project to auto-detect coding standards and generate project documentation:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
/flow-init
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This scans your codebase and creates `.owflow/` with standards, docs, and task folders. May take a few minutes on larger projects.
|
|
44
|
+
|
|
45
|
+
If you have another project already using Owflow, you can reuse its standards as a starting point:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
/flow-init --standards-from=/path/to/other-project
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### First Workflow
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
/development Add user profile page with avatar upload
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or just discuss your task with LLM model and then run:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
/development
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The plugin picks up context from your conversation - no arguments needed.
|
|
64
|
+
|
|
65
|
+
## How It Works
|
|
66
|
+
|
|
67
|
+
1. You describe a task - either as an argument or just in conversation
|
|
68
|
+
2. The plugin classifies it (feature, bug, enhancement, etc.) and proposes a workflow
|
|
69
|
+
3. You confirm, and it guides you through phases: **requirements → spec → plan → implement → verify**
|
|
70
|
+
4. At each phase, it asks for your input and decisions
|
|
71
|
+
5. You get tested, verified code with a detailed work log
|
|
72
|
+
|
|
73
|
+
All artifacts are saved in `.owflow/tasks/` organized by type and date.
|
|
74
|
+
|
|
75
|
+
### Context-Aware Commands
|
|
76
|
+
|
|
77
|
+
Every workflow command works without arguments. The plugin reads your current conversation to extract the task description and auto-detect the task type:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
You: "The login page throws a 500 error when the session expires"
|
|
81
|
+
You: /development
|
|
82
|
+
→ Auto-detects: bug fix, extracts description from conversation
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
You: /standards-update
|
|
87
|
+
→ Scans conversation for patterns like "we always use..." or "prefer X over Y"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
You can always be explicit when you prefer - arguments and flags simply override the auto-detection.
|
|
91
|
+
|
|
92
|
+
## Supported Workflows
|
|
93
|
+
|
|
94
|
+
| Command | Use When |
|
|
95
|
+
| ----------------- | ------------------------------------------- |
|
|
96
|
+
| `/development` | Features, bug fixes, enhancements |
|
|
97
|
+
| `/research` | Research with synthesis and solution design |
|
|
98
|
+
| `/performance` | Optimizing speed or resource usage |
|
|
99
|
+
| `/migration` | Changing technologies or patterns |
|
|
100
|
+
| `/product-design` | Product and feature design |
|
|
101
|
+
|
|
102
|
+
Task type (feature/bug/enhancement) is auto-detected from context. Override with `--type=feature|bug|enhancement` if needed. Or use `/work` as a single entry point that routes to the right workflow.
|
|
103
|
+
|
|
104
|
+
### Quick Commands
|
|
105
|
+
|
|
106
|
+
For smaller tasks that don't need a full workflow:
|
|
107
|
+
|
|
108
|
+
| Command | Use When |
|
|
109
|
+
| --------------- | ----------------------------------------------------------- |
|
|
110
|
+
| `/quick-plan` | You want a plan with standards awareness before coding |
|
|
111
|
+
| `/quick-dev` | You know what to do - just implement with standards applied |
|
|
112
|
+
| `/quick-bugfix` | Quick TDD-driven bug fix — write failing test, fix, verify |
|
|
113
|
+
|
|
114
|
+
## Standards-Aware Development
|
|
115
|
+
|
|
116
|
+
This is the key differentiator. Owflow doesn't just run workflows - it learns your project's conventions and enforces them:
|
|
117
|
+
|
|
118
|
+
- **`flow-init`** scans config files, source code, and documentation to auto-detect your coding standards
|
|
119
|
+
- **Continuous checking** - standards are consulted before specification, during planning, and while coding (not just at the start)
|
|
120
|
+
- **`/standards-discover`** refreshes standards from your evolving codebase
|
|
121
|
+
- **`/standards-update`** lets you add or refine standards manually, or sync from another project with `--from=PATH`
|
|
122
|
+
|
|
123
|
+
Standards live in `.owflow/docs/standards/` and are indexed in `.owflow/docs/INDEX.md`.
|
|
124
|
+
|
|
125
|
+
**Important**: Do not use plan mode with workflows (see [Best Practices](#best-practices) below).
|
|
126
|
+
|
|
127
|
+
## Beta Channel
|
|
128
|
+
|
|
129
|
+
Want to try experimental features before they hit stable? Install from the beta channel:
|
|
130
|
+
|
|
131
|
+
TBD
|
|
132
|
+
|
|
133
|
+
## Best Practices
|
|
134
|
+
|
|
135
|
+
**Don't use plan mode when starting a workflow.** Planning is a built-in part of every workflow — the orchestrator creates specs, plans, and other files as it goes. Claude Code's plan mode restricts file creation, which conflicts with this. Let the workflow handle planning on its own.
|
|
136
|
+
|
|
137
|
+
**Start workflows in a fresh session.** This is especially useful when chaining workflows (e.g., research → development). Research and product-design artifacts already contain all the context needed, so a clean session avoids noise from prior conversation.
|
|
138
|
+
|
|
139
|
+
**Chain workflows by passing a task folder.** If you've completed a research or product-design workflow and want to build on those results, pass the task folder directly:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
/development .owflow/tasks/research/2026-01-12-oauth-research
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
You can also append additional instructions to narrow scope or guide the workflow:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
/development .owflow/tasks/product-design/2026-03-10-dashboard-redesign Implement only phase 1
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Known Issues
|
|
152
|
+
|
|
153
|
+
**Orchestrator may stall after long phases.** After context compaction (which typically happens after lengthy phases like implementation), the main agent may stop progressing automatically. If you notice it's idle, just type something like "continue" or "proceed" — it will pick up where it left off. You can also re-invoke the workflow in resume mode to reload the orchestrator state:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
/development .owflow/tasks/development/2026-03-24-my-feature
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Learn More
|
|
160
|
+
|
|
161
|
+
- [Workflow Details](docs/workflows.md) - phases, examples, and task structure for each workflow type
|
|
162
|
+
- [Full Command Reference](docs/commands.md) - all workflow, review, utility, and quick commands
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owflow OpenCode Plugin
|
|
3
|
+
* Registers skills/agents/commands via OpenCode's JS event system.
|
|
4
|
+
*/
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import matter from "gray-matter";
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const PLUGIN_ROOT = path.resolve(__dirname, "../..");
|
|
11
|
+
/**
|
|
12
|
+
* Resolves model aliases to the configured small_model value.
|
|
13
|
+
* Maps common small/fast model aliases to OpenCode's small_model config.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} modelValue - The model value from agent frontmatter
|
|
16
|
+
* @param {string} smallModel - The small_model from OpenCode config
|
|
17
|
+
* @returns {string|undefined} - Resolved model value or undefined
|
|
18
|
+
*/
|
|
19
|
+
const resolveModelAlias = (modelValue, smallModel) => {
|
|
20
|
+
// Aliases that should map to small_model
|
|
21
|
+
const SMALL_MODEL_ALIASES = [
|
|
22
|
+
"haiku", // Claude Code alias
|
|
23
|
+
"gpt-4o-mini", // OpenAI small model
|
|
24
|
+
"small", // Generic alias
|
|
25
|
+
"fast", // Generic alias
|
|
26
|
+
];
|
|
27
|
+
// If no model specified or it's 'inherit', return undefined (let OpenCode decide)
|
|
28
|
+
if (!modelValue || modelValue === "inherit") {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
// If it's a small model alias and we have a configured small_model, use it
|
|
32
|
+
if (SMALL_MODEL_ALIASES.includes(modelValue.toLowerCase())) {
|
|
33
|
+
if (smallModel) {
|
|
34
|
+
return smallModel;
|
|
35
|
+
}
|
|
36
|
+
// If no small_model configured, return undefined (inherit)
|
|
37
|
+
console.warn(`[owflow] Agent uses '${modelValue}' but no small_model configured in opencode.json - falling back to inherit`);
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
// Otherwise, pass through the original value
|
|
41
|
+
return modelValue;
|
|
42
|
+
};
|
|
43
|
+
const loadMarkdownDir = (dirPath) => {
|
|
44
|
+
try {
|
|
45
|
+
return fs
|
|
46
|
+
.readdirSync(dirPath)
|
|
47
|
+
.filter((f) => f.endsWith(".md"))
|
|
48
|
+
.map((f) => {
|
|
49
|
+
const raw = fs.readFileSync(path.join(dirPath, f), "utf8");
|
|
50
|
+
return matter(raw);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const prepareAgent = ({ data, content }, smallModel) => {
|
|
58
|
+
return {
|
|
59
|
+
prompt: content,
|
|
60
|
+
...(data.description && { description: data.description }),
|
|
61
|
+
// Resolve model aliases (haiku, gpt-4o-mini, etc.) to small_model
|
|
62
|
+
...(() => {
|
|
63
|
+
const resolvedModel = resolveModelAlias(data.model, smallModel);
|
|
64
|
+
return resolvedModel ? { model: resolvedModel } : {};
|
|
65
|
+
})(),
|
|
66
|
+
...(data.mode && { mode: data.mode }),
|
|
67
|
+
// Add skills array if present
|
|
68
|
+
...(data.skills && Array.isArray(data.skills) && { skills: data.skills }),
|
|
69
|
+
...(data.hidden === "true" && { hidden: true }),
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
export const OwflowPlugin = async ({ $, directory }) => {
|
|
73
|
+
const agentBySession = new Map();
|
|
74
|
+
return {
|
|
75
|
+
/**
|
|
76
|
+
* Register owflow's skills, commands, and agents so OpenCode discovers
|
|
77
|
+
* them without requiring manual config file edits.
|
|
78
|
+
*/
|
|
79
|
+
config: async (config) => {
|
|
80
|
+
let typedConfig = config;
|
|
81
|
+
// Get small_model config for alias resolution
|
|
82
|
+
// Try from config parameter first, fallback to reading opencode.json
|
|
83
|
+
let smallModel = typedConfig.small_model ?? "";
|
|
84
|
+
if (!smallModel) {
|
|
85
|
+
try {
|
|
86
|
+
// Check project-level opencode.json
|
|
87
|
+
const projectConfigPath = path.join(directory, "opencode.json");
|
|
88
|
+
if (fs.existsSync(projectConfigPath)) {
|
|
89
|
+
const rawConfig = fs.readFileSync(projectConfigPath, "utf8");
|
|
90
|
+
const opencodeConfig = JSON.parse(rawConfig);
|
|
91
|
+
smallModel = opencodeConfig.small_model;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
console.warn(`Couldn't read or parse opencode.json config - small_model remains undefined`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// --- Skills ---
|
|
99
|
+
typedConfig.skills = typedConfig.skills || {};
|
|
100
|
+
typedConfig.skills.paths = typedConfig.skills.paths || [];
|
|
101
|
+
const skillsDir = path.join(PLUGIN_ROOT, "skills");
|
|
102
|
+
if (!typedConfig.skills.paths.includes(skillsDir)) {
|
|
103
|
+
typedConfig.skills.paths.push(skillsDir);
|
|
104
|
+
}
|
|
105
|
+
// --- Commands ---
|
|
106
|
+
typedConfig.command = typedConfig.command || {};
|
|
107
|
+
const commandsDir = path.join(PLUGIN_ROOT, "commands");
|
|
108
|
+
for (const { data, content } of loadMarkdownDir(commandsDir)) {
|
|
109
|
+
const name = data.name;
|
|
110
|
+
if (!name)
|
|
111
|
+
continue;
|
|
112
|
+
// Don't overwrite commands the user has explicitly configured
|
|
113
|
+
if (typedConfig.command[name])
|
|
114
|
+
continue;
|
|
115
|
+
typedConfig.command[name] = {
|
|
116
|
+
template: content,
|
|
117
|
+
...(data.description && { description: data.description }),
|
|
118
|
+
...(data.agent && { agent: data.agent }),
|
|
119
|
+
...(data.model && { model: data.model }),
|
|
120
|
+
...(data.subtask !== undefined && {
|
|
121
|
+
subtask: data.subtask === "true",
|
|
122
|
+
}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// --- Agents ---
|
|
126
|
+
typedConfig.agent = typedConfig.agent || {};
|
|
127
|
+
const agentsDir = path.join(PLUGIN_ROOT, "agents");
|
|
128
|
+
for (const markdown of loadMarkdownDir(agentsDir)) {
|
|
129
|
+
const name = markdown.data.name;
|
|
130
|
+
if (!name)
|
|
131
|
+
continue;
|
|
132
|
+
// Don't overwrite agents the user has explicitly configured
|
|
133
|
+
if (typedConfig.agent[name])
|
|
134
|
+
continue;
|
|
135
|
+
try {
|
|
136
|
+
typedConfig.agent[name] = prepareAgent(markdown, smallModel);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
console.warn(`[owflow] Failed to register agent '${name}': ${error.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
config = typedConfig;
|
|
143
|
+
},
|
|
144
|
+
/**
|
|
145
|
+
* Post-compaction reminder.
|
|
146
|
+
* Injects orchestrator state reminder into the compaction context so the
|
|
147
|
+
* AI knows to re-read orchestrator-state.yml after the context is rebuilt.
|
|
148
|
+
*/
|
|
149
|
+
"experimental.session.compacting": async (input, output) => {
|
|
150
|
+
const tasksDir = `${directory}/.owflow/tasks`;
|
|
151
|
+
try {
|
|
152
|
+
await $ `test -d ${tasksDir}`;
|
|
153
|
+
output.context.push(`## owflow Workflow State
|
|
154
|
+
If an orchestrator workflow was active before compaction, you MUST re-read
|
|
155
|
+
orchestrator-state.yml in that task's directory to verify completed_phases
|
|
156
|
+
and determine the next phase. Use the question tool at Phase Gates.`);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// No .owflow/tasks directory — not a owflow project, skip injection
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
"chat.message": async (input, _output) => {
|
|
163
|
+
try {
|
|
164
|
+
agentBySession.set(input.sessionID, input.agent ?? "main");
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// ignore
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
"tool.execute.before": async (input, output) => {
|
|
171
|
+
try {
|
|
172
|
+
if (input.tool !== "bash")
|
|
173
|
+
return;
|
|
174
|
+
const agentName = agentBySession.get(input.sessionID) ?? "main";
|
|
175
|
+
const WHITELIST = [
|
|
176
|
+
"task-group-implementer",
|
|
177
|
+
"test-suite-runner",
|
|
178
|
+
"e2e-test-verifier",
|
|
179
|
+
"user-docs-generator",
|
|
180
|
+
"docs-operator",
|
|
181
|
+
];
|
|
182
|
+
if (WHITELIST.includes(agentName))
|
|
183
|
+
return;
|
|
184
|
+
const cmd = output?.args?.command ?? "";
|
|
185
|
+
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;
|
|
186
|
+
if (DESTRUCTIVE.test(cmd)) {
|
|
187
|
+
throw new Error(`Blocked: destructive command not permitted for agent "${agentName}"`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
// Re-throw only intentional blocks; swallow unexpected errors
|
|
192
|
+
if (e.message?.startsWith("Blocked:"))
|
|
193
|
+
throw e;
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
};
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
// Parse --task-path from CLI args
|
|
7
|
+
const taskPathArg = process.argv.find((a) => a.startsWith("--task-path="));
|
|
8
|
+
const taskPath = taskPathArg ? taskPathArg.split("=")[1] : null;
|
|
9
|
+
if (!taskPath) {
|
|
10
|
+
console.warn("[visual-companion] No --task-path provided. Mockups will NOT be saved to disk.");
|
|
11
|
+
}
|
|
12
|
+
// In-memory state: array of all mockups (screens)
|
|
13
|
+
const mockups = [];
|
|
14
|
+
let latestId = null;
|
|
15
|
+
let version = 0;
|
|
16
|
+
const sseClients = [];
|
|
17
|
+
function slugify(title) {
|
|
18
|
+
return (title
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
21
|
+
.replace(/^-|-$/g, "") || "untitled");
|
|
22
|
+
}
|
|
23
|
+
// Save a rendered standalone HTML file to disk. Returns true if saved, false if skipped.
|
|
24
|
+
function saveToDisk(mockup) {
|
|
25
|
+
if (!taskPath) {
|
|
26
|
+
console.warn(`[visual-companion] Skipping disk save for "${mockup.id}" — no task path configured.`);
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const dir = path.join(taskPath, "analysis", "mockups");
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
+
const html = renderScreen(mockup);
|
|
32
|
+
const filePath = path.join(dir, `${mockup.id}.html`);
|
|
33
|
+
fs.writeFileSync(filePath, html, "utf-8");
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
// Render a single screen page with navigation
|
|
37
|
+
function renderScreen(mockup) {
|
|
38
|
+
const templatePath = path.join(__dirname, "template.html");
|
|
39
|
+
let html = fs.readFileSync(templatePath, "utf-8");
|
|
40
|
+
const title = mockup.title || "Untitled";
|
|
41
|
+
const content = `<style>${mockup.css || ""}</style>\n<div id="mockup-content">${mockup.html || ""}</div>`;
|
|
42
|
+
const annotations = JSON.stringify(mockup.annotations || []);
|
|
43
|
+
// Build screen nav
|
|
44
|
+
const navItems = mockups
|
|
45
|
+
.map((m) => `<a href="/screen/${m.id}" class="nav-screen${m.id === mockup.id ? " active" : ""}">${m.title}</a>`)
|
|
46
|
+
.join("");
|
|
47
|
+
const idx = mockups.indexOf(mockup);
|
|
48
|
+
const prev = idx > 0 ? mockups[idx - 1] : null;
|
|
49
|
+
const next = idx < mockups.length - 1 ? mockups[idx + 1] : null;
|
|
50
|
+
const prevLink = prev
|
|
51
|
+
? `<a href="/screen/${prev.id}" class="nav-arrow">← ${prev.title}</a>`
|
|
52
|
+
: "<span></span>";
|
|
53
|
+
const nextLink = next
|
|
54
|
+
? `<a href="/screen/${next.id}" class="nav-arrow">${next.title} →</a>`
|
|
55
|
+
: "<span></span>";
|
|
56
|
+
const nav = mockups.length > 1
|
|
57
|
+
? `<nav class="screen-nav"><div class="nav-screens">${navItems}</div><div class="nav-prevnext">${prevLink}${nextLink}</div></nav>`
|
|
58
|
+
: "";
|
|
59
|
+
html = html.replace("{{TITLE}}", title).replace("{{TITLE}}", title);
|
|
60
|
+
html = html.replace("{{NAV}}", nav);
|
|
61
|
+
html = html.replace("{{CONTENT}}", content);
|
|
62
|
+
html = html.replace("{{ANNOTATIONS}}", annotations);
|
|
63
|
+
return html;
|
|
64
|
+
}
|
|
65
|
+
// Render the gallery index page
|
|
66
|
+
function renderGallery() {
|
|
67
|
+
const templatePath = path.join(__dirname, "template.html");
|
|
68
|
+
let html = fs.readFileSync(templatePath, "utf-8");
|
|
69
|
+
const title = `Design Gallery — ${mockups.length} screen${mockups.length !== 1 ? "s" : ""}`;
|
|
70
|
+
let content;
|
|
71
|
+
if (mockups.length === 0) {
|
|
72
|
+
content =
|
|
73
|
+
'<div class="placeholder">Waiting for design mockups...<br>The orchestrator will send screens here.</div>';
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const cards = mockups
|
|
77
|
+
.map((m) => `
|
|
78
|
+
<a href="/screen/${m.id}" class="gallery-card">
|
|
79
|
+
<div class="gallery-card-title">${m.title}</div>
|
|
80
|
+
<div class="gallery-card-preview"><style scoped>${m.css || ""}</style>${m.html || ""}</div>
|
|
81
|
+
</a>
|
|
82
|
+
`)
|
|
83
|
+
.join("");
|
|
84
|
+
content = `<div class="gallery-grid">${cards}</div>`;
|
|
85
|
+
}
|
|
86
|
+
html = html.replace("{{TITLE}}", title).replace("{{TITLE}}", title);
|
|
87
|
+
html = html.replace("{{NAV}}", "");
|
|
88
|
+
html = html.replace("{{CONTENT}}", content);
|
|
89
|
+
html = html.replace("{{ANNOTATIONS}}", "[]");
|
|
90
|
+
return html;
|
|
91
|
+
}
|
|
92
|
+
// Parse JSON body from request
|
|
93
|
+
function parseBody(req) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
let data = "";
|
|
96
|
+
req.on("data", (chunk) => {
|
|
97
|
+
data += chunk;
|
|
98
|
+
});
|
|
99
|
+
req.on("end", () => {
|
|
100
|
+
try {
|
|
101
|
+
resolve(data ? JSON.parse(data) : {});
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
reject(new Error("Invalid JSON body"));
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
req.on("error", reject);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Notify all SSE clients
|
|
111
|
+
function notifyClients() {
|
|
112
|
+
for (let i = sseClients.length - 1; i >= 0; i--) {
|
|
113
|
+
try {
|
|
114
|
+
sseClients[i].write("data: refresh\n\n");
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
sseClients.splice(i, 1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function jsonResponse(res, statusCode, body) {
|
|
122
|
+
const payload = JSON.stringify(body);
|
|
123
|
+
res.writeHead(statusCode, {
|
|
124
|
+
"Content-Type": "application/json",
|
|
125
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
126
|
+
});
|
|
127
|
+
res.end(payload);
|
|
128
|
+
}
|
|
129
|
+
function htmlResponse(res, html) {
|
|
130
|
+
res.writeHead(200, {
|
|
131
|
+
"Content-Type": "text/html",
|
|
132
|
+
"Content-Length": Buffer.byteLength(html),
|
|
133
|
+
});
|
|
134
|
+
res.end(html);
|
|
135
|
+
}
|
|
136
|
+
// Main request handler
|
|
137
|
+
async function handler(req, res) {
|
|
138
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
139
|
+
try {
|
|
140
|
+
// GET /status
|
|
141
|
+
if (req.method === "GET" && url.pathname === "/status") {
|
|
142
|
+
jsonResponse(res, 200, {
|
|
143
|
+
status: "ok",
|
|
144
|
+
version: "1.0.0",
|
|
145
|
+
port: activePort,
|
|
146
|
+
screens: mockups.length,
|
|
147
|
+
taskPath: taskPath || null,
|
|
148
|
+
persistence: !!taskPath,
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// POST /shutdown
|
|
153
|
+
if (req.method === "POST" && url.pathname === "/shutdown") {
|
|
154
|
+
jsonResponse(res, 200, { status: "shutting_down" });
|
|
155
|
+
cleanupPidFile();
|
|
156
|
+
setTimeout(() => process.exit(0), 100);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// GET /events (SSE)
|
|
160
|
+
if (req.method === "GET" && url.pathname === "/events") {
|
|
161
|
+
res.writeHead(200, {
|
|
162
|
+
"Content-Type": "text/event-stream",
|
|
163
|
+
"Cache-Control": "no-cache",
|
|
164
|
+
Connection: "keep-alive",
|
|
165
|
+
});
|
|
166
|
+
res.write("data: connected\n\n");
|
|
167
|
+
sseClients.push(res);
|
|
168
|
+
req.on("close", () => {
|
|
169
|
+
const idx = sseClients.indexOf(res);
|
|
170
|
+
if (idx !== -1)
|
|
171
|
+
sseClients.splice(idx, 1);
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// POST /update
|
|
176
|
+
if (req.method === "POST" && url.pathname === "/update") {
|
|
177
|
+
const body = await parseBody(req);
|
|
178
|
+
const id = slugify(body.title || "untitled");
|
|
179
|
+
const mockup = {
|
|
180
|
+
id,
|
|
181
|
+
type: body.type || "mockup",
|
|
182
|
+
title: body.title || "Untitled",
|
|
183
|
+
html: body.html || "",
|
|
184
|
+
css: body.css || "",
|
|
185
|
+
annotations: body.annotations || [],
|
|
186
|
+
};
|
|
187
|
+
// Update existing or add new
|
|
188
|
+
const existingIdx = mockups.findIndex((m) => m.id === id);
|
|
189
|
+
if (existingIdx !== -1) {
|
|
190
|
+
mockups[existingIdx] = mockup;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
mockups.push(mockup);
|
|
194
|
+
}
|
|
195
|
+
latestId = id;
|
|
196
|
+
version++;
|
|
197
|
+
const saved = saveToDisk(mockup);
|
|
198
|
+
notifyClients();
|
|
199
|
+
jsonResponse(res, 200, {
|
|
200
|
+
status: "updated",
|
|
201
|
+
version,
|
|
202
|
+
id,
|
|
203
|
+
screens: mockups.length,
|
|
204
|
+
saved,
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// GET /screen/:id
|
|
209
|
+
const screenMatch = url.pathname.match(/^\/screen\/([a-z0-9-]+)$/);
|
|
210
|
+
if (req.method === "GET" && screenMatch) {
|
|
211
|
+
const mockup = mockups.find((m) => m.id === screenMatch[1]);
|
|
212
|
+
if (!mockup) {
|
|
213
|
+
jsonResponse(res, 404, { error: "Screen not found" });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
htmlResponse(res, renderScreen(mockup));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// GET /latest
|
|
220
|
+
if (req.method === "GET" && url.pathname === "/latest") {
|
|
221
|
+
const mockup = mockups.find((m) => m.id === latestId);
|
|
222
|
+
if (!mockup) {
|
|
223
|
+
htmlResponse(res, renderGallery());
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
htmlResponse(res, renderScreen(mockup));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// GET / (gallery)
|
|
230
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
231
|
+
htmlResponse(res, renderGallery());
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
jsonResponse(res, 404, { error: "Not found" });
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
console.error("Request error:", err.message);
|
|
238
|
+
jsonResponse(res, 500, { error: err.message });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// PID file management
|
|
242
|
+
function pidFilePath() {
|
|
243
|
+
if (!taskPath)
|
|
244
|
+
return null;
|
|
245
|
+
return path.join(taskPath, "analysis", "mockups", ".visual-companion.pid");
|
|
246
|
+
}
|
|
247
|
+
function writePidFile() {
|
|
248
|
+
const p = pidFilePath();
|
|
249
|
+
if (!p)
|
|
250
|
+
return;
|
|
251
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
252
|
+
fs.writeFileSync(p, String(process.pid), "utf-8");
|
|
253
|
+
}
|
|
254
|
+
function cleanupPidFile() {
|
|
255
|
+
const p = pidFilePath();
|
|
256
|
+
if (p)
|
|
257
|
+
try {
|
|
258
|
+
fs.unlinkSync(p);
|
|
259
|
+
}
|
|
260
|
+
catch { }
|
|
261
|
+
}
|
|
262
|
+
process.on("SIGTERM", () => {
|
|
263
|
+
cleanupPidFile();
|
|
264
|
+
process.exit(0);
|
|
265
|
+
});
|
|
266
|
+
process.on("SIGINT", () => {
|
|
267
|
+
cleanupPidFile();
|
|
268
|
+
process.exit(0);
|
|
269
|
+
});
|
|
270
|
+
// Port fallback logic
|
|
271
|
+
let activePort = null;
|
|
272
|
+
function tryPort(port) {
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const server = http.createServer(handler);
|
|
275
|
+
server.listen(port, () => resolve(server));
|
|
276
|
+
server.on("error", reject);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
async function start() {
|
|
280
|
+
const ports = [3847, 3848, 3849, 3850];
|
|
281
|
+
for (const port of ports) {
|
|
282
|
+
try {
|
|
283
|
+
await tryPort(port);
|
|
284
|
+
activePort = port;
|
|
285
|
+
console.log(`Visual companion server running at http://localhost:${port}`);
|
|
286
|
+
if (taskPath)
|
|
287
|
+
console.log(`Saving mockups to: ${path.join(taskPath, "analysis", "mockups")}`);
|
|
288
|
+
writePidFile();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
if (err.code === "EADDRINUSE") {
|
|
293
|
+
console.error(`Port ${port} in use, trying next...`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
console.error("All ports (3847-3850) in use. Cannot start server.");
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
start();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// workaround til something is not done with issue https://github.com/anomalyco/opencode/issues/24065
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "owflow",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Spec-driven development AI workflow for OpenCode",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "index.ts",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "rm -rf dist && tsc"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"plugin",
|
|
19
|
+
"workflow",
|
|
20
|
+
"spec-driven",
|
|
21
|
+
"development"
|
|
22
|
+
],
|
|
23
|
+
"author": "palucdev",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@opencode-ai/plugin": "^1.14.44",
|
|
27
|
+
"@types/bun": "^1.3.13",
|
|
28
|
+
"gray-matter": "^4.0.3"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"typescript": "^5"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": "^25"
|
|
35
|
+
}
|
|
36
|
+
}
|