@nt-ai-lab/deterministic-agent-workflow-opencode 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.
@@ -0,0 +1,10 @@
1
+ import type { Hooks } from '@opencode-ai/plugin';
2
+ import type { BaseWorkflowState, RehydratableWorkflow } from '@nt-ai-lab/deterministic-agent-workflow-engine';
3
+ import type { IdleEventHookDeps, OpenCodePlugin, OpenCodeWorkflowPluginConfig } from '../../../platform/domain/opencode-workflow-plugin-types';
4
+ export declare const IDLE_RECOVERY_MESSAGE = "You have stopped. You should never stop until the workflow is complete unless your current state permits stopping.";
5
+ type OpenCodeEventHook = NonNullable<Hooks['event']>;
6
+ /** @riviere-role cli-entrypoint */
7
+ export declare function createSessionIdleEventHook(deps: IdleEventHookDeps): OpenCodeEventHook;
8
+ /** @riviere-role cli-entrypoint */
9
+ export declare function createOpenCodeWorkflowPlugin<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string = string, TOperation extends string = string>(config: OpenCodeWorkflowPluginConfig<TWorkflow, TState, TDeps, TStateName, TOperation>): OpenCodePlugin;
10
+ export {};
@@ -0,0 +1,242 @@
1
+ import { appendFileSync, readFileSync, readdirSync, } from 'node:fs';
2
+ import { basename, extname, join, } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { tool } from '@opencode-ai/plugin/tool';
5
+ import { WorkflowEngine } from '@nt-ai-lab/deterministic-agent-workflow-engine';
6
+ import { createPreToolUseHandler, createWorkflowRunner, getRepositoryName, } from '@nt-ai-lab/deterministic-agent-workflow-cli';
7
+ import { createStore } from '@nt-ai-lab/deterministic-agent-workflow-event-store';
8
+ import { OpenCodeTranscriptReader } from '../../../platform/infra/external-clients/opencode/opencode-transcript-reader.js';
9
+ export const IDLE_RECOVERY_MESSAGE = 'You have stopped. You should never stop until the workflow is complete unless your current state permits stopping.';
10
+ const TRANSLATION_NOTE = [
11
+ '> **OpenCode**: When instructions say `/dev-workflow-v2:workflow <op> [args]`, call',
12
+ '> the `workflow` tool instead: `operation: "<op>"`, `args: ["<arg>", ...]`.',
13
+ '> Example: `/dev-workflow-v2:workflow transition REVIEWING`',
14
+ '> → `workflow({ operation: "transition", args: ["REVIEWING"] })`',
15
+ '',
16
+ '---',
17
+ '',
18
+ '',
19
+ ].join('\n');
20
+ function injectTranslationNote(content) {
21
+ return `${TRANSLATION_NOTE}${content}`;
22
+ }
23
+ function isSessionPromptClient(value) {
24
+ return typeof value === 'object' && value !== null && 'session' in value;
25
+ }
26
+ async function promptIdleRecovery(client, sessionID) {
27
+ await client.session.promptAsync({
28
+ path: { id: sessionID },
29
+ body: {
30
+ parts: [{
31
+ type: 'text',
32
+ text: IDLE_RECOVERY_MESSAGE,
33
+ }],
34
+ },
35
+ });
36
+ }
37
+ /** @riviere-role cli-entrypoint */
38
+ export function createSessionIdleEventHook(deps) {
39
+ return async ({ event }) => {
40
+ if (event.type !== 'session.idle') {
41
+ return;
42
+ }
43
+ if (!deps.hasSessionStarted(event.properties.sessionID)) {
44
+ return;
45
+ }
46
+ await deps.sendIdleRecoveryPrompt(event.properties.sessionID);
47
+ };
48
+ }
49
+ /** @riviere-role cli-entrypoint */
50
+ export function createOpenCodeWorkflowPlugin(config) {
51
+ const store = createStore(resolveWorkflowEventsDatabasePath());
52
+ const dbPath = resolveOpenCodeDatabasePath(config.databasePath);
53
+ function buildEngineContext(sessionID) {
54
+ const transcriptReader = new OpenCodeTranscriptReader(sessionID);
55
+ const now = () => new Date().toISOString();
56
+ const rawReadFile = (path) => readFileSync(path, 'utf8');
57
+ const readFile = config.routes === undefined
58
+ ? rawReadFile
59
+ : (path) => injectTranslationNote(rawReadFile(path));
60
+ const engineDeps = {
61
+ store,
62
+ getPluginRoot: () => config.pluginRoot,
63
+ getEnvFilePath: () => join(homedir(), '.opencode', 'opencode.env'),
64
+ getRepositoryName: () => getRepositoryName(process.cwd()),
65
+ readFile,
66
+ appendToFile: (path, content) => appendFileSync(path, content),
67
+ now,
68
+ transcriptReader,
69
+ };
70
+ const platformCtx = {
71
+ getPluginRoot: () => config.pluginRoot,
72
+ now,
73
+ getSessionId: () => sessionID,
74
+ store,
75
+ };
76
+ return {
77
+ engineDeps,
78
+ workflowDeps: config.buildWorkflowDeps(platformCtx),
79
+ };
80
+ }
81
+ return async (input) => {
82
+ const handler = config.customGates === undefined
83
+ ? createPreToolUseHandler({
84
+ bashForbidden: config.bashForbidden,
85
+ isWriteAllowed: config.isWriteAllowed,
86
+ })
87
+ : createPreToolUseHandler({
88
+ bashForbidden: config.bashForbidden,
89
+ isWriteAllowed: config.isWriteAllowed,
90
+ customGates: config.customGates,
91
+ });
92
+ const eventHook = createSessionIdleEventHook({
93
+ hasSessionStarted: (sessionID) => {
94
+ const { engineDeps, workflowDeps } = buildEngineContext(sessionID);
95
+ const engine = new WorkflowEngine(config.workflowDefinition, engineDeps, workflowDeps);
96
+ return engine.hasSessionStarted(sessionID);
97
+ },
98
+ sendIdleRecoveryPrompt: async (sessionID) => {
99
+ if (input !== undefined && isSessionPromptClient(input.client)) {
100
+ await promptIdleRecovery(input.client, sessionID);
101
+ }
102
+ },
103
+ });
104
+ const toolExecuteBefore = async (hookInput, output) => {
105
+ const { engineDeps, workflowDeps } = buildEngineContext(hookInput.sessionID);
106
+ const engine = new WorkflowEngine(config.workflowDefinition, engineDeps, workflowDeps);
107
+ if (config.routes === undefined) {
108
+ if (engine.hasSession(hookInput.sessionID)) {
109
+ // Session already exists for the default non-router path.
110
+ }
111
+ else {
112
+ engine.startSession(hookInput.sessionID, dbPath);
113
+ }
114
+ }
115
+ else if (engine.hasSessionStarted(hookInput.sessionID)) {
116
+ // Routed mode only enforces tools after the session starts.
117
+ }
118
+ else {
119
+ return;
120
+ }
121
+ const result = handler(engine, hookInput.sessionID, hookInput.tool, output.args);
122
+ if (result.type === 'blocked') {
123
+ throw new TypeError(result.output);
124
+ }
125
+ };
126
+ if (config.routes === undefined) {
127
+ return {
128
+ event: eventHook,
129
+ 'tool.execute.before': toolExecuteBefore,
130
+ };
131
+ }
132
+ const routes = config.routes;
133
+ const workflowTool = tool({
134
+ description: 'Execute a workflow operation (init, transition, record-*)',
135
+ args: {
136
+ operation: tool.schema.string().describe('operation name, e.g. "init", "transition", "record-issue"'),
137
+ args: tool.schema.array(tool.schema.string()).optional().describe('operation arguments'),
138
+ },
139
+ execute: async (rawArgs, ctx) => {
140
+ const operation = rawArgs.operation;
141
+ const argList = rawArgs.args ?? [];
142
+ const { engineDeps, workflowDeps } = buildEngineContext(ctx.sessionID);
143
+ const runner = config.customGates === undefined
144
+ ? createWorkflowRunner({
145
+ workflowDefinition: config.workflowDefinition,
146
+ routes,
147
+ bashForbidden: config.bashForbidden,
148
+ isWriteAllowed: config.isWriteAllowed,
149
+ })
150
+ : createWorkflowRunner({
151
+ workflowDefinition: config.workflowDefinition,
152
+ routes,
153
+ bashForbidden: config.bashForbidden,
154
+ isWriteAllowed: config.isWriteAllowed,
155
+ customGates: config.customGates,
156
+ });
157
+ return runner([operation, ...argList], engineDeps, workflowDeps, {
158
+ getSessionId: () => ctx.sessionID,
159
+ getSessionTranscriptPath: () => dbPath,
160
+ getSessionRepository: () => getRepositoryName(ctx.worktree),
161
+ }).output;
162
+ },
163
+ });
164
+ const commands = loadCommands(resolveCommandDirectories(config.commandDirectories), resolveCommandPrefix(config.commandPrefix));
165
+ return {
166
+ event: eventHook,
167
+ 'tool.execute.before': toolExecuteBefore,
168
+ tool: { workflow: workflowTool },
169
+ ...(Object.keys(commands).length > 0
170
+ ? {
171
+ config: async (openCodeConfig) => {
172
+ registerCommands(openCodeConfig, commands);
173
+ },
174
+ }
175
+ : {}),
176
+ };
177
+ };
178
+ }
179
+ function loadCommands(commandDirectories, commandPrefix) {
180
+ const commands = {};
181
+ for (const dir of commandDirectories) {
182
+ const files = readCommandFiles(dir);
183
+ if (files === undefined) {
184
+ continue;
185
+ }
186
+ for (const file of files) {
187
+ if (!file.endsWith('.md'))
188
+ continue;
189
+ const baseName = basename(file, extname(file));
190
+ const name = `${commandPrefix}${baseName}`;
191
+ if (Object.hasOwn(commands, name))
192
+ continue;
193
+ const filePath = join(dir, file);
194
+ const content = readFileSync(filePath, 'utf8');
195
+ commands[name] = {
196
+ description: `Workflow command: ${name}`,
197
+ template: injectTranslationNote(content),
198
+ };
199
+ }
200
+ }
201
+ return commands;
202
+ }
203
+ function registerCommands(config, commands) {
204
+ config.command ??= {};
205
+ for (const [name, command] of Object.entries(commands)) {
206
+ if (Object.hasOwn(config.command, name)) {
207
+ continue;
208
+ }
209
+ config.command[name] = command;
210
+ }
211
+ }
212
+ function readCommandFiles(directory) {
213
+ try {
214
+ return readdirSync(directory);
215
+ }
216
+ catch {
217
+ return undefined;
218
+ }
219
+ }
220
+ function resolveCommandDirectories(directories) {
221
+ if (directories === undefined) {
222
+ return [];
223
+ }
224
+ return directories;
225
+ }
226
+ function resolveCommandPrefix(prefix) {
227
+ if (prefix === undefined) {
228
+ return '';
229
+ }
230
+ return prefix;
231
+ }
232
+ function resolveOpenCodeDatabasePath(configured) {
233
+ if (configured !== undefined)
234
+ return configured;
235
+ return process.env['OPENCODE_DB'] ?? join(homedir(), '.local', 'share', 'opencode', 'opencode.db');
236
+ }
237
+ function resolveWorkflowEventsDatabasePath() {
238
+ const configured = process.env['WORKFLOW_EVENTS_DB'];
239
+ if (configured !== undefined && configured !== '')
240
+ return configured;
241
+ return join(homedir(), '.workflow-events.db');
242
+ }
@@ -0,0 +1,3 @@
1
+ export { createOpenCodeWorkflowPlugin, createSessionIdleEventHook, } from './features/opencode-plugin/entrypoint/opencode-workflow-plugin';
2
+ export type { IdleEventHookDeps, OpenCodePlugin, OpenCodeWorkflowPluginConfig, } from './platform/domain/opencode-workflow-plugin-types';
3
+ export { OpenCodeTranscriptReader } from './platform/infra/external-clients/opencode/opencode-transcript-reader';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createOpenCodeWorkflowPlugin, createSessionIdleEventHook, } from './features/opencode-plugin/entrypoint/opencode-workflow-plugin.js';
2
+ export { OpenCodeTranscriptReader } from './platform/infra/external-clients/opencode/opencode-transcript-reader.js';
@@ -0,0 +1,23 @@
1
+ import type { BaseWorkflowState, RehydratableWorkflow, WorkflowDefinition } from '@nt-ai-lab/deterministic-agent-workflow-engine';
2
+ import type { PlatformContext, PreToolUseHandlerConfig, RouteMap } from '@nt-ai-lab/deterministic-agent-workflow-cli';
3
+ import type { Hooks, Plugin } from '@opencode-ai/plugin';
4
+ type OpenCodePluginInput = Parameters<Plugin>[0];
5
+ type OpenCodePluginOptions = Parameters<Plugin>[1];
6
+ /** @riviere-role value-object */
7
+ export type IdleEventHookDeps = {
8
+ readonly hasSessionStarted: (sessionID: string) => boolean;
9
+ readonly sendIdleRecoveryPrompt: (sessionID: string) => Promise<void>;
10
+ };
11
+ /** @riviere-role value-object */
12
+ export type OpenCodePlugin = (input?: OpenCodePluginInput, options?: OpenCodePluginOptions) => Promise<Hooks>;
13
+ /** @riviere-role value-object */
14
+ export type OpenCodeWorkflowPluginConfig<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string = string, TOperation extends string = string> = PreToolUseHandlerConfig<TWorkflow, TState, TStateName> & {
15
+ readonly workflowDefinition: WorkflowDefinition<TWorkflow, TState, TDeps, TStateName, TOperation>;
16
+ readonly buildWorkflowDeps: (platform: PlatformContext) => TDeps;
17
+ readonly pluginRoot: string;
18
+ readonly databasePath?: string;
19
+ readonly routes?: RouteMap<TWorkflow, TState>;
20
+ readonly commandDirectories?: readonly string[];
21
+ readonly commandPrefix?: string;
22
+ };
23
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { TranscriptMessage, TranscriptReader } from '@nt-ai-lab/deterministic-agent-workflow-engine';
2
+ /** @riviere-role external-client-model */
3
+ export declare class OpenCodeTranscriptReader implements TranscriptReader {
4
+ private readonly sessionId;
5
+ constructor(sessionId: string);
6
+ readMessages(dbPath: string): readonly TranscriptMessage[];
7
+ private queryMessages;
8
+ private parseRow;
9
+ }
@@ -0,0 +1,63 @@
1
+ import { openSqliteDatabase, } from '@nt-ai-lab/deterministic-agent-workflow-event-store';
2
+ import { z } from 'zod';
3
+ const textPartSchema = z.object({
4
+ type: z.literal('text'),
5
+ text: z.string()
6
+ });
7
+ const messageDataSchema = z.object({ parts: z.array(z.unknown()) });
8
+ const messageRowSchema = z.object({
9
+ id: z.string(),
10
+ data: z.string(),
11
+ });
12
+ /** @riviere-role external-client-model */
13
+ export class OpenCodeTranscriptReader {
14
+ sessionId;
15
+ constructor(sessionId) {
16
+ this.sessionId = sessionId;
17
+ }
18
+ readMessages(dbPath) {
19
+ try {
20
+ const db = openSqliteDatabase(dbPath, { readonly: true });
21
+ try {
22
+ return this.queryMessages(db);
23
+ }
24
+ finally {
25
+ db.close();
26
+ }
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ }
32
+ queryMessages(db) {
33
+ return db
34
+ .prepare(`SELECT id, data FROM message
35
+ WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant'
36
+ ORDER BY time_created ASC`)
37
+ .all(this.sessionId)
38
+ .flatMap((row) => this.parseRow(row));
39
+ }
40
+ parseRow(row) {
41
+ const rowResult = messageRowSchema.safeParse(row);
42
+ if (!rowResult.success) {
43
+ return [];
44
+ }
45
+ const parsedData = JSON.parse(rowResult.data.data);
46
+ const dataResult = messageDataSchema.safeParse(parsedData);
47
+ if (!dataResult.success) {
48
+ return [];
49
+ }
50
+ return [{
51
+ id: rowResult.data.id,
52
+ textContent: extractFirstText(dataResult.data.parts),
53
+ }];
54
+ }
55
+ }
56
+ function extractFirstText(parts) {
57
+ for (const part of parts) {
58
+ const result = textPartSchema.safeParse(part);
59
+ if (result.success)
60
+ return result.data.text;
61
+ }
62
+ return undefined;
63
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@nt-ai-lab/deterministic-agent-workflow-opencode",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./dist/index.js"
8
+ },
9
+ "types": "./dist/index.d.ts",
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "dependencies": {
14
+ "@nt-ai-lab/deterministic-agent-workflow-cli": "workspace:*",
15
+ "@nt-ai-lab/deterministic-agent-workflow-engine": "workspace:*",
16
+ "@nt-ai-lab/deterministic-agent-workflow-event-store": "workspace:*",
17
+ "@opencode-ai/plugin": "^1.4.3",
18
+ "zod": "^3.25.76"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ }
23
+ }