From 635684582113bad90bcd36b1dd647a7a5ed49191 Mon Sep 17 00:00:00 2001 From: Nick Tune Date: Sat, 7 Mar 2026 10:47:20 +0000 Subject: [PATCH 1/4] Thread TStateName through platform and rewrite consumer CLI with defineCommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform changes: - BaseWorkflowState generic replaces string state names in core - WorkflowFactory/WorkflowEngine thread TStateName + TOperation generics - New CLI module: defineCommands, createWorkflowRunner, arg helpers, hook schemas - CommandDefinition is a discriminated union — handler only on transaction type - New recording-ops module for gate-checked workflow operations - parseStateName on factory for edge-to-core string narrowing Consumer changes: - WorkflowState.currentStateMachineState narrowed to StateName - WorkflowAdapter implements full generic factory interface - CLI entry point rewritten from 371 to 249 lines using platform runner - 16 handler functions replaced with declarative command definitions - Event schemas validate StateName at Zod boundary - All test fixtures updated with strong StateName types Co-Authored-By: Claude Opus 4.6 --- .../agentic-workflow-builder/package.json | 3 +- .../src/cli/domain/arg-helpers.spec.ts | 112 +++++ .../src/cli/domain/arg-helpers.ts | 72 ++++ .../src/cli/domain/command-definition.spec.ts | 19 + .../src/cli/domain/command-definition.ts | 33 ++ .../src/cli/domain/exit-codes.spec.ts | 16 + .../src/cli/domain/exit-codes.ts | 3 + .../src/cli/domain/extract-field.spec.ts | 24 ++ .../src/cli/domain/extract-field.ts | 12 + .../src/cli/domain/hook-definition.spec.ts | 18 + .../src/cli/domain/hook-definition.ts | 14 + .../src/cli/domain/hook-schemas.spec.ts | 60 +++ .../src/cli/domain/hook-schemas.ts | 17 + .../src/cli/domain/workflow-cli.spec.ts | 8 + .../src/cli/domain/workflow-cli.ts | 30 ++ .../src/cli/domain/workflow-runner.spec.ts | 383 ++++++++++++++++++ .../src/cli/domain/workflow-runner.ts | 212 ++++++++++ .../src/cli/index.spec.ts | 17 + .../agentic-workflow-builder/src/cli/index.ts | 13 + .../src/dsl/domain/recording-ops.spec.ts | 155 +++++++ .../src/dsl/domain/recording-ops.ts | 69 ++++ .../src/dsl/domain/types.ts | 1 + .../agentic-workflow-builder/src/dsl/index.ts | 2 + .../src/engine/domain/workflow-engine.spec.ts | 363 ++++++++++++++++- .../src/engine/domain/workflow-engine.ts | 164 +++++++- .../src/engine/domain/workflow-state.ts | 2 +- src/autonomous-claude-agent-team-workflow.ts | 331 +++++---------- .../event-display-annotation.spec.ts | 28 +- src/workflow-analysis/event-display.spec.ts | 4 +- src/workflow-analysis/insight-rules.spec.ts | 5 +- src/workflow-analysis/report-assembly.spec.ts | 2 +- .../report-html-header-and-simple.spec.ts | 4 +- .../report-html-tabs.spec.ts | 11 +- .../session-report-rework.spec.ts | 4 +- src/workflow-analysis/session-report.spec.ts | 5 +- src/workflow-definition/domain/registry.ts | 7 +- .../domain/workflow-adapter.spec.ts | 50 ++- .../domain/workflow-adapter.ts | 35 +- ...kflow-cr-review-to-complete-states.spec.ts | 36 +- .../domain/workflow-events.ts | 7 +- .../workflow-lint-and-iterations.spec.ts | 7 +- .../domain/workflow-predicates.ts | 6 + ...rkflow-reviewing-committing-states.spec.ts | 23 +- .../workflow-spawn-planning-states.spec.ts | 37 +- .../domain/workflow-test-fixtures.ts | 51 ++- .../domain/workflow-types.ts | 22 +- .../workflow-write-bash-hook-checks.spec.ts | 334 +++++---------- src/workflow-definition/domain/workflow.ts | 106 +---- src/workflow-definition/index.ts | 3 + 49 files changed, 2237 insertions(+), 703 deletions(-) create mode 100644 packages/agentic-workflow-builder/src/cli/domain/arg-helpers.spec.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/arg-helpers.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/command-definition.spec.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/command-definition.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/exit-codes.spec.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/exit-codes.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/extract-field.spec.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/extract-field.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/hook-definition.spec.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/hook-definition.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/hook-schemas.spec.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/hook-schemas.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/workflow-cli.spec.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts create mode 100644 packages/agentic-workflow-builder/src/cli/index.spec.ts create mode 100644 packages/agentic-workflow-builder/src/cli/index.ts create mode 100644 packages/agentic-workflow-builder/src/dsl/domain/recording-ops.spec.ts create mode 100644 packages/agentic-workflow-builder/src/dsl/domain/recording-ops.ts diff --git a/packages/agentic-workflow-builder/package.json b/packages/agentic-workflow-builder/package.json index b255158..c2e5dd2 100644 --- a/packages/agentic-workflow-builder/package.json +++ b/packages/agentic-workflow-builder/package.json @@ -7,7 +7,8 @@ "./dsl": "./src/dsl/index.ts", "./engine": "./src/engine/index.ts", "./event-store": "./src/event-store/index.ts", - "./testing": "./src/testing/index.ts" + "./testing": "./src/testing/index.ts", + "./cli": "./src/cli/index.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.spec.ts new file mode 100644 index 0000000..7bbd548 --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.spec.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest' +import { z } from 'zod' +import { arg } from './arg-helpers.js' + +describe('arg.number', () => { + const parser = arg.number('count') + + it('parses a valid integer', () => { + const result = parser.parse(['cmd', '42'], 1, 'cmd') + expect(result).toEqual({ ok: true, value: 42 }) + }) + + it('returns error for missing argument', () => { + const result = parser.parse(['cmd'], 1, 'cmd') + expect(result).toEqual({ ok: false, message: 'cmd: missing required argument ' }) + }) + + it('returns error for non-numeric value', () => { + const result = parser.parse(['cmd', 'abc'], 1, 'cmd') + expect(result).toEqual({ ok: false, message: "cmd: not a valid number: 'abc'" }) + }) + + it('optional returns undefined for missing argument', () => { + const optional = parser.optional() + const result = optional.parse(['cmd'], 1, 'cmd') + expect(result).toEqual({ ok: true, value: undefined }) + }) + + it('optional parses a valid value', () => { + const optional = parser.optional() + const result = optional.parse(['cmd', '5'], 1, 'cmd') + expect(result).toEqual({ ok: true, value: 5 }) + }) + + it('optional returns error for invalid value', () => { + const optional = parser.optional() + const result = optional.parse(['cmd', 'xyz'], 1, 'cmd') + expect(result).toEqual({ ok: false, message: "cmd: not a valid number: 'xyz'" }) + }) +}) + +describe('arg.string', () => { + const parser = arg.string('name') + + it('parses a valid string', () => { + const result = parser.parse(['cmd', 'hello'], 1, 'cmd') + expect(result).toEqual({ ok: true, value: 'hello' }) + }) + + it('returns error for missing argument', () => { + const result = parser.parse(['cmd'], 1, 'cmd') + expect(result).toEqual({ ok: false, message: 'cmd: missing required argument ' }) + }) + + it('optional returns undefined for missing argument', () => { + const optional = parser.optional() + const result = optional.parse(['cmd'], 1, 'cmd') + expect(result).toEqual({ ok: true, value: undefined }) + }) + + it('optional parses a valid value', () => { + const optional = parser.optional() + const result = optional.parse(['cmd', 'world'], 1, 'cmd') + expect(result).toEqual({ ok: true, value: 'world' }) + }) +}) + +describe('arg.state', () => { + const StateSchema = z.enum(['planning', 'coding', 'review']) + const parser = arg.state('state', StateSchema) + + it('parses a valid state', () => { + const result = parser.parse(['cmd', 'planning'], 1, 'cmd') + expect(result).toEqual({ ok: true, value: 'planning' }) + }) + + it('returns error for missing argument', () => { + const result = parser.parse(['cmd'], 1, 'cmd') + expect(result).toEqual({ ok: false, message: 'cmd: missing required argument ' }) + }) + + it('returns error for invalid state value', () => { + const result = parser.parse(['cmd', 'unknown'], 1, 'cmd') + expect(result).toEqual({ ok: false, message: "cmd: invalid state 'unknown'" }) + }) + + it('optional returns undefined for missing argument', () => { + const optional = parser.optional() + const result = optional.parse(['cmd'], 1, 'cmd') + expect(result).toEqual({ ok: true, value: undefined }) + }) + + it('optional parses a valid state', () => { + const optional = parser.optional() + const result = optional.parse(['cmd', 'coding'], 1, 'cmd') + expect(result).toEqual({ ok: true, value: 'coding' }) + }) + + it('optional returns error for invalid state', () => { + const optional = parser.optional() + const result = optional.parse(['cmd', 'bad'], 1, 'cmd') + expect(result).toEqual({ ok: false, message: "cmd: invalid state 'bad'" }) + }) +}) + +describe('double optional', () => { + it('calling optional twice still works', () => { + const parser = arg.string('name').optional().optional() + const result = parser.parse(['cmd'], 1, 'cmd') + expect(result).toEqual({ ok: true, value: undefined }) + }) +}) diff --git a/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.ts b/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.ts new file mode 100644 index 0000000..88b0e59 --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.ts @@ -0,0 +1,72 @@ +import type { ZodType } from 'zod' + +export type ArgResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly message: string } + +export type ArgParser = { + readonly parse: (args: readonly string[], position: number, commandName: string) => ArgResult + readonly optional: () => ArgParser +} + +function makeOptional(parser: ArgParser): ArgParser { + return { + parse: (args, position, commandName) => { + const raw = args[position] + if (raw === undefined) { + return { ok: true, value: undefined } + } + return parser.parse(args, position, commandName) + }, + optional: () => makeOptional(parser), + } +} + +export const arg = { + number: (name: string): ArgParser => ({ + parse: (args, position, commandName) => { + const raw = args[position] + if (raw === undefined) { + return { ok: false, message: `${commandName}: missing required argument <${name}>` } + } + const parsed = Number.parseInt(raw, 10) + if (Number.isNaN(parsed)) { + return { ok: false, message: `${commandName}: not a valid number: '${raw}'` } + } + return { ok: true, value: parsed } + }, + optional: function () { + return makeOptional(this) + }, + }), + + string: (name: string): ArgParser => ({ + parse: (args, position, commandName) => { + const raw = args[position] + if (raw === undefined) { + return { ok: false, message: `${commandName}: missing required argument <${name}>` } + } + return { ok: true, value: raw } + }, + optional: function () { + return makeOptional(this) + }, + }), + + state: (name: string, schema: ZodType): ArgParser => ({ + parse: (args, position, commandName) => { + const raw = args[position] + if (raw === undefined) { + return { ok: false, message: `${commandName}: missing required argument <${name}>` } + } + const result = schema.safeParse(raw) + if (!result.success) { + return { ok: false, message: `${commandName}: invalid state '${raw}'` } + } + return { ok: true, value: result.data } + }, + optional: function () { + return makeOptional(this) + }, + }), +} diff --git a/packages/agentic-workflow-builder/src/cli/domain/command-definition.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/command-definition.spec.ts new file mode 100644 index 0000000..2aaf58a --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/command-definition.spec.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' +import { defineCommands } from './command-definition.js' +import { pass } from '../../dsl/index.js' + +describe('defineCommands', () => { + it('returns the commands map unchanged', () => { + const commands = { + init: { + type: 'session-start' as const, + }, + doSomething: { + type: 'transaction' as const, + handler: () => pass(), + }, + } + const result = defineCommands(commands) + expect(result).toBe(commands) + }) +}) diff --git a/packages/agentic-workflow-builder/src/cli/domain/command-definition.ts b/packages/agentic-workflow-builder/src/cli/domain/command-definition.ts new file mode 100644 index 0000000..1c6a607 --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/command-definition.ts @@ -0,0 +1,33 @@ +import type { PreconditionResult } from '../../dsl/index.js' +import type { ArgParser } from './arg-helpers.js' + +type CommandHandler = (workflow: TWorkflow, ...parsedArgs: readonly unknown[]) => PreconditionResult + +type TransactionCommand = { + readonly type: 'transaction' + readonly args?: readonly ArgParser[] + readonly handler: CommandHandler +} + +type TransitionCommand = { + readonly type: 'transition' + readonly args?: readonly ArgParser[] +} + +type SessionStartCommand = { + readonly type: 'session-start' + readonly args?: readonly ArgParser[] +} + +export type CommandDefinition = + | TransactionCommand + | TransitionCommand + | SessionStartCommand + +export type CommandMap = Record> + +export function defineCommands( + commands: CommandMap, +): CommandMap { + return commands +} diff --git a/packages/agentic-workflow-builder/src/cli/domain/exit-codes.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/exit-codes.spec.ts new file mode 100644 index 0000000..27adc73 --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/exit-codes.spec.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from './exit-codes.js' + +describe('exit-codes', () => { + it('EXIT_ALLOW is 0', () => { + expect(EXIT_ALLOW).toBe(0) + }) + + it('EXIT_ERROR is 1', () => { + expect(EXIT_ERROR).toBe(1) + }) + + it('EXIT_BLOCK is 2', () => { + expect(EXIT_BLOCK).toBe(2) + }) +}) diff --git a/packages/agentic-workflow-builder/src/cli/domain/exit-codes.ts b/packages/agentic-workflow-builder/src/cli/domain/exit-codes.ts new file mode 100644 index 0000000..14ee01e --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/exit-codes.ts @@ -0,0 +1,3 @@ +export const EXIT_ALLOW = 0 +export const EXIT_ERROR = 1 +export const EXIT_BLOCK = 2 diff --git a/packages/agentic-workflow-builder/src/cli/domain/extract-field.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/extract-field.spec.ts new file mode 100644 index 0000000..322869d --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/extract-field.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { extractField } from './extract-field.js' + +describe('extractField', () => { + it('extracts a string field from tool input', () => { + const extract = extractField('command') + expect(extract({ command: 'git status' })).toBe('git status') + }) + + it('returns empty string when field is missing', () => { + const extract = extractField('command') + expect(extract({})).toBe('') + }) + + it('returns empty string when field is null', () => { + const extract = extractField('command') + expect(extract({ command: null })).toBe('') + }) + + it('throws when field is present but not a string', () => { + const extract = extractField('command') + expect(() => extract({ command: 42 })).toThrow("Expected 'command' to be a string, got number") + }) +}) diff --git a/packages/agentic-workflow-builder/src/cli/domain/extract-field.ts b/packages/agentic-workflow-builder/src/cli/domain/extract-field.ts new file mode 100644 index 0000000..09eb86c --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/extract-field.ts @@ -0,0 +1,12 @@ +export function extractField(fieldName: string): (toolInput: Record) => string { + return (toolInput) => { + const value = toolInput[fieldName] + if (value === undefined || value === null) { + return '' + } + if (typeof value !== 'string') { + throw new Error(`Expected '${fieldName}' to be a string, got ${typeof value}`) + } + return value + } +} diff --git a/packages/agentic-workflow-builder/src/cli/domain/hook-definition.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/hook-definition.spec.ts new file mode 100644 index 0000000..2e2be5e --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/hook-definition.spec.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest' +import { defineHooks } from './hook-definition.js' +import { pass } from '../../dsl/index.js' + +describe('defineHooks', () => { + it('returns the hooks definition unchanged', () => { + const hooks = { + preToolUse: { + Bash: { + extract: () => ({ command: 'ls' }), + check: () => pass(), + }, + }, + } + const result = defineHooks(hooks) + expect(result).toBe(hooks) + }) +}) diff --git a/packages/agentic-workflow-builder/src/cli/domain/hook-definition.ts b/packages/agentic-workflow-builder/src/cli/domain/hook-definition.ts new file mode 100644 index 0000000..6f8d6a4 --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/hook-definition.ts @@ -0,0 +1,14 @@ +import type { PreconditionResult } from '../../dsl/index.js' + +export type HookCheck = { + readonly extract: (toolInput: Record) => Record + readonly check: (workflow: TWorkflow, extracted: Record, toolName: string) => PreconditionResult +} + +export type HookDefinition = { + readonly preToolUse?: Record> +} + +export function defineHooks(hooks: HookDefinition): HookDefinition { + return hooks +} diff --git a/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.spec.ts new file mode 100644 index 0000000..41a05a2 --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { HookCommonInputSchema, PreToolUseInputSchema } from './hook-schemas.js' + +const validCommon = { + session_id: 'abc-123', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user/project', + hook_event_name: 'PreToolUse', +} + +describe('HookCommonInputSchema', () => { + it('parses valid common input', () => { + const result = HookCommonInputSchema.parse(validCommon) + expect(result.session_id).toBe('abc-123') + expect(result.hook_event_name).toBe('PreToolUse') + }) + + it('accepts optional permission_mode', () => { + const result = HookCommonInputSchema.parse({ ...validCommon, permission_mode: 'auto' }) + expect(result.permission_mode).toBe('auto') + }) + + it('rejects missing session_id', () => { + const { session_id: _, ...noSession } = validCommon + expect(() => HookCommonInputSchema.parse(noSession)).toThrow() + }) +}) + +describe('PreToolUseInputSchema', () => { + it('parses valid pre-tool-use input', () => { + const input = { + ...validCommon, + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_use_id: 'tool-1', + } + const result = PreToolUseInputSchema.parse(input) + expect(result.tool_name).toBe('Bash') + expect(result.tool_input).toEqual({ command: 'ls' }) + expect(result.tool_use_id).toBe('tool-1') + }) + + it('rejects missing tool_name', () => { + const input = { + ...validCommon, + tool_input: { command: 'ls' }, + tool_use_id: 'tool-1', + } + expect(() => PreToolUseInputSchema.parse(input)).toThrow() + }) + + it('rejects missing tool_input', () => { + const input = { + ...validCommon, + tool_name: 'Bash', + tool_use_id: 'tool-1', + } + expect(() => PreToolUseInputSchema.parse(input)).toThrow() + }) +}) diff --git a/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.ts b/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.ts new file mode 100644 index 0000000..6940d6b --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +export const HookCommonInputSchema = z.object({ + session_id: z.string(), + transcript_path: z.string(), + cwd: z.string(), + permission_mode: z.string().optional(), + hook_event_name: z.string(), +}) + +export const PreToolUseInputSchema = HookCommonInputSchema.extend({ + tool_name: z.string(), + tool_input: z.record(z.unknown()), + tool_use_id: z.string(), +}) + +export type PreToolUseInput = z.infer diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.spec.ts new file mode 100644 index 0000000..60d10dc --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.spec.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest' +import { createWorkflowCli } from './workflow-cli.js' + +describe('workflow-cli', () => { + it('exports createWorkflowCli function', () => { + expect(typeof createWorkflowCli).toBe('function') + }) +}) diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts new file mode 100644 index 0000000..938942c --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts @@ -0,0 +1,30 @@ +/* v8 ignore start */ +import type { BaseWorkflowState } from '../../engine/index.js' +import type { RehydratableWorkflow, WorkflowEngineDeps } from '../../engine/index.js' +import type { WorkflowCliConfig } from './workflow-runner.js' +import { createWorkflowRunner } from './workflow-runner.js' + +export function createWorkflowCli< + TWorkflow extends RehydratableWorkflow, + TState extends BaseWorkflowState, + TDeps, +>( + config: WorkflowCliConfig, + buildDeps: () => { engineDeps: WorkflowEngineDeps; workflowDeps: TDeps; readStdin: () => string }, +): void { + const runner = createWorkflowRunner(config) + try { + const deps = buildDeps() + const args = process.argv.slice(2) + const result = runner(args, deps.engineDeps, deps.workflowDeps, deps.readStdin) + if (result.output) { + process.stdout.write(result.output) + } + process.exit(result.exitCode) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(message + '\n') + process.exit(1) + } +} +/* v8 ignore stop */ diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts new file mode 100644 index 0000000..4dc4a07 --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts @@ -0,0 +1,383 @@ +import { describe, it, expect } from 'vitest' +import { createWorkflowRunner } from './workflow-runner.js' +import type { WorkflowCliConfig } from './workflow-runner.js' +import { pass, fail } from '../../dsl/index.js' +import type { PreconditionResult } from '../../dsl/index.js' +import type { + RehydratableWorkflow, + WorkflowFactory, + WorkflowEngineDeps, + WorkflowEventStore, +} from '../../engine/index.js' +import type { BaseEvent } from '../../engine/index.js' +import { arg } from './arg-helpers.js' +import { EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from './exit-codes.js' + +type TestState = { currentStateMachineState: string } +type TestDeps = Record + +type TestWorkflow = RehydratableWorkflow & { + doSomething(): PreconditionResult +} + +function createMockWorkflow(initialState: TestState = { currentStateMachineState: 'planning' }): TestWorkflow { + let state = initialState + const pending: BaseEvent[] = [] + return { + getState: () => state, + getAgentInstructions: () => '/tmp/instructions.md', + appendEvent: (event: BaseEvent) => { + pending.push(event) + if (event.type === 'transitioned') { + const e = event as BaseEvent & { to: string } + state = { ...state, currentStateMachineState: e.to } + } + }, + getPendingEvents: () => pending, + startSession: () => undefined, + doSomething: () => pass(), + } +} + +function createMockStore(hasSession = false): WorkflowEventStore { + return { + readEvents: () => [], + appendEvents: () => undefined, + sessionExists: () => hasSession, + } +} + +function createMockEngineDeps(hasSession = false): WorkflowEngineDeps { + return { + store: createMockStore(hasSession), + getPluginRoot: () => '/tmp/plugin', + getEnvFilePath: () => '/tmp/.env', + readFile: () => '# Procedure content', + appendToFile: () => undefined, + now: () => '2024-01-01T00:00:00Z', + } +} + +function createMockFactory(): WorkflowFactory { + return { + rehydrate: (_events: readonly BaseEvent[]) => createMockWorkflow(), + createFresh: () => createMockWorkflow(), + procedurePath: () => '/tmp/procedure.md', + initialState: () => ({ currentStateMachineState: 'planning' }), + getRegistry: () => ({ + planning: { + emoji: '📋', + agentInstructions: 'states/planning.md', + canTransitionTo: ['coding'], + allowedWorkflowOperations: [], + }, + coding: { + emoji: '💻', + agentInstructions: 'states/coding.md', + canTransitionTo: [], + allowedWorkflowOperations: [], + }, + }), + buildTransitionContext: (state, from, to, _deps) => ({ + state, + gitInfo: { currentBranch: 'main', workingTreeClean: true, headCommit: 'abc', changedFilesVsDefault: [], hasCommitsVsDefault: false }, + prChecksPass: false, + from, + to, + }), + getOperationBody: (op: string) => `Body for ${op}`, + getTransitionTitle: (to: string) => `Transitioned to ${to}`, + parseStateName: (v: string) => v, + } +} + +function createTestConfig(overrides?: Partial>): WorkflowCliConfig { + return { + factory: createMockFactory(), + commands: { + init: { + type: 'session-start', + args: [arg.string('session-id')], + }, + transition: { + type: 'transition', + args: [arg.string('session-id'), arg.string('target')], + }, + 'do-something': { + type: 'transaction', + args: [arg.string('session-id')], + handler: (w: TestWorkflow) => w.doSomething(), + }, + 'no-extra-args': { + type: 'transaction', + args: [arg.string('session-id')], + handler: () => pass(), + }, + }, + ...overrides, + } +} + +describe('createWorkflowRunner', () => { + describe('command routing', () => { + it('routes session-start commands to engine.startSession', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const result = runner(['init', 'session-1'], createMockEngineDeps(), {}) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('routes transition commands to engine.transition', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const deps = createMockEngineDeps(true) + const result = runner(['transition', 'session-1', 'coding'], deps, {}) + expect(result.exitCode).toBe(EXIT_ALLOW) + expect(result.output).toContain('Transitioned to coding') + }) + + it('routes transaction commands to engine.transaction', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const deps = createMockEngineDeps(true) + const result = runner(['do-something', 'session-1'], deps, {}) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('returns error for unknown commands', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const result = runner(['unknown-cmd'], createMockEngineDeps(), {}) + expect(result.exitCode).toBe(EXIT_ERROR) + expect(result.output).toBe('Unknown command: unknown-cmd') + }) + + it('returns error when arg parsing fails', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const result = runner(['init'], createMockEngineDeps(), {}) + expect(result.exitCode).toBe(EXIT_ERROR) + expect(result.output).toBe('init: missing required argument ') + }) + + it('handles transaction commands with only session-id arg', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const deps = createMockEngineDeps(true) + const result = runner(['no-extra-args', 'session-1'], deps, {}) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + }) + + describe('engine result mapping', () => { + it('maps blocked engine result to EXIT_BLOCK', () => { + const factory = createMockFactory() + factory.rehydrate = () => ({ + ...createMockWorkflow(), + doSomething: () => fail('not allowed'), + }) + const config = createTestConfig({ factory }) + const runner = createWorkflowRunner(config) + const deps = createMockEngineDeps(true) + const result = runner(['do-something', 'session-1'], deps, {}) + expect(result.exitCode).toBe(EXIT_BLOCK) + }) + + it('maps blocked transition to EXIT_BLOCK', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const deps = createMockEngineDeps(true) + const result = runner(['transition', 'session-1', 'invalid-state'], deps, {}) + expect(result.exitCode).toBe(EXIT_BLOCK) + }) + }) + + describe('hook mode (no command)', () => { + it('returns error when no command and no stdin', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const result = runner([], createMockEngineDeps(), {}) + expect(result.exitCode).toBe(EXIT_ERROR) + expect(result.output).toBe('No command and no stdin available') + }) + + it('handles SessionStart hook event', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'SessionStart', + }) + const appendedFiles: string[] = [] + const deps: WorkflowEngineDeps = { + ...createMockEngineDeps(), + appendToFile: (path: string) => { appendedFiles.push(path) }, + } + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + expect(appendedFiles).toContain('/tmp/.env') + }) + + it('handles PreToolUse hook with matching tool check', () => { + const config = createTestConfig({ + hooks: { + preToolUse: { + Bash: { + extract: (toolInput) => ({ command: String(toolInput['command'] ?? '') }), + check: (_w, extracted) => { + if (extracted['command'] === 'rm -rf /') return fail('Dangerous command') + return pass() + }, + }, + }, + }, + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_use_id: 'tool-1', + }) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('returns allow for PreToolUse with no hooks configured', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_use_id: 'tool-1', + }) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('returns allow for PreToolUse with no matching tool check', () => { + const config = createTestConfig({ + hooks: { + preToolUse: { + Write: { + extract: () => ({}), + check: () => pass(), + }, + }, + }, + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_use_id: 'tool-1', + }) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('returns allow for PreToolUse when session does not exist', () => { + const config = createTestConfig({ + hooks: { + preToolUse: { + Bash: { + extract: () => ({}), + check: () => fail('should not reach'), + }, + }, + }, + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'no-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_use_id: 'tool-1', + }) + const deps = createMockEngineDeps(false) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('returns allow for unknown hook events', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'UnknownEvent', + }) + const result = runner([], createMockEngineDeps(), {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + expect(result.output).toBe('') + }) + + it('returns error for invalid hook input JSON', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const result = runner([], createMockEngineDeps(), {}, () => '{}') + expect(result.exitCode).toBe(EXIT_ERROR) + expect(result.output).toContain('Invalid hook input') + }) + + it('returns error for invalid pre-tool-use input', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'PreToolUse', + }) + const result = runner([], createMockEngineDeps(), {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ERROR) + expect(result.output).toContain('Invalid pre-tool-use input') + }) + + it('handles PreToolUse hook that blocks', () => { + const config = createTestConfig({ + hooks: { + preToolUse: { + Bash: { + extract: (toolInput) => ({ command: String(toolInput['command'] ?? '') }), + check: () => fail('Blocked by hook'), + }, + }, + }, + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'rm -rf /' }, + tool_use_id: 'tool-1', + }) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_BLOCK) + }) + }) +}) diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts new file mode 100644 index 0000000..d792213 --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts @@ -0,0 +1,212 @@ +import type { BaseWorkflowState } from '../../engine/index.js' +import { WorkflowEngine } from '../../engine/index.js' +import type { + RehydratableWorkflow, + WorkflowFactory, + WorkflowEngineDeps, + EngineResult, +} from '../../engine/index.js' +import type { ArgParser } from './arg-helpers.js' +import type { CommandMap } from './command-definition.js' +import type { HookDefinition } from './hook-definition.js' +import type { PreToolUseInput } from './hook-schemas.js' +import { EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from './exit-codes.js' +import { HookCommonInputSchema, PreToolUseInputSchema } from './hook-schemas.js' + +export type RunnerResult = { readonly output: string; readonly exitCode: number } + +export type WorkflowCliConfig< + TWorkflow extends RehydratableWorkflow, + TState extends BaseWorkflowState, + TDeps, + TStateName extends string = string, + TOperation extends string = string, +> = { + readonly factory: WorkflowFactory + readonly commands: CommandMap + readonly hooks?: HookDefinition +} + +function engineResultToRunnerResult(result: EngineResult): RunnerResult { + switch (result.type) { + case 'success': + return { output: result.output, exitCode: EXIT_ALLOW } + case 'blocked': + return { output: result.output, exitCode: EXIT_BLOCK } + /* v8 ignore next 2 */ + case 'error': + return { output: result.output, exitCode: EXIT_ERROR } + } +} + +function parseArgs( + argParsers: readonly ArgParser[] | undefined, + args: readonly string[], + commandName: string, +): { readonly ok: true; readonly values: readonly unknown[] } | { readonly ok: false; readonly message: string } { + const values: unknown[] = [] + /* v8 ignore next */ + for (const [i, parser] of (argParsers ?? []).entries()) { + const result = parser.parse(args, i + 1, commandName) + if (!result.ok) { + return { ok: false, message: result.message } + } + values.push(result.value) + } + return { ok: true, values } +} + +function assertSessionId(values: readonly unknown[]): string { + const id = values[0] + /* v8 ignore next */ + if (typeof id !== 'string') throw new Error('session-id argument must be a string') + return id +} + +function assertTarget(values: readonly unknown[]): string { + const target = values[1] + /* v8 ignore next */ + if (typeof target !== 'string') throw new Error('target argument must be a string') + return target +} + +export function createWorkflowRunner< + TWorkflow extends RehydratableWorkflow, + TState extends BaseWorkflowState, + TDeps, + TStateName extends string = string, + TOperation extends string = string, +>( + config: WorkflowCliConfig, +): (args: readonly string[], engineDeps: WorkflowEngineDeps, workflowDeps: TDeps, readStdin?: () => string) => RunnerResult { + return (args, engineDeps, workflowDeps, readStdin) => { + const engine = new WorkflowEngine(config.factory, engineDeps, workflowDeps) + const commandName = args[0] + + if (commandName !== undefined) { + return handleCommand(engine, config, args, commandName) + } + + if (readStdin === undefined) { + return { output: 'No command and no stdin available', exitCode: EXIT_ERROR } + } + + return handleHook(engine, config, readStdin) + } +} + +function handleCommand< + TWorkflow extends RehydratableWorkflow, + TState extends BaseWorkflowState, + TDeps, + TStateName extends string, + TOperation extends string, +>( + engine: WorkflowEngine, + config: WorkflowCliConfig, + args: readonly string[], + commandName: string, +): RunnerResult { + const commandDef = config.commands[commandName] + if (commandDef === undefined) { + return { output: `Unknown command: ${commandName}`, exitCode: EXIT_ERROR } + } + + const parsedArgs = parseArgs(commandDef.args, args, commandName) + if (!parsedArgs.ok) { + return { output: parsedArgs.message, exitCode: EXIT_ERROR } + } + + switch (commandDef.type) { + case 'session-start': { + const sessionId = assertSessionId(parsedArgs.values) + const result = engine.startSession(sessionId) + return engineResultToRunnerResult(result) + } + case 'transition': { + const sessionId = assertSessionId(parsedArgs.values) + const target = config.factory.parseStateName(assertTarget(parsedArgs.values)) + const result = engine.transition(sessionId, target) + return engineResultToRunnerResult(result) + } + case 'transaction': { + const sessionId = assertSessionId(parsedArgs.values) + const restArgs = parsedArgs.values.slice(1) + const result = engine.transaction(sessionId, commandName, (w: TWorkflow) => + commandDef.handler(w, ...restArgs), + ) + return engineResultToRunnerResult(result) + } + } +} + +function handleHook< + TWorkflow extends RehydratableWorkflow, + TState extends BaseWorkflowState, + TDeps, + TStateName extends string, + TOperation extends string, +>( + engine: WorkflowEngine, + config: WorkflowCliConfig, + readStdin: () => string, +): RunnerResult { + const stdin = readStdin() + const commonParse = HookCommonInputSchema.safeParse(JSON.parse(stdin)) + if (!commonParse.success) { + return { output: `Invalid hook input: ${commonParse.error.message}`, exitCode: EXIT_ERROR } + } + const common = commonParse.data + + switch (common.hook_event_name) { + case 'SessionStart': { + const result = engine.startSession(common.session_id, common.transcript_path) + engine.persistSessionId(common.session_id) + return engineResultToRunnerResult(result) + } + case 'PreToolUse': { + const toolParse = PreToolUseInputSchema.safeParse(JSON.parse(stdin)) + if (!toolParse.success) { + return { output: `Invalid pre-tool-use input: ${toolParse.error.message}`, exitCode: EXIT_ERROR } + } + return handlePreToolUse(engine, config, toolParse.data) + } + default: + return { output: '', exitCode: EXIT_ALLOW } + } +} + +function handlePreToolUse< + TWorkflow extends RehydratableWorkflow, + TState extends BaseWorkflowState, + TDeps, + TStateName extends string, + TOperation extends string, +>( + engine: WorkflowEngine, + config: WorkflowCliConfig, + input: PreToolUseInput, +): RunnerResult { + if (config.hooks?.preToolUse === undefined) { + return { output: '', exitCode: EXIT_ALLOW } + } + + if (!engine.hasSession(input.session_id)) { + return { output: '', exitCode: EXIT_ALLOW } + } + + const hookCheck = config.hooks.preToolUse[input.tool_name] + if (hookCheck === undefined) { + return { output: '', exitCode: EXIT_ALLOW } + } + + const extracted = hookCheck.extract(input.tool_input) + const result = engine.transaction( + input.session_id, + `hook:${input.tool_name}`, + (w: TWorkflow) => hookCheck.check(w, extracted, input.tool_name), + input.transcript_path, + ) + + return engineResultToRunnerResult(result) +} diff --git a/packages/agentic-workflow-builder/src/cli/index.spec.ts b/packages/agentic-workflow-builder/src/cli/index.spec.ts new file mode 100644 index 0000000..bb10118 --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/index.spec.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest' +import { arg, extractField, defineCommands, defineHooks, createWorkflowRunner, EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK, HookCommonInputSchema, PreToolUseInputSchema } from './index.js' + +describe('cli barrel exports', () => { + it('exports all public API', () => { + expect(arg).toBeDefined() + expect(extractField).toBeDefined() + expect(defineCommands).toBeDefined() + expect(defineHooks).toBeDefined() + expect(createWorkflowRunner).toBeDefined() + expect(EXIT_ALLOW).toBe(0) + expect(EXIT_ERROR).toBe(1) + expect(EXIT_BLOCK).toBe(2) + expect(HookCommonInputSchema).toBeDefined() + expect(PreToolUseInputSchema).toBeDefined() + }) +}) diff --git a/packages/agentic-workflow-builder/src/cli/index.ts b/packages/agentic-workflow-builder/src/cli/index.ts new file mode 100644 index 0000000..5a5dc80 --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/index.ts @@ -0,0 +1,13 @@ +export { arg } from './domain/arg-helpers.js' +export type { ArgParser, ArgResult } from './domain/arg-helpers.js' +export { extractField } from './domain/extract-field.js' +export { defineCommands } from './domain/command-definition.js' +export type { CommandDefinition, CommandMap } from './domain/command-definition.js' +export { defineHooks } from './domain/hook-definition.js' +export type { HookCheck, HookDefinition } from './domain/hook-definition.js' +export { createWorkflowRunner } from './domain/workflow-runner.js' +export type { RunnerResult, WorkflowCliConfig } from './domain/workflow-runner.js' +export { createWorkflowCli } from './domain/workflow-cli.js' +export { EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from './domain/exit-codes.js' +export { HookCommonInputSchema, PreToolUseInputSchema } from './domain/hook-schemas.js' +export type { PreToolUseInput } from './domain/hook-schemas.js' diff --git a/packages/agentic-workflow-builder/src/dsl/domain/recording-ops.spec.ts b/packages/agentic-workflow-builder/src/dsl/domain/recording-ops.spec.ts new file mode 100644 index 0000000..27fb4f9 --- /dev/null +++ b/packages/agentic-workflow-builder/src/dsl/domain/recording-ops.spec.ts @@ -0,0 +1,155 @@ +import { checkOperationGate, defineRecordingOps } from './recording-ops.js' +import type { WorkflowRegistry, WorkflowStateDefinition } from './types.js' + +type TestState = { + readonly currentStateMachineState: TestStateName + readonly data: string +} + +type TestStateName = 'idle' | 'working' +type TestOperation = 'record-issue' | 'record-branch' + +const idleState: WorkflowStateDefinition = { + emoji: '🟡', + agentInstructions: 'idle instructions', + canTransitionTo: ['working'], + allowedWorkflowOperations: ['record-issue', 'record-branch'], +} + +const workingState: WorkflowStateDefinition = { + emoji: '🟢', + agentInstructions: 'working instructions', + canTransitionTo: ['idle'], + allowedWorkflowOperations: [], +} + +const registry: WorkflowRegistry = { + idle: idleState, + working: workingState, +} + +describe('checkOperationGate', () => { + it('returns pass when operation is in allowedWorkflowOperations', () => { + const state: TestState = { currentStateMachineState: 'idle' as const, data: 'test' } + const result = checkOperationGate('record-issue', state, registry) + expect(result).toStrictEqual({ pass: true }) + }) + + it('returns fail with message when operation is not allowed', () => { + const state: TestState = { currentStateMachineState: 'working' as const, data: 'test' } + const result = checkOperationGate('record-issue', state, registry) + expect(result).toStrictEqual({ + pass: false, + reason: 'record-issue is not allowed in state working.', + }) + }) + +}) + +describe('defineRecordingOps', () => { + const ops = defineRecordingOps(registry, { + 'record-issue': { + event: 'issue-recorded', + payload: (issueNumber: number) => ({ issueNumber }), + }, + 'record-branch': { + event: 'branch-recorded', + payload: (branch: string) => ({ branch }), + }, + }) + + it('returns pass with correct event when gate check passes', () => { + const state: TestState = { currentStateMachineState: 'idle' as const, data: 'test' } + const result = ops.executeOp('record-issue', state, '2026-03-07T00:00:00Z', [42]) + expect(result).toStrictEqual({ + pass: true, + event: { type: 'issue-recorded', at: '2026-03-07T00:00:00Z', issueNumber: 42 }, + }) + }) + + it('returns fail when gate check blocks (wrong state)', () => { + const state: TestState = { currentStateMachineState: 'working' as const, data: 'test' } + const result = ops.executeOp('record-issue', state, '2026-03-07T00:00:00Z', [42]) + expect(result).toStrictEqual({ + pass: false, + reason: 'record-issue is not allowed in state working.', + }) + }) + + it('returns fail for unknown recording operation', () => { + type OpenState = { readonly currentStateMachineState: 'open'; readonly data: string } + const permissiveRegistry: WorkflowRegistry = { + open: { + emoji: '🔓', + agentInstructions: '', + canTransitionTo: [], + allowedWorkflowOperations: ['do-something'], + }, + } + const permissiveOps = defineRecordingOps(permissiveRegistry, {}) + const state: OpenState = { currentStateMachineState: 'open' as const, data: 'test' } + const result = permissiveOps.executeOp('do-something', state, '2026-03-07T00:00:00Z', []) + expect(result).toStrictEqual({ + pass: false, + reason: 'Unknown recording operation: do-something', + }) + }) + + it('correctly calls payload function with provided args', () => { + const state: TestState = { currentStateMachineState: 'idle' as const, data: 'test' } + const result = ops.executeOp('record-branch', state, '2026-03-07T00:00:00Z', ['feature/test']) + expect(result).toStrictEqual({ + pass: true, + event: { type: 'branch-recorded', at: '2026-03-07T00:00:00Z', branch: 'feature/test' }, + }) + }) + + it('event includes type, at, and all payload fields', () => { + const multiFieldOps = defineRecordingOps(registry, { + 'record-issue': { + event: 'issue-recorded', + payload: (issueNumber: number, label: string) => ({ issueNumber, label }), + }, + }) + const state: TestState = { currentStateMachineState: 'idle' as const, data: 'test' } + const result = multiFieldOps.executeOp('record-issue', state, '2026-03-07T00:00:00Z', [99, 'bug']) + expect(result).toStrictEqual({ + pass: true, + event: { + type: 'issue-recorded', + at: '2026-03-07T00:00:00Z', + issueNumber: 99, + label: 'bug', + }, + }) + }) + + it('works with multiple operations defined', () => { + const state: TestState = { currentStateMachineState: 'idle' as const, data: 'test' } + const issueResult = ops.executeOp('record-issue', state, '2026-03-07T00:00:00Z', [1]) + const branchResult = ops.executeOp('record-branch', state, '2026-03-07T00:00:00Z', ['main']) + expect(issueResult).toStrictEqual({ + pass: true, + event: { type: 'issue-recorded', at: '2026-03-07T00:00:00Z', issueNumber: 1 }, + }) + expect(branchResult).toStrictEqual({ + pass: true, + event: { type: 'branch-recorded', at: '2026-03-07T00:00:00Z', branch: 'main' }, + }) + }) + + it('works with zero-arg operations (no payload args)', () => { + const zeroArgOps = defineRecordingOps(registry, { + 'record-issue': { + event: 'issue-acknowledged', + payload: () => ({}), + }, + }) + const state: TestState = { currentStateMachineState: 'idle' as const, data: 'test' } + const result = zeroArgOps.executeOp('record-issue', state, '2026-03-07T00:00:00Z', []) + expect(result).toStrictEqual({ + pass: true, + event: { type: 'issue-acknowledged', at: '2026-03-07T00:00:00Z' }, + }) + }) +}) diff --git a/packages/agentic-workflow-builder/src/dsl/domain/recording-ops.ts b/packages/agentic-workflow-builder/src/dsl/domain/recording-ops.ts new file mode 100644 index 0000000..80329c5 --- /dev/null +++ b/packages/agentic-workflow-builder/src/dsl/domain/recording-ops.ts @@ -0,0 +1,69 @@ +import type { PreconditionResult } from './result.js' +import { pass, fail } from './result.js' +import type { WorkflowRegistry } from './types.js' + +export function checkOperationGate< + TStateName extends string, + TState extends { currentStateMachineState: TStateName }, + TOperation extends string, +>( + op: TOperation, + state: TState, + registry: WorkflowRegistry, +): PreconditionResult { + const stateName = state.currentStateMachineState + const stateDef = registry[stateName] + if (stateDef.allowedWorkflowOperations.includes(op)) { + return pass() + } + return fail(`${op} is not allowed in state ${state.currentStateMachineState}.`) +} + +export type RecordingOpDefinition = { + readonly event: string + readonly payload: (...args: TArgs) => Record +} + +export type RecordingOpResult = + | { readonly pass: true; readonly event: { readonly type: string; readonly at: string } & Record } + | { readonly pass: false; readonly reason: string } + +export type RecordingOpsFactory< + TStateName extends string, + TState extends { currentStateMachineState: TStateName }, + TOperation extends string, +> = { + readonly executeOp: ( + opName: TOperation, + state: TState, + now: string, + args: readonly unknown[], + ) => RecordingOpResult +} + +export function defineRecordingOps< + TStateName extends string, + TState extends { currentStateMachineState: TStateName }, + TOperation extends string, +>( + registry: WorkflowRegistry, + ops: { readonly [K in string]: RecordingOpDefinition }, +): RecordingOpsFactory { + return { + executeOp: ( + opName: TOperation, + state: TState, + now: string, + args: readonly unknown[], + ): RecordingOpResult => { + const gate = checkOperationGate(opName, state, registry) + if (!gate.pass) return { pass: false, reason: gate.reason } + const opDef = ops[opName] + if (opDef === undefined) { + return { pass: false, reason: `Unknown recording operation: ${opName}` } + } + const payload = opDef.payload(...(args as readonly never[])) + return { pass: true, event: { type: opDef.event, at: now, ...payload } } + }, + } +} diff --git a/packages/agentic-workflow-builder/src/dsl/domain/types.ts b/packages/agentic-workflow-builder/src/dsl/domain/types.ts index d10b9b9..e9e3cbb 100644 --- a/packages/agentic-workflow-builder/src/dsl/domain/types.ts +++ b/packages/agentic-workflow-builder/src/dsl/domain/types.ts @@ -38,6 +38,7 @@ export type WorkflowStateDefinition< } readonly transitionGuard?: (ctx: TransitionContext) => PreconditionResult readonly onEntry?: (state: TState, ctx: TransitionContext) => TState + readonly afterEntry?: () => void } export type WorkflowRegistry< diff --git a/packages/agentic-workflow-builder/src/dsl/index.ts b/packages/agentic-workflow-builder/src/dsl/index.ts index a0845da..9ef23c0 100644 --- a/packages/agentic-workflow-builder/src/dsl/index.ts +++ b/packages/agentic-workflow-builder/src/dsl/index.ts @@ -8,3 +8,5 @@ export type { WorkflowRegistry, } from './domain/types.js' export { checkBashCommand } from './domain/bash-enforcement.js' +export { defineRecordingOps, checkOperationGate } from './domain/recording-ops.js' +export type { RecordingOpDefinition, RecordingOpResult, RecordingOpsFactory } from './domain/recording-ops.js' diff --git a/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.spec.ts b/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.spec.ts index 80b68a1..354f418 100644 --- a/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.spec.ts +++ b/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.spec.ts @@ -9,17 +9,18 @@ import type { } from './workflow-engine.js' import type { BaseWorkflowState } from './workflow-state.js' import type { BaseEvent } from './base-event.js' -import type { PreconditionResult } from '../../dsl/index.js' +import type { PreconditionResult, WorkflowRegistry, TransitionContext, BashForbiddenConfig } from '../../dsl/index.js' import { pass, fail } from '../../dsl/index.js' import type { TranscriptReader } from './transcript-reader.js' import type { TranscriptMessage } from './transcript-reader.js' import type { PrefixConfig } from './identity-verification.js' -type TestState = BaseWorkflowState & { readonly iteration: number } +type TestStateName = 'SPAWN' | 'PLANNING' | 'DEVELOPING' | 'BLOCKED' | 'REVIEWING' | 'RESPAWN' +type TestState = BaseWorkflowState & { readonly iteration: number } type TestDeps = { readonly pluginRoot: string } const INITIAL_STATE: TestState = { - currentStateMachineState: 'SPAWN', + currentStateMachineState: 'SPAWN' as const, iteration: 0, } @@ -39,11 +40,12 @@ class StubWorkflow implements RehydratableWorkflow { return '/plugin/states/spawn.md' } - transitionTo(target: string): PreconditionResult { - const event: BaseEvent = { type: 'transitioned', at: '2026-01-01T00:00:00.000Z' } + appendEvent(event: BaseEvent): void { this.pending = [...this.pending, event] - this.state = { ...this.state, currentStateMachineState: target } - return pass() + if (event.type === 'transitioned') { + const e = event as BaseEvent & { to: TestStateName } + this.state = { ...this.state, currentStateMachineState: e.to } + } } getPendingEvents(): readonly BaseEvent[] { @@ -65,21 +67,64 @@ class StubWorkflow implements RehydratableWorkflow { } } -class FailingWorkflow extends StubWorkflow { - override transitionTo(_target: string): PreconditionResult { - return fail('Guard failed: missing issue') - } +const TEST_REGISTRY: WorkflowRegistry = { + SPAWN: { + emoji: '🟣', + agentInstructions: 'states/spawn.md', + canTransitionTo: ['PLANNING', 'BLOCKED'], + allowedWorkflowOperations: ['record-issue'], + forbidden: { write: true }, + allowForbidden: { bash: ['pnpm test'] }, + }, + PLANNING: { + emoji: '🔨', + agentInstructions: 'states/planning.md', + canTransitionTo: ['DEVELOPING'], + allowedWorkflowOperations: [], + }, + DEVELOPING: { + emoji: '⚡', + agentInstructions: 'states/developing.md', + canTransitionTo: ['REVIEWING'], + allowedWorkflowOperations: [], + }, + BLOCKED: { + emoji: '🚫', + agentInstructions: 'states/blocked.md', + canTransitionTo: ['SPAWN'], + allowedWorkflowOperations: [], + }, + REVIEWING: { + emoji: '👀', + agentInstructions: 'states/reviewing.md', + canTransitionTo: ['RESPAWN'], + allowedWorkflowOperations: [], + }, + RESPAWN: { + emoji: '🔄', + agentInstructions: 'states/respawn.md', + canTransitionTo: [], + allowedWorkflowOperations: [], + }, } -function makeFactory(workflow?: StubWorkflow): WorkflowFactory { +function makeFactory(workflow?: StubWorkflow): WorkflowFactory { return { rehydrate: (_events, _deps) => workflow ?? new StubWorkflow(INITIAL_STATE), createFresh: (_deps) => workflow ?? new StubWorkflow(INITIAL_STATE), procedurePath: (state, pluginRoot) => `${pluginRoot}/states/${state.toLowerCase()}.md`, initialState: () => INITIAL_STATE, - getEmojiForState: (state) => state === 'SPAWN' ? '🟣' : '🔨', + getRegistry: () => TEST_REGISTRY, + buildTransitionContext: (state, from, to, _deps) => ({ + state, + gitInfo: { currentBranch: 'main', workingTreeClean: true, headCommit: 'abc123', changedFilesVsDefault: [], hasCommitsVsDefault: false }, + prChecksPass: false, + from, + to, + }), getOperationBody: (op, state) => `${op} completed for state ${state.currentStateMachineState}`, getTransitionTitle: (to, state) => to === 'RESPAWN' ? `RESPAWN (iteration: ${state.iteration})` : to, + parseStateName: (v: string) => v as TestStateName, } } @@ -114,7 +159,7 @@ function makeTestDeps(): TestDeps { function makeEngine( engineOverrides?: EngineDepsOverrides, factoryWorkflow?: StubWorkflow, -): WorkflowEngine { +): WorkflowEngine { return new WorkflowEngine( makeFactory(factoryWorkflow), makeEngineDeps(engineOverrides), @@ -130,7 +175,7 @@ function makeEngineWithPrefixConfig( transcriptReader: TranscriptReader, prefixConfig: PrefixConfig, engineOverrides?: EngineDepsOverrides, -): WorkflowEngine { +): WorkflowEngine { const factory = { ...makeFactory(), getPrefixConfig: () => prefixConfig } return new WorkflowEngine( factory, @@ -263,6 +308,7 @@ describe('WorkflowEngine.transaction', () => { expect(result.output).toContain('recover SPAWN') }) + it('allows operation when identity is verified', () => { const messages: TranscriptMessage[] = [ { id: 'msg-1', textContent: 'LEAD: SPAWN' }, @@ -307,6 +353,15 @@ describe('WorkflowEngine.transaction', () => { try { unlinkSync(testFile) } catch (_cause) { } } }) + + it('uses default operation body when getOperationBody is not provided', () => { + const { getOperationBody: _, ...rest } = makeFactory() + const factory: WorkflowFactory = rest + const engine = new WorkflowEngine(factory, makeEngineDeps(), makeTestDeps()) + const result = engine.transaction('sess1', 'record-issue', () => pass()) + expect(result.type).toStrictEqual('success') + expect(result.output).toContain('record-issue') + }) }) describe('WorkflowEngine.transition', () => { @@ -319,9 +374,29 @@ describe('WorkflowEngine.transition', () => { expect(appended[0]?.events[0]?.type).toStrictEqual('transitioned') }) - it('returns blocked with procedure when transition fails', () => { - const workflow = new FailingWorkflow(INITIAL_STATE) - const engine = makeEngine({}, workflow) + it('returns blocked with illegal transition error when target not in canTransitionTo', () => { + const engine = makeEngine() + const result = engine.transition('sess1', 'REVIEWING') + expect(result.type).toStrictEqual('blocked') + expect(result.output).toContain('Illegal transition') + expect(result.output).toContain('SPAWN -> REVIEWING') + expect(result.output).toContain('PLANNING') + expect(result.output).toContain('BLOCKED') + }) + + it('returns blocked when transition guard fails', () => { + const guardRegistry: WorkflowRegistry = { + ...TEST_REGISTRY, + SPAWN: { + ...TEST_REGISTRY['SPAWN']!, + transitionGuard: () => fail('Guard failed: missing issue'), + }, + } + const factory: WorkflowFactory = { + ...makeFactory(), + getRegistry: () => guardRegistry, + } + const engine = new WorkflowEngine(factory, makeEngineDeps(), makeTestDeps()) const result = engine.transition('sess1', 'PLANNING') expect(result.type).toStrictEqual('blocked') expect(result.output).toContain('Cannot transition to PLANNING') @@ -329,11 +404,263 @@ describe('WorkflowEngine.transition', () => { expect(result.output).toContain('Do the thing') }) + it('skips guard for BLOCKED target', () => { + const guardRegistry: WorkflowRegistry = { + ...TEST_REGISTRY, + SPAWN: { + ...TEST_REGISTRY['SPAWN']!, + transitionGuard: () => fail('Should not run'), + }, + } + const factory: WorkflowFactory = { + ...makeFactory(), + getRegistry: () => guardRegistry, + } + const engine = new WorkflowEngine(factory, makeEngineDeps(), makeTestDeps()) + const result = engine.transition('sess1', 'BLOCKED') + expect(result.type).toStrictEqual('success') + }) + + it('calls onEntry when present on target state', () => { + const onEntryCalls: Array<{ state: TestState; from: TestStateName; to: TestStateName }> = [] + const entryRegistry: WorkflowRegistry = { + ...TEST_REGISTRY, + PLANNING: { + ...TEST_REGISTRY['PLANNING']!, + onEntry: (state, ctx) => { + onEntryCalls.push({ state, from: ctx.from, to: ctx.to }) + return { ...state, iteration: state.iteration + 1 } + }, + }, + } + const factory: WorkflowFactory = { + ...makeFactory(), + getRegistry: () => entryRegistry, + } + const engine = new WorkflowEngine(factory, makeEngineDeps(), makeTestDeps()) + const result = engine.transition('sess1', 'PLANNING') + expect(result.type).toStrictEqual('success') + expect(onEntryCalls).toHaveLength(1) + expect(onEntryCalls[0]).toMatchObject({ from: 'SPAWN', to: 'PLANNING' }) + }) + + it('calls afterEntry when present on target state', () => { + let afterEntryCalled = false + const afterEntryRegistry: WorkflowRegistry = { + ...TEST_REGISTRY, + PLANNING: { + ...TEST_REGISTRY['PLANNING']!, + afterEntry: () => { afterEntryCalled = true }, + }, + } + const factory: WorkflowFactory = { + ...makeFactory(), + getRegistry: () => afterEntryRegistry, + } + const engine = new WorkflowEngine(factory, makeEngineDeps(), makeTestDeps()) + const result = engine.transition('sess1', 'PLANNING') + expect(result.type).toStrictEqual('success') + expect(afterEntryCalled).toStrictEqual(true) + }) + + it('uses custom buildTransitionEvent when provided', () => { + const appended: Array<{ sessionId: string; events: readonly BaseEvent[] }> = [] + const factory: WorkflowFactory = { + ...makeFactory(), + buildTransitionEvent: (from, to, _before, _after, now) => ({ + type: 'transitioned', + at: now, + from, + to, + iteration: 42, + }), + } + const engine = new WorkflowEngine( + factory, + makeEngineDeps({ store: { appendEvents: (id, events) => appended.push({ sessionId: id, events }) } }), + makeTestDeps(), + ) + const result = engine.transition('sess1', 'PLANNING') + expect(result.type).toStrictEqual('success') + const transitioned = appended[0]?.events[0] as BaseEvent & { iteration: number } + expect(transitioned.iteration).toStrictEqual(42) + }) + + it('uses default transition event when buildTransitionEvent is not provided', () => { + const appended: Array<{ sessionId: string; events: readonly BaseEvent[] }> = [] + const engine = makeEngine({ store: { appendEvents: (id, events) => appended.push({ sessionId: id, events }) } }) + const result = engine.transition('sess1', 'PLANNING') + expect(result.type).toStrictEqual('success') + const transitioned = appended[0]?.events[0] as BaseEvent & { from: TestStateName; to: TestStateName } + expect(transitioned.from).toStrictEqual('SPAWN') + expect(transitioned.to).toStrictEqual('PLANNING') + }) + + it('uses default transition title when getTransitionTitle is not provided', () => { + const { getTransitionTitle: _, ...rest } = makeFactory() + const factory: WorkflowFactory = rest + const engine = new WorkflowEngine(factory, makeEngineDeps(), makeTestDeps()) + const result = engine.transition('sess1', 'PLANNING') + expect(result.type).toStrictEqual('success') + expect(result.output).toContain('PLANNING') + }) + it('throws WorkflowStateError when session does not exist', () => { const engine = makeEngine({ store: { sessionExists: () => false } }) expect(() => engine.transition('missing', 'PLANNING')) .toThrow("No session found for 'missing'. Run init first.") }) + + it('returns blocked with empty legal targets when state has no transitions', () => { + const workflow = new StubWorkflow({ currentStateMachineState: 'RESPAWN', iteration: 0 }) + const engine = makeEngine({}, workflow) + const result = engine.transition('sess1', 'PLANNING') + expect(result.type).toStrictEqual('blocked') + expect(result.output).toContain('Illegal transition') + expect(result.output).toContain('none') + }) + +}) + +describe('WorkflowEngine.checkBash', () => { + const bashForbidden: BashForbiddenConfig = { + patterns: [/git push/], + flags: ['--force'], + } + + it('allows non-Bash tools', () => { + const appended: Array<{ sessionId: string; events: readonly BaseEvent[] }> = [] + const engine = makeEngine({ store: { appendEvents: (id, events) => appended.push({ sessionId: id, events }) } }) + const result = engine.checkBash('sess1', 'Read', 'anything', bashForbidden) + expect(result.type).toStrictEqual('success') + const event = appended[0]?.events[0] as BaseEvent & { tool: string; allowed: boolean } + expect(event.type).toStrictEqual('bash-checked') + expect(event.tool).toStrictEqual('Read') + expect(event.allowed).toStrictEqual(true) + }) + + it('allows Bash commands that are not forbidden', () => { + const appended: Array<{ sessionId: string; events: readonly BaseEvent[] }> = [] + const engine = makeEngine({ store: { appendEvents: (id, events) => appended.push({ sessionId: id, events }) } }) + const result = engine.checkBash('sess1', 'Bash', 'pnpm test', bashForbidden) + expect(result.type).toStrictEqual('success') + const event = appended[0]?.events[0] as BaseEvent & { allowed: boolean } + expect(event.allowed).toStrictEqual(true) + }) + + it('blocks Bash commands matching forbidden patterns', () => { + const appended: Array<{ sessionId: string; events: readonly BaseEvent[] }> = [] + const engine = makeEngine({ store: { appendEvents: (id, events) => appended.push({ sessionId: id, events }) } }) + const result = engine.checkBash('sess1', 'Bash', 'git push origin main', bashForbidden) + expect(result.type).toStrictEqual('blocked') + expect(result.output).toContain('Bash command blocked') + const event = appended[0]?.events[0] as BaseEvent & { allowed: boolean; reason: string } + expect(event.allowed).toStrictEqual(false) + }) + + it('blocks Bash commands with forbidden flags', () => { + const engine = makeEngine() + const result = engine.checkBash('sess1', 'Bash', 'git commit --force', bashForbidden) + expect(result.type).toStrictEqual('blocked') + expect(result.output).toContain('Forbidden flag') + }) + + it('uses state exemptions from registry', () => { + const engine = makeEngine() + const pnpmForbidden: BashForbiddenConfig = { + patterns: [/pnpm/], + } + const result = engine.checkBash('sess1', 'Bash', 'pnpm test', pnpmForbidden) + expect(result.type).toStrictEqual('success') + }) + + it('uses empty exemptions when state has no allowForbidden', () => { + const workflow = new StubWorkflow({ currentStateMachineState: 'PLANNING', iteration: 0 }) + const engine = makeEngine({}, workflow) + const result = engine.checkBash('sess1', 'Bash', 'git push origin main', bashForbidden) + expect(result.type).toStrictEqual('blocked') + }) + + it('throws WorkflowStateError when session does not exist', () => { + const engine = makeEngine({ store: { sessionExists: () => false } }) + expect(() => engine.checkBash('missing', 'Bash', 'ls', bashForbidden)) + .toThrow("No session found for 'missing'. Run init first.") + }) + + it('blocks when identity is lost', () => { + const messages: TranscriptMessage[] = [ + { id: 'msg-1', textContent: 'LEAD: SPAWN' }, + { id: 'msg-2', textContent: 'No prefix here' }, + ] + const engine = makeEngineWithPrefixConfig( + stubTranscriptReader(messages), + { pattern: /^LEAD:/m, buildRecoveryMessage: () => 'identity lost' }, + ) + const result = engine.checkBash('sess1', 'Bash', 'ls', bashForbidden, '/transcript.jsonl') + expect(result.type).toStrictEqual('blocked') + expect(result.output).toContain('identity lost') + }) +}) + +describe('WorkflowEngine.checkWrite', () => { + const alwaysAllow = (): PreconditionResult => pass() + const alwaysDeny = (_t: string, _f: string, _s: TestState): PreconditionResult => fail('Write forbidden') + + it('allows writes when state has no write forbidden', () => { + const workflow = new StubWorkflow({ currentStateMachineState: 'PLANNING', iteration: 0 }) + const appended: Array<{ sessionId: string; events: readonly BaseEvent[] }> = [] + const engine = makeEngine( + { store: { appendEvents: (id, events) => appended.push({ sessionId: id, events }) } }, + workflow, + ) + const result = engine.checkWrite('sess1', 'Edit', '/some/file.ts', alwaysDeny) + expect(result.type).toStrictEqual('success') + const event = appended[0]?.events[0] as BaseEvent & { allowed: boolean } + expect(event.type).toStrictEqual('write-checked') + expect(event.allowed).toStrictEqual(true) + }) + + it('checks predicate when state has write forbidden', () => { + const engine = makeEngine() + const result = engine.checkWrite('sess1', 'Edit', '/some/file.ts', alwaysDeny) + expect(result.type).toStrictEqual('blocked') + expect(result.output).toContain('Write forbidden') + }) + + it('allows write when predicate passes in forbidden state', () => { + const engine = makeEngine() + const result = engine.checkWrite('sess1', 'Edit', '/state/file.ts', alwaysAllow) + expect(result.type).toStrictEqual('success') + }) + + it('throws WorkflowStateError when session does not exist', () => { + const engine = makeEngine({ store: { sessionExists: () => false } }) + expect(() => engine.checkWrite('missing', 'Edit', '/file.ts', alwaysAllow)) + .toThrow("No session found for 'missing'. Run init first.") + }) + + it('blocks when identity is lost', () => { + const messages: TranscriptMessage[] = [ + { id: 'msg-1', textContent: 'LEAD: SPAWN' }, + { id: 'msg-2', textContent: 'No prefix here' }, + ] + const engine = makeEngineWithPrefixConfig( + stubTranscriptReader(messages), + { pattern: /^LEAD:/m, buildRecoveryMessage: () => 'identity lost' }, + ) + const result = engine.checkWrite('sess1', 'Edit', '/file.ts', alwaysAllow, '/transcript.jsonl') + expect(result.type).toStrictEqual('blocked') + expect(result.output).toContain('identity lost') + }) + + it('appends denied write-checked event with reason', () => { + const appended: Array<{ sessionId: string; events: readonly BaseEvent[] }> = [] + const engine = makeEngine({ store: { appendEvents: (id, events) => appended.push({ sessionId: id, events }) } }) + engine.checkWrite('sess1', 'Edit', '/file.ts', alwaysDeny) + const event = appended[0]?.events[0] as BaseEvent & { allowed: boolean; reason: string } + expect(event.allowed).toStrictEqual(false) + expect(event.reason).toStrictEqual('Write forbidden') + }) }) describe('WorkflowEngine.persistSessionId', () => { diff --git a/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.ts b/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.ts index e801aa1..40b6dac 100644 --- a/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.ts +++ b/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.ts @@ -1,10 +1,12 @@ -import type { PreconditionResult } from '../../dsl/index.js' +import type { PreconditionResult, WorkflowRegistry, TransitionContext, BashForbiddenConfig } from '../../dsl/index.js' +import { checkBashCommand } from '../../dsl/index.js' import type { BaseWorkflowState } from './workflow-state.js' import { WorkflowStateError } from './workflow-state.js' import type { BaseEvent } from './base-event.js' import { formatTransitionSuccess, formatTransitionError, + formatIllegalTransitionError, formatOperationGateError, formatOperationSuccess, formatInitSuccess, @@ -22,19 +24,28 @@ export type EngineResult = export interface RehydratableWorkflow { getState(): TState getAgentInstructions(pluginRoot: string): string - transitionTo(target: string): PreconditionResult + appendEvent(event: BaseEvent): void getPendingEvents(): readonly BaseEvent[] startSession(transcriptPath: string | undefined, repository: string | undefined): void } -export interface WorkflowFactory, TState extends BaseWorkflowState, TDeps> { +export interface WorkflowFactory< + TWorkflow extends RehydratableWorkflow, + TState extends BaseWorkflowState, + TDeps, + TStateName extends string = string, + TOperation extends string = string, +> { rehydrate(events: readonly BaseEvent[], deps: TDeps): TWorkflow createFresh(deps: TDeps): TWorkflow - procedurePath(state: string, pluginRoot: string): string + procedurePath(state: TStateName, pluginRoot: string): string initialState(): TState - getEmojiForState(state: string): string - getOperationBody(op: string, state: TState): string - getTransitionTitle(to: string, state: TState): string + getRegistry(): WorkflowRegistry + buildTransitionContext(state: TState, from: TStateName, to: TStateName, deps: TDeps): TransitionContext + getOperationBody?(op: string, state: TState): string + getTransitionTitle?(to: TStateName, state: TState): string + buildTransitionEvent?(from: TStateName, to: TStateName, stateBefore: TState, stateAfter: TState, now: string): BaseEvent + parseStateName(value: string): TStateName getPrefixConfig?(): PrefixConfig } @@ -54,13 +65,19 @@ export type WorkflowEngineDeps = { readonly transcriptReader?: TranscriptReader } -export class WorkflowEngine, TState extends BaseWorkflowState, TDeps> { - private readonly factory: WorkflowFactory +export class WorkflowEngine< + TWorkflow extends RehydratableWorkflow, + TState extends BaseWorkflowState, + TDeps, + TStateName extends string = string, + TOperation extends string = string, +> { + private readonly factory: WorkflowFactory private readonly engineDeps: WorkflowEngineDeps private readonly workflowDeps: TDeps constructor( - factory: WorkflowFactory, + factory: WorkflowFactory, engineDeps: WorkflowEngineDeps, workflowDeps: TDeps, ) { @@ -100,25 +117,135 @@ export class WorkflowEngine, TSta if (!result.pass) { return { type: 'blocked', output: formatOperationGateError(op, result.reason) } } - const body = this.factory.getOperationBody(op, workflow.getState()) + const body = this.factory.getOperationBody?.(op, workflow.getState()) ?? op return { type: 'success', output: formatOperationSuccess(op, body) } } - transition(sessionId: string, target: string): EngineResult { + transition(sessionId: string, target: TStateName): EngineResult { this.requireSession(sessionId) const workflow = this.rehydrateFromEvents(sessionId) - const result = workflow.transitionTo(target) - if (!result.pass) { + const state = workflow.getState() + const currentStateName = state.currentStateMachineState + const registry = this.factory.getRegistry() + const currentDef = registry[currentStateName] + + if (!currentDef.canTransitionTo.includes(target)) { + const legalTargets = currentDef.canTransitionTo + const reason = `Illegal transition ${currentStateName} -> ${target}. Legal targets from ${currentStateName}: [${legalTargets.join(', ') || 'none'}].` const currentProcedure = this.readProcedure(workflow) - return { type: 'blocked', output: formatTransitionError(target, result.reason, currentProcedure) } + return { type: 'blocked', output: formatIllegalTransitionError(reason, currentProcedure) } } + + if (target !== 'BLOCKED' && currentDef.transitionGuard) { + const ctx = this.factory.buildTransitionContext(state, currentStateName, target, this.workflowDeps) + const guardResult = currentDef.transitionGuard(ctx) + if (!guardResult.pass) { + const currentProcedure = this.readProcedure(workflow) + return { type: 'blocked', output: formatTransitionError(target, guardResult.reason, currentProcedure) } + } + } + + const targetDef = registry[target] + const stateBefore = workflow.getState() + const stateAfter = targetDef.onEntry + ? targetDef.onEntry(stateBefore, this.factory.buildTransitionContext(stateBefore, currentStateName, target, this.workflowDeps)) + : stateBefore + + const transitionEvent = this.factory.buildTransitionEvent + ? this.factory.buildTransitionEvent(currentStateName, target, stateBefore, stateAfter, this.engineDeps.now()) + : { type: 'transitioned', at: this.engineDeps.now(), from: currentStateName, to: target } + + workflow.appendEvent(transitionEvent) + targetDef.afterEntry?.() this.persistEvents(sessionId, workflow) - const state = workflow.getState() - const title = this.factory.getTransitionTitle(state.currentStateMachineState, state) + + const newState = workflow.getState() + const title = this.factory.getTransitionTitle?.(newState.currentStateMachineState, newState) ?? newState.currentStateMachineState const procedure = this.readProcedure(workflow) return { type: 'success', output: formatTransitionSuccess(title, procedure) } } + checkBash( + sessionId: string, + toolName: string, + command: string, + bashForbidden: BashForbiddenConfig, + transcriptPath?: string, + ): EngineResult { + this.requireSession(sessionId) + const workflow = this.rehydrateFromEvents(sessionId) + const identityResult = this.verifyIdentity(sessionId, workflow, transcriptPath) + if (identityResult !== undefined) { + this.persistEvents(sessionId, workflow) + return { type: 'blocked', output: formatOperationGateError('bash-check', identityResult) } + } + + if (toolName !== 'Bash') { + const allowedEvent = { type: 'bash-checked', at: this.engineDeps.now(), tool: toolName, command, allowed: true } + workflow.appendEvent(allowedEvent) + this.persistEvents(sessionId, workflow) + return { type: 'success', output: '' } + } + + const registry = this.factory.getRegistry() + const currentState = workflow.getState().currentStateMachineState + const exemptions = registry[currentState].allowForbidden?.bash ?? [] + const result = checkBashCommand(command, bashForbidden, exemptions) + + if (!result.pass) { + const reason = `Bash command blocked in ${currentState}. ${result.reason}` + const deniedEvent = { type: 'bash-checked', at: this.engineDeps.now(), tool: toolName, command, allowed: false, reason } + workflow.appendEvent(deniedEvent) + this.persistEvents(sessionId, workflow) + return { type: 'blocked', output: formatOperationGateError('bash-check', reason) } + } + + const passedEvent = { type: 'bash-checked', at: this.engineDeps.now(), tool: toolName, command, allowed: true } + workflow.appendEvent(passedEvent) + this.persistEvents(sessionId, workflow) + return { type: 'success', output: '' } + } + + checkWrite( + sessionId: string, + toolName: string, + filePath: string, + isWriteAllowed: (toolName: string, filePath: string, state: TState) => PreconditionResult, + transcriptPath?: string, + ): EngineResult { + this.requireSession(sessionId) + const workflow = this.rehydrateFromEvents(sessionId) + const identityResult = this.verifyIdentity(sessionId, workflow, transcriptPath) + if (identityResult !== undefined) { + this.persistEvents(sessionId, workflow) + return { type: 'blocked', output: formatOperationGateError('write-check', identityResult) } + } + + const registry = this.factory.getRegistry() + const currentState = workflow.getState().currentStateMachineState + const isForbidden = registry[currentState].forbidden?.write ?? false + + if (!isForbidden) { + const allowedEvent = { type: 'write-checked', at: this.engineDeps.now(), tool: toolName, filePath, allowed: true } + workflow.appendEvent(allowedEvent) + this.persistEvents(sessionId, workflow) + return { type: 'success', output: '' } + } + + const result = isWriteAllowed(toolName, filePath, workflow.getState()) + if (!result.pass) { + const deniedEvent = { type: 'write-checked', at: this.engineDeps.now(), tool: toolName, filePath, allowed: false, reason: result.reason } + workflow.appendEvent(deniedEvent) + this.persistEvents(sessionId, workflow) + return { type: 'blocked', output: formatOperationGateError('write-check', result.reason) } + } + + const passedEvent = { type: 'write-checked', at: this.engineDeps.now(), tool: toolName, filePath, allowed: true } + workflow.appendEvent(passedEvent) + this.persistEvents(sessionId, workflow) + return { type: 'success', output: '' } + } + persistSessionId(sessionId: string): void { const envFilePath = this.engineDeps.getEnvFilePath() this.engineDeps.appendToFile(envFilePath, `export CLAUDE_SESSION_ID='${sessionId}'\n`) @@ -154,7 +281,8 @@ export class WorkflowEngine, TSta const reader = this.engineDeps.transcriptReader ?? new ClaudeCodeTranscriptReader() const messages = reader.readMessages(transcriptPath) const state = workflow.getState().currentStateMachineState - const emoji = this.factory.getEmojiForState(state) + const registry = this.factory.getRegistry() + const emoji = registry[state].emoji const result = checkIdentity(messages, prefixConfig.pattern) const identityEvent = { type: 'identity-verified', diff --git a/packages/agentic-workflow-builder/src/engine/domain/workflow-state.ts b/packages/agentic-workflow-builder/src/engine/domain/workflow-state.ts index 6097fdb..12625a9 100644 --- a/packages/agentic-workflow-builder/src/engine/domain/workflow-state.ts +++ b/packages/agentic-workflow-builder/src/engine/domain/workflow-state.ts @@ -5,4 +5,4 @@ export class WorkflowStateError extends Error { } } -export type BaseWorkflowState = { currentStateMachineState: string } +export type BaseWorkflowState = { currentStateMachineState: TStateName } diff --git a/src/autonomous-claude-agent-team-workflow.ts b/src/autonomous-claude-agent-team-workflow.ts index 805fbc2..1ff9c8f 100644 --- a/src/autonomous-claude-agent-team-workflow.ts +++ b/src/autonomous-claude-agent-team-workflow.ts @@ -2,9 +2,12 @@ import { appendFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import type { EngineResult } from '@ntcoding/agentic-workflow-builder/engine' import { WorkflowEngine } from '@ntcoding/agentic-workflow-builder/engine' -import { WorkflowAdapter, StateNameSchema } from './workflow-definition/index.js' +import { createWorkflowRunner, defineCommands, arg, EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from '@ntcoding/agentic-workflow-builder/cli' +import type { RunnerResult } from '@ntcoding/agentic-workflow-builder/cli' +import { pass } from '@ntcoding/agentic-workflow-builder/dsl' +import { WorkflowAdapter, StateNameSchema, BASH_FORBIDDEN, checkWriteAllowed } from './workflow-definition/index.js' import type { Workflow, WorkflowDeps } from './workflow-definition/index.js' -import type { WorkflowState } from './workflow-definition/index.js' +import type { StateName, WorkflowOperation, WorkflowState } from './workflow-definition/domain/workflow-types.js' import { parsePreToolUseInput, parseSubagentStartInput, @@ -12,75 +15,87 @@ import { parseCommonInput, formatDenyDecision, formatContextInjection, - EXIT_ALLOW, - EXIT_ERROR, - EXIT_BLOCK, } from './infra/hook-io.js' import { WorkflowError } from './infra/workflow-error.js' import { buildRealDeps } from './infra/composition-root.js' import type { AnalyticsDeps, ReportDeps, ReportResult, AdapterDeps } from './infra/composition-root.js' export type { AnalyticsDeps, ReportDeps, ReportResult, AdapterDeps } -type OperationResult = { readonly output: string; readonly exitCode: number } +type Engine = WorkflowEngine -type CommandHandler = (args: readonly string[], engine: WorkflowEngine, deps: AdapterDeps) => OperationResult +function narrowNumber(v: unknown): number { + /* v8 ignore next */ + if (typeof v !== 'number') throw new WorkflowError(`Expected number, got ${typeof v}`) + return v +} -const COMMAND_HANDLERS: Readonly> = { - analyze: handleAnalyze, +function narrowString(v: unknown): string { + /* v8 ignore next */ + if (typeof v !== 'string') throw new WorkflowError(`Expected string, got ${typeof v}`) + return v +} + +const COMMANDS = defineCommands({ + transition: { type: 'transition', args: [arg.string('session-id'), arg.state('STATE', StateNameSchema)] }, + 'record-issue': { type: 'transaction', args: [arg.string('session-id'), arg.number('number')], handler: (w, n) => w.recordIssue(narrowNumber(n)) }, + 'record-branch': { type: 'transaction', args: [arg.string('session-id'), arg.string('branch')], handler: (w, b) => w.recordBranch(narrowString(b)) }, + 'record-plan-approval': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.recordPlanApproval() }, + 'assign-iteration-task': { type: 'transaction', args: [arg.string('session-id'), arg.string('task')], handler: (w, t) => w.assignIterationTask(narrowString(t)) }, + 'signal-done': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.signalDone() }, + 'record-pr': { type: 'transaction', args: [arg.string('session-id'), arg.number('number')], handler: (w, n) => w.recordPr(narrowNumber(n)) }, + 'create-pr': { type: 'transaction', args: [arg.string('session-id'), arg.string('title'), arg.string('body')], handler: (w, t, b) => w.createPr(narrowString(t), narrowString(b)) }, + 'append-issue-checklist': { type: 'transaction', args: [arg.string('session-id'), arg.number('issue-number'), arg.string('checklist')], handler: (w, n, c) => w.appendIssueChecklist(narrowNumber(n), narrowString(c)) }, + 'tick-iteration': { type: 'transaction', args: [arg.string('session-id'), arg.number('issue-number')], handler: (w, n) => w.tickIteration(narrowNumber(n)) }, + 'review-approved': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.reviewApproved() }, + 'review-rejected': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.reviewRejected() }, + 'coderabbit-feedback-addressed': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.coderabbitFeedbackAddressed() }, + 'coderabbit-feedback-ignored': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.coderabbitFeedbackIgnored() }, + 'shut-down': { type: 'transaction', args: [arg.string('session-id'), arg.string('agent-name')], handler: (w, name) => w.shutDown(narrowString(name)) }, + 'write-journal': { type: 'transaction', args: [arg.string('session-id'), arg.string('agent-name'), arg.string('content')], handler: (w, a, c) => w.writeJournal(narrowString(a), narrowString(c)) }, +}) + +const platformRunner = createWorkflowRunner({ + factory: WorkflowAdapter, + commands: COMMANDS, +}) + +type CustomCommandHandler = (args: readonly string[], engine: Engine, deps: AdapterDeps) => RunnerResult + +const CUSTOM_COMMANDS: Readonly> = { init: handleInit, - transition: handleTransition, - 'record-issue': handleRecordIssue, - 'record-branch': handleRecordBranch, - 'record-plan-approval': handleRecordPlanApproval, - 'assign-iteration-task': handleAssignIterationTask, - 'signal-done': handleSignalDone, - 'record-pr': handleRecordPr, - 'create-pr': handleCreatePr, - 'append-issue-checklist': handleAppendIssueChecklist, - 'tick-iteration': handleTickIteration, - 'run-lint': handleRunLint, - 'review-approved': handleReviewApproved, - 'review-rejected': handleReviewRejected, - 'coderabbit-feedback-addressed': handleCoderabbitFeedbackAddressed, - 'coderabbit-feedback-ignored': handleCoderabbitFeedbackIgnored, - 'shut-down': handleShutDown, - 'write-journal': handleWriteJournal, - 'event-context': handleEventContext, + analyze: handleAnalyze, 'view-report': handleViewReport, + 'event-context': handleEventContext, + 'run-lint': handleRunLint, } -const HOOK_HANDLERS: Readonly, deps: AdapterDeps) => OperationResult>> = { - SessionStart: handleSessionStart, - PreToolUse: handlePreToolUse, - SubagentStart: handleSubagentStart, - TeammateIdle: handleTeammateIdle, -} - -export function runWorkflow(args: readonly string[], deps: AdapterDeps): OperationResult { - const engine = new WorkflowEngine(WorkflowAdapter, deps.engineDeps, deps.workflowDeps) +export function runWorkflow(args: readonly string[], deps: AdapterDeps): RunnerResult { const command = args[0] if (!command) { - return runHookMode(engine, deps) + return runHookMode(deps) } - const handler = COMMAND_HANDLERS[command] - if (!handler) { - return { output: `Unknown command: ${command}`, exitCode: EXIT_ERROR } + const customHandler = CUSTOM_COMMANDS[command] + if (customHandler) { + const engine = new WorkflowEngine(WorkflowAdapter, deps.engineDeps, deps.workflowDeps) + return customHandler(args, engine, deps) } - return handler(args, engine, deps) + const sessionId = deps.getSessionId() + return platformRunner([command, sessionId, ...args.slice(1)], deps.engineDeps, deps.workflowDeps) } -function runHookMode(engine: WorkflowEngine, deps: AdapterDeps): OperationResult { - const stdin = deps.readStdin() - const cachedDeps: AdapterDeps = { ...deps, readStdin: () => stdin } - const common = parseCommonInput(stdin) - const handler = HOOK_HANDLERS[common.hook_event_name] - if (!handler) { - return { output: `Unknown hook event: ${common.hook_event_name}`, exitCode: EXIT_ERROR } +function handleInit(_args: readonly string[], engine: Engine, deps: AdapterDeps): RunnerResult { + return mapResult(engine.startSession(deps.getSessionId(), undefined, deps.getRepositoryName())) +} + +function handleAnalyze(args: readonly string[], _engine: Engine, deps: AdapterDeps): RunnerResult { + if (args[1] === '--all') { + return { output: deps.analyticsDeps.computeAll(), exitCode: EXIT_ALLOW } } - if (common.hook_event_name !== 'SessionStart' && !engine.hasSession(common.session_id)) { - return { output: '', exitCode: EXIT_ALLOW } + const sessionId = args[1] + if (!sessionId) { + return { output: 'analyze: missing required argument or --all', exitCode: EXIT_ERROR } } - return handler(engine, cachedDeps) + return { output: deps.analyticsDeps.computeSession(sessionId), exitCode: EXIT_ALLOW } } function extractPositionalArgs(args: readonly string[]): readonly string[] { @@ -89,7 +104,7 @@ function extractPositionalArgs(args: readonly string[]): readonly string[] { return args.filter((a, i) => !a.startsWith('--') && i !== renderValueIdx) } -function handleViewReport(args: readonly string[], _engine: WorkflowEngine, deps: AdapterDeps): OperationResult { +function handleViewReport(args: readonly string[], _engine: Engine, deps: AdapterDeps): RunnerResult { const positionalArgs = extractPositionalArgs(args) const sessionId = positionalArgs[1] if (!sessionId) { @@ -101,16 +116,12 @@ function handleViewReport(args: readonly string[], _engine: WorkflowEngine, deps: AdapterDeps): OperationResult { - if (args[1] === '--all') { - return { output: deps.analyticsDeps.computeAll(), exitCode: EXIT_ALLOW } - } - const sessionId = args[1] - if (!sessionId) { - return { output: 'analyze: missing required argument or --all', exitCode: EXIT_ERROR } - } - return { output: deps.analyticsDeps.computeSession(sessionId), exitCode: EXIT_ALLOW } -} - -function handleInit(_args: readonly string[], engine: WorkflowEngine, deps: AdapterDeps): OperationResult { - return mapResult(engine.startSession(deps.getSessionId(), undefined, deps.getRepositoryName())) -} - -function handleTransition(args: readonly string[], engine: WorkflowEngine, deps: AdapterDeps): OperationResult { - const rawState = args[1] - if (!rawState) { - return { output: 'transition: missing required argument ', exitCode: EXIT_ERROR } - } - const parseResult = StateNameSchema.safeParse(rawState) - if (!parseResult.success) { - return { output: `transition: invalid state '${rawState}'`, exitCode: EXIT_ERROR } - } - return mapResult(engine.transition(deps.getSessionId(), parseResult.data)) -} - -function handleRecordIssue(args: readonly string[], engine: WorkflowEngine, deps: AdapterDeps): OperationResult { - const rawNumber = args[1] - if (!rawNumber) { - return { output: 'record-issue: missing required argument ', exitCode: EXIT_ERROR } - } - const issueNumber = Number.parseInt(rawNumber, 10) - if (Number.isNaN(issueNumber)) { - return { output: `record-issue: not a valid number: '${rawNumber}'`, exitCode: EXIT_ERROR } - } - return mapResult(engine.transaction(deps.getSessionId(), 'record-issue', (w) => w.recordIssue(issueNumber))) -} - -function handleRecordBranch(args: readonly string[], engine: WorkflowEngine, deps: AdapterDeps): OperationResult { - const branch = args[1] - if (!branch) { - return { output: 'record-branch: missing required argument ', exitCode: EXIT_ERROR } - } - return mapResult(engine.transaction(deps.getSessionId(), 'record-branch', (w) => w.recordBranch(branch))) -} - -function handleRecordPlanApproval(_args: readonly string[], engine: WorkflowEngine, deps: AdapterDeps): OperationResult { - return mapResult(engine.transaction(deps.getSessionId(), 'record-plan-approval', (w) => w.recordPlanApproval())) -} - -function handleAssignIterationTask(args: readonly string[], engine: WorkflowEngine, deps: AdapterDeps): OperationResult { - const task = args[1] - if (!task) { - return { output: 'assign-iteration-task: missing required argument ', exitCode: EXIT_ERROR } - } - return mapResult(engine.transaction(deps.getSessionId(), 'assign-iteration-task', (w) => w.assignIterationTask(task))) -} - -function handleSignalDone(_args: readonly string[], engine: WorkflowEngine, deps: AdapterDeps): OperationResult { - return mapResult(engine.transaction(deps.getSessionId(), 'signal-done', (w) => w.signalDone())) -} - -function handleRecordPr(args: readonly string[], engine: WorkflowEngine, deps: AdapterDeps): OperationResult { - const rawNumber = args[1] - if (!rawNumber) { - return { output: 'record-pr: missing required argument ', exitCode: EXIT_ERROR } - } - const prNumber = Number.parseInt(rawNumber, 10) - if (Number.isNaN(prNumber)) { - return { output: `record-pr: not a valid number: '${rawNumber}'`, exitCode: EXIT_ERROR } - } - return mapResult(engine.transaction(deps.getSessionId(), 'record-pr', (w) => w.recordPr(prNumber))) -} - -function handleCreatePr(args: readonly string[], engine: WorkflowEngine, deps: AdapterDeps): OperationResult { - const title = args[1] - const body = args[2] - if (!title) { - return { output: 'create-pr: missing required argument ', exitCode: EXIT_ERROR } - } - if (!body) { - return { output: 'create-pr: missing required argument <body>', exitCode: EXIT_ERROR } - } - return mapResult(engine.transaction(deps.getSessionId(), 'create-pr', (w) => w.createPr(title, body))) -} - -function handleAppendIssueChecklist(args: readonly string[], engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { - const rawNumber = args[1] - const checklist = args[2] - if (!rawNumber) { - return { output: 'append-issue-checklist: missing required argument <issue-number>', exitCode: EXIT_ERROR } - } - const issueNumber = Number.parseInt(rawNumber, 10) - if (Number.isNaN(issueNumber)) { - return { output: `append-issue-checklist: not a valid number: '${rawNumber}'`, exitCode: EXIT_ERROR } - } - if (!checklist) { - return { output: 'append-issue-checklist: missing required argument <checklist>', exitCode: EXIT_ERROR } - } - return mapResult(engine.transaction(deps.getSessionId(), 'append-issue-checklist', (w) => w.appendIssueChecklist(issueNumber, checklist))) -} - -function handleTickIteration(args: readonly string[], engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { - const rawNumber = args[1] - if (!rawNumber) { - return { output: 'tick-iteration: missing required argument <issue-number>', exitCode: EXIT_ERROR } - } - const issueNumber = Number.parseInt(rawNumber, 10) - if (Number.isNaN(issueNumber)) { - return { output: `tick-iteration: not a valid number: '${rawNumber}'`, exitCode: EXIT_ERROR } - } - return mapResult(engine.transaction(deps.getSessionId(), 'tick-iteration', (w) => w.tickIteration(issueNumber))) +function handleEventContext(args: readonly string[], engine: Engine, deps: AdapterDeps): RunnerResult { + const sessionId = deps.getSessionId() + const agentName = args[1] ?? '' + engine.transaction(sessionId, 'event-context', (w) => w.requestContext(agentName)) + return { output: deps.analyticsDeps.computeEventContext(sessionId), exitCode: EXIT_ALLOW } } -function handleRunLint(args: readonly string[], engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { +function handleRunLint(args: readonly string[], engine: Engine, deps: AdapterDeps): RunnerResult { const sessionId = deps.getSessionId() if (!engine.hasSession(sessionId)) { return { output: 'run-lint: no state file. Run init first.', exitCode: EXIT_ALLOW } @@ -243,37 +146,35 @@ function handleRunLint(args: readonly string[], engine: WorkflowEngine<Workflow, return mapResult(engine.transaction(sessionId, 'run-lint', (w) => w.runLint(args.slice(1)))) } -function handleReviewApproved(_args: readonly string[], engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { - return mapResult(engine.transaction(deps.getSessionId(), 'review-approved', (w) => w.reviewApproved())) -} - -function handleReviewRejected(_args: readonly string[], engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { - return mapResult(engine.transaction(deps.getSessionId(), 'review-rejected', (w) => w.reviewRejected())) -} - -function handleCoderabbitFeedbackAddressed(_args: readonly string[], engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { - return mapResult(engine.transaction(deps.getSessionId(), 'coderabbit-feedback-addressed', (w) => w.coderabbitFeedbackAddressed())) -} - -function handleCoderabbitFeedbackIgnored(_args: readonly string[], engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { - return mapResult(engine.transaction(deps.getSessionId(), 'coderabbit-feedback-ignored', (w) => w.coderabbitFeedbackIgnored())) +const HOOK_HANDLERS: Readonly<Record<string, (engine: Engine, deps: AdapterDeps) => RunnerResult>> = { + SessionStart: handleSessionStart, + PreToolUse: handlePreToolUse, + SubagentStart: handleSubagentStart, + TeammateIdle: handleTeammateIdle, } -function handleShutDown(args: readonly string[], engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { - const agentName = args[1] - if (!agentName) { - return { output: 'shut-down: missing required argument <agent-name>', exitCode: EXIT_ERROR } +function runHookMode(deps: AdapterDeps): RunnerResult { + const engine = new WorkflowEngine(WorkflowAdapter, deps.engineDeps, deps.workflowDeps) + const stdin = deps.readStdin() + const cachedDeps: AdapterDeps = { ...deps, readStdin: () => stdin } + const common = parseCommonInput(stdin) + const handler = HOOK_HANDLERS[common.hook_event_name] + if (!handler) { + return { output: `Unknown hook event: ${common.hook_event_name}`, exitCode: EXIT_ERROR } + } + if (common.hook_event_name !== 'SessionStart' && !engine.hasSession(common.session_id)) { + return { output: '', exitCode: EXIT_ALLOW } } - return mapResult(engine.transaction(deps.getSessionId(), 'shut-down', (w) => w.shutDown(agentName))) + return handler(engine, cachedDeps) } -function handleSessionStart(engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { +function handleSessionStart(engine: Engine, deps: AdapterDeps): RunnerResult { const hookInput = parseCommonInput(deps.readStdin()) engine.persistSessionId(hookInput.session_id) return { output: '', exitCode: EXIT_ALLOW } } -function handlePreToolUse(engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { +function handlePreToolUse(engine: Engine, deps: AdapterDeps): RunnerResult { const hookInput = parsePreToolUseInput(deps.readStdin()) const filePath = resolveStringField(hookInput.tool_input['file_path']) @@ -281,21 +182,22 @@ function handlePreToolUse(engine: WorkflowEngine<Workflow, WorkflowState, Workfl || resolveStringField(hookInput.tool_input['pattern']) const command = resolveStringField(hookInput.tool_input['command']) - const hookCheck = engine.transaction(hookInput.session_id, 'hook-check', (w) => { - const pluginCheck = w.checkPluginSourceRead(hookInput.tool_name, filePath, command) - if (!pluginCheck.pass) return pluginCheck - const writeCheck = w.checkWriteAllowed(hookInput.tool_name, filePath) - if (!writeCheck.pass) return writeCheck - return w.checkBashAllowed(hookInput.tool_name, command) + const pluginCheck = engine.transaction(hookInput.session_id, 'hook-check', (w) => { + return w.checkPluginSourceRead(hookInput.tool_name, filePath, command) }, hookInput.transcript_path) - if (hookCheck.type === 'blocked') return { output: formatDenyDecision(hookCheck.output), exitCode: EXIT_BLOCK } + if (pluginCheck.type === 'blocked') return { output: formatDenyDecision(pluginCheck.output), exitCode: EXIT_BLOCK } + + const writeCheck = engine.checkWrite(hookInput.session_id, hookInput.tool_name, filePath, checkWriteAllowed, hookInput.transcript_path) + if (writeCheck.type === 'blocked') return { output: formatDenyDecision(writeCheck.output), exitCode: EXIT_BLOCK } + + const bashCheck = engine.checkBash(hookInput.session_id, hookInput.tool_name, command, BASH_FORBIDDEN, hookInput.transcript_path) + if (bashCheck.type === 'blocked') return { output: formatDenyDecision(bashCheck.output), exitCode: EXIT_BLOCK } return { output: '', exitCode: EXIT_ALLOW } } -function handleSubagentStart(engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { +function handleSubagentStart(engine: Engine, deps: AdapterDeps): RunnerResult { const hookInput = parseSubagentStartInput(deps.readStdin()) - const result = engine.transaction(hookInput.session_id, 'register-agent', (w) => { return w.registerAgent(hookInput.agent_type, hookInput.agent_id) }) @@ -304,44 +206,23 @@ function handleSubagentStart(engine: WorkflowEngine<Workflow, WorkflowState, Wor return { output: formatContextInjection(state), exitCode: EXIT_ALLOW } } -function handleTeammateIdle(engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { +function handleTeammateIdle(engine: Engine, deps: AdapterDeps): RunnerResult { const hookInput = parseTeammateIdleInput(deps.readStdin()) - const agentName = hookInput.teammate_name ?? '' const result = engine.transaction(hookInput.session_id, 'check-idle', (w) => w.checkIdleAllowed(agentName)) if (result.type === 'blocked') { return { output: result.output, exitCode: EXIT_BLOCK } } - return { output: '', exitCode: EXIT_ALLOW } } -function handleWriteJournal(args: readonly string[], engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { - const agentName = args[1] - if (!agentName) { - return { output: 'write-journal: missing required argument <agent-name>', exitCode: EXIT_ERROR } - } - const content = args[2] - if (!content) { - return { output: 'write-journal: missing required argument <content>', exitCode: EXIT_ERROR } - } - return mapResult(engine.transaction(deps.getSessionId(), 'write-journal', (w) => w.writeJournal(agentName, content))) -} - -function handleEventContext(_args: readonly string[], engine: WorkflowEngine<Workflow, WorkflowState, WorkflowDeps>, deps: AdapterDeps): OperationResult { - const sessionId = deps.getSessionId() - const agentName = _args[1] ?? '' - engine.transaction(sessionId, 'event-context', (w) => w.requestContext(agentName)) - return { output: deps.analyticsDeps.computeEventContext(sessionId), exitCode: EXIT_ALLOW } -} - function resolveStringField(value: unknown): string { if (value === undefined || value === null) return '' if (typeof value === 'string') return value throw new WorkflowError(`Expected string or undefined. Got ${typeof value}: ${String(value)}`) } -function mapResult(result: EngineResult): OperationResult { +function mapResult(result: EngineResult): RunnerResult { /* v8 ignore next */ const exitCode = result.type === 'success' ? EXIT_ALLOW : result.type === 'blocked' ? EXIT_BLOCK : EXIT_ERROR return { output: result.output, exitCode } diff --git a/src/workflow-analysis/event-display-annotation.spec.ts b/src/workflow-analysis/event-display-annotation.spec.ts index 49e7c1e..16f7317 100644 --- a/src/workflow-analysis/event-display-annotation.spec.ts +++ b/src/workflow-analysis/event-display-annotation.spec.ts @@ -1,4 +1,4 @@ -import type { WorkflowEvent } from '../workflow-definition/index.js' +import type { WorkflowEvent, StateName } from '../workflow-definition/index.js' import { annotateEventsWithState, annotateEventsWithIteration, annotateEvents } from './event-display.js' const T0 = '2026-01-01T00:00:00.000Z' @@ -7,7 +7,7 @@ const T2 = '2026-01-01T00:02:00.000Z' const T3 = '2026-01-01T00:03:00.000Z' const T4 = '2026-01-01T00:04:00.000Z' -function transition(at: string, from: string, to: string): WorkflowEvent { +function transition(at: string, from: StateName, to: StateName): WorkflowEvent { return { type: 'transitioned' as const, at, from, to } } @@ -26,23 +26,23 @@ describe('annotateEventsWithState', () => { it('assigns state from most recent transitioned event', () => { const events: readonly WorkflowEvent[] = [ - transition(T0, 'idle', 'SPAWN'), + transition(T0, 'SPAWN', 'PLANNING'), { type: 'session-started' as const, at: T1 }, ] const result = annotateEventsWithState(events) - expect(result[1]?.state).toStrictEqual('SPAWN') + expect(result[1]?.state).toStrictEqual('PLANNING') }) it('updates state as transitions occur', () => { const events: readonly WorkflowEvent[] = [ - transition(T0, 'idle', 'SPAWN'), - transition(T1, 'SPAWN', 'PLANNING'), + transition(T0, 'SPAWN', 'PLANNING'), + transition(T1, 'PLANNING', 'RESPAWN'), { type: 'plan-approval-recorded' as const, at: T2 }, ] const result = annotateEventsWithState(events) expect(result[0]?.state).toStrictEqual('idle') - expect(result[1]?.state).toStrictEqual('SPAWN') - expect(result[2]?.state).toStrictEqual('PLANNING') + expect(result[1]?.state).toStrictEqual('PLANNING') + expect(result[2]?.state).toStrictEqual('RESPAWN') }) it('returns empty array for empty events', () => { @@ -61,8 +61,8 @@ describe('annotateEventsWithState', () => { describe('annotateEventsWithIteration', () => { it('assigns iteration 0 to events before any task-assigned', () => { const events: readonly WorkflowEvent[] = [ - transition(T0, 'idle', 'SPAWN'), - transition(T1, 'SPAWN', 'PLANNING'), + transition(T0, 'SPAWN', 'PLANNING'), + transition(T1, 'PLANNING', 'RESPAWN'), ] const result = annotateEventsWithIteration(events) expect(result[0]?.iteration).toStrictEqual(0) @@ -95,7 +95,7 @@ describe('annotateEventsWithIteration', () => { it('assigns pre-iteration events iteration 0 and post-iteration events keep last iteration', () => { const events: readonly WorkflowEvent[] = [ - transition(T0, 'idle', 'SPAWN'), + transition(T0, 'SPAWN', 'PLANNING'), taskAssigned(T1, 'Task A'), transition(T2, 'DEVELOPING', 'REVIEWING'), { type: 'review-approved' as const, at: T3 }, @@ -111,7 +111,7 @@ describe('annotateEventsWithIteration', () => { describe('annotateEvents', () => { it('populates both state and iteration for each event', () => { const events: readonly WorkflowEvent[] = [ - transition(T0, 'idle', 'SPAWN'), + transition(T0, 'SPAWN', 'PLANNING'), taskAssigned(T1, 'Task A'), transition(T2, 'DEVELOPING', 'REVIEWING'), { type: 'review-approved' as const, at: T3 }, @@ -119,8 +119,8 @@ describe('annotateEvents', () => { const result = annotateEvents(events) expect(result).toStrictEqual([ { event: events[0], state: 'idle', iteration: 0 }, - { event: events[1], state: 'SPAWN', iteration: 1 }, - { event: events[2], state: 'SPAWN', iteration: 1 }, + { event: events[1], state: 'PLANNING', iteration: 1 }, + { event: events[2], state: 'PLANNING', iteration: 1 }, { event: events[3], state: 'REVIEWING', iteration: 1 }, ]) }) diff --git a/src/workflow-analysis/event-display.spec.ts b/src/workflow-analysis/event-display.spec.ts index bda5878..356eed3 100644 --- a/src/workflow-analysis/event-display.spec.ts +++ b/src/workflow-analysis/event-display.spec.ts @@ -1,4 +1,4 @@ -import type { WorkflowEvent } from '../workflow-definition/index.js' +import type { WorkflowEvent, StateName } from '../workflow-definition/index.js' import { categorizeEvent, extractStructuredFields, @@ -8,7 +8,7 @@ import { } from './event-display.js' import type { EventCategory, AnnotatedEvent } from './event-display.js' -function transition(at: string, from: string, to: string): WorkflowEvent { +function transition(at: string, from: StateName, to: StateName): WorkflowEvent { return { type: 'transitioned' as const, at, from, to } } diff --git a/src/workflow-analysis/insight-rules.spec.ts b/src/workflow-analysis/insight-rules.spec.ts index 9fc36a1..6a634c2 100644 --- a/src/workflow-analysis/insight-rules.spec.ts +++ b/src/workflow-analysis/insight-rules.spec.ts @@ -1,6 +1,7 @@ import type { EnhancedSessionSummary, IterationMetrics } from './session-report.js' import { evaluateInsightRules } from './insight-rules.js' import type { Insight } from './insight-rules.js' +import type { WorkflowEvent } from '../workflow-definition/index.js' function ms(minutes: number): number { return minutes * 60_000 @@ -107,7 +108,7 @@ describe('rework-dominated-iteration insight', () => { describe('hook-denial-cluster insight', () => { it('fires warning when totalDenials >= 3 and >60% in one state', () => { - const events = [ + const events: WorkflowEvent[] = [ { type: 'transitioned' as const, at: '2026-01-01T00:00:00.000Z', from: 'RESPAWN', to: 'REVIEWING' }, { type: 'bash-checked' as const, at: '2026-01-01T00:00:30.000Z', tool: 'Bash', command: 'rm x', allowed: false }, { type: 'transitioned' as const, at: '2026-01-01T00:01:00.000Z', from: 'REVIEWING', to: 'DEVELOPING' }, @@ -121,7 +122,7 @@ describe('hook-denial-cluster insight', () => { }) it('does not fire when denials >= 3 but spread across states below 60% threshold', () => { - const events = [ + const events: WorkflowEvent[] = [ { type: 'transitioned' as const, at: '2026-01-01T00:00:00.000Z', from: 'RESPAWN', to: 'DEVELOPING' }, { type: 'write-checked' as const, at: '2026-01-01T00:01:00.000Z', tool: 'Write', filePath: 'a.ts', allowed: false }, { type: 'transitioned' as const, at: '2026-01-01T00:02:00.000Z', from: 'DEVELOPING', to: 'REVIEWING' }, diff --git a/src/workflow-analysis/report-assembly.spec.ts b/src/workflow-analysis/report-assembly.spec.ts index 2c518a3..5de378c 100644 --- a/src/workflow-analysis/report-assembly.spec.ts +++ b/src/workflow-analysis/report-assembly.spec.ts @@ -172,7 +172,7 @@ describe('assembleReportData', () => { const baseEvents: readonly WorkflowEvent[] = [ { type: 'session-started' as const, at: T0 }, - { type: 'transitioned' as const, at: T1, from: 'idle', to: 'SPAWN' }, + { type: 'transitioned' as const, at: T1, from: 'SPAWN', to: 'PLANNING' }, ] it('returns ReportData with all fields populated', () => { diff --git a/src/workflow-analysis/report-html-header-and-simple.spec.ts b/src/workflow-analysis/report-html-header-and-simple.spec.ts index 6db847b..3745bae 100644 --- a/src/workflow-analysis/report-html-header-and-simple.spec.ts +++ b/src/workflow-analysis/report-html-header-and-simple.spec.ts @@ -281,7 +281,7 @@ describe('generateReportHtml — iteration metrics row', () => { describe('generateReportHtml — event log facet filtering', () => { it('sets data-outcome to none when event has no outcome', () => { const data = buildMinimalReportData({ - annotatedEvents: [{ event: { type: 'transitioned' as const, at: T0, from: 'idle', to: 'SPAWN' }, state: 'idle', iteration: 0 }], + annotatedEvents: [{ event: { type: 'transitioned' as const, at: T0, from: 'SPAWN', to: 'PLANNING' }, state: 'SPAWN', iteration: 0 }], }) expect(generateReportHtml(data)).toContain('data-outcome="none"') }) @@ -289,7 +289,7 @@ describe('generateReportHtml — event log facet filtering', () => { it('does not include none in outcome facet counts', () => { const data = buildMinimalReportData({ annotatedEvents: [ - { event: { type: 'transitioned' as const, at: T0, from: 'idle', to: 'SPAWN' }, state: 'idle', iteration: 0 }, + { event: { type: 'transitioned' as const, at: T0, from: 'SPAWN', to: 'PLANNING' }, state: 'SPAWN', iteration: 0 }, { event: { type: 'review-approved' as const, at: T1 }, state: 'REVIEWING', iteration: 1 }, ], }) diff --git a/src/workflow-analysis/report-html-tabs.spec.ts b/src/workflow-analysis/report-html-tabs.spec.ts index 525ae0c..fd16bcb 100644 --- a/src/workflow-analysis/report-html-tabs.spec.ts +++ b/src/workflow-analysis/report-html-tabs.spec.ts @@ -52,20 +52,20 @@ function buildMinimalReportData(overrides: Partial<ReportData> = {}): ReportData describe('generateReportHtml — event log tab', () => { it('renders log entries with data attributes for filtering', () => { - const transitionEvent: WorkflowEvent = { type: 'transitioned' as const, at: T0, from: 'idle', to: 'SPAWN' } + const transitionEvent: WorkflowEvent = { type: 'transitioned' as const, at: T0, from: 'SPAWN', to: 'PLANNING' } const data = buildMinimalReportData({ - annotatedEvents: [{ event: transitionEvent, state: 'idle', iteration: 0 }], + annotatedEvents: [{ event: transitionEvent, state: 'SPAWN', iteration: 0 }], }) const html = generateReportHtml(data) expect(html).toContain('data-cat="transition"') - expect(html).toContain('data-state="idle"') + expect(html).toContain('data-state="SPAWN"') expect(html).toContain('data-iter="0"') }) it('renders facet sidebar with category counts', () => { const data = buildMinimalReportData({ annotatedEvents: [ - { event: { type: 'transitioned' as const, at: T0, from: 'idle', to: 'SPAWN' }, state: 'idle', iteration: 0 }, + { event: { type: 'transitioned' as const, at: T0, from: 'SPAWN', to: 'PLANNING' }, state: 'SPAWN', iteration: 0 }, { event: { type: 'session-started' as const, at: T1 }, state: 'SPAWN', iteration: 0 }, ], }) @@ -133,10 +133,11 @@ describe('generateReportHtml — event log tab', () => { it('renders events with unknown state using fallback abbreviation', () => { const event: WorkflowEvent = { type: 'session-started' as const, at: T0 } const data = buildMinimalReportData({ - annotatedEvents: [{ event, state: 'idle', iteration: 0 }], + annotatedEvents: [{ event, state: 'BLOCKED', iteration: 0 }], }) const html = generateReportHtml(data) expect(html).toContain('s-plan') + expect(html).toContain('BLOC') }) }) diff --git a/src/workflow-analysis/session-report-rework.spec.ts b/src/workflow-analysis/session-report-rework.spec.ts index 8780d6d..9883cfe 100644 --- a/src/workflow-analysis/session-report-rework.spec.ts +++ b/src/workflow-analysis/session-report-rework.spec.ts @@ -1,4 +1,4 @@ -import type { WorkflowEvent } from '../workflow-definition/index.js' +import type { WorkflowEvent, StateName } from '../workflow-definition/index.js' import type { SessionSummary } from './workflow-analytics.js' import type { SessionViewData, IterationGroup } from './session-view.js' import { computeEnhancedSessionSummary } from './session-report.js' @@ -19,7 +19,7 @@ function ms(minutes: number): number { return minutes * 60_000 } -function transition(at: string, from: string, to: string): WorkflowEvent { +function transition(at: string, from: StateName, to: StateName): WorkflowEvent { return { type: 'transitioned' as const, at, from, to } } diff --git a/src/workflow-analysis/session-report.spec.ts b/src/workflow-analysis/session-report.spec.ts index e0f3d49..3b44282 100644 --- a/src/workflow-analysis/session-report.spec.ts +++ b/src/workflow-analysis/session-report.spec.ts @@ -1,4 +1,4 @@ -import type { WorkflowEvent } from '../workflow-definition/index.js' +import type { WorkflowEvent, StateName } from '../workflow-definition/index.js' import type { SessionSummary } from './workflow-analytics.js' import type { SessionViewData, IterationGroup } from './session-view.js' import { computeEnhancedSessionSummary } from './session-report.js' @@ -15,7 +15,7 @@ function ms(minutes: number): number { return minutes * 60_000 } -function transition(at: string, from: string, to: string): WorkflowEvent { +function transition(at: string, from: StateName, to: StateName): WorkflowEvent { return { type: 'transitioned' as const, at, from, to } } @@ -183,7 +183,6 @@ describe('computeEnhancedSessionSummary — single clean iteration', () => { }) const allEvents: readonly WorkflowEvent[] = [ sessionStarted(T0, 'sess-1'), - transition(T0, 'idle', 'SPAWN'), transition(T0, 'SPAWN', 'PLANNING'), transition(T1, 'PLANNING', 'RESPAWN'), ...iterEvents, diff --git a/src/workflow-definition/domain/registry.ts b/src/workflow-definition/domain/registry.ts index 656da61..b76514d 100644 --- a/src/workflow-definition/domain/registry.ts +++ b/src/workflow-definition/domain/registry.ts @@ -1,6 +1,5 @@ import type { BashForbiddenConfig } from '@ntcoding/agentic-workflow-builder/dsl' -import type { ConcreteRegistry, ConcreteStateDefinition } from './workflow-types.js' -import { parseStateName } from './workflow-types.js' +import type { ConcreteRegistry, ConcreteStateDefinition, StateName } from './workflow-types.js' import { spawnState } from './states/spawn.js' import { planningState } from './states/planning.js' import { respawnState } from './states/respawn.js' @@ -22,8 +21,8 @@ export const BASH_FORBIDDEN: BashForbiddenConfig = { export const PLUGIN_SOURCE_PATTERN = /\.claude\/plugins\/cache\// -export function getStateDefinition(state: string): ConcreteStateDefinition { - return WORKFLOW_REGISTRY[parseStateName(state)] +export function getStateDefinition(state: StateName): ConcreteStateDefinition { + return WORKFLOW_REGISTRY[state] } export const WORKFLOW_REGISTRY: ConcreteRegistry = { diff --git a/src/workflow-definition/domain/workflow-adapter.spec.ts b/src/workflow-definition/domain/workflow-adapter.spec.ts index fbfd59b..75caa36 100644 --- a/src/workflow-definition/domain/workflow-adapter.spec.ts +++ b/src/workflow-definition/domain/workflow-adapter.spec.ts @@ -1,5 +1,6 @@ import { WorkflowAdapter } from './workflow-adapter.js' import type { WorkflowDeps } from '../../workflow-definition/domain/workflow.js' +import type { WorkflowState } from './workflow-types.js' import type { BaseEvent } from '@ntcoding/agentic-workflow-builder/engine' function makeWorkflowDeps(): WorkflowDeps { @@ -47,18 +48,51 @@ describe('WorkflowAdapter', () => { expect(path).toContain('/plugin/') }) - it('returns emoji for known state', () => { - const emoji = WorkflowAdapter.getEmojiForState('SPAWN') - expect(typeof emoji).toStrictEqual('string') + it('returns initial state with SPAWN', () => { + const initial = WorkflowAdapter.initialState() + expect(initial.currentStateMachineState).toStrictEqual('SPAWN') }) - it('throws on unknown state', () => { - expect(() => WorkflowAdapter.getEmojiForState('UNKNOWN_STATE')).toThrow('invalid_enum_value') + it('returns workflow registry', () => { + const registry = WorkflowAdapter.getRegistry() + expect(registry['SPAWN']).toBeDefined() + expect(registry['SPAWN']?.emoji).toStrictEqual('🟣') }) - it('returns initial state with SPAWN', () => { - const initial = WorkflowAdapter.initialState() - expect(initial.currentStateMachineState).toStrictEqual('SPAWN') + it('builds transition context with git info and PR checks from deps', () => { + const deps = makeWorkflowDeps() + const state = WorkflowAdapter.initialState() + const ctx = WorkflowAdapter.buildTransitionContext(state, 'SPAWN', 'PLANNING', deps) + expect(ctx).toMatchObject({ state, from: 'SPAWN', to: 'PLANNING', prChecksPass: false }) + expect(ctx.gitInfo.currentBranch).toStrictEqual('main') + }) + + it('builds transition context with prChecksPass true when PR exists', () => { + const deps = makeWorkflowDeps() + const state = { ...WorkflowAdapter.initialState(), prNumber: 42 } + const ctx = WorkflowAdapter.buildTransitionContext(state, 'PR_CREATION', 'FEEDBACK', deps) + expect(ctx.prChecksPass).toStrictEqual(true) + }) + + it('builds transition event with from/to', () => { + const stateBefore = WorkflowAdapter.initialState() + const stateAfter: WorkflowState = { ...stateBefore, currentStateMachineState: 'PLANNING' as const } + const event = WorkflowAdapter.buildTransitionEvent?.('SPAWN', 'PLANNING', stateBefore, stateAfter, '2026-01-01T00:00:00Z') + expect(event).toMatchObject({ type: 'transitioned', from: 'SPAWN', to: 'PLANNING' }) + }) + + it('builds transition event with iteration when changed', () => { + const stateBefore = { ...WorkflowAdapter.initialState(), iteration: 0 } + const stateAfter = { ...stateBefore, iteration: 1 } + const event = WorkflowAdapter.buildTransitionEvent?.('COMMITTING', 'RESPAWN', stateBefore, stateAfter, '2026-01-01T00:00:00Z') + expect(event).toMatchObject({ iteration: 1 }) + }) + + it('builds transition event with developingHeadCommit for DEVELOPING', () => { + const stateBefore = { ...WorkflowAdapter.initialState(), iteration: 0, iterations: [{ task: 't', developerDone: false, developingHeadCommit: 'abc123', reviewApproved: false, reviewRejected: false, coderabbitFeedbackAddressed: false, coderabbitFeedbackIgnored: false, lintedFiles: [], lintRanIteration: false }] } + const stateAfter = { ...stateBefore } + const event = WorkflowAdapter.buildTransitionEvent?.('RESPAWN', 'DEVELOPING', stateBefore, stateAfter, '2026-01-01T00:00:00Z') + expect(event).toMatchObject({ developingHeadCommit: 'abc123' }) }) it('returns prefix config with LEAD pattern', () => { diff --git a/src/workflow-definition/domain/workflow-adapter.ts b/src/workflow-definition/domain/workflow-adapter.ts index 07cf612..1dd7213 100644 --- a/src/workflow-definition/domain/workflow-adapter.ts +++ b/src/workflow-definition/domain/workflow-adapter.ts @@ -1,11 +1,14 @@ import type { WorkflowFactory, BaseEvent, PrefixConfig } from '@ntcoding/agentic-workflow-builder/engine' import { WorkflowStateError } from '@ntcoding/agentic-workflow-builder/engine' -import type { WorkflowState } from './workflow-types.js' +import type { TransitionContext } from '@ntcoding/agentic-workflow-builder/dsl' +import type { WorkflowState, StateName, WorkflowOperation } from './workflow-types.js' +import { parseStateName } from './workflow-types.js' import { Workflow, type WorkflowDeps } from './workflow.js' -import { INITIAL_STATE, STATE_EMOJI_MAP, parseStateName } from './workflow-types.js' +import { INITIAL_STATE } from './workflow-types.js' import { getOperationBody, getTransitionTitle } from './output-messages.js' import { applyEvents } from './fold.js' import { WorkflowEventSchema } from './workflow-events.js' +import { WORKFLOW_REGISTRY } from './registry.js' const LEAD_PREFIX_PATTERN = /^LEAD:/m @@ -24,7 +27,7 @@ function buildRecoveryMessage(state: string, emoji: string, _pluginRoot: string) ) } -export const WorkflowAdapter: WorkflowFactory<Workflow, WorkflowState, WorkflowDeps> = { +export const WorkflowAdapter: WorkflowFactory<Workflow, WorkflowState, WorkflowDeps, StateName, WorkflowOperation> = { createFresh(deps: WorkflowDeps): Workflow { return Workflow.createFresh(deps) }, @@ -39,17 +42,37 @@ export const WorkflowAdapter: WorkflowFactory<Workflow, WorkflowState, WorkflowD const state = applyEvents(workflowEvents) return Workflow.rehydrate(state, deps) }, - procedurePath(state: string, pluginRoot: string): string { + procedurePath(state: StateName, pluginRoot: string): string { return Workflow.procedurePath(state, pluginRoot) }, initialState(): typeof INITIAL_STATE { return INITIAL_STATE }, - getEmojiForState(state: string): string { - return STATE_EMOJI_MAP[parseStateName(state)] + getRegistry() { + return WORKFLOW_REGISTRY + }, + buildTransitionContext(state: WorkflowState, from: StateName, to: StateName, deps: WorkflowDeps): TransitionContext<WorkflowState, StateName> { + const prChecksPass = state.prNumber === undefined ? false : deps.checkPrChecks(state.prNumber) + return { state, gitInfo: deps.getGitInfo(), prChecksPass, from, to } }, getOperationBody, getTransitionTitle, + buildTransitionEvent(from: StateName, to: StateName, stateBefore: WorkflowState, stateAfter: WorkflowState, now: string): BaseEvent { + const iterationChanged = stateAfter.iteration !== stateBefore.iteration + const developingHeadCommit = to === 'DEVELOPING' + ? stateAfter.iterations[stateAfter.iteration]?.developingHeadCommit + : undefined + const event = { + type: 'transitioned', + at: now, + from, + to, + ...(iterationChanged ? { iteration: stateAfter.iteration } : {}), + ...(developingHeadCommit === undefined ? {} : { developingHeadCommit }), + } + return event + }, + parseStateName, getPrefixConfig(): PrefixConfig { return { pattern: LEAD_PREFIX_PATTERN, buildRecoveryMessage } }, diff --git a/src/workflow-definition/domain/workflow-cr-review-to-complete-states.spec.ts b/src/workflow-definition/domain/workflow-cr-review-to-complete-states.spec.ts index 0bd5601..79c8833 100644 --- a/src/workflow-definition/domain/workflow-cr-review-to-complete-states.spec.ts +++ b/src/workflow-definition/domain/workflow-cr-review-to-complete-states.spec.ts @@ -15,6 +15,7 @@ import { coderabbitIgnored, prRecorded, transitioned, + transitionTo, iterationTaskAssigned, } from './workflow-test-fixtures.js' @@ -25,28 +26,28 @@ describe('Workflow', () => { const { result } = spec .given(...eventsToCrReview(), coderabbitAddressed()) .withDeps({ getGitInfo: () => gitWithCommits }) - .when((wf) => wf.transitionTo('PR_CREATION')) + .when((wf) => transitionTo(wf,'PR_CREATION')) expect(result).toStrictEqual({ pass: true }) }) it('transitions to PR_CREATION when feedback ignored', () => { const { result } = spec .given(...eventsToCrReview(), coderabbitIgnored()) - .when((wf) => wf.transitionTo('PR_CREATION')) + .when((wf) => transitionTo(wf,'PR_CREATION')) expect(result).toStrictEqual({ pass: true }) }) it('fails transition when neither addressed nor ignored', () => { const { result } = spec .given(...eventsToCrReview()) - .when((wf) => wf.transitionTo('PR_CREATION')) + .when((wf) => transitionTo(wf,'PR_CREATION')) expect(result.pass).toBe(false) }) it('fails transition when addressed but no commits', () => { const { result } = spec .given(...eventsToCrReview(), coderabbitAddressed()) - .when((wf) => wf.transitionTo('PR_CREATION')) + .when((wf) => transitionTo(wf,'PR_CREATION')) expect(result.pass).toBe(false) }) @@ -95,7 +96,7 @@ describe('Workflow', () => { it('transitions to FEEDBACK when prNumber set and PR checks pass', () => { const { result, state } = spec .given(...eventsToPrCreation(), prRecorded(42)) - .when((wf) => wf.transitionTo('FEEDBACK')) + .when((wf) => transitionTo(wf,'FEEDBACK')) expect(result).toStrictEqual({ pass: true }) expect(state.currentStateMachineState).toBe('FEEDBACK') }) @@ -103,7 +104,7 @@ describe('Workflow', () => { it('fails transition when no prNumber', () => { const { result } = spec .given(...eventsToPrCreation()) - .when((wf) => wf.transitionTo('FEEDBACK')) + .when((wf) => transitionTo(wf,'FEEDBACK')) expect(result.pass).toBe(false) }) @@ -111,7 +112,7 @@ describe('Workflow', () => { const { result } = spec .given(...eventsToPrCreation(), prRecorded(42)) .withDeps({ checkPrChecks: () => false }) - .when((wf) => wf.transitionTo('FEEDBACK')) + .when((wf) => transitionTo(wf,'FEEDBACK')) expect(result.pass).toBe(false) }) @@ -149,7 +150,7 @@ describe('Workflow', () => { it('transitions to COMPLETE when prNumber set and checks pass', () => { const { result, state } = spec .given(...eventsToFeedback()) - .when((wf) => wf.transitionTo('COMPLETE')) + .when((wf) => transitionTo(wf,'COMPLETE')) expect(result).toStrictEqual({ pass: true }) expect(state.currentStateMachineState).toBe('COMPLETE') }) @@ -160,7 +161,7 @@ describe('Workflow', () => { ...eventsToPrCreation(), transitioned('PR_CREATION', 'FEEDBACK'), ) - .when((wf) => wf.transitionTo('COMPLETE')) + .when((wf) => transitionTo(wf,'COMPLETE')) expect(result.pass).toBe(false) }) @@ -168,7 +169,7 @@ describe('Workflow', () => { const { result } = spec .given(...eventsToFeedback()) .withDeps({ checkPrChecks: () => false }) - .when((wf) => wf.transitionTo('COMPLETE')) + .when((wf) => transitionTo(wf,'COMPLETE')) expect(result.pass).toBe(false) }) @@ -178,7 +179,7 @@ describe('Workflow', () => { ...eventsToPrCreation(), transitioned('PR_CREATION', 'FEEDBACK'), ) - .when((wf) => wf.transitionTo('RESPAWN')) + .when((wf) => transitionTo(wf,'RESPAWN')) expect(result).toStrictEqual({ pass: true }) }) }) @@ -187,7 +188,7 @@ describe('Workflow', () => { it('allows transition TO BLOCKED from any state and sets preBlockedState', () => { const { result, state, events } = spec .given(...eventsToDeveloping()) - .when((wf) => wf.transitionTo('BLOCKED')) + .when((wf) => transitionTo(wf,'BLOCKED')) expect(result).toStrictEqual({ pass: true }) expect(state.currentStateMachineState).toBe('BLOCKED') expect(state.preBlockedState).toBe('DEVELOPING') @@ -202,7 +203,7 @@ describe('Workflow', () => { ...eventsToDeveloping(), transitioned('DEVELOPING', 'BLOCKED'), ) - .when((wf) => wf.transitionTo('DEVELOPING')) + .when((wf) => transitionTo(wf,'DEVELOPING')) expect(result).toStrictEqual({ pass: true }) expect(state.currentStateMachineState).toBe('DEVELOPING') }) @@ -213,13 +214,14 @@ describe('Workflow', () => { ...eventsToDeveloping(), transitioned('DEVELOPING', 'BLOCKED'), ) - .when((wf) => wf.transitionTo('PLANNING')) + .when((wf) => transitionTo(wf,'PLANNING')) expect(result.pass).toBe(false) }) it('includes unknown in error when not set', () => { - const wf = Workflow.rehydrate({ ...INITIAL_STATE, currentStateMachineState: 'BLOCKED' }, makeDeps()) - const result = wf.transitionTo('PLANNING') + const deps = makeDeps() + const wf = Workflow.rehydrate({ ...INITIAL_STATE, currentStateMachineState: 'BLOCKED' }, deps) + const result = transitionTo(wf, 'PLANNING', deps) expect(result.pass).toBe(false) if (!result.pass) { expect(result.reason).toContain('unknown') @@ -231,7 +233,7 @@ describe('Workflow', () => { it('fails all transitions since canTransitionTo is empty', () => { const { result } = spec .given(...eventsToComplete()) - .when((wf) => wf.transitionTo('SPAWN')) + .when((wf) => transitionTo(wf,'SPAWN')) expect(result.pass).toBe(false) }) }) diff --git a/src/workflow-definition/domain/workflow-events.ts b/src/workflow-definition/domain/workflow-events.ts index 9cad3a3..5556edb 100644 --- a/src/workflow-definition/domain/workflow-events.ts +++ b/src/workflow-definition/domain/workflow-events.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { StateNameSchema } from './workflow-types.js' const SessionStartedSchema = z.object({ type: z.literal('session-started'), @@ -103,9 +104,9 @@ const AgentShutDownSchema = z.object({ const TransitionedSchema = z.object({ type: z.literal('transitioned'), at: z.string(), - from: z.string(), - to: z.string(), - preBlockedState: z.string().optional(), + from: StateNameSchema, + to: StateNameSchema, + preBlockedState: StateNameSchema.optional(), iteration: z.number().optional(), developingHeadCommit: z.string().optional(), developerDone: z.boolean().optional(), diff --git a/src/workflow-definition/domain/workflow-lint-and-iterations.spec.ts b/src/workflow-definition/domain/workflow-lint-and-iterations.spec.ts index 38d9c29..0798134 100644 --- a/src/workflow-definition/domain/workflow-lint-and-iterations.spec.ts +++ b/src/workflow-definition/domain/workflow-lint-and-iterations.spec.ts @@ -10,6 +10,7 @@ import { reviewRejected, lintRan, transitioned, + transitionTo, planApprovalRecorded, agentShutDown, iterationTaskAssigned, @@ -118,7 +119,7 @@ describe('Workflow', () => { it('emits transitioned event for transition', () => { const { events } = spec .given(...eventsToPlanning(), planApprovalRecorded()) - .when((wf) => wf.transitionTo('RESPAWN')) + .when((wf) => transitionTo(wf,'RESPAWN')) expect(events).toStrictEqual( expect.arrayContaining([expect.objectContaining({ type: 'transitioned', from: 'PLANNING', to: 'RESPAWN' })]) ) @@ -127,7 +128,7 @@ describe('Workflow', () => { it('emits transitioned event for BLOCKED transition', () => { const { events } = spec .given(...eventsToPlanning()) - .when((wf) => wf.transitionTo('BLOCKED')) + .when((wf) => transitionTo(wf,'BLOCKED')) expect(events).toStrictEqual( expect.arrayContaining([expect.objectContaining({ type: 'transitioned', from: 'PLANNING', to: 'BLOCKED' })]) ) @@ -139,7 +140,7 @@ describe('Workflow', () => { ...eventsToPlanning(), transitioned('PLANNING', 'BLOCKED'), ) - .when((wf) => wf.transitionTo('PLANNING')) + .when((wf) => transitionTo(wf,'PLANNING')) expect(events).toStrictEqual( expect.arrayContaining([expect.objectContaining({ type: 'transitioned', from: 'BLOCKED', to: 'PLANNING' })]) ) diff --git a/src/workflow-definition/domain/workflow-predicates.ts b/src/workflow-definition/domain/workflow-predicates.ts index 80693ea..f5b0d8d 100644 --- a/src/workflow-definition/domain/workflow-predicates.ts +++ b/src/workflow-definition/domain/workflow-predicates.ts @@ -57,3 +57,9 @@ export function checkOperationGate(op: WorkflowOperation, state: WorkflowState): } return fail(`${op} is not allowed in state ${state.currentStateMachineState}.`) } + +export function checkWriteAllowed(toolName: string, filePath: string, state: WorkflowState): PreconditionResult { + if (!FILE_WRITING_TOOLS.has(toolName)) return pass() + if (isStateFile(filePath)) return pass() + return fail(`Write operation '${toolName}' is forbidden in state: ${state.currentStateMachineState}`) +} diff --git a/src/workflow-definition/domain/workflow-reviewing-committing-states.spec.ts b/src/workflow-definition/domain/workflow-reviewing-committing-states.spec.ts index 7eafff5..e9ae61a 100644 --- a/src/workflow-definition/domain/workflow-reviewing-committing-states.spec.ts +++ b/src/workflow-definition/domain/workflow-reviewing-committing-states.spec.ts @@ -10,6 +10,7 @@ import { reviewRejected, lintRan, transitioned, + transitionTo, developerDoneSignaled, eventsToDeveloping, } from './workflow-test-fixtures.js' @@ -19,7 +20,7 @@ describe('Workflow', () => { it('transitions to COMMITTING when reviewApproved', () => { const { result, state } = spec .given(...eventsToReviewing(), reviewApproved()) - .when((wf) => wf.transitionTo('COMMITTING')) + .when((wf) => transitionTo(wf,'COMMITTING')) expect(result).toStrictEqual({ pass: true }) expect(state.currentStateMachineState).toBe('COMMITTING') }) @@ -27,21 +28,21 @@ describe('Workflow', () => { it('fails transition to COMMITTING when not approved', () => { const { result } = spec .given(...eventsToReviewing()) - .when((wf) => wf.transitionTo('COMMITTING')) + .when((wf) => transitionTo(wf,'COMMITTING')) expect(result.pass).toBe(false) }) it('transitions to DEVELOPING when reviewRejected', () => { const { result } = spec .given(...eventsToReviewing(), reviewRejected()) - .when((wf) => wf.transitionTo('DEVELOPING')) + .when((wf) => transitionTo(wf,'DEVELOPING')) expect(result).toStrictEqual({ pass: true }) }) it('fails transition to DEVELOPING when not rejected', () => { const { result } = spec .given(...eventsToReviewing()) - .when((wf) => wf.transitionTo('DEVELOPING')) + .when((wf) => transitionTo(wf,'DEVELOPING')) expect(result.pass).toBe(false) }) @@ -99,7 +100,7 @@ describe('Workflow', () => { lintRan({ files: 1, passed: true, lintedFiles: ['src/a.ts'] }), ) .withDeps({ getGitInfo: () => gitWithCommits }) - .when((wf) => wf.transitionTo('RESPAWN')) + .when((wf) => transitionTo(wf,'RESPAWN')) expect(result).toStrictEqual({ pass: true }) }) @@ -115,7 +116,7 @@ describe('Workflow', () => { lintRan({ files: 1, passed: true, lintedFiles: ['src/a.ts'] }), ) .withDeps({ getGitInfo: () => gitWithCommits }) - .when((wf) => wf.transitionTo('CR_REVIEW')) + .when((wf) => transitionTo(wf,'CR_REVIEW')) expect(result).toStrictEqual({ pass: true }) }) @@ -123,7 +124,7 @@ describe('Workflow', () => { const { result } = spec .given(...eventsToCommitting()) .withDeps({ getGitInfo: () => dirtyGit }) - .when((wf) => wf.transitionTo('RESPAWN')) + .when((wf) => transitionTo(wf,'RESPAWN')) expect(result.pass).toBe(false) }) @@ -136,7 +137,7 @@ describe('Workflow', () => { const { result } = spec .given(...eventsToCommitting()) .withDeps({ getGitInfo: () => gitWithFiles }) - .when((wf) => wf.transitionTo('RESPAWN')) + .when((wf) => transitionTo(wf,'RESPAWN')) expect(result.pass).toBe(false) }) @@ -152,14 +153,14 @@ describe('Workflow', () => { lintRan({ files: 1, passed: true, lintedFiles: ['src/a.ts'] }), ) .withDeps({ getGitInfo: () => gitWithFiles }) - .when((wf) => wf.transitionTo('RESPAWN')) + .when((wf) => transitionTo(wf,'RESPAWN')) expect(result.pass).toBe(false) }) it('fails transition when no commits beyond default', () => { const { result } = spec .given(...eventsToCommitting()) - .when((wf) => wf.transitionTo('RESPAWN')) + .when((wf) => transitionTo(wf,'RESPAWN')) expect(result.pass).toBe(false) }) @@ -172,7 +173,7 @@ describe('Workflow', () => { const { result } = spec .given(...eventsToCommitting()) .withDeps({ getGitInfo: () => gitWithCommitsNoTs }) - .when((wf) => wf.transitionTo('CR_REVIEW')) + .when((wf) => transitionTo(wf,'CR_REVIEW')) expect(result).toStrictEqual({ pass: true }) }) diff --git a/src/workflow-definition/domain/workflow-spawn-planning-states.spec.ts b/src/workflow-definition/domain/workflow-spawn-planning-states.spec.ts index 957bee3..fdcf437 100644 --- a/src/workflow-definition/domain/workflow-spawn-planning-states.spec.ts +++ b/src/workflow-definition/domain/workflow-spawn-planning-states.spec.ts @@ -9,6 +9,7 @@ import { agentRegistered, agentShutDown, transitioned, + transitionTo, branchRecorded, planApprovalRecorded, iterationTaskAssigned, @@ -63,7 +64,7 @@ describe('Workflow', () => { it('transitions to PLANNING when issue set and developer and reviewer agents present', () => { const { result, state } = spec .given(issueRecorded(1), agentRegistered('developer-1'), agentRegistered('reviewer-1')) - .when((wf) => wf.transitionTo('PLANNING')) + .when((wf) => transitionTo(wf,'PLANNING')) expect(result).toStrictEqual({ pass: true }) expect(state.currentStateMachineState).toBe('PLANNING') }) @@ -71,28 +72,28 @@ describe('Workflow', () => { it('fails transition to PLANNING when no githubIssue', () => { const { result } = spec .given(agentRegistered('developer-1'), agentRegistered('reviewer-1')) - .when((wf) => wf.transitionTo('PLANNING')) + .when((wf) => transitionTo(wf,'PLANNING')) expect(result.pass).toBe(false) }) it('fails transition to PLANNING when no developer agent', () => { const { result } = spec .given(issueRecorded(1), agentRegistered('reviewer-1')) - .when((wf) => wf.transitionTo('PLANNING')) + .when((wf) => transitionTo(wf,'PLANNING')) expect(result.pass).toBe(false) }) it('fails transition to PLANNING when no reviewer agent', () => { const { result } = spec .given(issueRecorded(1), agentRegistered('developer-1')) - .when((wf) => wf.transitionTo('PLANNING')) + .when((wf) => transitionTo(wf,'PLANNING')) expect(result.pass).toBe(false) }) it('fails transition to non-PLANNING states', () => { const { result } = spec .given(issueRecorded(1), agentRegistered('developer-1'), agentRegistered('reviewer-1')) - .when((wf) => wf.transitionTo('DEVELOPING')) + .when((wf) => transitionTo(wf,'DEVELOPING')) expect(result.pass).toBe(false) }) @@ -117,7 +118,7 @@ describe('Workflow', () => { it('transitions to RESPAWN when plan approved and clean tree', () => { const { result, state } = spec .given(...eventsToPlanning(), planApprovalRecorded()) - .when((wf) => wf.transitionTo('RESPAWN')) + .when((wf) => transitionTo(wf,'RESPAWN')) expect(result).toStrictEqual({ pass: true }) expect(state.currentStateMachineState).toBe('RESPAWN') }) @@ -125,7 +126,7 @@ describe('Workflow', () => { it('fails transition to RESPAWN when plan not approved', () => { const { result } = spec .given(...eventsToPlanning()) - .when((wf) => wf.transitionTo('RESPAWN')) + .when((wf) => transitionTo(wf,'RESPAWN')) expect(result.pass).toBe(false) }) @@ -133,7 +134,7 @@ describe('Workflow', () => { const { result } = spec .given(...eventsToPlanning(), planApprovalRecorded()) .withDeps({ getGitInfo: () => dirtyGit }) - .when((wf) => wf.transitionTo('RESPAWN')) + .when((wf) => transitionTo(wf,'RESPAWN')) expect(result.pass).toBe(false) }) @@ -197,7 +198,7 @@ describe('Workflow', () => { agentShutDown('reviewer-1'), iterationTaskAssigned('test task'), ) - .when((wf) => wf.transitionTo('DEVELOPING')) + .when((wf) => transitionTo(wf,'DEVELOPING')) expect(result).toStrictEqual({ pass: true }) expect(state.currentStateMachineState).toBe('DEVELOPING') }) @@ -209,14 +210,14 @@ describe('Workflow', () => { agentShutDown('developer-1'), agentShutDown('reviewer-1'), ) - .when((wf) => wf.transitionTo('DEVELOPING')) + .when((wf) => transitionTo(wf,'DEVELOPING')) expect(result.pass).toBe(false) }) it('fails transition to DEVELOPING when active agents present', () => { const { result } = spec .given(...eventsToRespawn(), iterationTaskAssigned('test task')) - .when((wf) => wf.transitionTo('DEVELOPING')) + .when((wf) => transitionTo(wf,'DEVELOPING')) expect(result.pass).toBe(false) }) @@ -241,7 +242,7 @@ describe('Workflow', () => { const { result, state } = spec .given(...eventsToDeveloping(), developerDoneSignaled()) .withDeps({ getGitInfo: () => dirtyGit }) - .when((wf) => wf.transitionTo('REVIEWING')) + .when((wf) => transitionTo(wf,'REVIEWING')) expect(result).toStrictEqual({ pass: true }) expect(state.currentStateMachineState).toBe('REVIEWING') }) @@ -250,14 +251,14 @@ describe('Workflow', () => { const { result } = spec .given(...eventsToDeveloping()) .withDeps({ getGitInfo: () => dirtyGit }) - .when((wf) => wf.transitionTo('REVIEWING')) + .when((wf) => transitionTo(wf,'REVIEWING')) expect(result.pass).toBe(false) }) it('fails transition to REVIEWING when tree is clean', () => { const { result } = spec .given(...eventsToDeveloping(), developerDoneSignaled()) - .when((wf) => wf.transitionTo('REVIEWING')) + .when((wf) => transitionTo(wf,'REVIEWING')) expect(result.pass).toBe(false) }) @@ -265,7 +266,7 @@ describe('Workflow', () => { const { result } = spec .given(...eventsToDeveloping(), developerDoneSignaled()) .withDeps({ getGitInfo: () => ({ ...dirtyGit, headCommit: 'different' }) }) - .when((wf) => wf.transitionTo('REVIEWING')) + .when((wf) => transitionTo(wf,'REVIEWING')) expect(result.pass).toBe(false) }) @@ -278,7 +279,7 @@ describe('Workflow', () => { ] const { state } = spec .given(...events) - .when((wf) => wf.transitionTo('DEVELOPING')) + .when((wf) => transitionTo(wf,'DEVELOPING')) expect(state.iteration).toBe(0) expect(state.iterations[0]?.developingHeadCommit).toBe('abc123') }) @@ -293,7 +294,7 @@ describe('Workflow', () => { ] const { state } = spec .given(...events) - .when((wf) => wf.transitionTo('DEVELOPING')) + .when((wf) => transitionTo(wf,'DEVELOPING')) expect(state.iteration).toBe(1) }) @@ -306,7 +307,7 @@ describe('Workflow', () => { ] const { state } = spec .given(...events) - .when((wf) => wf.transitionTo('DEVELOPING')) + .when((wf) => transitionTo(wf,'DEVELOPING')) expect(state.iteration).toBe(0) expect(state.iterations[0]?.developerDone).toBe(false) }) diff --git a/src/workflow-definition/domain/workflow-test-fixtures.ts b/src/workflow-definition/domain/workflow-test-fixtures.ts index c615282..4a371da 100644 --- a/src/workflow-definition/domain/workflow-test-fixtures.ts +++ b/src/workflow-definition/domain/workflow-test-fixtures.ts @@ -1,9 +1,14 @@ import { workflowSpec } from '@ntcoding/agentic-workflow-builder/testing' +import type { PreconditionResult } from '@ntcoding/agentic-workflow-builder/dsl' +import { pass, fail } from '@ntcoding/agentic-workflow-builder/dsl' import type { WorkflowEvent } from './workflow-events.js' -import type { WorkflowState, IterationState } from './workflow-types.js' +import type { WorkflowState, IterationState, StateName } from './workflow-types.js' +import { parseStateName } from './workflow-types.js' import type { WorkflowDeps } from './workflow.js' import { Workflow } from './workflow.js' import { applyEvents } from './fold.js' +import { WORKFLOW_REGISTRY } from './registry.js' +import { WorkflowAdapter } from './workflow-adapter.js' import type { GitInfo } from '@ntcoding/agentic-workflow-builder/dsl' const AT = '2026-01-01T00:00:00Z' @@ -64,8 +69,8 @@ export function agentShutDown(agentName: string): WorkflowEvent { } export function transitioned( - from: string, - to: string, + from: StateName, + to: StateName, extras?: { readonly iteration?: number readonly developingHeadCommit?: string @@ -206,11 +211,49 @@ export function eventsToComplete(): readonly WorkflowEvent[] { } +const capturedDepsRef: { current: WorkflowDeps } = { current: makeDeps() } + export const spec = workflowSpec<WorkflowEvent, WorkflowState, WorkflowDeps, Workflow>({ fold: applyEvents, - rehydrate: (state, deps) => Workflow.rehydrate(state, deps), + rehydrate: (state, deps) => { + capturedDepsRef.current = deps + return Workflow.rehydrate(state, deps) + }, defaultDeps: makeDeps, getPendingEvents: (wf) => wf.getPendingEvents(), getState: (wf) => wf.getState(), mergeDeps: (defaults, overrides) => ({ ...defaults, ...overrides }), }) + +export function transitionTo(wf: Workflow, target: StateName, depsOverride?: WorkflowDeps): PreconditionResult { + const deps = depsOverride ?? capturedDepsRef.current + const state = wf.getState() + const from = state.currentStateMachineState + const currentDef = WORKFLOW_REGISTRY[from] + + if (!currentDef.canTransitionTo.includes(target)) { + return fail( + `Illegal transition ${from} -> ${target}. Legal targets from ${from}: [${currentDef.canTransitionTo.join(', ') || 'none'}].`, + ) + } + + if (target !== 'BLOCKED' && currentDef.transitionGuard) { + const ctx = WorkflowAdapter.buildTransitionContext(state, from, target, deps) + const guardResult = currentDef.transitionGuard(ctx) + if (!guardResult.pass) return guardResult + } + + const targetDef = WORKFLOW_REGISTRY[target] + const stateBefore = wf.getState() + const ctx = WorkflowAdapter.buildTransitionContext(stateBefore, from, target, deps) + const stateAfter = targetDef.onEntry + ? targetDef.onEntry(stateBefore, ctx) + : stateBefore + + const event = WorkflowAdapter.buildTransitionEvent + ? WorkflowAdapter.buildTransitionEvent(from, target, stateBefore, stateAfter, AT) + : { type: 'transitioned', at: AT } + wf.appendEvent(event) + targetDef.afterEntry?.() + return pass() +} diff --git a/src/workflow-definition/domain/workflow-types.ts b/src/workflow-definition/domain/workflow-types.ts index 37c5aa3..dc5e509 100644 --- a/src/workflow-definition/domain/workflow-types.ts +++ b/src/workflow-definition/domain/workflow-types.ts @@ -33,7 +33,7 @@ const IterationStateSchema = z.object({ export type IterationState = z.infer<typeof IterationStateSchema> -export function createWorkflowStateSchema(stateNames: readonly [string, ...string[]]) { +export function createWorkflowStateSchema<T extends readonly [string, ...string[]]>(stateNames: T) { const stateNameSchema = z.enum(stateNames) return z.object({ currentStateMachineState: stateNameSchema, @@ -45,14 +45,14 @@ export function createWorkflowStateSchema(stateNames: readonly [string, ...strin userApprovedPlan: z.boolean(), activeAgents: z.array(z.string()), transcriptPath: z.string().optional(), - preBlockedState: z.string().optional(), + preBlockedState: stateNameSchema.optional(), }) } export const WorkflowStateSchema = createWorkflowStateSchema(STATE_NAMES) export type WorkflowState = { - currentStateMachineState: string + currentStateMachineState: StateName iteration: number iterations: IterationState[] githubIssue?: number | undefined @@ -61,7 +61,7 @@ export type WorkflowState = { userApprovedPlan: boolean activeAgents: string[] transcriptPath?: string | undefined - preBlockedState?: string | undefined + preBlockedState?: StateName | undefined } export type WorkflowOperation = @@ -95,16 +95,4 @@ export function parseStateName(value: string): StateName { return StateNameSchema.parse(value) } -export const STATE_EMOJI_MAP: Readonly<Record<StateName, string>> = { - SPAWN: '🟣', - PLANNING: '⚪', - RESPAWN: '🔄', - DEVELOPING: '🔨', - REVIEWING: '📋', - COMMITTING: '💾', - CR_REVIEW: '🐰', - PR_CREATION: '🚀', - FEEDBACK: '💬', - BLOCKED: '⚠️', - COMPLETE: '✅', -} + diff --git a/src/workflow-definition/domain/workflow-write-bash-hook-checks.spec.ts b/src/workflow-definition/domain/workflow-write-bash-hook-checks.spec.ts index a72974e..077f21d 100644 --- a/src/workflow-definition/domain/workflow-write-bash-hook-checks.spec.ts +++ b/src/workflow-definition/domain/workflow-write-bash-hook-checks.spec.ts @@ -1,228 +1,110 @@ -import { - spec, - eventsToDeveloping, - eventsToReviewing, - eventsToCommitting, - eventsToRespawn, - eventsToPlanning, - transitioned, - agentShutDown, - iterationTaskAssigned, -} from './workflow-test-fixtures.js' - -describe('Workflow', () => { - describe('checkWriteAllowed', () => { - it('allows writes in non-RESPAWN states', () => { - const { result } = spec - .given(...eventsToDeveloping()) - .when((wf) => wf.checkWriteAllowed('Write', '/some/file.ts')) - expect(result).toStrictEqual({ pass: true }) - }) - - it('blocks Write tool in RESPAWN with generic message', () => { - const { result } = spec - .given(...eventsToRespawn()) - .when((wf) => wf.checkWriteAllowed('Write', '/some/file.ts')) - expect(result.pass).toBe(false) - if (!result.pass) { - expect(result.reason).toBe("Write operation 'Write' is forbidden in state: RESPAWN") - } - }) - - it('blocks Edit tool in RESPAWN', () => { - const { result } = spec - .given(...eventsToRespawn()) - .when((wf) => wf.checkWriteAllowed('Edit', '/some/file.ts')) - expect(result.pass).toBe(false) - }) - - it('blocks NotebookEdit tool in RESPAWN', () => { - const { result } = spec - .given(...eventsToRespawn()) - .when((wf) => wf.checkWriteAllowed('NotebookEdit', '/some/file.ts')) - expect(result.pass).toBe(false) - }) - - it('allows non-write tools in RESPAWN', () => { - const { result } = spec - .given(...eventsToRespawn()) - .when((wf) => wf.checkWriteAllowed('Read', '/some/file.ts')) - expect(result).toStrictEqual({ pass: true }) - }) - - it('allows state file writes in RESPAWN', () => { - const { result } = spec - .given(...eventsToRespawn()) - .when((wf) => wf.checkWriteAllowed('Write', '/tmp/feature-team-state-abc.json')) - expect(result).toStrictEqual({ pass: true }) - }) - - it('appends write-checked event with allowed=true when no write restriction', () => { - const { events } = spec - .given(...eventsToDeveloping()) - .when((wf) => wf.checkWriteAllowed('Write', '/some/file.ts')) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ type: 'write-checked', tool: 'Write', filePath: '/some/file.ts', allowed: true }) - }) - - it('appends write-checked event with allowed=true for non-write tool in RESPAWN', () => { - const { events } = spec - .given(...eventsToRespawn()) - .when((wf) => wf.checkWriteAllowed('Read', '/some/file.ts')) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ type: 'write-checked', tool: 'Read', filePath: '/some/file.ts', allowed: true }) - }) - - it('appends write-checked event with allowed=true for state file in RESPAWN', () => { - const { events } = spec - .given(...eventsToRespawn()) - .when((wf) => wf.checkWriteAllowed('Write', '/tmp/feature-team-state-abc.json')) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ type: 'write-checked', tool: 'Write', filePath: '/tmp/feature-team-state-abc.json', allowed: true }) - }) - - it('appends write-checked event with allowed=false and reason when blocked', () => { - const { events } = spec - .given(...eventsToRespawn()) - .when((wf) => wf.checkWriteAllowed('Write', '/some/file.ts')) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ - type: 'write-checked', - tool: 'Write', - filePath: '/some/file.ts', - allowed: false, - reason: "Write operation 'Write' is forbidden in state: RESPAWN", - }) - }) - }) - - describe('checkBashAllowed', () => { - it('allows non-Bash tools', () => { - const { result } = spec - .given(...eventsToDeveloping()) - .when((wf) => wf.checkBashAllowed('Write', 'git commit')) - expect(result).toStrictEqual({ pass: true }) - }) - - it('allows non-git commands in DEVELOPING', () => { - const { result } = spec - .given(...eventsToDeveloping()) - .when((wf) => wf.checkBashAllowed('Bash', 'npm test')) - expect(result).toStrictEqual({ pass: true }) - }) - - it('blocks git commit in DEVELOPING', () => { - const { result } = spec - .given(...eventsToDeveloping()) - .when((wf) => wf.checkBashAllowed('Bash', 'git commit -m "test"')) - expect(result.pass).toBe(false) - }) - - it('blocks git push in REVIEWING', () => { - const { result } = spec - .given(...eventsToReviewing()) - .when((wf) => wf.checkBashAllowed('Bash', 'git push origin main')) - expect(result.pass).toBe(false) - }) - - it('blocks git commit in RESPAWN with state name in message', () => { - const { result } = spec - .given(...eventsToRespawn()) - .when((wf) => wf.checkBashAllowed('Bash', 'git commit -m "test"')) - expect(result.pass).toBe(false) - if (!result.pass) { - expect(result.reason).toContain('RESPAWN') - } - }) - - it('allows git commit in COMMITTING (exempt via allowForbidden)', () => { - const { result } = spec - .given(...eventsToCommitting()) - .when((wf) => wf.checkBashAllowed('Bash', 'git commit -m "test"')) - expect(result).toStrictEqual({ pass: true }) - }) - - it('allows git push in COMMITTING (exempt via allowForbidden)', () => { - const { result } = spec - .given(...eventsToCommitting()) - .when((wf) => wf.checkBashAllowed('Bash', 'git push origin main')) - expect(result).toStrictEqual({ pass: true }) - }) - - it('blocks git commit in SPAWN (globally forbidden, no exemption)', () => { - const { result } = spec - .given() - .when((wf) => wf.checkBashAllowed('Bash', 'git commit -m "test"')) - expect(result.pass).toBe(false) - if (!result.pass) { - expect(result.reason).toContain('SPAWN') - } - }) - - it('blocks git checkout in DEVELOPING', () => { - const { result } = spec - .given(...eventsToDeveloping()) - .when((wf) => wf.checkBashAllowed('Bash', 'git checkout main')) - expect(result.pass).toBe(false) - }) - - it('allows git checkout in PLANNING (exempt via allowForbidden)', () => { - const { result } = spec - .given(...eventsToPlanning()) - .when((wf) => wf.checkBashAllowed('Bash', 'git checkout -b feature')) - expect(result).toStrictEqual({ pass: true }) - }) - - it('appends bash-checked event with allowed=true for non-Bash tool', () => { - const { events } = spec - .given(...eventsToDeveloping()) - .when((wf) => wf.checkBashAllowed('Write', 'git commit')) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ type: 'bash-checked', tool: 'Write', command: 'git commit', allowed: true }) - }) - - it('appends bash-checked event with allowed=true for allowed Bash command', () => { - const { events } = spec - .given(...eventsToDeveloping()) - .when((wf) => wf.checkBashAllowed('Bash', 'npm test')) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ type: 'bash-checked', tool: 'Bash', command: 'npm test', allowed: true }) - }) - - it('appends bash-checked event with allowed=false and reason when git commit blocked in DEVELOPING', () => { - const { events } = spec - .given(...eventsToDeveloping()) - .when((wf) => wf.checkBashAllowed('Bash', 'git commit -m "test"')) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ - type: 'bash-checked', - tool: 'Bash', - command: 'git commit -m "test"', - allowed: false, - reason: expect.stringContaining('DEVELOPING'), - }) - }) - - it('appends bash-checked event with allowed=false and reason when git commit blocked in RESPAWN', () => { - const { events } = spec - .given(...eventsToRespawn()) - .when((wf) => wf.checkBashAllowed('Bash', 'git commit -m "test"')) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ - type: 'bash-checked', - tool: 'Bash', - command: 'git commit -m "test"', - allowed: false, - reason: expect.stringContaining('RESPAWN'), - }) - }) - - it('appends bash-checked event with allowed=true for exempt git commit in COMMITTING', () => { - const { events } = spec - .given(...eventsToCommitting()) - .when((wf) => wf.checkBashAllowed('Bash', 'git commit -m "test"')) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ type: 'bash-checked', tool: 'Bash', command: 'git commit -m "test"', allowed: true }) - }) +import { checkBashCommand } from '@ntcoding/agentic-workflow-builder/dsl' +import { checkWriteAllowed } from './workflow-predicates.js' +import { BASH_FORBIDDEN, WORKFLOW_REGISTRY } from './registry.js' +import type { WorkflowState, StateName } from './workflow-types.js' +import { EMPTY_STATE } from './fold.js' + +function stateInPhase(phase: StateName): WorkflowState { + return { ...EMPTY_STATE, currentStateMachineState: phase } +} + +function bashCheck(command: string, stateName: StateName) { + const exemptions = WORKFLOW_REGISTRY[stateName].allowForbidden?.bash ?? [] + return checkBashCommand(command, BASH_FORBIDDEN, exemptions) +} + +describe('checkWriteAllowed predicate', () => { + it('allows non-write tools', () => { + const result = checkWriteAllowed('Read', '/some/file.ts', stateInPhase('RESPAWN')) + expect(result).toStrictEqual({ pass: true }) + }) + + it('allows state file writes', () => { + const result = checkWriteAllowed('Write', '/tmp/feature-team-state-abc.json', stateInPhase('RESPAWN')) + expect(result).toStrictEqual({ pass: true }) + }) + + it('blocks Write tool with reason containing state name', () => { + const result = checkWriteAllowed('Write', '/some/file.ts', stateInPhase('RESPAWN')) + expect(result.pass).toBe(false) + if (!result.pass) { + expect(result.reason).toBe("Write operation 'Write' is forbidden in state: RESPAWN") + } + }) + + it('blocks Edit tool', () => { + const result = checkWriteAllowed('Edit', '/some/file.ts', stateInPhase('RESPAWN')) + expect(result.pass).toBe(false) + }) + + it('blocks NotebookEdit tool', () => { + const result = checkWriteAllowed('NotebookEdit', '/some/file.ts', stateInPhase('RESPAWN')) + expect(result.pass).toBe(false) + }) +}) + +describe('registry forbidden.write', () => { + it('RESPAWN has write forbidden', () => { + expect(WORKFLOW_REGISTRY.RESPAWN.forbidden?.write).toBe(true) + }) + + it('DEVELOPING does not have write forbidden', () => { + expect(WORKFLOW_REGISTRY.DEVELOPING.forbidden?.write).toBeUndefined() + }) +}) + +describe('bash enforcement via BASH_FORBIDDEN + registry exemptions', () => { + it('blocks git commit in DEVELOPING (no exemptions)', () => { + const result = bashCheck('git commit -m "test"', 'DEVELOPING') + expect(result.pass).toBe(false) + }) + + it('blocks git push in REVIEWING (no exemptions)', () => { + const result = bashCheck('git push origin main', 'REVIEWING') + expect(result.pass).toBe(false) + }) + + it('blocks git commit in RESPAWN', () => { + const result = bashCheck('git commit -m "test"', 'RESPAWN') + expect(result.pass).toBe(false) + }) + + it('blocks git commit in SPAWN', () => { + const result = bashCheck('git commit -m "test"', 'SPAWN') + expect(result.pass).toBe(false) + }) + + it('blocks git checkout in DEVELOPING', () => { + const result = bashCheck('git checkout main', 'DEVELOPING') + expect(result.pass).toBe(false) + }) + + it('allows non-git commands in DEVELOPING', () => { + const result = bashCheck('npm test', 'DEVELOPING') + expect(result).toStrictEqual({ pass: true }) + }) + + it('allows git commit in COMMITTING (exempt via allowForbidden)', () => { + const result = bashCheck('git commit -m "test"', 'COMMITTING') + expect(result).toStrictEqual({ pass: true }) + }) + + it('allows git push in COMMITTING (exempt via allowForbidden)', () => { + const result = bashCheck('git push origin main', 'COMMITTING') + expect(result).toStrictEqual({ pass: true }) + }) + + it('allows git checkout in PLANNING (exempt via allowForbidden)', () => { + const result = bashCheck('git checkout -b feature', 'PLANNING') + expect(result).toStrictEqual({ pass: true }) + }) + + it('COMMITTING registry has git commit and git push exemptions', () => { + expect(WORKFLOW_REGISTRY.COMMITTING.allowForbidden?.bash).toContain('git commit') + expect(WORKFLOW_REGISTRY.COMMITTING.allowForbidden?.bash).toContain('git push') + }) + + it('PLANNING registry has git checkout exemption', () => { + expect(WORKFLOW_REGISTRY.PLANNING.allowForbidden?.bash).toContain('git checkout') }) }) diff --git a/src/workflow-definition/domain/workflow.ts b/src/workflow-definition/domain/workflow.ts index 5bd7fe8..22ba54d 100644 --- a/src/workflow-definition/domain/workflow.ts +++ b/src/workflow-definition/domain/workflow.ts @@ -1,17 +1,16 @@ -import type { PreconditionResult, TransitionContext, GitInfo } from '@ntcoding/agentic-workflow-builder/dsl' -import { pass, fail, checkBashCommand } from '@ntcoding/agentic-workflow-builder/dsl' +import type { PreconditionResult, GitInfo } from '@ntcoding/agentic-workflow-builder/dsl' +import { pass, fail } from '@ntcoding/agentic-workflow-builder/dsl' import { WorkflowStateError } from '@ntcoding/agentic-workflow-builder/engine' -import type { WorkflowState } from './workflow-types.js' -import { WORKFLOW_REGISTRY, getStateDefinition, BASH_FORBIDDEN } from './registry.js' -import type { StateName } from './workflow-types.js' -import { parseStateName, WorkflowStateSchema, STATE_EMOJI_MAP } from './workflow-types.js' +import type { BaseEvent } from '@ntcoding/agentic-workflow-builder/engine' +import type { WorkflowState, StateName } from './workflow-types.js' +import { getStateDefinition } from './registry.js' +import { WorkflowStateSchema } from './workflow-types.js' import type { WorkflowEvent } from './workflow-events.js' +import { WorkflowEventSchema } from './workflow-events.js' import { applyEvent, EMPTY_STATE } from './fold.js' import { - FILE_WRITING_TOOLS, READ_TOOLS, BASH_READ_PATTERN, - isStateFile, isPluginSourcePath, checkLeadIdle, checkDeveloperIdle, @@ -48,7 +47,7 @@ export class Workflow { return new Workflow(WorkflowStateSchema.parse(state), deps) } - static procedurePath(state: string, pluginRoot: string): string { + static procedurePath(state: StateName, pluginRoot: string): string { return `${pluginRoot}/${getStateDefinition(state).agentInstructions}` } @@ -56,6 +55,12 @@ export class Workflow { return this.pendingEvents } + appendEvent(event: BaseEvent): void { + const workflowEvent = WorkflowEventSchema.parse(event) + this.pendingEvents = [...this.pendingEvents, workflowEvent] + this.state = applyEvent(this.state, workflowEvent) + } + private append(event: WorkflowEvent): void { this.pendingEvents = [...this.pendingEvents, event] this.state = applyEvent(this.state, event) @@ -221,48 +226,6 @@ export class Workflow { return pass() } - checkWriteAllowed(toolName: string, filePath: string): PreconditionResult { - const currentDef = getStateDefinition(this.state.currentStateMachineState) - if (!currentDef.forbidden?.write) { - this.append({ type: 'write-checked', at: this.deps.now(), tool: toolName, filePath, allowed: true }) - return pass() - } - - if (!FILE_WRITING_TOOLS.has(toolName)) { - this.append({ type: 'write-checked', at: this.deps.now(), tool: toolName, filePath, allowed: true }) - return pass() - } - - if (isStateFile(filePath)) { - this.append({ type: 'write-checked', at: this.deps.now(), tool: toolName, filePath, allowed: true }) - return pass() - } - - const reason = `Write operation '${toolName}' is forbidden in state: ${this.state.currentStateMachineState}` - this.append({ type: 'write-checked', at: this.deps.now(), tool: toolName, filePath, allowed: false, reason }) - return fail(reason) - } - - checkBashAllowed(toolName: string, command: string): PreconditionResult { - if (toolName !== 'Bash') { - this.append({ type: 'bash-checked', at: this.deps.now(), tool: toolName, command, allowed: true }) - return pass() - } - - const currentDef = getStateDefinition(this.state.currentStateMachineState) - const exemptions = currentDef.allowForbidden?.bash ?? [] - const check = checkBashCommand(command, BASH_FORBIDDEN, exemptions) - - if (!check.pass) { - const reason = `Bash command blocked in ${this.state.currentStateMachineState}. ${check.reason}` - this.append({ type: 'bash-checked', at: this.deps.now(), tool: toolName, command, allowed: false, reason }) - return fail(reason) - } - - this.append({ type: 'bash-checked', at: this.deps.now(), tool: toolName, command, allowed: true }) - return pass() - } - checkPluginSourceRead(toolName: string, filePath: string, command: string): PreconditionResult { const pluginRoot = this.deps.getPluginRoot() @@ -325,46 +288,5 @@ export class Workflow { return pass() } - transitionTo(target: string): PreconditionResult { - const from = parseStateName(this.state.currentStateMachineState) - const targetState = parseStateName(target) - - const currentDef = WORKFLOW_REGISTRY[from] - if (!currentDef.canTransitionTo.includes(targetState)) { - return fail(`Illegal transition ${from} -> ${targetState}. Legal targets from ${from}: [${currentDef.canTransitionTo.join(', ') || 'none'}].`) - } - - if (targetState !== 'BLOCKED' && currentDef.transitionGuard) { - const ctx = this.buildTransitionContext(from, targetState) - const guardResult = currentDef.transitionGuard(ctx) - if (!guardResult.pass) return guardResult - } - - const targetDef = WORKFLOW_REGISTRY[targetState] - const base = targetDef.onEntry ? targetDef.onEntry(this.state, this.buildTransitionContext(from, targetState)) : this.state - - const iterationChanged = base.iteration !== this.state.iteration - const developingHeadCommit = targetState === 'DEVELOPING' - ? base.iterations[base.iteration]?.developingHeadCommit - : undefined - - this.append({ - type: 'transitioned', - at: this.deps.now(), - from, - to: targetState, - ...(iterationChanged ? { iteration: base.iteration } : {}), - ...(developingHeadCommit === undefined ? {} : { developingHeadCommit }), - }) - - return pass() - } - - private buildTransitionContext(from: StateName, to: StateName): TransitionContext<WorkflowState, StateName> { - const prChecksPass = this.state.prNumber === undefined - ? false - : this.deps.checkPrChecks(this.state.prNumber) - return { state: this.state, gitInfo: this.deps.getGitInfo(), prChecksPass, from, to } - } } diff --git a/src/workflow-definition/index.ts b/src/workflow-definition/index.ts index acb3e85..90ac7a7 100644 --- a/src/workflow-definition/index.ts +++ b/src/workflow-definition/index.ts @@ -19,3 +19,6 @@ export type { WorkflowEvent } from './domain/workflow-events.js' export { WorkflowEventSchema } from './domain/workflow-events.js' export { applyEvents } from './domain/fold.js' + +export { checkWriteAllowed } from './domain/workflow-predicates.js' +export { BASH_FORBIDDEN } from './domain/registry.js' From d6055432d61f5cd8a43ba32c2c49af50c33eac77 Mon Sep 17 00:00:00 2001 From: Nick Tune <nick@ntcoding.co.uk> Date: Sat, 7 Mar 2026 12:09:24 +0000 Subject: [PATCH 2/4] Refactor entrypoint: extract hook routes, analytics routes, consolidate fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decompose the 240-line entry point into focused modules: - Rename autonomous-claude-agent-team-workflow.ts → entrypoint.ts - Extract hook handling → src/hook-routes.ts (uses platform Zod schemas) - Extract analytics/view-report → src/analytics/cli-routes.ts - Remove consumer parse functions from hook-io.ts (replaced by platform schemas) - Consolidate duplicated test fixtures across 3 spec files into shared imports - Entry point now ~80 lines with 4 clear routing paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- hooks/hooks.json | 8 +- .../src/cli/domain/arg-helpers.spec.ts | 20 ++ .../src/cli/domain/arg-helpers.ts | 9 + .../src/cli/domain/command-definition.spec.ts | 12 +- .../src/cli/domain/command-definition.ts | 28 +- .../src/cli/domain/hook-definition.spec.ts | 13 + .../src/cli/domain/hook-definition.ts | 10 + .../src/cli/domain/hook-schemas.spec.ts | 55 +++- .../src/cli/domain/hook-schemas.ts | 11 + .../src/cli/domain/workflow-cli.ts | 4 +- .../src/cli/domain/workflow-runner.spec.ts | 190 +++++++++++++- .../src/cli/domain/workflow-runner.ts | 163 +++++++++--- .../src/cli/index.spec.ts | 6 +- .../agentic-workflow-builder/src/cli/index.ts | 12 +- .../src/engine/domain/workflow-engine.spec.ts | 18 +- .../src/engine/domain/workflow-engine.ts | 6 +- .../src/engine/index.ts | 2 +- src/analytics/cli-routes.spec.ts | 215 +++++++++++++++ src/analytics/cli-routes.ts | 66 +++++ ...-team-workflow-argument-validation.spec.ts | 151 +---------- ...e-agent-team-workflow-cli-commands.spec.ts | 63 ++--- ...e-agent-team-workflow-cli-test-fixtures.ts | 2 +- ...de-agent-team-workflow-view-report.spec.ts | 2 +- ...m-workflow-workflow-state-commands.spec.ts | 220 ++-------------- src/autonomous-claude-agent-team-workflow.ts | 247 ------------------ src/entrypoint.ts | 97 +++++++ ...ok-routing.spec.ts => hook-routes.spec.ts} | 236 +++++------------ src/hook-routes.ts | 100 +++++++ src/infra/hook-io.spec.ts | 75 ------ src/infra/hook-io.ts | 64 ----- .../domain/output-messages.spec.ts | 33 +++ .../domain/output-messages.ts | 18 ++ .../domain/workflow-adapter.spec.ts | 40 +-- .../domain/workflow-adapter.ts | 4 +- .../domain/workflow-agent-lifecycle.spec.ts | 10 + .../domain/workflow-test-fixtures.ts | 10 +- .../domain/workflow-types.ts | 1 + src/workflow-definition/domain/workflow.ts | 5 + src/workflow-definition/index.ts | 2 +- 39 files changed, 1145 insertions(+), 1083 deletions(-) create mode 100644 src/analytics/cli-routes.spec.ts create mode 100644 src/analytics/cli-routes.ts delete mode 100644 src/autonomous-claude-agent-team-workflow.ts create mode 100644 src/entrypoint.ts rename src/{autonomous-claude-agent-team-workflow-hook-routing.spec.ts => hook-routes.spec.ts} (53%) create mode 100644 src/hook-routes.ts diff --git a/hooks/hooks.json b/hooks/hooks.json index 6fdb1cd..648368a 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,8 +1,8 @@ { "hooks": { - "SessionStart": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/autonomous-claude-agent-team-workflow.ts", "timeout": 30 }] }], - "PreToolUse": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/autonomous-claude-agent-team-workflow.ts", "timeout": 30 }] }], - "SubagentStart": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/autonomous-claude-agent-team-workflow.ts", "timeout": 30 }] }], - "TeammateIdle": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/autonomous-claude-agent-team-workflow.ts", "timeout": 30 }] }] + "SessionStart": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/entrypoint.ts", "timeout": 30 }] }], + "PreToolUse": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/entrypoint.ts", "timeout": 30 }] }], + "SubagentStart": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/entrypoint.ts", "timeout": 30 }] }], + "TeammateIdle": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/entrypoint.ts", "timeout": 30 }] }] } } diff --git a/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.spec.ts index 7bbd548..53246fc 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.spec.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.spec.ts @@ -103,6 +103,26 @@ describe('arg.state', () => { }) }) +describe('arg.rest', () => { + const parser = arg.rest('files') + + it('collects all remaining args from position', () => { + const result = parser.parse(['cmd', 'session-1', 'a.ts', 'b.ts'], 2, 'cmd') + expect(result).toEqual({ ok: true, value: ['a.ts', 'b.ts'] }) + }) + + it('returns empty array when no remaining args', () => { + const result = parser.parse(['cmd', 'session-1'], 2, 'cmd') + expect(result).toEqual({ ok: true, value: [] }) + }) + + it('optional rest with no args at position returns undefined', () => { + const optional = parser.optional() + const result = optional.parse(['cmd'], 1, 'cmd') + expect(result).toEqual({ ok: true, value: undefined }) + }) +}) + describe('double optional', () => { it('calling optional twice still works', () => { const parser = arg.string('name').optional().optional() diff --git a/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.ts b/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.ts index 88b0e59..bbd7e08 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/arg-helpers.ts @@ -53,6 +53,15 @@ export const arg = { }, }), + rest: (_name: string): ArgParser<readonly string[]> => ({ + parse: (args, position) => { + return { ok: true, value: args.slice(position) } + }, + optional: function () { + return makeOptional(this) + }, + }), + state: <T extends string>(name: string, schema: ZodType<T>): ArgParser<T> => ({ parse: (args, position, commandName) => { const raw = args[position] diff --git a/packages/agentic-workflow-builder/src/cli/domain/command-definition.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/command-definition.spec.ts index 2aaf58a..5ee4229 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/command-definition.spec.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/command-definition.spec.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest' -import { defineCommands } from './command-definition.js' +import { defineRoutes } from './command-definition.js' import { pass } from '../../dsl/index.js' -describe('defineCommands', () => { - it('returns the commands map unchanged', () => { - const commands = { +describe('defineRoutes', () => { + it('returns the routes map unchanged', () => { + const routes = { init: { type: 'session-start' as const, }, @@ -13,7 +13,7 @@ describe('defineCommands', () => { handler: () => pass(), }, } - const result = defineCommands(commands) - expect(result).toBe(commands) + const result = defineRoutes(routes) + expect(result).toBe(routes) }) }) diff --git a/packages/agentic-workflow-builder/src/cli/domain/command-definition.ts b/packages/agentic-workflow-builder/src/cli/domain/command-definition.ts index 1c6a607..896ff1c 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/command-definition.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/command-definition.ts @@ -1,33 +1,33 @@ import type { PreconditionResult } from '../../dsl/index.js' import type { ArgParser } from './arg-helpers.js' -type CommandHandler<TWorkflow> = (workflow: TWorkflow, ...parsedArgs: readonly unknown[]) => PreconditionResult +type RouteHandler<TWorkflow> = (workflow: TWorkflow, ...parsedArgs: readonly unknown[]) => PreconditionResult -type TransactionCommand<TWorkflow> = { +type TransactionRoute<TWorkflow> = { readonly type: 'transaction' readonly args?: readonly ArgParser<unknown>[] - readonly handler: CommandHandler<TWorkflow> + readonly handler: RouteHandler<TWorkflow> } -type TransitionCommand = { +type TransitionRoute = { readonly type: 'transition' readonly args?: readonly ArgParser<unknown>[] } -type SessionStartCommand = { +type SessionStartRoute = { readonly type: 'session-start' readonly args?: readonly ArgParser<unknown>[] } -export type CommandDefinition<TWorkflow, TState> = - | TransactionCommand<TWorkflow> - | TransitionCommand - | SessionStartCommand +export type RouteDefinition<TWorkflow, TState> = + | TransactionRoute<TWorkflow> + | TransitionRoute + | SessionStartRoute -export type CommandMap<TWorkflow, TState> = Record<string, CommandDefinition<TWorkflow, TState>> +export type RouteMap<TWorkflow, TState> = Record<string, RouteDefinition<TWorkflow, TState>> -export function defineCommands<TWorkflow, TState>( - commands: CommandMap<TWorkflow, TState>, -): CommandMap<TWorkflow, TState> { - return commands +export function defineRoutes<TWorkflow, TState>( + routes: RouteMap<TWorkflow, TState>, +): RouteMap<TWorkflow, TState> { + return routes } diff --git a/packages/agentic-workflow-builder/src/cli/domain/hook-definition.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/hook-definition.spec.ts index 2e2be5e..19efa01 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/hook-definition.spec.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/hook-definition.spec.ts @@ -15,4 +15,17 @@ describe('defineHooks', () => { const result = defineHooks(hooks) expect(result).toBe(hooks) }) + + it('returns hooks with subagentStart and teammateIdle unchanged', () => { + const hooks = { + subagentStart: { + register: () => pass(), + }, + teammateIdle: { + check: () => pass(), + }, + } + const result = defineHooks(hooks) + expect(result).toBe(hooks) + }) }) diff --git a/packages/agentic-workflow-builder/src/cli/domain/hook-definition.ts b/packages/agentic-workflow-builder/src/cli/domain/hook-definition.ts index 6f8d6a4..d4e5512 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/hook-definition.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/hook-definition.ts @@ -5,8 +5,18 @@ export type HookCheck<TWorkflow> = { readonly check: (workflow: TWorkflow, extracted: Record<string, string>, toolName: string) => PreconditionResult } +export type SubagentStartHandler<TWorkflow> = { + readonly register: (workflow: TWorkflow, agentType: string, agentId: string) => PreconditionResult +} + +export type TeammateIdleHandler<TWorkflow> = { + readonly check: (workflow: TWorkflow, agentName: string) => PreconditionResult +} + export type HookDefinition<TWorkflow> = { readonly preToolUse?: Record<string, HookCheck<TWorkflow>> + readonly subagentStart?: SubagentStartHandler<TWorkflow> + readonly teammateIdle?: TeammateIdleHandler<TWorkflow> } export function defineHooks<TWorkflow>(hooks: HookDefinition<TWorkflow>): HookDefinition<TWorkflow> { diff --git a/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.spec.ts index 41a05a2..89acb3e 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.spec.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { HookCommonInputSchema, PreToolUseInputSchema } from './hook-schemas.js' +import { HookCommonInputSchema, PreToolUseInputSchema, SubagentStartInputSchema, TeammateIdleInputSchema } from './hook-schemas.js' const validCommon = { session_id: 'abc-123', @@ -58,3 +58,56 @@ describe('PreToolUseInputSchema', () => { expect(() => PreToolUseInputSchema.parse(input)).toThrow() }) }) + +describe('SubagentStartInputSchema', () => { + it('parses valid subagent start input', () => { + const input = { + ...validCommon, + hook_event_name: 'SubagentStart', + agent_id: 'agt-1', + agent_type: 'developer-1', + } + const result = SubagentStartInputSchema.parse(input) + expect(result.agent_id).toBe('agt-1') + expect(result.agent_type).toBe('developer-1') + }) + + it('rejects missing agent_id', () => { + const input = { + ...validCommon, + hook_event_name: 'SubagentStart', + agent_type: 'developer-1', + } + expect(() => SubagentStartInputSchema.parse(input)).toThrow() + }) + + it('rejects missing agent_type', () => { + const input = { + ...validCommon, + hook_event_name: 'SubagentStart', + agent_id: 'agt-1', + } + expect(() => SubagentStartInputSchema.parse(input)).toThrow() + }) +}) + +describe('TeammateIdleInputSchema', () => { + it('parses valid teammate idle input', () => { + const input = { + ...validCommon, + hook_event_name: 'TeammateIdle', + teammate_name: 'developer-1', + } + const result = TeammateIdleInputSchema.parse(input) + expect(result.teammate_name).toBe('developer-1') + }) + + it('accepts missing teammate_name as optional', () => { + const input = { + ...validCommon, + hook_event_name: 'TeammateIdle', + } + const result = TeammateIdleInputSchema.parse(input) + expect(result.teammate_name).toBeUndefined() + }) +}) diff --git a/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.ts b/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.ts index 6940d6b..70f8109 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/hook-schemas.ts @@ -14,4 +14,15 @@ export const PreToolUseInputSchema = HookCommonInputSchema.extend({ tool_use_id: z.string(), }) +export const SubagentStartInputSchema = HookCommonInputSchema.extend({ + agent_id: z.string(), + agent_type: z.string(), +}) + +export const TeammateIdleInputSchema = HookCommonInputSchema.extend({ + teammate_name: z.string().optional(), +}) + export type PreToolUseInput = z.infer<typeof PreToolUseInputSchema> +export type SubagentStartInput = z.infer<typeof SubagentStartInputSchema> +export type TeammateIdleInput = z.infer<typeof TeammateIdleInputSchema> diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts index 938942c..1507fff 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts @@ -1,7 +1,7 @@ /* v8 ignore start */ import type { BaseWorkflowState } from '../../engine/index.js' import type { RehydratableWorkflow, WorkflowEngineDeps } from '../../engine/index.js' -import type { WorkflowCliConfig } from './workflow-runner.js' +import type { WorkflowRunnerConfig } from './workflow-runner.js' import { createWorkflowRunner } from './workflow-runner.js' export function createWorkflowCli< @@ -9,7 +9,7 @@ export function createWorkflowCli< TState extends BaseWorkflowState, TDeps, >( - config: WorkflowCliConfig<TWorkflow, TState, TDeps>, + config: WorkflowRunnerConfig<TWorkflow, TState, TDeps>, buildDeps: () => { engineDeps: WorkflowEngineDeps; workflowDeps: TDeps; readStdin: () => string }, ): void { const runner = createWorkflowRunner(config) diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts index 4dc4a07..e51fd41 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest' import { createWorkflowRunner } from './workflow-runner.js' -import type { WorkflowCliConfig } from './workflow-runner.js' +import type { WorkflowRunnerConfig } from './workflow-runner.js' import { pass, fail } from '../../dsl/index.js' import type { PreconditionResult } from '../../dsl/index.js' import type { RehydratableWorkflow, - WorkflowFactory, + WorkflowDefinition, WorkflowEngineDeps, WorkflowEventStore, } from '../../engine/index.js' @@ -58,7 +58,7 @@ function createMockEngineDeps(hasSession = false): WorkflowEngineDeps { } } -function createMockFactory(): WorkflowFactory<TestWorkflow, TestState, TestDeps> { +function createMockFactory(): WorkflowDefinition<TestWorkflow, TestState, TestDeps> { return { rehydrate: (_events: readonly BaseEvent[]) => createMockWorkflow(), createFresh: () => createMockWorkflow(), @@ -91,10 +91,10 @@ function createMockFactory(): WorkflowFactory<TestWorkflow, TestState, TestDeps> } } -function createTestConfig(overrides?: Partial<WorkflowCliConfig<TestWorkflow, TestState, TestDeps>>): WorkflowCliConfig<TestWorkflow, TestState, TestDeps> { +function createTestConfig(overrides?: Partial<WorkflowRunnerConfig<TestWorkflow, TestState, TestDeps>>): WorkflowRunnerConfig<TestWorkflow, TestState, TestDeps> { return { - factory: createMockFactory(), - commands: { + workflowDefinition: createMockFactory(), + routes: { init: { type: 'session-start', args: [arg.string('session-id')], @@ -176,7 +176,7 @@ describe('createWorkflowRunner', () => { ...createMockWorkflow(), doSomething: () => fail('not allowed'), }) - const config = createTestConfig({ factory }) + const config = createTestConfig({ workflowDefinition: factory }) const runner = createWorkflowRunner(config) const deps = createMockEngineDeps(true) const result = runner(['do-something', 'session-1'], deps, {}) @@ -349,7 +349,8 @@ describe('createWorkflowRunner', () => { cwd: '/home/user', hook_event_name: 'PreToolUse', }) - const result = runner([], createMockEngineDeps(), {}, () => hookInput) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, () => hookInput) expect(result.exitCode).toBe(EXIT_ERROR) expect(result.output).toContain('Invalid pre-tool-use input') }) @@ -379,5 +380,178 @@ describe('createWorkflowRunner', () => { const result = runner([], deps, {}, () => hookInput) expect(result.exitCode).toBe(EXIT_BLOCK) }) + + it('returns allow for SubagentStart when no handler configured', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'SubagentStart', + agent_id: 'agt-1', + agent_type: 'developer-1', + }) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('handles SubagentStart hook with registered handler', () => { + const registered: Array<{ agentType: string; agentId: string }> = [] + const config = createTestConfig({ + hooks: { + subagentStart: { + register: (_w, agentType, agentId) => { + registered.push({ agentType, agentId }) + return pass() + }, + }, + }, + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'SubagentStart', + agent_id: 'agt-1', + agent_type: 'developer-1', + }) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + expect(registered[0]).toEqual({ agentType: 'developer-1', agentId: 'agt-1' }) + }) + + it('returns allow for SubagentStart when session does not exist', () => { + const config = createTestConfig({ + hooks: { + subagentStart: { + register: () => fail('should not reach'), + }, + }, + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'no-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'SubagentStart', + agent_id: 'agt-1', + agent_type: 'developer-1', + }) + const deps = createMockEngineDeps(false) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('returns allow for TeammateIdle when no handler configured', () => { + const config = createTestConfig() + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'TeammateIdle', + teammate_name: 'developer-1', + }) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('handles TeammateIdle hook that allows', () => { + const config = createTestConfig({ + hooks: { + teammateIdle: { + check: (_w, agentName) => { + if (agentName.includes('lead')) return fail('Lead cannot go idle') + return pass() + }, + }, + }, + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'TeammateIdle', + teammate_name: 'developer-1', + }) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('handles TeammateIdle hook that blocks', () => { + const config = createTestConfig({ + hooks: { + teammateIdle: { + check: (_w, agentName) => { + if (agentName.includes('lead')) return fail('Lead cannot go idle') + return pass() + }, + }, + }, + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'TeammateIdle', + teammate_name: 'lead-1', + }) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_BLOCK) + }) + + it('returns allow for TeammateIdle when session does not exist', () => { + const config = createTestConfig({ + hooks: { + teammateIdle: { + check: () => fail('should not reach'), + }, + }, + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'no-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'TeammateIdle', + }) + const deps = createMockEngineDeps(false) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('handles TeammateIdle with missing teammate_name', () => { + const receivedNames: string[] = [] + const config = createTestConfig({ + hooks: { + teammateIdle: { + check: (_w, agentName) => { + receivedNames.push(agentName) + return pass() + }, + }, + }, + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'TeammateIdle', + }) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, () => hookInput) + expect(result.exitCode).toBe(EXIT_ALLOW) + expect(receivedNames[0]).toBe('') + }) }) }) diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts index d792213..8579f19 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts @@ -2,28 +2,28 @@ import type { BaseWorkflowState } from '../../engine/index.js' import { WorkflowEngine } from '../../engine/index.js' import type { RehydratableWorkflow, - WorkflowFactory, + WorkflowDefinition, WorkflowEngineDeps, EngineResult, } from '../../engine/index.js' import type { ArgParser } from './arg-helpers.js' -import type { CommandMap } from './command-definition.js' +import type { RouteMap } from './command-definition.js' import type { HookDefinition } from './hook-definition.js' -import type { PreToolUseInput } from './hook-schemas.js' +import type { PreToolUseInput, SubagentStartInput, TeammateIdleInput } from './hook-schemas.js' import { EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from './exit-codes.js' -import { HookCommonInputSchema, PreToolUseInputSchema } from './hook-schemas.js' +import { HookCommonInputSchema, PreToolUseInputSchema, SubagentStartInputSchema, TeammateIdleInputSchema } from './hook-schemas.js' export type RunnerResult = { readonly output: string; readonly exitCode: number } -export type WorkflowCliConfig< +export type WorkflowRunnerConfig< TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string = string, TOperation extends string = string, > = { - readonly factory: WorkflowFactory<TWorkflow, TState, TDeps, TStateName, TOperation> - readonly commands: CommandMap<TWorkflow, TState> + readonly workflowDefinition: WorkflowDefinition<TWorkflow, TState, TDeps, TStateName, TOperation> + readonly routes: RouteMap<TWorkflow, TState> readonly hooks?: HookDefinition<TWorkflow> } @@ -42,12 +42,12 @@ function engineResultToRunnerResult(result: EngineResult): RunnerResult { function parseArgs( argParsers: readonly ArgParser<unknown>[] | undefined, args: readonly string[], - commandName: string, + routeName: string, ): { readonly ok: true; readonly values: readonly unknown[] } | { readonly ok: false; readonly message: string } { const values: unknown[] = [] /* v8 ignore next */ for (const [i, parser] of (argParsers ?? []).entries()) { - const result = parser.parse(args, i + 1, commandName) + const result = parser.parse(args, i + 1, routeName) if (!result.ok) { return { ok: false, message: result.message } } @@ -77,14 +77,14 @@ export function createWorkflowRunner< TStateName extends string = string, TOperation extends string = string, >( - config: WorkflowCliConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, + config: WorkflowRunnerConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, ): (args: readonly string[], engineDeps: WorkflowEngineDeps, workflowDeps: TDeps, readStdin?: () => string) => RunnerResult { return (args, engineDeps, workflowDeps, readStdin) => { - const engine = new WorkflowEngine(config.factory, engineDeps, workflowDeps) - const commandName = args[0] + const engine = new WorkflowEngine(config.workflowDefinition, engineDeps, workflowDeps) + const routeName = args[0] - if (commandName !== undefined) { - return handleCommand(engine, config, args, commandName) + if (routeName !== undefined) { + return handleRoute(engine, config, args, routeName) } if (readStdin === undefined) { @@ -95,7 +95,7 @@ export function createWorkflowRunner< } } -function handleCommand< +function handleRoute< TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, @@ -103,21 +103,21 @@ function handleCommand< TOperation extends string, >( engine: WorkflowEngine<TWorkflow, TState, TDeps, TStateName, TOperation>, - config: WorkflowCliConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, + config: WorkflowRunnerConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, args: readonly string[], - commandName: string, + routeName: string, ): RunnerResult { - const commandDef = config.commands[commandName] - if (commandDef === undefined) { - return { output: `Unknown command: ${commandName}`, exitCode: EXIT_ERROR } + const routeDef = config.routes[routeName] + if (routeDef === undefined) { + return { output: `Unknown command: ${routeName}`, exitCode: EXIT_ERROR } } - const parsedArgs = parseArgs(commandDef.args, args, commandName) + const parsedArgs = parseArgs(routeDef.args, args, routeName) if (!parsedArgs.ok) { return { output: parsedArgs.message, exitCode: EXIT_ERROR } } - switch (commandDef.type) { + switch (routeDef.type) { case 'session-start': { const sessionId = assertSessionId(parsedArgs.values) const result = engine.startSession(sessionId) @@ -125,15 +125,15 @@ function handleCommand< } case 'transition': { const sessionId = assertSessionId(parsedArgs.values) - const target = config.factory.parseStateName(assertTarget(parsedArgs.values)) + const target = config.workflowDefinition.parseStateName(assertTarget(parsedArgs.values)) const result = engine.transition(sessionId, target) return engineResultToRunnerResult(result) } case 'transaction': { const sessionId = assertSessionId(parsedArgs.values) const restArgs = parsedArgs.values.slice(1) - const result = engine.transaction(sessionId, commandName, (w: TWorkflow) => - commandDef.handler(w, ...restArgs), + const result = engine.transaction(sessionId, routeName, (w: TWorkflow) => + routeDef.handler(w, ...restArgs), ) return engineResultToRunnerResult(result) } @@ -148,7 +148,7 @@ function handleHook< TOperation extends string, >( engine: WorkflowEngine<TWorkflow, TState, TDeps, TStateName, TOperation>, - config: WorkflowCliConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, + config: WorkflowRunnerConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, readStdin: () => string, ): RunnerResult { const stdin = readStdin() @@ -158,24 +158,46 @@ function handleHook< } const common = commonParse.data + if (common.hook_event_name === 'SessionStart') { + const result = engine.startSession(common.session_id, common.transcript_path) + engine.persistSessionId(common.session_id) + return engineResultToRunnerResult(result) + } + + if (!engine.hasSession(common.session_id)) { + return { output: '', exitCode: EXIT_ALLOW } + } + switch (common.hook_event_name) { - case 'SessionStart': { - const result = engine.startSession(common.session_id, common.transcript_path) - engine.persistSessionId(common.session_id) - return engineResultToRunnerResult(result) - } - case 'PreToolUse': { - const toolParse = PreToolUseInputSchema.safeParse(JSON.parse(stdin)) - if (!toolParse.success) { - return { output: `Invalid pre-tool-use input: ${toolParse.error.message}`, exitCode: EXIT_ERROR } - } - return handlePreToolUse(engine, config, toolParse.data) - } + case 'PreToolUse': + return handlePreToolUseHook(engine, config, stdin) + case 'SubagentStart': + return handleSubagentStartHook(engine, config, stdin) + case 'TeammateIdle': + return handleTeammateIdleHook(engine, config, stdin) default: return { output: '', exitCode: EXIT_ALLOW } } } +function handlePreToolUseHook< + TWorkflow extends RehydratableWorkflow<TState>, + TState extends BaseWorkflowState<TStateName>, + TDeps, + TStateName extends string, + TOperation extends string, +>( + engine: WorkflowEngine<TWorkflow, TState, TDeps, TStateName, TOperation>, + config: WorkflowRunnerConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, + stdin: string, +): RunnerResult { + const toolParse = PreToolUseInputSchema.safeParse(JSON.parse(stdin)) + if (!toolParse.success) { + return { output: `Invalid pre-tool-use input: ${toolParse.error.message}`, exitCode: EXIT_ERROR } + } + return handlePreToolUse(engine, config, toolParse.data) +} + function handlePreToolUse< TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, @@ -184,7 +206,7 @@ function handlePreToolUse< TOperation extends string, >( engine: WorkflowEngine<TWorkflow, TState, TDeps, TStateName, TOperation>, - config: WorkflowCliConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, + config: WorkflowRunnerConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, input: PreToolUseInput, ): RunnerResult { if (config.hooks?.preToolUse === undefined) { @@ -210,3 +232,66 @@ function handlePreToolUse< return engineResultToRunnerResult(result) } + +function handleSubagentStartHook< + TWorkflow extends RehydratableWorkflow<TState>, + TState extends BaseWorkflowState<TStateName>, + TDeps, + TStateName extends string, + TOperation extends string, +>( + engine: WorkflowEngine<TWorkflow, TState, TDeps, TStateName, TOperation>, + config: WorkflowRunnerConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, + stdin: string, +): RunnerResult { + const handler = config.hooks?.subagentStart + if (handler === undefined) { + return { output: '', exitCode: EXIT_ALLOW } + } + + const parsed = SubagentStartInputSchema.safeParse(JSON.parse(stdin)) + if (!parsed.success) { + return { output: `Invalid subagent-start input: ${parsed.error.message}`, exitCode: EXIT_ERROR } + } + const input = parsed.data + + const result = engine.transaction( + input.session_id, + 'register-agent', + (w: TWorkflow) => handler.register(w, input.agent_type, input.agent_id), + ) + + return engineResultToRunnerResult(result) +} + +function handleTeammateIdleHook< + TWorkflow extends RehydratableWorkflow<TState>, + TState extends BaseWorkflowState<TStateName>, + TDeps, + TStateName extends string, + TOperation extends string, +>( + engine: WorkflowEngine<TWorkflow, TState, TDeps, TStateName, TOperation>, + config: WorkflowRunnerConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, + stdin: string, +): RunnerResult { + const handler = config.hooks?.teammateIdle + if (handler === undefined) { + return { output: '', exitCode: EXIT_ALLOW } + } + + const parsed = TeammateIdleInputSchema.safeParse(JSON.parse(stdin)) + if (!parsed.success) { + return { output: `Invalid teammate-idle input: ${parsed.error.message}`, exitCode: EXIT_ERROR } + } + const input = parsed.data + const agentName = input.teammate_name ?? '' + + const result = engine.transaction( + input.session_id, + 'check-idle', + (w: TWorkflow) => handler.check(w, agentName), + ) + + return engineResultToRunnerResult(result) +} diff --git a/packages/agentic-workflow-builder/src/cli/index.spec.ts b/packages/agentic-workflow-builder/src/cli/index.spec.ts index bb10118..679710d 100644 --- a/packages/agentic-workflow-builder/src/cli/index.spec.ts +++ b/packages/agentic-workflow-builder/src/cli/index.spec.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from 'vitest' -import { arg, extractField, defineCommands, defineHooks, createWorkflowRunner, EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK, HookCommonInputSchema, PreToolUseInputSchema } from './index.js' +import { arg, extractField, defineRoutes, defineHooks, createWorkflowRunner, EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK, HookCommonInputSchema, PreToolUseInputSchema, SubagentStartInputSchema, TeammateIdleInputSchema } from './index.js' describe('cli barrel exports', () => { it('exports all public API', () => { expect(arg).toBeDefined() expect(extractField).toBeDefined() - expect(defineCommands).toBeDefined() + expect(defineRoutes).toBeDefined() expect(defineHooks).toBeDefined() expect(createWorkflowRunner).toBeDefined() expect(EXIT_ALLOW).toBe(0) @@ -13,5 +13,7 @@ describe('cli barrel exports', () => { expect(EXIT_BLOCK).toBe(2) expect(HookCommonInputSchema).toBeDefined() expect(PreToolUseInputSchema).toBeDefined() + expect(SubagentStartInputSchema).toBeDefined() + expect(TeammateIdleInputSchema).toBeDefined() }) }) diff --git a/packages/agentic-workflow-builder/src/cli/index.ts b/packages/agentic-workflow-builder/src/cli/index.ts index 5a5dc80..7b64dab 100644 --- a/packages/agentic-workflow-builder/src/cli/index.ts +++ b/packages/agentic-workflow-builder/src/cli/index.ts @@ -1,13 +1,13 @@ export { arg } from './domain/arg-helpers.js' export type { ArgParser, ArgResult } from './domain/arg-helpers.js' export { extractField } from './domain/extract-field.js' -export { defineCommands } from './domain/command-definition.js' -export type { CommandDefinition, CommandMap } from './domain/command-definition.js' +export { defineRoutes } from './domain/command-definition.js' +export type { RouteDefinition, RouteMap } from './domain/command-definition.js' export { defineHooks } from './domain/hook-definition.js' -export type { HookCheck, HookDefinition } from './domain/hook-definition.js' +export type { HookCheck, HookDefinition, SubagentStartHandler, TeammateIdleHandler } from './domain/hook-definition.js' export { createWorkflowRunner } from './domain/workflow-runner.js' -export type { RunnerResult, WorkflowCliConfig } from './domain/workflow-runner.js' +export type { RunnerResult, WorkflowRunnerConfig } from './domain/workflow-runner.js' export { createWorkflowCli } from './domain/workflow-cli.js' export { EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from './domain/exit-codes.js' -export { HookCommonInputSchema, PreToolUseInputSchema } from './domain/hook-schemas.js' -export type { PreToolUseInput } from './domain/hook-schemas.js' +export { HookCommonInputSchema, PreToolUseInputSchema, SubagentStartInputSchema, TeammateIdleInputSchema } from './domain/hook-schemas.js' +export type { PreToolUseInput, SubagentStartInput, TeammateIdleInput } from './domain/hook-schemas.js' diff --git a/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.spec.ts b/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.spec.ts index 354f418..7547c4c 100644 --- a/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.spec.ts +++ b/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.spec.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { WorkflowEngine } from './workflow-engine.js' import type { RehydratableWorkflow, - WorkflowFactory, + WorkflowDefinition, WorkflowEventStore, WorkflowEngineDeps, } from './workflow-engine.js' @@ -108,7 +108,7 @@ const TEST_REGISTRY: WorkflowRegistry<TestState, TestStateName, string> = { }, } -function makeFactory(workflow?: StubWorkflow): WorkflowFactory<StubWorkflow, TestState, TestDeps, TestStateName, string> { +function makeFactory(workflow?: StubWorkflow): WorkflowDefinition<StubWorkflow, TestState, TestDeps, TestStateName, string> { return { rehydrate: (_events, _deps) => workflow ?? new StubWorkflow(INITIAL_STATE), createFresh: (_deps) => workflow ?? new StubWorkflow(INITIAL_STATE), @@ -356,7 +356,7 @@ describe('WorkflowEngine.transaction', () => { it('uses default operation body when getOperationBody is not provided', () => { const { getOperationBody: _, ...rest } = makeFactory() - const factory: WorkflowFactory<StubWorkflow, TestState, TestDeps, TestStateName, string> = rest + const factory: WorkflowDefinition<StubWorkflow, TestState, TestDeps, TestStateName, string> = rest const engine = new WorkflowEngine(factory, makeEngineDeps(), makeTestDeps()) const result = engine.transaction('sess1', 'record-issue', () => pass()) expect(result.type).toStrictEqual('success') @@ -392,7 +392,7 @@ describe('WorkflowEngine.transition', () => { transitionGuard: () => fail('Guard failed: missing issue'), }, } - const factory: WorkflowFactory<StubWorkflow, TestState, TestDeps, TestStateName, string> = { + const factory: WorkflowDefinition<StubWorkflow, TestState, TestDeps, TestStateName, string> = { ...makeFactory(), getRegistry: () => guardRegistry, } @@ -412,7 +412,7 @@ describe('WorkflowEngine.transition', () => { transitionGuard: () => fail('Should not run'), }, } - const factory: WorkflowFactory<StubWorkflow, TestState, TestDeps, TestStateName, string> = { + const factory: WorkflowDefinition<StubWorkflow, TestState, TestDeps, TestStateName, string> = { ...makeFactory(), getRegistry: () => guardRegistry, } @@ -433,7 +433,7 @@ describe('WorkflowEngine.transition', () => { }, }, } - const factory: WorkflowFactory<StubWorkflow, TestState, TestDeps, TestStateName, string> = { + const factory: WorkflowDefinition<StubWorkflow, TestState, TestDeps, TestStateName, string> = { ...makeFactory(), getRegistry: () => entryRegistry, } @@ -453,7 +453,7 @@ describe('WorkflowEngine.transition', () => { afterEntry: () => { afterEntryCalled = true }, }, } - const factory: WorkflowFactory<StubWorkflow, TestState, TestDeps, TestStateName, string> = { + const factory: WorkflowDefinition<StubWorkflow, TestState, TestDeps, TestStateName, string> = { ...makeFactory(), getRegistry: () => afterEntryRegistry, } @@ -465,7 +465,7 @@ describe('WorkflowEngine.transition', () => { it('uses custom buildTransitionEvent when provided', () => { const appended: Array<{ sessionId: string; events: readonly BaseEvent[] }> = [] - const factory: WorkflowFactory<StubWorkflow, TestState, TestDeps, TestStateName, string> = { + const factory: WorkflowDefinition<StubWorkflow, TestState, TestDeps, TestStateName, string> = { ...makeFactory(), buildTransitionEvent: (from, to, _before, _after, now) => ({ type: 'transitioned', @@ -498,7 +498,7 @@ describe('WorkflowEngine.transition', () => { it('uses default transition title when getTransitionTitle is not provided', () => { const { getTransitionTitle: _, ...rest } = makeFactory() - const factory: WorkflowFactory<StubWorkflow, TestState, TestDeps, TestStateName, string> = rest + const factory: WorkflowDefinition<StubWorkflow, TestState, TestDeps, TestStateName, string> = rest const engine = new WorkflowEngine(factory, makeEngineDeps(), makeTestDeps()) const result = engine.transition('sess1', 'PLANNING') expect(result.type).toStrictEqual('success') diff --git a/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.ts b/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.ts index 40b6dac..62645c8 100644 --- a/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.ts +++ b/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.ts @@ -29,7 +29,7 @@ export interface RehydratableWorkflow<TState extends BaseWorkflowState> { startSession(transcriptPath: string | undefined, repository: string | undefined): void } -export interface WorkflowFactory< +export interface WorkflowDefinition< TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, @@ -72,12 +72,12 @@ export class WorkflowEngine< TStateName extends string = string, TOperation extends string = string, > { - private readonly factory: WorkflowFactory<TWorkflow, TState, TDeps, TStateName, TOperation> + private readonly factory: WorkflowDefinition<TWorkflow, TState, TDeps, TStateName, TOperation> private readonly engineDeps: WorkflowEngineDeps private readonly workflowDeps: TDeps constructor( - factory: WorkflowFactory<TWorkflow, TState, TDeps, TStateName, TOperation>, + factory: WorkflowDefinition<TWorkflow, TState, TDeps, TStateName, TOperation>, engineDeps: WorkflowEngineDeps, workflowDeps: TDeps, ) { diff --git a/packages/agentic-workflow-builder/src/engine/index.ts b/packages/agentic-workflow-builder/src/engine/index.ts index 8bf41bb..1b01772 100644 --- a/packages/agentic-workflow-builder/src/engine/index.ts +++ b/packages/agentic-workflow-builder/src/engine/index.ts @@ -32,7 +32,7 @@ export { WorkflowEngine } from './domain/workflow-engine.js' export type { EngineResult, RehydratableWorkflow, - WorkflowFactory, + WorkflowDefinition, WorkflowEventStore, WorkflowEngineDeps, } from './domain/workflow-engine.js' diff --git a/src/analytics/cli-routes.spec.ts b/src/analytics/cli-routes.spec.ts new file mode 100644 index 0000000..2101069 --- /dev/null +++ b/src/analytics/cli-routes.spec.ts @@ -0,0 +1,215 @@ +import { isAnalyticsCommand, routeAnalytics } from './cli-routes.js' +import { EXIT_ERROR, EXIT_ALLOW } from '@ntcoding/agentic-workflow-builder/cli' +import { WorkflowError } from '../infra/workflow-error.js' +import type { AnalyticsDeps, ReportDeps } from '../infra/composition-root.js' + +function makeAnalyticsDeps(overrides?: Partial<AnalyticsDeps>): AnalyticsDeps { + return { + computeSession: (_sessionId: string) => 'Session: test-session\n===', + computeAll: () => 'Total Sessions: 0', + computeEventContext: (_sessionId: string) => 'Session: test-session\nState: SPAWN (iteration: 0)', + ...overrides, + } +} + +function makeReportDeps(overrides?: Partial<ReportDeps>): ReportDeps { + return { + getAnalysisContext: () => '# Session Analysis Context\ntest data', + generateReport: () => ({ path: '/tmp/session-report-test.html' }), + readAnalysisFile: () => '# Analysis\ntest', + ...overrides, + } +} + +function makeDeps(overrides?: { analyticsDeps?: Partial<AnalyticsDeps>; reportDeps?: Partial<ReportDeps> }) { + return { + analyticsDeps: makeAnalyticsDeps(overrides?.analyticsDeps), + reportDeps: makeReportDeps(overrides?.reportDeps), + } +} + +describe('isAnalyticsCommand', () => { + it('returns true for analyze', () => { + expect(isAnalyticsCommand('analyze')).toStrictEqual(true) + }) + + it('returns true for view-report', () => { + expect(isAnalyticsCommand('view-report')).toStrictEqual(true) + }) + + it('returns false for non-analytics commands', () => { + expect(isAnalyticsCommand('init')).toStrictEqual(false) + expect(isAnalyticsCommand('transition')).toStrictEqual(false) + }) +}) + +describe('routeAnalytics - analyze command', () => { + it('returns EXIT_ERROR when no sessionId or --all is given', () => { + const result = routeAnalytics('analyze', ['analyze'], makeDeps()) + expect(result.exitCode).toStrictEqual(EXIT_ERROR) + expect(result.output).toContain('missing required argument') + }) + + it('returns EXIT_ALLOW and calls computeSession with the given sessionId', () => { + const calledWith: string[] = [] + const result = routeAnalytics('analyze', ['analyze', 'my-session'], makeDeps({ + analyticsDeps: { + computeSession: (sessionId) => { + calledWith.push(sessionId) + return 'Session: my-session\n===' + }, + }, + })) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + expect(calledWith[0]).toStrictEqual('my-session') + }) + + it('returns EXIT_ALLOW and calls computeAll when --all is given', () => { + const computeAllCalls: string[] = [] + const result = routeAnalytics('analyze', ['analyze', '--all'], makeDeps({ + analyticsDeps: { + computeAll: () => { + computeAllCalls.push('called') + return 'Total Sessions: 5' + }, + }, + })) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + expect(computeAllCalls).toHaveLength(1) + }) +}) + +describe('routeAnalytics - view-report command', () => { + it('returns EXIT_ERROR when no sessionId is given', () => { + const result = routeAnalytics('view-report', ['view-report'], makeDeps()) + expect(result.exitCode).toStrictEqual(EXIT_ERROR) + expect(result.output).toContain('missing required argument') + }) + + describe('phase 1 — analysis context output', () => { + it('outputs analysisContext from getAnalysisContext when no flags are given', () => { + const result = routeAnalytics('view-report', ['view-report', 'my-session'], makeDeps({ + reportDeps: { + getAnalysisContext: (sessionId) => `# Context for ${sessionId}`, + }, + })) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + expect(result.output).toStrictEqual('# Context for my-session') + }) + + it('does not call generateReport in phase 1', () => { + const calls: string[] = [] + routeAnalytics('view-report', ['view-report', 'abc'], makeDeps({ + reportDeps: { + getAnalysisContext: () => 'context', + generateReport: () => { calls.push('called'); return { path: '/tmp/report.html' } }, + }, + })) + expect(calls).toStrictEqual([]) + }) + }) + + describe('phase 2 — render with analysis file', () => { + it('reads analysis file and passes content to generateReport', () => { + const capturedAnalysis: Array<string | undefined> = [] + const result = routeAnalytics('view-report', ['view-report', 'abc-123', '--render', '/tmp/analysis.md'], makeDeps({ + reportDeps: { + readAnalysisFile: (path) => `analysis from ${path}`, + generateReport: (_id, options) => { + capturedAnalysis.push(options?.analysis) + return { path: '/tmp/session-report.html' } + }, + }, + })) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + expect(result.output).toStrictEqual('/tmp/session-report.html') + expect(capturedAnalysis[0]).toStrictEqual('analysis from /tmp/analysis.md') + }) + + it('handles --render before sessionId in args', () => { + const calledWith: string[] = [] + const result = routeAnalytics('view-report', ['view-report', '--render', '/tmp/a.md', 'abc-123'], makeDeps({ + reportDeps: { + readAnalysisFile: () => 'content', + generateReport: (sessionId) => { + calledWith.push(sessionId) + return { path: '/tmp/report.html' } + }, + }, + })) + expect(calledWith[0]).toStrictEqual('abc-123') + expect(result.output).toStrictEqual('/tmp/report.html') + }) + }) + + describe('simple mode — no analysis', () => { + it('generates report without analysis and outputs only link', () => { + const capturedOptions: Array<{ analysis?: string } | undefined> = [] + const result = routeAnalytics('view-report', ['view-report', 'abc-123', '--simple'], makeDeps({ + reportDeps: { + generateReport: (_id, options) => { + capturedOptions.push(options) + return { path: '/tmp/session-report-abc-123.html' } + }, + }, + })) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + expect(result.output).toStrictEqual('/tmp/session-report-abc-123.html') + expect(capturedOptions[0]).toStrictEqual(undefined) + }) + + it('handles --simple before sessionId in args', () => { + const calledWith: string[] = [] + const result = routeAnalytics('view-report', ['view-report', '--simple', 'abc-123'], makeDeps({ + reportDeps: { + generateReport: (sessionId) => { + calledWith.push(sessionId) + return { path: '/tmp/report.html' } + }, + }, + })) + expect(calledWith[0]).toStrictEqual('abc-123') + expect(result.output).toStrictEqual('/tmp/report.html') + }) + }) + + describe('error handling', () => { + it('returns EXIT_ERROR with message when getAnalysisContext throws WorkflowError', () => { + const result = routeAnalytics('view-report', ['view-report', 'bad-id'], makeDeps({ + reportDeps: { + getAnalysisContext: () => { throw new WorkflowError('No events found for session "bad-id"') }, + }, + })) + expect(result.exitCode).toStrictEqual(EXIT_ERROR) + expect(result.output).toContain('view-report: No events found for session "bad-id"') + }) + + it('returns EXIT_ERROR with message when generateReport throws WorkflowError', () => { + const result = routeAnalytics('view-report', ['view-report', 'bad-id', '--simple'], makeDeps({ + reportDeps: { + generateReport: () => { throw new WorkflowError('No events found for session "bad-id"') }, + }, + })) + expect(result.exitCode).toStrictEqual(EXIT_ERROR) + expect(result.output).toContain('view-report: No events found for session "bad-id"') + }) + + it('returns EXIT_ERROR with message when readAnalysisFile throws WorkflowError', () => { + const result = routeAnalytics('view-report', ['view-report', 'abc', '--render', '/tmp/missing.md'], makeDeps({ + reportDeps: { + readAnalysisFile: () => { throw new WorkflowError('File not found') }, + }, + })) + expect(result.exitCode).toStrictEqual(EXIT_ERROR) + expect(result.output).toContain('view-report: File not found') + }) + + it('re-throws non-WorkflowError exceptions', () => { + expect(() => routeAnalytics('view-report', ['view-report', 'bad-id'], makeDeps({ + reportDeps: { + getAnalysisContext: () => { throw new TypeError('unexpected') }, + }, + }))).toThrow('unexpected') + }) + }) +}) diff --git a/src/analytics/cli-routes.ts b/src/analytics/cli-routes.ts new file mode 100644 index 0000000..e9565b3 --- /dev/null +++ b/src/analytics/cli-routes.ts @@ -0,0 +1,66 @@ +import type { RunnerResult } from '@ntcoding/agentic-workflow-builder/cli' +import { EXIT_ALLOW, EXIT_ERROR } from '@ntcoding/agentic-workflow-builder/cli' +import type { AnalyticsDeps, ReportDeps } from '../infra/composition-root.js' +import { WorkflowError } from '../infra/workflow-error.js' + +const ANALYTICS_COMMANDS: ReadonlySet<string> = new Set(['analyze', 'view-report']) + +export function isAnalyticsCommand(command: string): boolean { + return ANALYTICS_COMMANDS.has(command) +} + +export function routeAnalytics(command: string, args: readonly string[], deps: { readonly analyticsDeps: AnalyticsDeps; readonly reportDeps: ReportDeps }): RunnerResult { + switch (command) { + case 'analyze': + return handleAnalyze(args, deps.analyticsDeps) + case 'view-report': + return handleViewReport(args, deps.reportDeps) + /* v8 ignore next 2 */ + default: + return { output: `Unknown analytics command: ${command}`, exitCode: EXIT_ERROR } + } +} + +function handleAnalyze(args: readonly string[], analyticsDeps: AnalyticsDeps): RunnerResult { + if (args[1] === '--all') { + return { output: analyticsDeps.computeAll(), exitCode: EXIT_ALLOW } + } + const sessionId = args[1] + if (!sessionId) { + return { output: 'analyze: missing required argument <sessionId> or --all', exitCode: EXIT_ERROR } + } + return { output: analyticsDeps.computeSession(sessionId), exitCode: EXIT_ALLOW } +} + +function extractPositionalArgs(args: readonly string[]): readonly string[] { + const renderIdx = args.indexOf('--render') + const renderValueIdx = renderIdx === -1 ? -1 : renderIdx + 1 + return args.filter((a, i) => !a.startsWith('--') && i !== renderValueIdx) +} + +function handleViewReport(args: readonly string[], reportDeps: ReportDeps): RunnerResult { + const positionalArgs = extractPositionalArgs(args) + const sessionId = positionalArgs[1] + if (!sessionId) { + return { output: 'view-report: missing required argument <sessionId>', exitCode: EXIT_ERROR } + } + const simple = args.includes('--simple') + const renderIdx = args.indexOf('--render') + const analysisFile = renderIdx === -1 ? undefined : args[renderIdx + 1] + + try { + if (simple) { + return { output: reportDeps.generateReport(sessionId).path, exitCode: EXIT_ALLOW } + } + if (analysisFile) { + const analysis = reportDeps.readAnalysisFile(analysisFile) + return { output: reportDeps.generateReport(sessionId, { analysis }).path, exitCode: EXIT_ALLOW } + } + return { output: reportDeps.getAnalysisContext(sessionId), exitCode: EXIT_ALLOW } + } catch (error) { + if (error instanceof WorkflowError) { + return { output: `view-report: ${error.message}`, exitCode: EXIT_ERROR } + } + throw error + } +} diff --git a/src/autonomous-claude-agent-team-workflow-argument-validation.spec.ts b/src/autonomous-claude-agent-team-workflow-argument-validation.spec.ts index b568079..d113259 100644 --- a/src/autonomous-claude-agent-team-workflow-argument-validation.spec.ts +++ b/src/autonomous-claude-agent-team-workflow-argument-validation.spec.ts @@ -1,155 +1,10 @@ -import { runWorkflow } from './autonomous-claude-agent-team-workflow.js' -import type { AdapterDeps, AnalyticsDeps } from './autonomous-claude-agent-team-workflow.js' -import type { WorkflowEngineDeps, WorkflowEventStore } from '@ntcoding/agentic-workflow-builder/engine' -import type { WorkflowDeps } from './workflow-definition/index.js' +import { runWorkflow } from './entrypoint.js' +import { EXIT_ERROR, EXIT_ALLOW } from './infra/hook-io.js' import type { WorkflowEvent } from './workflow-definition/index.js' -import { EXIT_ERROR, EXIT_ALLOW, EXIT_BLOCK } from './infra/hook-io.js' +import { makeDeps } from './autonomous-claude-agent-team-workflow-cli-test-fixtures.js' const AT = '2026-01-01T00:00:00Z' -function makeHookStdin(overrides: Record<string, unknown> = {}): string { - return JSON.stringify({ - session_id: 'test-session', - transcript_path: '/test/transcript.jsonl', - cwd: '/project', - permission_mode: 'default', - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: {}, - tool_use_id: 'tool-1', - ...overrides, - }) -} - -function spawnReadyEvents(): readonly WorkflowEvent[] { - return [ - { type: 'issue-recorded', at: AT, issueNumber: 42 }, - { type: 'agent-registered', at: AT, agentType: 'developer-1', agentId: 'agt-1' }, - { type: 'agent-registered', at: AT, agentType: 'reviewer-1', agentId: 'agt-2' }, - ] -} - -function planningEvents(): readonly WorkflowEvent[] { - return [ - ...spawnReadyEvents(), - { type: 'transitioned', at: AT, from: 'SPAWN', to: 'PLANNING' }, - ] -} - -function developingEvents(): readonly WorkflowEvent[] { - return [ - ...planningEvents(), - { type: 'iteration-task-assigned', at: AT, task: 'Build the thing' }, - { type: 'transitioned', at: AT, from: 'PLANNING', to: 'RESPAWN', iteration: 0 }, - { type: 'transitioned', at: AT, from: 'RESPAWN', to: 'DEVELOPING', iteration: 0, developingHeadCommit: 'abc123' }, - ] -} - -function reviewingEvents(): readonly WorkflowEvent[] { - return [ - ...developingEvents(), - { type: 'developer-done-signaled', at: AT }, - { type: 'transitioned', at: AT, from: 'DEVELOPING', to: 'REVIEWING' }, - ] -} - -function committingEvents(): readonly WorkflowEvent[] { - return [ - ...reviewingEvents(), - { type: 'review-approved', at: AT }, - { type: 'transitioned', at: AT, from: 'REVIEWING', to: 'COMMITTING' }, - ] -} - -function crReviewEvents(): readonly WorkflowEvent[] { - return [ - ...committingEvents(), - { type: 'pr-created', at: AT, prNumber: 99 }, - { type: 'transitioned', at: AT, from: 'COMMITTING', to: 'CR_REVIEW' }, - ] -} - -function prCreationEvents(): readonly WorkflowEvent[] { - return [ - ...crReviewEvents(), - { type: 'coderabbit-addressed', at: AT }, - { type: 'transitioned', at: AT, from: 'CR_REVIEW', to: 'PR_CREATION' }, - ] -} - -type EngineDepsOverrides = { store?: Partial<WorkflowEventStore> } & Partial<Omit<WorkflowEngineDeps, 'store'>> - -function makeStore(overrides?: Partial<WorkflowEventStore>): WorkflowEventStore { - return { - readEvents: () => [], - appendEvents: () => undefined, - sessionExists: () => true, - ...overrides, - } -} - -function makeEngineDeps(overrides?: EngineDepsOverrides): WorkflowEngineDeps { - const { store: storeOverrides, ...rest } = overrides ?? {} - return { - store: makeStore(storeOverrides), - getPluginRoot: () => '/plugin', - getEnvFilePath: () => '/test/claude.env', - readFile: () => '', - appendToFile: () => undefined, - now: () => AT, - transcriptReader: { readMessages: () => [] }, - ...rest, - } -} - -function makeWorkflowDeps(overrides?: Partial<WorkflowDeps>): WorkflowDeps { - return { - getGitInfo: () => ({ - currentBranch: 'main', - workingTreeClean: true, - headCommit: 'abc123', - changedFilesVsDefault: [], - hasCommitsVsDefault: false, - }), - checkPrChecks: () => true, - createDraftPr: () => 99, - appendIssueChecklist: () => undefined, - tickFirstUncheckedIteration: () => undefined, - runEslintOnFiles: () => true, - fileExists: () => false, - getPluginRoot: () => '/plugin', - now: () => AT, - ...overrides, - } -} - -function makeAnalyticsDeps(overrides?: Partial<AnalyticsDeps>): AnalyticsDeps { - return { - computeSession: (_sessionId: string) => 'Session: test-session\n===', - computeAll: () => 'Total Sessions: 0', - computeEventContext: (_sessionId: string) => 'Session: test-session\nState: SPAWN (iteration: 0)', - ...overrides, - } -} - -function makeDeps(overrides?: { - engineDeps?: EngineDepsOverrides - workflowDeps?: Partial<WorkflowDeps> - analyticsDeps?: Partial<AnalyticsDeps> - getSessionId?: () => string - readStdin?: () => string -}): AdapterDeps { - return { - getSessionId: overrides?.getSessionId ?? (() => 'test-session'), - getRepositoryName: () => undefined, - readStdin: overrides?.readStdin ?? (() => makeHookStdin()), - engineDeps: makeEngineDeps(overrides?.engineDeps), - workflowDeps: makeWorkflowDeps(overrides?.workflowDeps), - analyticsDeps: makeAnalyticsDeps(overrides?.analyticsDeps), - reportDeps: { getAnalysisContext: () => '', generateReport: () => ({ path: '/tmp/report.html' }), readAnalysisFile: () => '' }, - } -} - describe('runWorkflow - unknown commands', () => { it('returns EXIT_ERROR with unknown command message for unrecognised command', () => { const result = runWorkflow(['bad-command'], makeDeps()) diff --git a/src/autonomous-claude-agent-team-workflow-cli-commands.spec.ts b/src/autonomous-claude-agent-team-workflow-cli-commands.spec.ts index 20e9896..7821c72 100644 --- a/src/autonomous-claude-agent-team-workflow-cli-commands.spec.ts +++ b/src/autonomous-claude-agent-team-workflow-cli-commands.spec.ts @@ -1,7 +1,8 @@ -import { runWorkflow } from './autonomous-claude-agent-team-workflow.js' -import { EXIT_ERROR, EXIT_ALLOW, EXIT_BLOCK } from './infra/hook-io.js' +import { runWorkflow } from './entrypoint.js' +import { EXIT_ALLOW, EXIT_BLOCK } from './infra/hook-io.js' import { makeDeps, + makeHookStdin, spawnReadyEvents, planningEvents, developingEvents, @@ -11,17 +12,31 @@ import { prCreationEvents, } from './autonomous-claude-agent-team-workflow-cli-test-fixtures.js' +describe('runWorkflow - routing overview', () => { + it('delegates to hook handling when no command is given', () => { + const result = runWorkflow( + [], + makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'SessionStart' }) }), + ) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + }) + + it('delegates to analytics when command is analyze', () => { + const result = runWorkflow(['analyze', '--all'], makeDeps()) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + }) +}) + describe('runWorkflow - CLI command routing', () => { it('dispatches init and returns success', () => { const result = runWorkflow(['init'], makeDeps()) expect(result.exitCode).toStrictEqual(0) }) - it('dispatches run-lint with no files and returns success when no session', () => { - const result = runWorkflow(['run-lint'], makeDeps({ + it('throws when run-lint has no session', () => { + expect(() => runWorkflow(['run-lint'], makeDeps({ engineDeps: { store: { sessionExists: () => false } }, - })) - expect(result.exitCode).toStrictEqual(0) + }))).toThrow("No session found for 'test-session'. Run init first.") }) it('dispatches run-lint with no files and returns success when session exists with iteration', () => { @@ -148,39 +163,3 @@ describe('runWorkflow - new review and coderabbit commands', () => { expect(result.exitCode).toStrictEqual(EXIT_BLOCK) }) }) - -describe('runWorkflow - analyze command', () => { - it('returns EXIT_ERROR when no sessionId or --all is given', () => { - const result = runWorkflow(['analyze'], makeDeps()) - expect(result.exitCode).toStrictEqual(EXIT_ERROR) - expect(result.output).toContain('missing required argument') - }) - - it('returns EXIT_ALLOW and calls computeSession with the given sessionId', () => { - const calledWith: string[] = [] - const result = runWorkflow(['analyze', 'my-session'], makeDeps({ - analyticsDeps: { - computeSession: (sessionId) => { - calledWith.push(sessionId) - return 'Session: my-session\n===' - }, - }, - })) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - expect(calledWith[0]).toStrictEqual('my-session') - }) - - it('returns EXIT_ALLOW and calls computeAll when --all is given', () => { - const computeAllCalls: string[] = [] - const result = runWorkflow(['analyze', '--all'], makeDeps({ - analyticsDeps: { - computeAll: () => { - computeAllCalls.push('called') - return 'Total Sessions: 5' - }, - }, - })) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - expect(computeAllCalls).toHaveLength(1) - }) -}) diff --git a/src/autonomous-claude-agent-team-workflow-cli-test-fixtures.ts b/src/autonomous-claude-agent-team-workflow-cli-test-fixtures.ts index b82ce54..e89c517 100644 --- a/src/autonomous-claude-agent-team-workflow-cli-test-fixtures.ts +++ b/src/autonomous-claude-agent-team-workflow-cli-test-fixtures.ts @@ -1,4 +1,4 @@ -import type { AdapterDeps, AnalyticsDeps, ReportDeps } from './autonomous-claude-agent-team-workflow.js' +import type { AdapterDeps, AnalyticsDeps, ReportDeps } from './entrypoint.js' import type { WorkflowEngineDeps, WorkflowEventStore } from '@ntcoding/agentic-workflow-builder/engine' import type { WorkflowDeps } from './workflow-definition/index.js' import type { WorkflowEvent } from './workflow-definition/index.js' diff --git a/src/autonomous-claude-agent-team-workflow-view-report.spec.ts b/src/autonomous-claude-agent-team-workflow-view-report.spec.ts index 5336abc..3a21539 100644 --- a/src/autonomous-claude-agent-team-workflow-view-report.spec.ts +++ b/src/autonomous-claude-agent-team-workflow-view-report.spec.ts @@ -1,4 +1,4 @@ -import { runWorkflow } from './autonomous-claude-agent-team-workflow.js' +import { runWorkflow } from './entrypoint.js' import { EXIT_ERROR, EXIT_ALLOW } from './infra/hook-io.js' import { WorkflowError } from './infra/workflow-error.js' import { makeDeps } from './autonomous-claude-agent-team-workflow-cli-test-fixtures.js' diff --git a/src/autonomous-claude-agent-team-workflow-workflow-state-commands.spec.ts b/src/autonomous-claude-agent-team-workflow-workflow-state-commands.spec.ts index 8ab9795..f3754bc 100644 --- a/src/autonomous-claude-agent-team-workflow-workflow-state-commands.spec.ts +++ b/src/autonomous-claude-agent-team-workflow-workflow-state-commands.spec.ts @@ -1,155 +1,14 @@ -import { runWorkflow } from './autonomous-claude-agent-team-workflow.js' -import type { AdapterDeps, AnalyticsDeps } from './autonomous-claude-agent-team-workflow.js' -import type { WorkflowEngineDeps, WorkflowEventStore } from '@ntcoding/agentic-workflow-builder/engine' -import type { WorkflowDeps } from './workflow-definition/index.js' +import { runWorkflow } from './entrypoint.js' +import { EXIT_ERROR, EXIT_ALLOW } from './infra/hook-io.js' import type { WorkflowEvent } from './workflow-definition/index.js' -import { EXIT_ERROR, EXIT_ALLOW, EXIT_BLOCK } from './infra/hook-io.js' +import { + makeDeps, + planningEvents, + developingEvents, +} from './autonomous-claude-agent-team-workflow-cli-test-fixtures.js' const AT = '2026-01-01T00:00:00Z' -function makeHookStdin(overrides: Record<string, unknown> = {}): string { - return JSON.stringify({ - session_id: 'test-session', - transcript_path: '/test/transcript.jsonl', - cwd: '/project', - permission_mode: 'default', - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: {}, - tool_use_id: 'tool-1', - ...overrides, - }) -} - -function spawnReadyEvents(): readonly WorkflowEvent[] { - return [ - { type: 'issue-recorded', at: AT, issueNumber: 42 }, - { type: 'agent-registered', at: AT, agentType: 'developer-1', agentId: 'agt-1' }, - { type: 'agent-registered', at: AT, agentType: 'reviewer-1', agentId: 'agt-2' }, - ] -} - -function planningEvents(): readonly WorkflowEvent[] { - return [ - ...spawnReadyEvents(), - { type: 'transitioned', at: AT, from: 'SPAWN', to: 'PLANNING' }, - ] -} - -function developingEvents(): readonly WorkflowEvent[] { - return [ - ...planningEvents(), - { type: 'iteration-task-assigned', at: AT, task: 'Build the thing' }, - { type: 'transitioned', at: AT, from: 'PLANNING', to: 'RESPAWN', iteration: 0 }, - { type: 'transitioned', at: AT, from: 'RESPAWN', to: 'DEVELOPING', iteration: 0, developingHeadCommit: 'abc123' }, - ] -} - -function reviewingEvents(): readonly WorkflowEvent[] { - return [ - ...developingEvents(), - { type: 'developer-done-signaled', at: AT }, - { type: 'transitioned', at: AT, from: 'DEVELOPING', to: 'REVIEWING' }, - ] -} - -function committingEvents(): readonly WorkflowEvent[] { - return [ - ...reviewingEvents(), - { type: 'review-approved', at: AT }, - { type: 'transitioned', at: AT, from: 'REVIEWING', to: 'COMMITTING' }, - ] -} - -function crReviewEvents(): readonly WorkflowEvent[] { - return [ - ...committingEvents(), - { type: 'pr-created', at: AT, prNumber: 99 }, - { type: 'transitioned', at: AT, from: 'COMMITTING', to: 'CR_REVIEW' }, - ] -} - -function prCreationEvents(): readonly WorkflowEvent[] { - return [ - ...crReviewEvents(), - { type: 'coderabbit-addressed', at: AT }, - { type: 'transitioned', at: AT, from: 'CR_REVIEW', to: 'PR_CREATION' }, - ] -} - -type EngineDepsOverrides = { store?: Partial<WorkflowEventStore> } & Partial<Omit<WorkflowEngineDeps, 'store'>> - -function makeStore(overrides?: Partial<WorkflowEventStore>): WorkflowEventStore { - return { - readEvents: () => [], - appendEvents: () => undefined, - sessionExists: () => true, - ...overrides, - } -} - -function makeEngineDeps(overrides?: EngineDepsOverrides): WorkflowEngineDeps { - const { store: storeOverrides, ...rest } = overrides ?? {} - return { - store: makeStore(storeOverrides), - getPluginRoot: () => '/plugin', - getEnvFilePath: () => '/test/claude.env', - readFile: () => '', - appendToFile: () => undefined, - now: () => AT, - transcriptReader: { readMessages: () => [] }, - ...rest, - } -} - -function makeWorkflowDeps(overrides?: Partial<WorkflowDeps>): WorkflowDeps { - return { - getGitInfo: () => ({ - currentBranch: 'main', - workingTreeClean: true, - headCommit: 'abc123', - changedFilesVsDefault: [], - hasCommitsVsDefault: false, - }), - checkPrChecks: () => true, - createDraftPr: () => 99, - appendIssueChecklist: () => undefined, - tickFirstUncheckedIteration: () => undefined, - runEslintOnFiles: () => true, - fileExists: () => false, - getPluginRoot: () => '/plugin', - now: () => AT, - ...overrides, - } -} - -function makeAnalyticsDeps(overrides?: Partial<AnalyticsDeps>): AnalyticsDeps { - return { - computeSession: (_sessionId: string) => 'Session: test-session\n===', - computeAll: () => 'Total Sessions: 0', - computeEventContext: (_sessionId: string) => 'Session: test-session\nState: SPAWN (iteration: 0)', - ...overrides, - } -} - -function makeDeps(overrides?: { - engineDeps?: EngineDepsOverrides - workflowDeps?: Partial<WorkflowDeps> - analyticsDeps?: Partial<AnalyticsDeps> - getSessionId?: () => string - readStdin?: () => string -}): AdapterDeps { - return { - getSessionId: overrides?.getSessionId ?? (() => 'test-session'), - getRepositoryName: () => undefined, - readStdin: overrides?.readStdin ?? (() => makeHookStdin()), - engineDeps: makeEngineDeps(overrides?.engineDeps), - workflowDeps: makeWorkflowDeps(overrides?.workflowDeps), - analyticsDeps: makeAnalyticsDeps(overrides?.analyticsDeps), - reportDeps: { getAnalysisContext: () => '', generateReport: () => ({ path: '/tmp/report.html' }), readAnalysisFile: () => '' }, - } -} - describe('runWorkflow - record-plan-approval command', () => { it('dispatches record-plan-approval and returns success when in PLANNING state', () => { const result = runWorkflow( @@ -227,42 +86,31 @@ describe('runWorkflow - write-journal command', () => { }) }) -describe('runWorkflow - event-context command', () => { +describe('runWorkflow - get-session-summary command', () => { it('throws when no session exists', () => { expect(() => runWorkflow( - ['event-context'], + ['get-session-summary'], makeDeps({ engineDeps: { store: { sessionExists: () => false } } }), )).toThrow("No session found for 'test-session'. Run init first.") }) - it('returns EXIT_ALLOW with output from computeEventContext when session exists', () => { + it('returns EXIT_ALLOW with state summary when session exists', () => { const result = runWorkflow( - ['event-context'], + ['get-session-summary'], makeDeps({ - engineDeps: { store: { sessionExists: () => true } }, - analyticsDeps: { computeEventContext: () => 'Session: test-session\nState: DEVELOPING (iteration: 0)' }, + engineDeps: { + store: { sessionExists: () => true, readEvents: () => developingEvents() }, + }, }), ) expect(result.exitCode).toStrictEqual(EXIT_ALLOW) expect(result.output).toContain('DEVELOPING') }) - it('passes sessionId to computeEventContext', () => { - const calls: string[] = [] - runWorkflow( - ['event-context'], - makeDeps({ - engineDeps: { store: { sessionExists: () => true } }, - analyticsDeps: { computeEventContext: (id) => { calls.push(id); return '' } }, - }), - ) - expect(calls[0]).toStrictEqual('test-session') - }) - it('appends context-requested event when session exists', () => { const appended: Array<{ firstEventType: string }> = [] runWorkflow( - ['event-context', 'developer-1'], + ['get-session-summary', 'developer-1'], makeDeps({ engineDeps: { store: { @@ -275,42 +123,4 @@ describe('runWorkflow - event-context command', () => { ) expect(appended[0]?.firstEventType).toStrictEqual('context-requested') }) - - it('PreToolUse records identity-verified event via engine prefix verification', () => { - const appended: Array<{ firstEventType: string }> = [] - const result = runWorkflow( - [], - makeDeps({ - readStdin: () => makeHookStdin({ hook_event_name: 'PreToolUse' }), - engineDeps: { - store: { - sessionExists: () => true, - readEvents: () => planningEvents(), - appendEvents: (_sessionId, events) => appended.push({ firstEventType: events[0]?.type ?? '' }), - }, - }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - expect(appended[0]?.firstEventType).toStrictEqual('identity-verified') - }) - - it('PreToolUse blocks when identity is lost', () => { - const result = runWorkflow( - [], - makeDeps({ - readStdin: () => makeHookStdin({ hook_event_name: 'PreToolUse' }), - engineDeps: { - transcriptReader: { - readMessages: () => [ - { id: '1', textContent: 'LEAD: PLANNING' }, - { id: '2', textContent: 'No prefix here' }, - ], - }, - store: { sessionExists: () => true, readEvents: () => planningEvents() }, - }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_BLOCK) - }) }) diff --git a/src/autonomous-claude-agent-team-workflow.ts b/src/autonomous-claude-agent-team-workflow.ts deleted file mode 100644 index 1ff9c8f..0000000 --- a/src/autonomous-claude-agent-team-workflow.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { appendFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import type { EngineResult } from '@ntcoding/agentic-workflow-builder/engine' -import { WorkflowEngine } from '@ntcoding/agentic-workflow-builder/engine' -import { createWorkflowRunner, defineCommands, arg, EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from '@ntcoding/agentic-workflow-builder/cli' -import type { RunnerResult } from '@ntcoding/agentic-workflow-builder/cli' -import { pass } from '@ntcoding/agentic-workflow-builder/dsl' -import { WorkflowAdapter, StateNameSchema, BASH_FORBIDDEN, checkWriteAllowed } from './workflow-definition/index.js' -import type { Workflow, WorkflowDeps } from './workflow-definition/index.js' -import type { StateName, WorkflowOperation, WorkflowState } from './workflow-definition/domain/workflow-types.js' -import { - parsePreToolUseInput, - parseSubagentStartInput, - parseTeammateIdleInput, - parseCommonInput, - formatDenyDecision, - formatContextInjection, -} from './infra/hook-io.js' -import { WorkflowError } from './infra/workflow-error.js' -import { buildRealDeps } from './infra/composition-root.js' -import type { AnalyticsDeps, ReportDeps, ReportResult, AdapterDeps } from './infra/composition-root.js' -export type { AnalyticsDeps, ReportDeps, ReportResult, AdapterDeps } - -type Engine = WorkflowEngine<Workflow, WorkflowState, WorkflowDeps, StateName, WorkflowOperation> - -function narrowNumber(v: unknown): number { - /* v8 ignore next */ - if (typeof v !== 'number') throw new WorkflowError(`Expected number, got ${typeof v}`) - return v -} - -function narrowString(v: unknown): string { - /* v8 ignore next */ - if (typeof v !== 'string') throw new WorkflowError(`Expected string, got ${typeof v}`) - return v -} - -const COMMANDS = defineCommands<Workflow, WorkflowState>({ - transition: { type: 'transition', args: [arg.string('session-id'), arg.state('STATE', StateNameSchema)] }, - 'record-issue': { type: 'transaction', args: [arg.string('session-id'), arg.number('number')], handler: (w, n) => w.recordIssue(narrowNumber(n)) }, - 'record-branch': { type: 'transaction', args: [arg.string('session-id'), arg.string('branch')], handler: (w, b) => w.recordBranch(narrowString(b)) }, - 'record-plan-approval': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.recordPlanApproval() }, - 'assign-iteration-task': { type: 'transaction', args: [arg.string('session-id'), arg.string('task')], handler: (w, t) => w.assignIterationTask(narrowString(t)) }, - 'signal-done': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.signalDone() }, - 'record-pr': { type: 'transaction', args: [arg.string('session-id'), arg.number('number')], handler: (w, n) => w.recordPr(narrowNumber(n)) }, - 'create-pr': { type: 'transaction', args: [arg.string('session-id'), arg.string('title'), arg.string('body')], handler: (w, t, b) => w.createPr(narrowString(t), narrowString(b)) }, - 'append-issue-checklist': { type: 'transaction', args: [arg.string('session-id'), arg.number('issue-number'), arg.string('checklist')], handler: (w, n, c) => w.appendIssueChecklist(narrowNumber(n), narrowString(c)) }, - 'tick-iteration': { type: 'transaction', args: [arg.string('session-id'), arg.number('issue-number')], handler: (w, n) => w.tickIteration(narrowNumber(n)) }, - 'review-approved': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.reviewApproved() }, - 'review-rejected': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.reviewRejected() }, - 'coderabbit-feedback-addressed': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.coderabbitFeedbackAddressed() }, - 'coderabbit-feedback-ignored': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.coderabbitFeedbackIgnored() }, - 'shut-down': { type: 'transaction', args: [arg.string('session-id'), arg.string('agent-name')], handler: (w, name) => w.shutDown(narrowString(name)) }, - 'write-journal': { type: 'transaction', args: [arg.string('session-id'), arg.string('agent-name'), arg.string('content')], handler: (w, a, c) => w.writeJournal(narrowString(a), narrowString(c)) }, -}) - -const platformRunner = createWorkflowRunner<Workflow, WorkflowState, WorkflowDeps, StateName, WorkflowOperation>({ - factory: WorkflowAdapter, - commands: COMMANDS, -}) - -type CustomCommandHandler = (args: readonly string[], engine: Engine, deps: AdapterDeps) => RunnerResult - -const CUSTOM_COMMANDS: Readonly<Record<string, CustomCommandHandler>> = { - init: handleInit, - analyze: handleAnalyze, - 'view-report': handleViewReport, - 'event-context': handleEventContext, - 'run-lint': handleRunLint, -} - -export function runWorkflow(args: readonly string[], deps: AdapterDeps): RunnerResult { - const command = args[0] - if (!command) { - return runHookMode(deps) - } - const customHandler = CUSTOM_COMMANDS[command] - if (customHandler) { - const engine = new WorkflowEngine(WorkflowAdapter, deps.engineDeps, deps.workflowDeps) - return customHandler(args, engine, deps) - } - const sessionId = deps.getSessionId() - return platformRunner([command, sessionId, ...args.slice(1)], deps.engineDeps, deps.workflowDeps) -} - -function handleInit(_args: readonly string[], engine: Engine, deps: AdapterDeps): RunnerResult { - return mapResult(engine.startSession(deps.getSessionId(), undefined, deps.getRepositoryName())) -} - -function handleAnalyze(args: readonly string[], _engine: Engine, deps: AdapterDeps): RunnerResult { - if (args[1] === '--all') { - return { output: deps.analyticsDeps.computeAll(), exitCode: EXIT_ALLOW } - } - const sessionId = args[1] - if (!sessionId) { - return { output: 'analyze: missing required argument <sessionId> or --all', exitCode: EXIT_ERROR } - } - return { output: deps.analyticsDeps.computeSession(sessionId), exitCode: EXIT_ALLOW } -} - -function extractPositionalArgs(args: readonly string[]): readonly string[] { - const renderIdx = args.indexOf('--render') - const renderValueIdx = renderIdx === -1 ? -1 : renderIdx + 1 - return args.filter((a, i) => !a.startsWith('--') && i !== renderValueIdx) -} - -function handleViewReport(args: readonly string[], _engine: Engine, deps: AdapterDeps): RunnerResult { - const positionalArgs = extractPositionalArgs(args) - const sessionId = positionalArgs[1] - if (!sessionId) { - return { output: 'view-report: missing required argument <sessionId>', exitCode: EXIT_ERROR } - } - const simple = args.includes('--simple') - const renderIdx = args.indexOf('--render') - const analysisFile = renderIdx === -1 ? undefined : args[renderIdx + 1] - - try { - if (simple) { - return { output: deps.reportDeps.generateReport(sessionId).path, exitCode: EXIT_ALLOW } - } - if (analysisFile) { - const analysis = deps.reportDeps.readAnalysisFile(analysisFile) - return { output: deps.reportDeps.generateReport(sessionId, { analysis }).path, exitCode: EXIT_ALLOW } - } - return { output: deps.reportDeps.getAnalysisContext(sessionId), exitCode: EXIT_ALLOW } - } catch (error) { - if (error instanceof WorkflowError) { - return { output: `view-report: ${error.message}`, exitCode: EXIT_ERROR } - } - throw error - } -} - -function handleEventContext(args: readonly string[], engine: Engine, deps: AdapterDeps): RunnerResult { - const sessionId = deps.getSessionId() - const agentName = args[1] ?? '' - engine.transaction(sessionId, 'event-context', (w) => w.requestContext(agentName)) - return { output: deps.analyticsDeps.computeEventContext(sessionId), exitCode: EXIT_ALLOW } -} - -function handleRunLint(args: readonly string[], engine: Engine, deps: AdapterDeps): RunnerResult { - const sessionId = deps.getSessionId() - if (!engine.hasSession(sessionId)) { - return { output: 'run-lint: no state file. Run init first.', exitCode: EXIT_ALLOW } - } - return mapResult(engine.transaction(sessionId, 'run-lint', (w) => w.runLint(args.slice(1)))) -} - -const HOOK_HANDLERS: Readonly<Record<string, (engine: Engine, deps: AdapterDeps) => RunnerResult>> = { - SessionStart: handleSessionStart, - PreToolUse: handlePreToolUse, - SubagentStart: handleSubagentStart, - TeammateIdle: handleTeammateIdle, -} - -function runHookMode(deps: AdapterDeps): RunnerResult { - const engine = new WorkflowEngine(WorkflowAdapter, deps.engineDeps, deps.workflowDeps) - const stdin = deps.readStdin() - const cachedDeps: AdapterDeps = { ...deps, readStdin: () => stdin } - const common = parseCommonInput(stdin) - const handler = HOOK_HANDLERS[common.hook_event_name] - if (!handler) { - return { output: `Unknown hook event: ${common.hook_event_name}`, exitCode: EXIT_ERROR } - } - if (common.hook_event_name !== 'SessionStart' && !engine.hasSession(common.session_id)) { - return { output: '', exitCode: EXIT_ALLOW } - } - return handler(engine, cachedDeps) -} - -function handleSessionStart(engine: Engine, deps: AdapterDeps): RunnerResult { - const hookInput = parseCommonInput(deps.readStdin()) - engine.persistSessionId(hookInput.session_id) - return { output: '', exitCode: EXIT_ALLOW } -} - -function handlePreToolUse(engine: Engine, deps: AdapterDeps): RunnerResult { - const hookInput = parsePreToolUseInput(deps.readStdin()) - - const filePath = resolveStringField(hookInput.tool_input['file_path']) - || resolveStringField(hookInput.tool_input['path']) - || resolveStringField(hookInput.tool_input['pattern']) - const command = resolveStringField(hookInput.tool_input['command']) - - const pluginCheck = engine.transaction(hookInput.session_id, 'hook-check', (w) => { - return w.checkPluginSourceRead(hookInput.tool_name, filePath, command) - }, hookInput.transcript_path) - if (pluginCheck.type === 'blocked') return { output: formatDenyDecision(pluginCheck.output), exitCode: EXIT_BLOCK } - - const writeCheck = engine.checkWrite(hookInput.session_id, hookInput.tool_name, filePath, checkWriteAllowed, hookInput.transcript_path) - if (writeCheck.type === 'blocked') return { output: formatDenyDecision(writeCheck.output), exitCode: EXIT_BLOCK } - - const bashCheck = engine.checkBash(hookInput.session_id, hookInput.tool_name, command, BASH_FORBIDDEN, hookInput.transcript_path) - if (bashCheck.type === 'blocked') return { output: formatDenyDecision(bashCheck.output), exitCode: EXIT_BLOCK } - - return { output: '', exitCode: EXIT_ALLOW } -} - -function handleSubagentStart(engine: Engine, deps: AdapterDeps): RunnerResult { - const hookInput = parseSubagentStartInput(deps.readStdin()) - const result = engine.transaction(hookInput.session_id, 'register-agent', (w) => { - return w.registerAgent(hookInput.agent_type, hookInput.agent_id) - }) - /* v8 ignore next */ - const state = result.type === 'success' ? result.output : '' - return { output: formatContextInjection(state), exitCode: EXIT_ALLOW } -} - -function handleTeammateIdle(engine: Engine, deps: AdapterDeps): RunnerResult { - const hookInput = parseTeammateIdleInput(deps.readStdin()) - const agentName = hookInput.teammate_name ?? '' - const result = engine.transaction(hookInput.session_id, 'check-idle', (w) => w.checkIdleAllowed(agentName)) - if (result.type === 'blocked') { - return { output: result.output, exitCode: EXIT_BLOCK } - } - return { output: '', exitCode: EXIT_ALLOW } -} - -function resolveStringField(value: unknown): string { - if (value === undefined || value === null) return '' - if (typeof value === 'string') return value - throw new WorkflowError(`Expected string or undefined. Got ${typeof value}: ${String(value)}`) -} - -function mapResult(result: EngineResult): RunnerResult { - /* v8 ignore next */ - const exitCode = result.type === 'success' ? EXIT_ALLOW : result.type === 'blocked' ? EXIT_BLOCK : EXIT_ERROR - return { output: result.output, exitCode } -} - -/* v8 ignore start */ -function main(): void { - try { - const result = runWorkflow(process.argv.slice(2), buildRealDeps()) - process.stdout.write(result.output, () => process.exit(result.exitCode)) - } catch (error) { - const message = `[${new Date().toISOString()}] HOOK ERROR: ${String(error)}\n` - process.stderr.write(message) - appendFileSync('/tmp/feature-team-hook-errors.log', message) - process.exit(EXIT_ERROR) - } -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - main() -} -/* v8 ignore stop */ diff --git a/src/entrypoint.ts b/src/entrypoint.ts new file mode 100644 index 0000000..2bdabf2 --- /dev/null +++ b/src/entrypoint.ts @@ -0,0 +1,97 @@ +import { appendFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import type { EngineResult } from '@ntcoding/agentic-workflow-builder/engine' +import { WorkflowEngine } from '@ntcoding/agentic-workflow-builder/engine' +import { createWorkflowRunner, defineRoutes, arg, EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from '@ntcoding/agentic-workflow-builder/cli' +import type { RunnerResult } from '@ntcoding/agentic-workflow-builder/cli' +import { FeatureTeamWorkflowDefinition, StateNameSchema } from './workflow-definition/index.js' +import type { Workflow, WorkflowDeps } from './workflow-definition/index.js' +import type { StateName, WorkflowOperation, WorkflowState } from './workflow-definition/domain/workflow-types.js' +import { WorkflowError } from './infra/workflow-error.js' +import { buildRealDeps } from './infra/composition-root.js' +import type { AnalyticsDeps, ReportDeps, ReportResult, AdapterDeps } from './infra/composition-root.js' +import { handleHookRoute } from './hook-routes.js' +import { isAnalyticsCommand, routeAnalytics } from './analytics/cli-routes.js' +export type { AnalyticsDeps, ReportDeps, ReportResult, AdapterDeps } + +function narrowNumber(v: unknown): number { + /* v8 ignore next */ + if (typeof v !== 'number') throw new WorkflowError(`Expected number, got ${typeof v}`) + return v +} + +function narrowString(v: unknown): string { + /* v8 ignore next */ + if (typeof v !== 'string') throw new WorkflowError(`Expected string, got ${typeof v}`) + return v +} + +function narrowStringArray(v: unknown): readonly string[] { + /* v8 ignore next */ + if (!Array.isArray(v)) throw new WorkflowError(`Expected string array, got ${typeof v}`) + return v +} + +const ROUTES = defineRoutes<Workflow, WorkflowState>({ + transition: { type: 'transition', args: [arg.string('session-id'), arg.state('STATE', StateNameSchema)] }, + 'record-issue': { type: 'transaction', args: [arg.string('session-id'), arg.number('number')], handler: (w, n) => w.recordIssue(narrowNumber(n)) }, + 'record-branch': { type: 'transaction', args: [arg.string('session-id'), arg.string('branch')], handler: (w, b) => w.recordBranch(narrowString(b)) }, + 'record-plan-approval': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.recordPlanApproval() }, + 'assign-iteration-task': { type: 'transaction', args: [arg.string('session-id'), arg.string('task')], handler: (w, t) => w.assignIterationTask(narrowString(t)) }, + 'signal-done': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.signalDone() }, + 'record-pr': { type: 'transaction', args: [arg.string('session-id'), arg.number('number')], handler: (w, n) => w.recordPr(narrowNumber(n)) }, + 'create-pr': { type: 'transaction', args: [arg.string('session-id'), arg.string('title'), arg.string('body')], handler: (w, t, b) => w.createPr(narrowString(t), narrowString(b)) }, + 'append-issue-checklist': { type: 'transaction', args: [arg.string('session-id'), arg.number('issue-number'), arg.string('checklist')], handler: (w, n, c) => w.appendIssueChecklist(narrowNumber(n), narrowString(c)) }, + 'tick-iteration': { type: 'transaction', args: [arg.string('session-id'), arg.number('issue-number')], handler: (w, n) => w.tickIteration(narrowNumber(n)) }, + 'review-approved': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.reviewApproved() }, + 'review-rejected': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.reviewRejected() }, + 'coderabbit-feedback-addressed': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.coderabbitFeedbackAddressed() }, + 'coderabbit-feedback-ignored': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.coderabbitFeedbackIgnored() }, + 'shut-down': { type: 'transaction', args: [arg.string('session-id'), arg.string('agent-name')], handler: (w, name) => w.shutDown(narrowString(name)) }, + 'write-journal': { type: 'transaction', args: [arg.string('session-id'), arg.string('agent-name'), arg.string('content')], handler: (w, a, c) => w.writeJournal(narrowString(a), narrowString(c)) }, + 'run-lint': { type: 'transaction', args: [arg.string('session-id'), arg.rest('files')], handler: (w, files) => w.runLint(narrowStringArray(files)) }, + 'get-session-summary': { type: 'transaction', args: [arg.string('session-id'), arg.string('agent-name').optional()], handler: (w, name) => w.getSessionSummary(typeof name === 'string' ? name : '') }, +}) + +const platformRunner = createWorkflowRunner<Workflow, WorkflowState, WorkflowDeps, StateName, WorkflowOperation>({ + workflowDefinition: FeatureTeamWorkflowDefinition, + routes: ROUTES, +}) + +export function runWorkflow(args: readonly string[], deps: AdapterDeps): RunnerResult { + const command = args[0] + if (!command) return handleHookRoute(deps) + if (command === 'init') return handleInit(deps) + if (isAnalyticsCommand(command)) return routeAnalytics(command, args, deps) + const sessionId = deps.getSessionId() + return platformRunner([command, sessionId, ...args.slice(1)], deps.engineDeps, deps.workflowDeps) +} + +function handleInit(deps: AdapterDeps): RunnerResult { + const engine = new WorkflowEngine(FeatureTeamWorkflowDefinition, deps.engineDeps, deps.workflowDeps) + return mapResult(engine.startSession(deps.getSessionId(), undefined, deps.getRepositoryName())) +} + +function mapResult(result: EngineResult): RunnerResult { + /* v8 ignore next */ + const exitCode = result.type === 'success' ? EXIT_ALLOW : result.type === 'blocked' ? EXIT_BLOCK : EXIT_ERROR + return { output: result.output, exitCode } +} + +/* v8 ignore start */ +function main(): void { + try { + const result = runWorkflow(process.argv.slice(2), buildRealDeps()) + process.stdout.write(result.output, () => process.exit(result.exitCode)) + } catch (error) { + const message = `[${new Date().toISOString()}] HOOK ERROR: ${String(error)}\n` + process.stderr.write(message) + appendFileSync('/tmp/feature-team-hook-errors.log', message) + process.exit(EXIT_ERROR) + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main() +} +/* v8 ignore stop */ diff --git a/src/autonomous-claude-agent-team-workflow-hook-routing.spec.ts b/src/hook-routes.spec.ts similarity index 53% rename from src/autonomous-claude-agent-team-workflow-hook-routing.spec.ts rename to src/hook-routes.spec.ts index db26580..16a75bf 100644 --- a/src/autonomous-claude-agent-team-workflow-hook-routing.spec.ts +++ b/src/hook-routes.spec.ts @@ -1,160 +1,19 @@ -import { runWorkflow } from './autonomous-claude-agent-team-workflow.js' -import type { AdapterDeps, AnalyticsDeps } from './autonomous-claude-agent-team-workflow.js' -import type { WorkflowEngineDeps, WorkflowEventStore } from '@ntcoding/agentic-workflow-builder/engine' -import type { WorkflowDeps } from './workflow-definition/index.js' -import type { WorkflowEvent } from './workflow-definition/index.js' +import { handleHookRoute } from './hook-routes.js' import { EXIT_ERROR, EXIT_ALLOW, EXIT_BLOCK } from './infra/hook-io.js' +import { + makeDeps, + makeHookStdin, + planningEvents, + developingEvents, +} from './autonomous-claude-agent-team-workflow-cli-test-fixtures.js' +import type { WorkflowEvent } from './workflow-definition/index.js' const AT = '2026-01-01T00:00:00Z' -function makeHookStdin(overrides: Record<string, unknown> = {}): string { - return JSON.stringify({ - session_id: 'test-session', - transcript_path: '/test/transcript.jsonl', - cwd: '/project', - permission_mode: 'default', - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: {}, - tool_use_id: 'tool-1', - ...overrides, - }) -} - -function spawnReadyEvents(): readonly WorkflowEvent[] { - return [ - { type: 'issue-recorded', at: AT, issueNumber: 42 }, - { type: 'agent-registered', at: AT, agentType: 'developer-1', agentId: 'agt-1' }, - { type: 'agent-registered', at: AT, agentType: 'reviewer-1', agentId: 'agt-2' }, - ] -} - -function planningEvents(): readonly WorkflowEvent[] { - return [ - ...spawnReadyEvents(), - { type: 'transitioned', at: AT, from: 'SPAWN', to: 'PLANNING' }, - ] -} - -function developingEvents(): readonly WorkflowEvent[] { - return [ - ...planningEvents(), - { type: 'iteration-task-assigned', at: AT, task: 'Build the thing' }, - { type: 'transitioned', at: AT, from: 'PLANNING', to: 'RESPAWN', iteration: 0 }, - { type: 'transitioned', at: AT, from: 'RESPAWN', to: 'DEVELOPING', iteration: 0, developingHeadCommit: 'abc123' }, - ] -} - -function reviewingEvents(): readonly WorkflowEvent[] { - return [ - ...developingEvents(), - { type: 'developer-done-signaled', at: AT }, - { type: 'transitioned', at: AT, from: 'DEVELOPING', to: 'REVIEWING' }, - ] -} - -function committingEvents(): readonly WorkflowEvent[] { - return [ - ...reviewingEvents(), - { type: 'review-approved', at: AT }, - { type: 'transitioned', at: AT, from: 'REVIEWING', to: 'COMMITTING' }, - ] -} - -function crReviewEvents(): readonly WorkflowEvent[] { - return [ - ...committingEvents(), - { type: 'pr-created', at: AT, prNumber: 99 }, - { type: 'transitioned', at: AT, from: 'COMMITTING', to: 'CR_REVIEW' }, - ] -} - -function prCreationEvents(): readonly WorkflowEvent[] { - return [ - ...crReviewEvents(), - { type: 'coderabbit-addressed', at: AT }, - { type: 'transitioned', at: AT, from: 'CR_REVIEW', to: 'PR_CREATION' }, - ] -} - -type EngineDepsOverrides = { store?: Partial<WorkflowEventStore> } & Partial<Omit<WorkflowEngineDeps, 'store'>> - -function makeStore(overrides?: Partial<WorkflowEventStore>): WorkflowEventStore { - return { - readEvents: () => [], - appendEvents: () => undefined, - sessionExists: () => true, - ...overrides, - } -} - -function makeEngineDeps(overrides?: EngineDepsOverrides): WorkflowEngineDeps { - const { store: storeOverrides, ...rest } = overrides ?? {} - return { - store: makeStore(storeOverrides), - getPluginRoot: () => '/plugin', - getEnvFilePath: () => '/test/claude.env', - readFile: () => '', - appendToFile: () => undefined, - now: () => AT, - transcriptReader: { readMessages: () => [] }, - ...rest, - } -} - -function makeWorkflowDeps(overrides?: Partial<WorkflowDeps>): WorkflowDeps { - return { - getGitInfo: () => ({ - currentBranch: 'main', - workingTreeClean: true, - headCommit: 'abc123', - changedFilesVsDefault: [], - hasCommitsVsDefault: false, - }), - checkPrChecks: () => true, - createDraftPr: () => 99, - appendIssueChecklist: () => undefined, - tickFirstUncheckedIteration: () => undefined, - runEslintOnFiles: () => true, - fileExists: () => false, - getPluginRoot: () => '/plugin', - now: () => AT, - ...overrides, - } -} - -function makeAnalyticsDeps(overrides?: Partial<AnalyticsDeps>): AnalyticsDeps { - return { - computeSession: (_sessionId: string) => 'Session: test-session\n===', - computeAll: () => 'Total Sessions: 0', - computeEventContext: (_sessionId: string) => 'Session: test-session\nState: SPAWN (iteration: 0)', - ...overrides, - } -} - -function makeDeps(overrides?: { - engineDeps?: EngineDepsOverrides - workflowDeps?: Partial<WorkflowDeps> - analyticsDeps?: Partial<AnalyticsDeps> - getSessionId?: () => string - readStdin?: () => string -}): AdapterDeps { - return { - getSessionId: overrides?.getSessionId ?? (() => 'test-session'), - getRepositoryName: () => undefined, - readStdin: overrides?.readStdin ?? (() => makeHookStdin()), - engineDeps: makeEngineDeps(overrides?.engineDeps), - workflowDeps: makeWorkflowDeps(overrides?.workflowDeps), - analyticsDeps: makeAnalyticsDeps(overrides?.analyticsDeps), - reportDeps: { getAnalysisContext: () => '', generateReport: () => ({ path: '/tmp/report.html' }), readAnalysisFile: () => '' }, - } -} - -describe('runWorkflow - hook mode routing', () => { +describe('handleHookRoute', () => { it('routes SessionStart to persist-session-id and appends session id to env file', () => { const appended: string[] = [] - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'SessionStart' }), engineDeps: { appendToFile: (_: string, content: string) => appended.push(content) }, @@ -165,8 +24,7 @@ describe('runWorkflow - hook mode routing', () => { }) it('routes PreToolUse and returns EXIT_ALLOW with no session', () => { - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'PreToolUse' }), engineDeps: { store: { sessionExists: () => false } }, @@ -176,8 +34,7 @@ describe('runWorkflow - hook mode routing', () => { }) it('returns EXIT_BLOCK from PreToolUse when write is blocked', () => { - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ @@ -196,8 +53,7 @@ describe('runWorkflow - hook mode routing', () => { }) it('returns EXIT_BLOCK from PreToolUse when plugin source read is blocked', () => { - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ @@ -217,8 +73,7 @@ describe('runWorkflow - hook mode routing', () => { }) it('returns EXIT_BLOCK from PreToolUse when bash command is blocked', () => { - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ @@ -235,8 +90,7 @@ describe('runWorkflow - hook mode routing', () => { }) it('returns empty output from PreToolUse when all checks pass', () => { - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'PreToolUse' }), engineDeps: { @@ -249,8 +103,7 @@ describe('runWorkflow - hook mode routing', () => { }) it('routes SubagentStart and returns EXIT_ALLOW with no session', () => { - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'SubagentStart', agent_id: 'agt-1', agent_type: 'developer-1' }), engineDeps: { store: { sessionExists: () => false } }, @@ -261,8 +114,7 @@ describe('runWorkflow - hook mode routing', () => { it('SubagentStart appends agent-registered event when session exists', () => { const appended: Array<{ sessionId: string; firstEventType: string }> = [] - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'SubagentStart', @@ -284,8 +136,7 @@ describe('runWorkflow - hook mode routing', () => { }) it('routes TeammateIdle and returns EXIT_ALLOW with no session', () => { - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'TeammateIdle' }), engineDeps: { store: { sessionExists: () => false } }, @@ -295,8 +146,7 @@ describe('runWorkflow - hook mode routing', () => { }) it('TeammateIdle allows unnamed agent', () => { - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'TeammateIdle' }), engineDeps: { @@ -308,8 +158,7 @@ describe('runWorkflow - hook mode routing', () => { }) it('TeammateIdle allows non-lead agent in DEVELOPING state', () => { - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer-1' }), engineDeps: { @@ -321,8 +170,7 @@ describe('runWorkflow - hook mode routing', () => { }) it('TeammateIdle blocks lead agent in non-BLOCKED state', () => { - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'TeammateIdle', teammate_name: 'lead-1' }), engineDeps: { @@ -335,8 +183,7 @@ describe('runWorkflow - hook mode routing', () => { }) it('throws when tool_input contains a non-string value', () => { - expect(() => runWorkflow( - [], + expect(() => handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'PreToolUse', @@ -348,11 +195,46 @@ describe('runWorkflow - hook mode routing', () => { }) it('returns EXIT_ERROR for unrecognised hook event', () => { - const result = runWorkflow( - [], + const result = handleHookRoute( makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'UnknownEvent' }) }), ) expect(result.exitCode).toStrictEqual(EXIT_ERROR) expect(result.output).toContain('Unknown hook event: UnknownEvent') }) + + it('PreToolUse records identity-verified event via engine prefix verification', () => { + const appended: Array<{ firstEventType: string }> = [] + const result = handleHookRoute( + makeDeps({ + readStdin: () => makeHookStdin({ hook_event_name: 'PreToolUse' }), + engineDeps: { + store: { + sessionExists: () => true, + readEvents: () => planningEvents(), + appendEvents: (_sessionId: string, events: readonly WorkflowEvent[]) => appended.push({ firstEventType: events[0]?.type ?? '' }), + }, + }, + }), + ) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + expect(appended[0]?.firstEventType).toStrictEqual('identity-verified') + }) + + it('PreToolUse blocks when identity is lost', () => { + const result = handleHookRoute( + makeDeps({ + readStdin: () => makeHookStdin({ hook_event_name: 'PreToolUse' }), + engineDeps: { + transcriptReader: { + readMessages: () => [ + { id: '1', textContent: 'LEAD: PLANNING' }, + { id: '2', textContent: 'No prefix here' }, + ], + }, + store: { sessionExists: () => true, readEvents: () => planningEvents() }, + }, + }), + ) + expect(result.exitCode).toStrictEqual(EXIT_BLOCK) + }) }) diff --git a/src/hook-routes.ts b/src/hook-routes.ts new file mode 100644 index 0000000..fbd3163 --- /dev/null +++ b/src/hook-routes.ts @@ -0,0 +1,100 @@ +import { WorkflowEngine } from '@ntcoding/agentic-workflow-builder/engine' +import type { RunnerResult } from '@ntcoding/agentic-workflow-builder/cli' +import { + HookCommonInputSchema, + PreToolUseInputSchema, + SubagentStartInputSchema, + TeammateIdleInputSchema, +} from '@ntcoding/agentic-workflow-builder/cli' +import { FeatureTeamWorkflowDefinition, BASH_FORBIDDEN, checkWriteAllowed } from './workflow-definition/index.js' +import type { Workflow, WorkflowDeps } from './workflow-definition/index.js' +import type { StateName, WorkflowOperation, WorkflowState } from './workflow-definition/domain/workflow-types.js' +import type { AdapterDeps } from './infra/composition-root.js' +import { + formatDenyDecision, + formatContextInjection, + EXIT_ALLOW, + EXIT_ERROR, + EXIT_BLOCK, +} from './infra/hook-io.js' +import { WorkflowError } from './infra/workflow-error.js' + +type Engine = WorkflowEngine<Workflow, WorkflowState, WorkflowDeps, StateName, WorkflowOperation> + +type HookHandler = (engine: Engine, deps: AdapterDeps) => RunnerResult + +const HOOK_HANDLERS: Readonly<Record<string, HookHandler>> = { + SessionStart: handleSessionStart, + PreToolUse: handlePreToolUse, + SubagentStart: handleSubagentStart, + TeammateIdle: handleTeammateIdle, +} + +export function handleHookRoute(deps: AdapterDeps): RunnerResult { + const engine = new WorkflowEngine(FeatureTeamWorkflowDefinition, deps.engineDeps, deps.workflowDeps) + const stdin = deps.readStdin() + const cachedDeps: AdapterDeps = { ...deps, readStdin: () => stdin } + const common = HookCommonInputSchema.parse(JSON.parse(stdin)) + const handler = HOOK_HANDLERS[common.hook_event_name] + if (!handler) { + return { output: `Unknown hook event: ${common.hook_event_name}`, exitCode: EXIT_ERROR } + } + if (common.hook_event_name !== 'SessionStart' && !engine.hasSession(common.session_id)) { + return { output: '', exitCode: EXIT_ALLOW } + } + return handler(engine, cachedDeps) +} + +function handleSessionStart(engine: Engine, deps: AdapterDeps): RunnerResult { + const hookInput = HookCommonInputSchema.parse(JSON.parse(deps.readStdin())) + engine.persistSessionId(hookInput.session_id) + return { output: '', exitCode: EXIT_ALLOW } +} + +function handlePreToolUse(engine: Engine, deps: AdapterDeps): RunnerResult { + const hookInput = PreToolUseInputSchema.parse(JSON.parse(deps.readStdin())) + + const filePath = resolveStringField(hookInput.tool_input['file_path']) + || resolveStringField(hookInput.tool_input['path']) + || resolveStringField(hookInput.tool_input['pattern']) + const command = resolveStringField(hookInput.tool_input['command']) + + const pluginCheck = engine.transaction(hookInput.session_id, 'hook-check', (w) => { + return w.checkPluginSourceRead(hookInput.tool_name, filePath, command) + }, hookInput.transcript_path) + if (pluginCheck.type === 'blocked') return { output: formatDenyDecision(pluginCheck.output), exitCode: EXIT_BLOCK } + + const writeCheck = engine.checkWrite(hookInput.session_id, hookInput.tool_name, filePath, checkWriteAllowed, hookInput.transcript_path) + if (writeCheck.type === 'blocked') return { output: formatDenyDecision(writeCheck.output), exitCode: EXIT_BLOCK } + + const bashCheck = engine.checkBash(hookInput.session_id, hookInput.tool_name, command, BASH_FORBIDDEN, hookInput.transcript_path) + if (bashCheck.type === 'blocked') return { output: formatDenyDecision(bashCheck.output), exitCode: EXIT_BLOCK } + + return { output: '', exitCode: EXIT_ALLOW } +} + +function handleSubagentStart(engine: Engine, deps: AdapterDeps): RunnerResult { + const hookInput = SubagentStartInputSchema.parse(JSON.parse(deps.readStdin())) + const result = engine.transaction(hookInput.session_id, 'register-agent', (w) => { + return w.registerAgent(hookInput.agent_type, hookInput.agent_id) + }) + /* v8 ignore next */ + const state = result.type === 'success' ? result.output : '' + return { output: formatContextInjection(state), exitCode: EXIT_ALLOW } +} + +function handleTeammateIdle(engine: Engine, deps: AdapterDeps): RunnerResult { + const hookInput = TeammateIdleInputSchema.parse(JSON.parse(deps.readStdin())) + const agentName = hookInput.teammate_name ?? '' + const result = engine.transaction(hookInput.session_id, 'check-idle', (w) => w.checkIdleAllowed(agentName)) + if (result.type === 'blocked') { + return { output: result.output, exitCode: EXIT_BLOCK } + } + return { output: '', exitCode: EXIT_ALLOW } +} + +function resolveStringField(value: unknown): string { + if (value === undefined || value === null) return '' + if (typeof value === 'string') return value + throw new WorkflowError(`Expected string or undefined. Got ${typeof value}: ${String(value)}`) +} diff --git a/src/infra/hook-io.spec.ts b/src/infra/hook-io.spec.ts index de004d7..13e5a4b 100644 --- a/src/infra/hook-io.spec.ts +++ b/src/infra/hook-io.spec.ts @@ -1,8 +1,4 @@ import { - parseCommonInput, - parsePreToolUseInput, - parseSubagentStartInput, - parseTeammateIdleInput, formatDenyDecision, formatContextInjection, EXIT_ALLOW, @@ -10,77 +6,6 @@ import { EXIT_ERROR, } from './hook-io.js' -const validCommon = { - session_id: 'sess-1', - transcript_path: '/test/t.jsonl', - cwd: '/project', - permission_mode: 'default', - hook_event_name: 'PreToolUse', -} - -describe('parseCommonInput', () => { - it('parses valid common input', () => { - const result = parseCommonInput(JSON.stringify(validCommon)) - expect(result.session_id).toStrictEqual('sess-1') - expect(result.transcript_path).toStrictEqual('/test/t.jsonl') - }) - - it('throws on invalid JSON', () => { - expect(() => parseCommonInput('not json')).toThrow('Cannot parse hook input JSON') - }) - - it('throws when required fields are missing', () => { - expect(() => parseCommonInput(JSON.stringify({ session_id: 'x' }))).toThrow( - 'Invalid hook input', - ) - }) -}) - -describe('parsePreToolUseInput', () => { - it('parses valid PreToolUse input', () => { - const input = { ...validCommon, tool_name: 'Bash', tool_input: {}, tool_use_id: 'tu-1' } - const result = parsePreToolUseInput(JSON.stringify(input)) - expect(result.tool_name).toStrictEqual('Bash') - expect(result.tool_use_id).toStrictEqual('tu-1') - }) - - it('throws when tool_name is missing', () => { - expect(() => parsePreToolUseInput(JSON.stringify(validCommon))).toThrow('Invalid hook input') - }) -}) - -describe('parseSubagentStartInput', () => { - it('parses with required agent_id and agent_type', () => { - const input = { ...validCommon, agent_id: 'a889ead9bc6dbee18', agent_type: 'developer-1' } - const result = parseSubagentStartInput(JSON.stringify(input)) - expect(result.agent_id).toStrictEqual('a889ead9bc6dbee18') - expect(result.agent_type).toStrictEqual('developer-1') - }) - - it('throws when agent_id is missing', () => { - const input = { ...validCommon, agent_type: 'developer-1' } - expect(() => parseSubagentStartInput(JSON.stringify(input))).toThrow('Invalid hook input') - }) - - it('throws when agent_type is missing', () => { - const input = { ...validCommon, agent_id: 'a889ead9bc6dbee18' } - expect(() => parseSubagentStartInput(JSON.stringify(input))).toThrow('Invalid hook input') - }) -}) - -describe('parseTeammateIdleInput', () => { - it('parses without optional teammate_name', () => { - const result = parseTeammateIdleInput(JSON.stringify(validCommon)) - expect(result.teammate_name).toStrictEqual(undefined) - }) - - it('parses with teammate_name', () => { - const input = { ...validCommon, teammate_name: 'developer-1' } - const result = parseTeammateIdleInput(JSON.stringify(input)) - expect(result.teammate_name).toStrictEqual('developer-1') - }) -}) - describe('formatDenyDecision', () => { it('includes permissionDecision deny and reason', () => { const output = formatDenyDecision('reason here') diff --git a/src/infra/hook-io.ts b/src/infra/hook-io.ts index 48eae6c..cc82b1f 100644 --- a/src/infra/hook-io.ts +++ b/src/infra/hook-io.ts @@ -1,54 +1,7 @@ -import { z } from 'zod' -import { WorkflowError } from './workflow-error.js' - -const HookCommonInput = z.object({ - session_id: z.string(), - transcript_path: z.string(), - cwd: z.string(), - permission_mode: z.string().optional(), - hook_event_name: z.string(), -}) - -const PreToolUseInput = HookCommonInput.extend({ - tool_name: z.string(), - tool_input: z.record(z.unknown()), - tool_use_id: z.string(), -}) - -const SubagentStartInput = HookCommonInput.extend({ - agent_id: z.string(), - agent_type: z.string(), -}) - -const TeammateIdleInput = HookCommonInput.extend({ - teammate_name: z.string().optional(), -}) - -type HookCommonInput = z.infer<typeof HookCommonInput> -type PreToolUseInput = z.infer<typeof PreToolUseInput> -type SubagentStartInput = z.infer<typeof SubagentStartInput> -type TeammateIdleInput = z.infer<typeof TeammateIdleInput> - export const EXIT_ALLOW = 0 export const EXIT_BLOCK = 2 export const EXIT_ERROR = 1 -export function parseCommonInput(raw: string): HookCommonInput { - return parseWithSchema(HookCommonInput, raw, 'HookCommonInput') -} - -export function parsePreToolUseInput(raw: string): PreToolUseInput { - return parseWithSchema(PreToolUseInput, raw, 'PreToolUseInput') -} - -export function parseSubagentStartInput(raw: string): SubagentStartInput { - return parseWithSchema(SubagentStartInput, raw, 'SubagentStartInput') -} - -export function parseTeammateIdleInput(raw: string): TeammateIdleInput { - return parseWithSchema(TeammateIdleInput, raw, 'TeammateIdleInput') -} - export function formatDenyDecision(reason: string): string { return JSON.stringify({ hookSpecificOutput: { @@ -62,20 +15,3 @@ export function formatDenyDecision(reason: string): string { export function formatContextInjection(context: string): string { return JSON.stringify({ additionalContext: context }) } - -function parseWithSchema<T>(schema: z.ZodType<T>, raw: string, schemaName: string): T { - const json = tryParseHookJson(raw, schemaName) - const result = schema.safeParse(json) - if (!result.success) { - throw new WorkflowError(`Invalid hook input for ${schemaName}: ${result.error.message}`) - } - return result.data -} - -function tryParseHookJson(raw: string, schemaName: string): unknown { - try { - return JSON.parse(raw) - } catch (cause) { - throw new WorkflowError(`Cannot parse hook input JSON for ${schemaName}: ${String(cause)}`) - } -} diff --git a/src/workflow-definition/domain/output-messages.spec.ts b/src/workflow-definition/domain/output-messages.spec.ts index f6c89e4..66edd89 100644 --- a/src/workflow-definition/domain/output-messages.spec.ts +++ b/src/workflow-definition/domain/output-messages.spec.ts @@ -103,6 +103,39 @@ describe('getOperationBody', () => { const body = getOperationBody('coderabbit-feedback-ignored', makeState()) expect(body).toContain('ignored') }) + + it('returns session summary with state and iteration for get-session-summary', () => { + const body = getOperationBody('get-session-summary', makeState({ + currentStateMachineState: 'DEVELOPING', + iteration: 2, + })) + expect(body).toContain('DEVELOPING') + expect(body).toContain('iteration: 2') + }) + + it('includes active agents in get-session-summary when present', () => { + const body = getOperationBody('get-session-summary', makeState({ + activeAgents: ['developer-1', 'reviewer-1'], + })) + expect(body).toContain('developer-1, reviewer-1') + }) + + it('includes iterations in get-session-summary when present', () => { + const body = getOperationBody('get-session-summary', makeState({ + iterations: [ + { task: 'Build auth', developerDone: false, reviewApproved: false, reviewRejected: false, coderabbitFeedbackAddressed: false, coderabbitFeedbackIgnored: false, lintedFiles: [], lintRanIteration: false }, + { task: 'Add tests', developerDone: false, reviewApproved: false, reviewRejected: false, coderabbitFeedbackAddressed: false, coderabbitFeedbackIgnored: false, lintedFiles: [], lintRanIteration: false }, + ], + })) + expect(body).toContain('[0] Build auth') + expect(body).toContain('[1] Add tests') + }) + + it('omits agents and iterations when empty in get-session-summary', () => { + const body = getOperationBody('get-session-summary', makeState()) + expect(body).not.toContain('Active agents') + expect(body).not.toContain('Iterations') + }) }) describe('getTransitionTitle', () => { diff --git a/src/workflow-definition/domain/output-messages.ts b/src/workflow-definition/domain/output-messages.ts index 253e1a9..9c9826e 100644 --- a/src/workflow-definition/domain/output-messages.ts +++ b/src/workflow-definition/domain/output-messages.ts @@ -35,8 +35,26 @@ const OPERATION_BODIES: Readonly<Record<string, OperationBodyFn | undefined>> = 'review-rejected': () => `Review rejected.\n\n ${CMD} transition DEVELOPING`, 'coderabbit-feedback-addressed': () => `CodeRabbit feedback marked as addressed.\n\n ${CMD} transition PR_CREATION`, 'coderabbit-feedback-ignored': () => `CodeRabbit feedback marked as ignored.\n\n ${CMD} transition PR_CREATION`, + 'get-session-summary': (s) => formatSessionSummaryBody(s), } satisfies Record<WorkflowOperation, OperationBodyFn> +function formatSessionSummaryBody(state: WorkflowState): string { + const lines: string[] = [ + `State: ${state.currentStateMachineState} (iteration: ${state.iteration})`, + ] + if (state.activeAgents.length > 0) { + lines.push(`Active agents: ${state.activeAgents.join(', ')}`) + } + if (state.iterations.length > 0) { + lines.push('') + lines.push('Iterations:') + for (const [i, iter] of state.iterations.entries()) { + lines.push(` [${i}] ${iter.task}`) + } + } + return lines.join('\n') +} + export function getOperationBody(op: string, state: WorkflowState): string { const bodyFn = OPERATION_BODIES[op] /* v8 ignore next */ diff --git a/src/workflow-definition/domain/workflow-adapter.spec.ts b/src/workflow-definition/domain/workflow-adapter.spec.ts index 75caa36..523f938 100644 --- a/src/workflow-definition/domain/workflow-adapter.spec.ts +++ b/src/workflow-definition/domain/workflow-adapter.spec.ts @@ -1,4 +1,4 @@ -import { WorkflowAdapter } from './workflow-adapter.js' +import { FeatureTeamWorkflowDefinition } from './workflow-adapter.js' import type { WorkflowDeps } from '../../workflow-definition/domain/workflow.js' import type { WorkflowState } from './workflow-types.js' import type { BaseEvent } from '@ntcoding/agentic-workflow-builder/engine' @@ -23,15 +23,15 @@ function makeWorkflowDeps(): WorkflowDeps { } } -describe('WorkflowAdapter', () => { +describe('FeatureTeamWorkflowDefinition', () => { it('creates a fresh Workflow with SPAWN state', () => { - const workflow = WorkflowAdapter.createFresh(makeWorkflowDeps()) + const workflow = FeatureTeamWorkflowDefinition.createFresh(makeWorkflowDeps()) expect(workflow.getState().currentStateMachineState).toStrictEqual('SPAWN') }) it('rehydrates a Workflow from events and deps', () => { const events: readonly BaseEvent[] = [] - const workflow = WorkflowAdapter.rehydrate(events, makeWorkflowDeps()) + const workflow = FeatureTeamWorkflowDefinition.rehydrate(events, makeWorkflowDeps()) expect(workflow.getState().currentStateMachineState).toStrictEqual('SPAWN') }) @@ -39,71 +39,71 @@ describe('WorkflowAdapter', () => { const events: readonly BaseEvent[] = [ { type: 'unknown-event', at: '2026-01-01T00:00:00.000Z' }, ] - expect(() => WorkflowAdapter.rehydrate(events, makeWorkflowDeps())).toThrow('Unknown event type in store') + expect(() => FeatureTeamWorkflowDefinition.rehydrate(events, makeWorkflowDeps())).toThrow('Unknown event type in store') }) it('returns procedure path for a given state', () => { - const path = WorkflowAdapter.procedurePath('SPAWN', '/plugin') + const path = FeatureTeamWorkflowDefinition.procedurePath('SPAWN', '/plugin') expect(path).toContain('spawn') expect(path).toContain('/plugin/') }) it('returns initial state with SPAWN', () => { - const initial = WorkflowAdapter.initialState() + const initial = FeatureTeamWorkflowDefinition.initialState() expect(initial.currentStateMachineState).toStrictEqual('SPAWN') }) it('returns workflow registry', () => { - const registry = WorkflowAdapter.getRegistry() + const registry = FeatureTeamWorkflowDefinition.getRegistry() expect(registry['SPAWN']).toBeDefined() expect(registry['SPAWN']?.emoji).toStrictEqual('🟣') }) it('builds transition context with git info and PR checks from deps', () => { const deps = makeWorkflowDeps() - const state = WorkflowAdapter.initialState() - const ctx = WorkflowAdapter.buildTransitionContext(state, 'SPAWN', 'PLANNING', deps) + const state = FeatureTeamWorkflowDefinition.initialState() + const ctx = FeatureTeamWorkflowDefinition.buildTransitionContext(state, 'SPAWN', 'PLANNING', deps) expect(ctx).toMatchObject({ state, from: 'SPAWN', to: 'PLANNING', prChecksPass: false }) expect(ctx.gitInfo.currentBranch).toStrictEqual('main') }) it('builds transition context with prChecksPass true when PR exists', () => { const deps = makeWorkflowDeps() - const state = { ...WorkflowAdapter.initialState(), prNumber: 42 } - const ctx = WorkflowAdapter.buildTransitionContext(state, 'PR_CREATION', 'FEEDBACK', deps) + const state = { ...FeatureTeamWorkflowDefinition.initialState(), prNumber: 42 } + const ctx = FeatureTeamWorkflowDefinition.buildTransitionContext(state, 'PR_CREATION', 'FEEDBACK', deps) expect(ctx.prChecksPass).toStrictEqual(true) }) it('builds transition event with from/to', () => { - const stateBefore = WorkflowAdapter.initialState() + const stateBefore = FeatureTeamWorkflowDefinition.initialState() const stateAfter: WorkflowState = { ...stateBefore, currentStateMachineState: 'PLANNING' as const } - const event = WorkflowAdapter.buildTransitionEvent?.('SPAWN', 'PLANNING', stateBefore, stateAfter, '2026-01-01T00:00:00Z') + const event = FeatureTeamWorkflowDefinition.buildTransitionEvent?.('SPAWN', 'PLANNING', stateBefore, stateAfter, '2026-01-01T00:00:00Z') expect(event).toMatchObject({ type: 'transitioned', from: 'SPAWN', to: 'PLANNING' }) }) it('builds transition event with iteration when changed', () => { - const stateBefore = { ...WorkflowAdapter.initialState(), iteration: 0 } + const stateBefore = { ...FeatureTeamWorkflowDefinition.initialState(), iteration: 0 } const stateAfter = { ...stateBefore, iteration: 1 } - const event = WorkflowAdapter.buildTransitionEvent?.('COMMITTING', 'RESPAWN', stateBefore, stateAfter, '2026-01-01T00:00:00Z') + const event = FeatureTeamWorkflowDefinition.buildTransitionEvent?.('COMMITTING', 'RESPAWN', stateBefore, stateAfter, '2026-01-01T00:00:00Z') expect(event).toMatchObject({ iteration: 1 }) }) it('builds transition event with developingHeadCommit for DEVELOPING', () => { - const stateBefore = { ...WorkflowAdapter.initialState(), iteration: 0, iterations: [{ task: 't', developerDone: false, developingHeadCommit: 'abc123', reviewApproved: false, reviewRejected: false, coderabbitFeedbackAddressed: false, coderabbitFeedbackIgnored: false, lintedFiles: [], lintRanIteration: false }] } + const stateBefore = { ...FeatureTeamWorkflowDefinition.initialState(), iteration: 0, iterations: [{ task: 't', developerDone: false, developingHeadCommit: 'abc123', reviewApproved: false, reviewRejected: false, coderabbitFeedbackAddressed: false, coderabbitFeedbackIgnored: false, lintedFiles: [], lintRanIteration: false }] } const stateAfter = { ...stateBefore } - const event = WorkflowAdapter.buildTransitionEvent?.('RESPAWN', 'DEVELOPING', stateBefore, stateAfter, '2026-01-01T00:00:00Z') + const event = FeatureTeamWorkflowDefinition.buildTransitionEvent?.('RESPAWN', 'DEVELOPING', stateBefore, stateAfter, '2026-01-01T00:00:00Z') expect(event).toMatchObject({ developingHeadCommit: 'abc123' }) }) it('returns prefix config with LEAD pattern', () => { - const config = WorkflowAdapter.getPrefixConfig?.() + const config = FeatureTeamWorkflowDefinition.getPrefixConfig?.() expect(config).toBeDefined() expect(config?.pattern.test('LEAD: SPAWN')).toBe(true) expect(config?.pattern.test('no prefix')).toBe(false) }) it('builds recovery message with state and emoji', () => { - const config = WorkflowAdapter.getPrefixConfig?.() + const config = FeatureTeamWorkflowDefinition.getPrefixConfig?.() const msg = config?.buildRecoveryMessage('DEVELOPING', '🔨', '/plugin') expect(msg).toContain('lost your feature-team-lead identity') expect(msg).toContain('DEVELOPING') diff --git a/src/workflow-definition/domain/workflow-adapter.ts b/src/workflow-definition/domain/workflow-adapter.ts index 1dd7213..5540aa8 100644 --- a/src/workflow-definition/domain/workflow-adapter.ts +++ b/src/workflow-definition/domain/workflow-adapter.ts @@ -1,4 +1,4 @@ -import type { WorkflowFactory, BaseEvent, PrefixConfig } from '@ntcoding/agentic-workflow-builder/engine' +import type { WorkflowDefinition, BaseEvent, PrefixConfig } from '@ntcoding/agentic-workflow-builder/engine' import { WorkflowStateError } from '@ntcoding/agentic-workflow-builder/engine' import type { TransitionContext } from '@ntcoding/agentic-workflow-builder/dsl' import type { WorkflowState, StateName, WorkflowOperation } from './workflow-types.js' @@ -27,7 +27,7 @@ function buildRecoveryMessage(state: string, emoji: string, _pluginRoot: string) ) } -export const WorkflowAdapter: WorkflowFactory<Workflow, WorkflowState, WorkflowDeps, StateName, WorkflowOperation> = { +export const FeatureTeamWorkflowDefinition: WorkflowDefinition<Workflow, WorkflowState, WorkflowDeps, StateName, WorkflowOperation> = { createFresh(deps: WorkflowDeps): Workflow { return Workflow.createFresh(deps) }, diff --git a/src/workflow-definition/domain/workflow-agent-lifecycle.spec.ts b/src/workflow-definition/domain/workflow-agent-lifecycle.spec.ts index 0bdfb7f..aa198f5 100644 --- a/src/workflow-definition/domain/workflow-agent-lifecycle.spec.ts +++ b/src/workflow-definition/domain/workflow-agent-lifecycle.spec.ts @@ -74,4 +74,14 @@ describe('Workflow', () => { ) }) }) + + describe('getSessionSummary', () => { + it('appends context-requested event and returns pass', () => { + const { result, events } = spec.given().when((wf) => wf.getSessionSummary('developer-1')) + expect(result).toStrictEqual({ pass: true }) + expect(events).toStrictEqual( + expect.arrayContaining([expect.objectContaining({ type: 'context-requested', agentName: 'developer-1' })]) + ) + }) + }) }) diff --git a/src/workflow-definition/domain/workflow-test-fixtures.ts b/src/workflow-definition/domain/workflow-test-fixtures.ts index 4a371da..6725cd1 100644 --- a/src/workflow-definition/domain/workflow-test-fixtures.ts +++ b/src/workflow-definition/domain/workflow-test-fixtures.ts @@ -8,7 +8,7 @@ import type { WorkflowDeps } from './workflow.js' import { Workflow } from './workflow.js' import { applyEvents } from './fold.js' import { WORKFLOW_REGISTRY } from './registry.js' -import { WorkflowAdapter } from './workflow-adapter.js' +import { FeatureTeamWorkflowDefinition } from './workflow-adapter.js' import type { GitInfo } from '@ntcoding/agentic-workflow-builder/dsl' const AT = '2026-01-01T00:00:00Z' @@ -238,20 +238,20 @@ export function transitionTo(wf: Workflow, target: StateName, depsOverride?: Wor } if (target !== 'BLOCKED' && currentDef.transitionGuard) { - const ctx = WorkflowAdapter.buildTransitionContext(state, from, target, deps) + const ctx = FeatureTeamWorkflowDefinition.buildTransitionContext(state, from, target, deps) const guardResult = currentDef.transitionGuard(ctx) if (!guardResult.pass) return guardResult } const targetDef = WORKFLOW_REGISTRY[target] const stateBefore = wf.getState() - const ctx = WorkflowAdapter.buildTransitionContext(stateBefore, from, target, deps) + const ctx = FeatureTeamWorkflowDefinition.buildTransitionContext(stateBefore, from, target, deps) const stateAfter = targetDef.onEntry ? targetDef.onEntry(stateBefore, ctx) : stateBefore - const event = WorkflowAdapter.buildTransitionEvent - ? WorkflowAdapter.buildTransitionEvent(from, target, stateBefore, stateAfter, AT) + const event = FeatureTeamWorkflowDefinition.buildTransitionEvent + ? FeatureTeamWorkflowDefinition.buildTransitionEvent(from, target, stateBefore, stateAfter, AT) : { type: 'transitioned', at: AT } wf.appendEvent(event) targetDef.afterEntry?.() diff --git a/src/workflow-definition/domain/workflow-types.ts b/src/workflow-definition/domain/workflow-types.ts index dc5e509..1b6f229 100644 --- a/src/workflow-definition/domain/workflow-types.ts +++ b/src/workflow-definition/domain/workflow-types.ts @@ -78,6 +78,7 @@ export type WorkflowOperation = | 'review-rejected' | 'coderabbit-feedback-addressed' | 'coderabbit-feedback-ignored' + | 'get-session-summary' export type ConcreteStateDefinition = WorkflowStateDefinition<WorkflowState, StateName, WorkflowOperation> diff --git a/src/workflow-definition/domain/workflow.ts b/src/workflow-definition/domain/workflow.ts index 22ba54d..141cf43 100644 --- a/src/workflow-definition/domain/workflow.ts +++ b/src/workflow-definition/domain/workflow.ts @@ -288,5 +288,10 @@ export class Workflow { return pass() } + getSessionSummary(agentName: string): PreconditionResult { + this.append({ type: 'context-requested', at: this.deps.now(), agentName }) + return pass() + } + } diff --git a/src/workflow-definition/index.ts b/src/workflow-definition/index.ts index 90ac7a7..8d28763 100644 --- a/src/workflow-definition/index.ts +++ b/src/workflow-definition/index.ts @@ -1,6 +1,6 @@ export { Workflow } from './domain/workflow.js' export type { WorkflowDeps } from './domain/workflow.js' -export { WorkflowAdapter } from './domain/workflow-adapter.js' +export { FeatureTeamWorkflowDefinition } from './domain/workflow-adapter.js' export { StateNameSchema, From d089aeee3ef26b90ef7510bac3ba318c98b883a8 Mon Sep 17 00:00:00 2001 From: Nick Tune <nick@ntcoding.co.uk> Date: Sat, 7 Mar 2026 16:40:44 +0000 Subject: [PATCH 3/4] Thread preToolUseHandler through platform and rewrite consumer CLI with defineCommands Platform changes: - Add preToolUseHandler to WorkflowRunnerConfig for engine-level PreToolUse hooks - Add RunnerOptions with getSessionId injection (removes session-id from route args) - Move formatDenyDecision/formatContextInjection to platform hook-output module - Add PlatformContext type for createWorkflowCli dep assembly Consumer changes: - Extract PreToolUse logic into pre-tool-use-handler.ts - Replace 5 hand-written recording methods with defineRecordingOps + executeRecording - Replace ad-hoc narrowing with Zod-based parseNumber/parseString/parseStringArray - Delete hook-routes.ts, composition-root.ts, hook-io.ts (replaced by platform) - Relocate all entrypoint files to src/workflow-definition/entrypoint/ - Shorten verbose spec/fixture filenames Net -1382 lines. All 1326 tests pass, 100% coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- hooks/hooks.json | 8 +- .../src/cli/domain/hook-output.spec.ts | 22 ++ .../src/cli/domain/hook-output.ts | 4 - .../src/cli/domain/platform-context.spec.ts | 19 ++ .../src/cli/domain/platform-context.ts | 8 + .../src/cli/domain/workflow-cli.ts | 83 +++++- .../src/cli/domain/workflow-runner.spec.ts | 163 ++++++++++-- .../src/cli/domain/workflow-runner.ts | 78 +++++- .../src/cli/index.spec.ts | 4 +- .../agentic-workflow-builder/src/cli/index.ts | 5 +- src/entrypoint.ts | 97 ------- src/hook-routes.spec.ts | 240 ------------------ src/hook-routes.ts | 100 -------- src/infra/arg-parsing.spec.ts | 64 +++++ src/infra/arg-parsing.ts | 6 + src/infra/composition-root.ts | 126 --------- src/infra/hook-io.spec.ts | 36 --- ...kflow-cr-review-to-complete-states.spec.ts | 4 +- .../workflow-spawn-planning-states.spec.ts | 16 +- src/workflow-definition/domain/workflow.ts | 50 ++-- .../entrypoint}/analytics/cli-routes.spec.ts | 4 +- .../entrypoint}/analytics/cli-routes.ts | 4 +- .../entrypoint/argument-validation.spec.ts} | 6 +- .../entrypoint/cli-commands.spec.ts} | 56 +++- .../entrypoint/cli-test-fixtures.ts} | 4 +- .../entrypoint/entrypoint.ts | 212 ++++++++++++++++ .../entrypoint/pre-tool-use-handler.spec.ts | 153 +++++++++++ .../entrypoint/pre-tool-use-handler.ts | 36 +++ .../entrypoint/view-report.spec.ts} | 6 +- .../workflow-state-commands.spec.ts} | 6 +- 30 files changed, 910 insertions(+), 710 deletions(-) create mode 100644 packages/agentic-workflow-builder/src/cli/domain/hook-output.spec.ts rename src/infra/hook-io.ts => packages/agentic-workflow-builder/src/cli/domain/hook-output.ts (80%) create mode 100644 packages/agentic-workflow-builder/src/cli/domain/platform-context.spec.ts create mode 100644 packages/agentic-workflow-builder/src/cli/domain/platform-context.ts delete mode 100644 src/entrypoint.ts delete mode 100644 src/hook-routes.spec.ts delete mode 100644 src/hook-routes.ts create mode 100644 src/infra/arg-parsing.spec.ts create mode 100644 src/infra/arg-parsing.ts delete mode 100644 src/infra/composition-root.ts delete mode 100644 src/infra/hook-io.spec.ts rename src/{ => workflow-definition/entrypoint}/analytics/cli-routes.spec.ts (98%) rename src/{ => workflow-definition/entrypoint}/analytics/cli-routes.ts (94%) rename src/{autonomous-claude-agent-team-workflow-argument-validation.spec.ts => workflow-definition/entrypoint/argument-validation.spec.ts} (96%) rename src/{autonomous-claude-agent-team-workflow-cli-commands.spec.ts => workflow-definition/entrypoint/cli-commands.spec.ts} (79%) rename src/{autonomous-claude-agent-team-workflow-cli-test-fixtures.ts => workflow-definition/entrypoint/cli-test-fixtures.ts} (97%) create mode 100644 src/workflow-definition/entrypoint/entrypoint.ts create mode 100644 src/workflow-definition/entrypoint/pre-tool-use-handler.spec.ts create mode 100644 src/workflow-definition/entrypoint/pre-tool-use-handler.ts rename src/{autonomous-claude-agent-team-workflow-view-report.spec.ts => workflow-definition/entrypoint/view-report.spec.ts} (96%) rename src/{autonomous-claude-agent-team-workflow-workflow-state-commands.spec.ts => workflow-definition/entrypoint/workflow-state-commands.spec.ts} (95%) diff --git a/hooks/hooks.json b/hooks/hooks.json index 648368a..ad197d9 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,8 +1,8 @@ { "hooks": { - "SessionStart": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/entrypoint.ts", "timeout": 30 }] }], - "PreToolUse": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/entrypoint.ts", "timeout": 30 }] }], - "SubagentStart": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/entrypoint.ts", "timeout": 30 }] }], - "TeammateIdle": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/entrypoint.ts", "timeout": 30 }] }] + "SessionStart": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/workflow-definition/entrypoint/entrypoint.ts", "timeout": 30 }] }], + "PreToolUse": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/workflow-definition/entrypoint/entrypoint.ts", "timeout": 30 }] }], + "SubagentStart": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/workflow-definition/entrypoint/entrypoint.ts", "timeout": 30 }] }], + "TeammateIdle": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/workflow-definition/entrypoint/entrypoint.ts", "timeout": 30 }] }] } } diff --git a/packages/agentic-workflow-builder/src/cli/domain/hook-output.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/hook-output.spec.ts new file mode 100644 index 0000000..c9e0c6e --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/hook-output.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { formatDenyDecision, formatContextInjection } from './hook-output.js' + +describe('formatDenyDecision', () => { + it('wraps reason in PreToolUse deny JSON structure', () => { + const result = JSON.parse(formatDenyDecision('Not allowed')) + expect(result).toStrictEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'Not allowed', + }, + }) + }) +}) + +describe('formatContextInjection', () => { + it('wraps context in additionalContext JSON structure', () => { + const result = JSON.parse(formatContextInjection('some context')) + expect(result).toStrictEqual({ additionalContext: 'some context' }) + }) +}) diff --git a/src/infra/hook-io.ts b/packages/agentic-workflow-builder/src/cli/domain/hook-output.ts similarity index 80% rename from src/infra/hook-io.ts rename to packages/agentic-workflow-builder/src/cli/domain/hook-output.ts index cc82b1f..39a1ee9 100644 --- a/src/infra/hook-io.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/hook-output.ts @@ -1,7 +1,3 @@ -export const EXIT_ALLOW = 0 -export const EXIT_BLOCK = 2 -export const EXIT_ERROR = 1 - export function formatDenyDecision(reason: string): string { return JSON.stringify({ hookSpecificOutput: { diff --git a/packages/agentic-workflow-builder/src/cli/domain/platform-context.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/platform-context.spec.ts new file mode 100644 index 0000000..819465a --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/platform-context.spec.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' +import type { PlatformContext } from './platform-context.js' + +describe('PlatformContext', () => { + it('defines the expected shape', () => { + const ctx: PlatformContext = { + getPluginRoot: () => '/plugin', + now: () => '2024-01-01T00:00:00Z', + getSessionId: () => 'session-1', + store: { + readEvents: () => [], + appendEvents: () => undefined, + sessionExists: () => false, + }, + } + expect(ctx.getPluginRoot()).toBe('/plugin') + expect(ctx.getSessionId()).toBe('session-1') + }) +}) diff --git a/packages/agentic-workflow-builder/src/cli/domain/platform-context.ts b/packages/agentic-workflow-builder/src/cli/domain/platform-context.ts new file mode 100644 index 0000000..e1cbe4a --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/platform-context.ts @@ -0,0 +1,8 @@ +import type { WorkflowEventStore } from '../../engine/index.js' + +export type PlatformContext = { + readonly getPluginRoot: () => string + readonly now: () => string + readonly getSessionId: () => string + readonly store: WorkflowEventStore +} diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts index 1507fff..03c3eb7 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts @@ -1,29 +1,96 @@ /* v8 ignore start */ +import { readFileSync, appendFileSync } from 'node:fs' +import { join } from 'node:path' import type { BaseWorkflowState } from '../../engine/index.js' import type { RehydratableWorkflow, WorkflowEngineDeps } from '../../engine/index.js' -import type { WorkflowRunnerConfig } from './workflow-runner.js' +import { createStore } from '../../event-store/index.js' +import type { WorkflowRunnerConfig, RunnerResult } from './workflow-runner.js' import { createWorkflowRunner } from './workflow-runner.js' +import type { PlatformContext } from './platform-context.js' + +export type WorkflowCliConfig< + TWorkflow extends RehydratableWorkflow<TState>, + TState extends BaseWorkflowState, + TDeps, +> = WorkflowRunnerConfig<TWorkflow, TState, TDeps> & { + readonly buildWorkflowDeps: (platform: PlatformContext) => TDeps + readonly customRouter?: (command: string, args: readonly string[], platform: PlatformContext) => RunnerResult | undefined +} + +function readEnvVar(name: string): string { + const value = process.env[name] + if (value === undefined || value === '') { + throw new Error(`Missing required environment variable: ${name}`) + } + return value +} export function createWorkflowCli< TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState, TDeps, >( - config: WorkflowRunnerConfig<TWorkflow, TState, TDeps>, - buildDeps: () => { engineDeps: WorkflowEngineDeps; workflowDeps: TDeps; readStdin: () => string }, + config: WorkflowCliConfig<TWorkflow, TState, TDeps>, ): void { - const runner = createWorkflowRunner(config) + const pluginRoot = readEnvVar('CLAUDE_PLUGIN_ROOT') + const sessionId = readEnvVar('CLAUDE_SESSION_ID') + + const store = createStore(join(pluginRoot, 'workflow.db')) + const now = () => new Date().toISOString() + + const platformCtx: PlatformContext = { + getPluginRoot: () => pluginRoot, + now, + getSessionId: () => sessionId, + store, + } + + const engineDeps: WorkflowEngineDeps = { + store, + getPluginRoot: () => pluginRoot, + getEnvFilePath: () => join(process.env['HOME'] ?? '', '.claude', 'claude.env'), + readFile: (path) => readFileSync(path, 'utf8'), + appendToFile: (path, content) => appendFileSync(path, content), + now, + } + + const workflowDeps = config.buildWorkflowDeps(platformCtx) + + const readStdin = () => readFileSync('/dev/stdin', 'utf8') + + const errorLogPath = join(pluginRoot, 'error.log') + try { - const deps = buildDeps() const args = process.argv.slice(2) - const result = runner(args, deps.engineDeps, deps.workflowDeps, deps.readStdin) + const command = args[0] + + if (command !== undefined && config.customRouter !== undefined) { + const custom = config.customRouter(command, args, platformCtx) + if (custom !== undefined) { + if (custom.output) process.stdout.write(custom.output) + process.exit(custom.exitCode) + return + } + } + + const runner = createWorkflowRunner(config) + const result = runner(args, engineDeps, workflowDeps, { + readStdin, + getSessionId: () => sessionId, + }) + if (result.output) { process.stdout.write(result.output) } process.exit(result.exitCode) } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error) - process.stderr.write(message + '\n') + const message = `[${new Date().toISOString()}] ERROR: ${String(error)}\n` + process.stderr.write(message) + try { + appendFileSync(errorLogPath, message) + } catch { + // Ignore write failures to error log + } process.exit(1) } } diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts index e51fd41..6925ff1 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts @@ -167,6 +167,56 @@ describe('createWorkflowRunner', () => { const result = runner(['no-extra-args', 'session-1'], deps, {}) expect(result.exitCode).toBe(EXIT_ALLOW) }) + + it('uses getSessionId from options instead of parsing session-id arg', () => { + const config: WorkflowRunnerConfig<TestWorkflow, TestState, TestDeps> = { + workflowDefinition: createMockFactory(), + routes: { + 'do-something': { + type: 'transaction', + args: [], + handler: (w: TestWorkflow) => w.doSomething(), + }, + }, + } + const runner = createWorkflowRunner(config) + const deps = createMockEngineDeps(true) + const result = runner(['do-something'], deps, {}, { getSessionId: () => 'injected-session' }) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('uses getSessionId for transition commands without session-id arg', () => { + const config: WorkflowRunnerConfig<TestWorkflow, TestState, TestDeps> = { + workflowDefinition: createMockFactory(), + routes: { + transition: { + type: 'transition', + args: [arg.string('target')], + }, + }, + } + const runner = createWorkflowRunner(config) + const deps = createMockEngineDeps(true) + const result = runner(['transition', 'coding'], deps, {}, { getSessionId: () => 'injected-session' }) + expect(result.exitCode).toBe(EXIT_ALLOW) + expect(result.output).toContain('Transitioned to coding') + }) + + it('uses getSessionId for session-start commands without session-id arg', () => { + const config: WorkflowRunnerConfig<TestWorkflow, TestState, TestDeps> = { + workflowDefinition: createMockFactory(), + routes: { + init: { + type: 'session-start', + args: [], + }, + }, + } + const runner = createWorkflowRunner(config) + const deps = createMockEngineDeps() + const result = runner(['init'], deps, {}, { getSessionId: () => 'injected-session' }) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) }) describe('engine result mapping', () => { @@ -215,7 +265,7 @@ describe('createWorkflowRunner', () => { ...createMockEngineDeps(), appendToFile: (path: string) => { appendedFiles.push(path) }, } - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) expect(appendedFiles).toContain('/tmp/.env') }) @@ -245,7 +295,7 @@ describe('createWorkflowRunner', () => { tool_use_id: 'tool-1', }) const deps = createMockEngineDeps(true) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) }) @@ -262,7 +312,7 @@ describe('createWorkflowRunner', () => { tool_use_id: 'tool-1', }) const deps = createMockEngineDeps(true) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) }) @@ -288,7 +338,7 @@ describe('createWorkflowRunner', () => { tool_use_id: 'tool-1', }) const deps = createMockEngineDeps(true) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) }) @@ -314,7 +364,7 @@ describe('createWorkflowRunner', () => { tool_use_id: 'tool-1', }) const deps = createMockEngineDeps(false) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) }) @@ -327,7 +377,7 @@ describe('createWorkflowRunner', () => { cwd: '/home/user', hook_event_name: 'UnknownEvent', }) - const result = runner([], createMockEngineDeps(), {}, () => hookInput) + const result = runner([], createMockEngineDeps(), {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) expect(result.output).toBe('') }) @@ -335,7 +385,7 @@ describe('createWorkflowRunner', () => { it('returns error for invalid hook input JSON', () => { const config = createTestConfig() const runner = createWorkflowRunner(config) - const result = runner([], createMockEngineDeps(), {}, () => '{}') + const result = runner([], createMockEngineDeps(), {}, { readStdin: () => '{}' }) expect(result.exitCode).toBe(EXIT_ERROR) expect(result.output).toContain('Invalid hook input') }) @@ -350,7 +400,7 @@ describe('createWorkflowRunner', () => { hook_event_name: 'PreToolUse', }) const deps = createMockEngineDeps(true) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ERROR) expect(result.output).toContain('Invalid pre-tool-use input') }) @@ -377,10 +427,85 @@ describe('createWorkflowRunner', () => { tool_use_id: 'tool-1', }) const deps = createMockEngineDeps(true) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_BLOCK) }) + it('dispatches to preToolUseHandler when configured', () => { + const config = createTestConfig({ + preToolUseHandler: (_engine, _sessionId, toolName, toolInput) => { + if (toolName === 'Bash' && toolInput['command'] === 'ls') { + return { type: 'success', output: '' } + } + return { type: 'blocked', output: 'Denied by handler' } + }, + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_use_id: 'tool-1', + }) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, { readStdin: () => hookInput }) + expect(result.exitCode).toBe(EXIT_ALLOW) + }) + + it('wraps preToolUseHandler blocked result with formatDenyDecision', () => { + const config = createTestConfig({ + preToolUseHandler: () => ({ type: 'blocked', output: 'Not permitted' }), + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'hook-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'PreToolUse', + tool_name: 'Write', + tool_input: { file_path: '/some/file.ts' }, + tool_use_id: 'tool-2', + }) + const deps = createMockEngineDeps(true) + const result = runner([], deps, {}, { readStdin: () => hookInput }) + expect(result.exitCode).toBe(EXIT_BLOCK) + const parsed = JSON.parse(result.output) + expect(parsed).toStrictEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'Not permitted', + }, + }) + }) + + it('skips preToolUseHandler when session does not exist', () => { + let handlerCalled = false + const config = createTestConfig({ + preToolUseHandler: () => { + handlerCalled = true + return { type: 'success', output: '' } + }, + }) + const runner = createWorkflowRunner(config) + const hookInput = JSON.stringify({ + session_id: 'no-session', + transcript_path: '/tmp/transcript.json', + cwd: '/home/user', + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_use_id: 'tool-1', + }) + const deps = createMockEngineDeps(false) + const result = runner([], deps, {}, { readStdin: () => hookInput }) + expect(result.exitCode).toBe(EXIT_ALLOW) + expect(handlerCalled).toBe(false) + }) + it('returns allow for SubagentStart when no handler configured', () => { const config = createTestConfig() const runner = createWorkflowRunner(config) @@ -393,11 +518,11 @@ describe('createWorkflowRunner', () => { agent_type: 'developer-1', }) const deps = createMockEngineDeps(true) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) }) - it('handles SubagentStart hook with registered handler', () => { + it('handles SubagentStart hook with registered handler and wraps output with formatContextInjection', () => { const registered: Array<{ agentType: string; agentId: string }> = [] const config = createTestConfig({ hooks: { @@ -419,9 +544,11 @@ describe('createWorkflowRunner', () => { agent_type: 'developer-1', }) const deps = createMockEngineDeps(true) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) expect(registered[0]).toEqual({ agentType: 'developer-1', agentId: 'agt-1' }) + const parsed = JSON.parse(result.output) + expect(parsed).toHaveProperty('additionalContext') }) it('returns allow for SubagentStart when session does not exist', () => { @@ -442,7 +569,7 @@ describe('createWorkflowRunner', () => { agent_type: 'developer-1', }) const deps = createMockEngineDeps(false) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) }) @@ -457,7 +584,7 @@ describe('createWorkflowRunner', () => { teammate_name: 'developer-1', }) const deps = createMockEngineDeps(true) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) }) @@ -481,7 +608,7 @@ describe('createWorkflowRunner', () => { teammate_name: 'developer-1', }) const deps = createMockEngineDeps(true) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) }) @@ -505,7 +632,7 @@ describe('createWorkflowRunner', () => { teammate_name: 'lead-1', }) const deps = createMockEngineDeps(true) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_BLOCK) }) @@ -525,7 +652,7 @@ describe('createWorkflowRunner', () => { hook_event_name: 'TeammateIdle', }) const deps = createMockEngineDeps(false) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) }) @@ -549,7 +676,7 @@ describe('createWorkflowRunner', () => { hook_event_name: 'TeammateIdle', }) const deps = createMockEngineDeps(true) - const result = runner([], deps, {}, () => hookInput) + const result = runner([], deps, {}, { readStdin: () => hookInput }) expect(result.exitCode).toBe(EXIT_ALLOW) expect(receivedNames[0]).toBe('') }) diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts index 8579f19..96098ce 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts @@ -12,9 +12,29 @@ import type { HookDefinition } from './hook-definition.js' import type { PreToolUseInput, SubagentStartInput, TeammateIdleInput } from './hook-schemas.js' import { EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from './exit-codes.js' import { HookCommonInputSchema, PreToolUseInputSchema, SubagentStartInputSchema, TeammateIdleInputSchema } from './hook-schemas.js' +import { formatDenyDecision, formatContextInjection } from './hook-output.js' export type RunnerResult = { readonly output: string; readonly exitCode: number } +export type PreToolUseHandlerFn< + TWorkflow extends RehydratableWorkflow<TState>, + TState extends BaseWorkflowState<TStateName>, + TDeps, + TStateName extends string = string, + TOperation extends string = string, +> = ( + engine: WorkflowEngine<TWorkflow, TState, TDeps, TStateName, TOperation>, + sessionId: string, + toolName: string, + toolInput: Record<string, unknown>, + transcriptPath: string | undefined, +) => EngineResult + +export type RunnerOptions = { + readonly readStdin?: () => string + readonly getSessionId?: () => string +} + export type WorkflowRunnerConfig< TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, @@ -25,6 +45,7 @@ export type WorkflowRunnerConfig< readonly workflowDefinition: WorkflowDefinition<TWorkflow, TState, TDeps, TStateName, TOperation> readonly routes: RouteMap<TWorkflow, TState> readonly hooks?: HookDefinition<TWorkflow> + readonly preToolUseHandler?: PreToolUseHandlerFn<TWorkflow, TState, TDeps, TStateName, TOperation> } function engineResultToRunnerResult(result: EngineResult): RunnerResult { @@ -78,20 +99,20 @@ export function createWorkflowRunner< TOperation extends string = string, >( config: WorkflowRunnerConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, -): (args: readonly string[], engineDeps: WorkflowEngineDeps, workflowDeps: TDeps, readStdin?: () => string) => RunnerResult { - return (args, engineDeps, workflowDeps, readStdin) => { +): (args: readonly string[], engineDeps: WorkflowEngineDeps, workflowDeps: TDeps, options?: RunnerOptions) => RunnerResult { + return (args, engineDeps, workflowDeps, options) => { const engine = new WorkflowEngine(config.workflowDefinition, engineDeps, workflowDeps) const routeName = args[0] if (routeName !== undefined) { - return handleRoute(engine, config, args, routeName) + return handleRoute(engine, config, args, routeName, options?.getSessionId) } - if (readStdin === undefined) { + if (options?.readStdin === undefined) { return { output: 'No command and no stdin available', exitCode: EXIT_ERROR } } - return handleHook(engine, config, readStdin) + return handleHook(engine, config, options.readStdin) } } @@ -106,6 +127,7 @@ function handleRoute< config: WorkflowRunnerConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, args: readonly string[], routeName: string, + getSessionId?: () => string, ): RunnerResult { const routeDef = config.routes[routeName] if (routeDef === undefined) { @@ -117,21 +139,41 @@ function handleRoute< return { output: parsedArgs.message, exitCode: EXIT_ERROR } } + const resolveSessionId = (): string => { + if (getSessionId !== undefined) return getSessionId() + return assertSessionId(parsedArgs.values) + } + + const argsAfterSessionId = (): readonly unknown[] => { + if (getSessionId !== undefined) return parsedArgs.values + return parsedArgs.values.slice(1) + } + + const resolveTarget = (): string => { + if (getSessionId !== undefined) { + const target = parsedArgs.values[0] + /* v8 ignore next */ + if (typeof target !== 'string') throw new Error('target argument must be a string') + return target + } + return assertTarget(parsedArgs.values) + } + switch (routeDef.type) { case 'session-start': { - const sessionId = assertSessionId(parsedArgs.values) + const sessionId = resolveSessionId() const result = engine.startSession(sessionId) return engineResultToRunnerResult(result) } case 'transition': { - const sessionId = assertSessionId(parsedArgs.values) - const target = config.workflowDefinition.parseStateName(assertTarget(parsedArgs.values)) + const sessionId = resolveSessionId() + const target = config.workflowDefinition.parseStateName(resolveTarget()) const result = engine.transition(sessionId, target) return engineResultToRunnerResult(result) } case 'transaction': { - const sessionId = assertSessionId(parsedArgs.values) - const restArgs = parsedArgs.values.slice(1) + const sessionId = resolveSessionId() + const restArgs = argsAfterSessionId() const result = engine.transaction(sessionId, routeName, (w: TWorkflow) => routeDef.handler(w, ...restArgs), ) @@ -209,11 +251,19 @@ function handlePreToolUse< config: WorkflowRunnerConfig<TWorkflow, TState, TDeps, TStateName, TOperation>, input: PreToolUseInput, ): RunnerResult { - if (config.hooks?.preToolUse === undefined) { + if (!engine.hasSession(input.session_id)) { return { output: '', exitCode: EXIT_ALLOW } } - if (!engine.hasSession(input.session_id)) { + if (config.preToolUseHandler !== undefined) { + const result = config.preToolUseHandler(engine, input.session_id, input.tool_name, input.tool_input, input.transcript_path) + if (result.type === 'blocked') { + return { output: formatDenyDecision(result.output), exitCode: EXIT_BLOCK } + } + return engineResultToRunnerResult(result) + } + + if (config.hooks?.preToolUse === undefined) { return { output: '', exitCode: EXIT_ALLOW } } @@ -261,7 +311,9 @@ function handleSubagentStartHook< (w: TWorkflow) => handler.register(w, input.agent_type, input.agent_id), ) - return engineResultToRunnerResult(result) + /* v8 ignore next */ + const contextOutput = result.type === 'success' ? result.output : '' + return { output: formatContextInjection(contextOutput), exitCode: EXIT_ALLOW } } function handleTeammateIdleHook< diff --git a/packages/agentic-workflow-builder/src/cli/index.spec.ts b/packages/agentic-workflow-builder/src/cli/index.spec.ts index 679710d..e6bbd09 100644 --- a/packages/agentic-workflow-builder/src/cli/index.spec.ts +++ b/packages/agentic-workflow-builder/src/cli/index.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { arg, extractField, defineRoutes, defineHooks, createWorkflowRunner, EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK, HookCommonInputSchema, PreToolUseInputSchema, SubagentStartInputSchema, TeammateIdleInputSchema } from './index.js' +import { arg, extractField, defineRoutes, defineHooks, createWorkflowRunner, EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK, HookCommonInputSchema, PreToolUseInputSchema, SubagentStartInputSchema, TeammateIdleInputSchema, formatDenyDecision, formatContextInjection } from './index.js' describe('cli barrel exports', () => { it('exports all public API', () => { @@ -15,5 +15,7 @@ describe('cli barrel exports', () => { expect(PreToolUseInputSchema).toBeDefined() expect(SubagentStartInputSchema).toBeDefined() expect(TeammateIdleInputSchema).toBeDefined() + expect(formatDenyDecision).toBeDefined() + expect(formatContextInjection).toBeDefined() }) }) diff --git a/packages/agentic-workflow-builder/src/cli/index.ts b/packages/agentic-workflow-builder/src/cli/index.ts index 7b64dab..1e062b0 100644 --- a/packages/agentic-workflow-builder/src/cli/index.ts +++ b/packages/agentic-workflow-builder/src/cli/index.ts @@ -6,8 +6,11 @@ export type { RouteDefinition, RouteMap } from './domain/command-definition.js' export { defineHooks } from './domain/hook-definition.js' export type { HookCheck, HookDefinition, SubagentStartHandler, TeammateIdleHandler } from './domain/hook-definition.js' export { createWorkflowRunner } from './domain/workflow-runner.js' -export type { RunnerResult, WorkflowRunnerConfig } from './domain/workflow-runner.js' +export type { RunnerResult, RunnerOptions, WorkflowRunnerConfig, PreToolUseHandlerFn } from './domain/workflow-runner.js' +export { formatDenyDecision, formatContextInjection } from './domain/hook-output.js' export { createWorkflowCli } from './domain/workflow-cli.js' +export type { WorkflowCliConfig } from './domain/workflow-cli.js' +export type { PlatformContext } from './domain/platform-context.js' export { EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from './domain/exit-codes.js' export { HookCommonInputSchema, PreToolUseInputSchema, SubagentStartInputSchema, TeammateIdleInputSchema } from './domain/hook-schemas.js' export type { PreToolUseInput, SubagentStartInput, TeammateIdleInput } from './domain/hook-schemas.js' diff --git a/src/entrypoint.ts b/src/entrypoint.ts deleted file mode 100644 index 2bdabf2..0000000 --- a/src/entrypoint.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { appendFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import type { EngineResult } from '@ntcoding/agentic-workflow-builder/engine' -import { WorkflowEngine } from '@ntcoding/agentic-workflow-builder/engine' -import { createWorkflowRunner, defineRoutes, arg, EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from '@ntcoding/agentic-workflow-builder/cli' -import type { RunnerResult } from '@ntcoding/agentic-workflow-builder/cli' -import { FeatureTeamWorkflowDefinition, StateNameSchema } from './workflow-definition/index.js' -import type { Workflow, WorkflowDeps } from './workflow-definition/index.js' -import type { StateName, WorkflowOperation, WorkflowState } from './workflow-definition/domain/workflow-types.js' -import { WorkflowError } from './infra/workflow-error.js' -import { buildRealDeps } from './infra/composition-root.js' -import type { AnalyticsDeps, ReportDeps, ReportResult, AdapterDeps } from './infra/composition-root.js' -import { handleHookRoute } from './hook-routes.js' -import { isAnalyticsCommand, routeAnalytics } from './analytics/cli-routes.js' -export type { AnalyticsDeps, ReportDeps, ReportResult, AdapterDeps } - -function narrowNumber(v: unknown): number { - /* v8 ignore next */ - if (typeof v !== 'number') throw new WorkflowError(`Expected number, got ${typeof v}`) - return v -} - -function narrowString(v: unknown): string { - /* v8 ignore next */ - if (typeof v !== 'string') throw new WorkflowError(`Expected string, got ${typeof v}`) - return v -} - -function narrowStringArray(v: unknown): readonly string[] { - /* v8 ignore next */ - if (!Array.isArray(v)) throw new WorkflowError(`Expected string array, got ${typeof v}`) - return v -} - -const ROUTES = defineRoutes<Workflow, WorkflowState>({ - transition: { type: 'transition', args: [arg.string('session-id'), arg.state('STATE', StateNameSchema)] }, - 'record-issue': { type: 'transaction', args: [arg.string('session-id'), arg.number('number')], handler: (w, n) => w.recordIssue(narrowNumber(n)) }, - 'record-branch': { type: 'transaction', args: [arg.string('session-id'), arg.string('branch')], handler: (w, b) => w.recordBranch(narrowString(b)) }, - 'record-plan-approval': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.recordPlanApproval() }, - 'assign-iteration-task': { type: 'transaction', args: [arg.string('session-id'), arg.string('task')], handler: (w, t) => w.assignIterationTask(narrowString(t)) }, - 'signal-done': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.signalDone() }, - 'record-pr': { type: 'transaction', args: [arg.string('session-id'), arg.number('number')], handler: (w, n) => w.recordPr(narrowNumber(n)) }, - 'create-pr': { type: 'transaction', args: [arg.string('session-id'), arg.string('title'), arg.string('body')], handler: (w, t, b) => w.createPr(narrowString(t), narrowString(b)) }, - 'append-issue-checklist': { type: 'transaction', args: [arg.string('session-id'), arg.number('issue-number'), arg.string('checklist')], handler: (w, n, c) => w.appendIssueChecklist(narrowNumber(n), narrowString(c)) }, - 'tick-iteration': { type: 'transaction', args: [arg.string('session-id'), arg.number('issue-number')], handler: (w, n) => w.tickIteration(narrowNumber(n)) }, - 'review-approved': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.reviewApproved() }, - 'review-rejected': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.reviewRejected() }, - 'coderabbit-feedback-addressed': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.coderabbitFeedbackAddressed() }, - 'coderabbit-feedback-ignored': { type: 'transaction', args: [arg.string('session-id')], handler: (w) => w.coderabbitFeedbackIgnored() }, - 'shut-down': { type: 'transaction', args: [arg.string('session-id'), arg.string('agent-name')], handler: (w, name) => w.shutDown(narrowString(name)) }, - 'write-journal': { type: 'transaction', args: [arg.string('session-id'), arg.string('agent-name'), arg.string('content')], handler: (w, a, c) => w.writeJournal(narrowString(a), narrowString(c)) }, - 'run-lint': { type: 'transaction', args: [arg.string('session-id'), arg.rest('files')], handler: (w, files) => w.runLint(narrowStringArray(files)) }, - 'get-session-summary': { type: 'transaction', args: [arg.string('session-id'), arg.string('agent-name').optional()], handler: (w, name) => w.getSessionSummary(typeof name === 'string' ? name : '') }, -}) - -const platformRunner = createWorkflowRunner<Workflow, WorkflowState, WorkflowDeps, StateName, WorkflowOperation>({ - workflowDefinition: FeatureTeamWorkflowDefinition, - routes: ROUTES, -}) - -export function runWorkflow(args: readonly string[], deps: AdapterDeps): RunnerResult { - const command = args[0] - if (!command) return handleHookRoute(deps) - if (command === 'init') return handleInit(deps) - if (isAnalyticsCommand(command)) return routeAnalytics(command, args, deps) - const sessionId = deps.getSessionId() - return platformRunner([command, sessionId, ...args.slice(1)], deps.engineDeps, deps.workflowDeps) -} - -function handleInit(deps: AdapterDeps): RunnerResult { - const engine = new WorkflowEngine(FeatureTeamWorkflowDefinition, deps.engineDeps, deps.workflowDeps) - return mapResult(engine.startSession(deps.getSessionId(), undefined, deps.getRepositoryName())) -} - -function mapResult(result: EngineResult): RunnerResult { - /* v8 ignore next */ - const exitCode = result.type === 'success' ? EXIT_ALLOW : result.type === 'blocked' ? EXIT_BLOCK : EXIT_ERROR - return { output: result.output, exitCode } -} - -/* v8 ignore start */ -function main(): void { - try { - const result = runWorkflow(process.argv.slice(2), buildRealDeps()) - process.stdout.write(result.output, () => process.exit(result.exitCode)) - } catch (error) { - const message = `[${new Date().toISOString()}] HOOK ERROR: ${String(error)}\n` - process.stderr.write(message) - appendFileSync('/tmp/feature-team-hook-errors.log', message) - process.exit(EXIT_ERROR) - } -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - main() -} -/* v8 ignore stop */ diff --git a/src/hook-routes.spec.ts b/src/hook-routes.spec.ts deleted file mode 100644 index 16a75bf..0000000 --- a/src/hook-routes.spec.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { handleHookRoute } from './hook-routes.js' -import { EXIT_ERROR, EXIT_ALLOW, EXIT_BLOCK } from './infra/hook-io.js' -import { - makeDeps, - makeHookStdin, - planningEvents, - developingEvents, -} from './autonomous-claude-agent-team-workflow-cli-test-fixtures.js' -import type { WorkflowEvent } from './workflow-definition/index.js' - -const AT = '2026-01-01T00:00:00Z' - -describe('handleHookRoute', () => { - it('routes SessionStart to persist-session-id and appends session id to env file', () => { - const appended: string[] = [] - const result = handleHookRoute( - makeDeps({ - readStdin: () => makeHookStdin({ hook_event_name: 'SessionStart' }), - engineDeps: { appendToFile: (_: string, content: string) => appended.push(content) }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - expect(appended[0]).toContain("CLAUDE_SESSION_ID='test-session'") - }) - - it('routes PreToolUse and returns EXIT_ALLOW with no session', () => { - const result = handleHookRoute( - makeDeps({ - readStdin: () => makeHookStdin({ hook_event_name: 'PreToolUse' }), - engineDeps: { store: { sessionExists: () => false } }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - }) - - it('returns EXIT_BLOCK from PreToolUse when write is blocked', () => { - const result = handleHookRoute( - makeDeps({ - readStdin: () => - makeHookStdin({ - hook_event_name: 'PreToolUse', - tool_name: 'Write', - tool_input: { file_path: '/project/src/foo.ts' }, - }), - engineDeps: { - store: { sessionExists: () => true, readEvents: () => [ - { type: 'transitioned', at: AT, from: 'SPAWN', to: 'RESPAWN' }, - ] }, - }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_BLOCK) - }) - - it('returns EXIT_BLOCK from PreToolUse when plugin source read is blocked', () => { - const result = handleHookRoute( - makeDeps({ - readStdin: () => - makeHookStdin({ - hook_event_name: 'PreToolUse', - tool_name: 'Read', - tool_input: { file_path: '/home/.claude/plugins/cache/myplugin/src/index.ts' }, - }), - engineDeps: { - store: { sessionExists: () => true, readEvents: () => [] }, - }, - workflowDeps: { - getPluginRoot: () => '/home/.claude/plugins/cache/myplugin', - }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_BLOCK) - }) - - it('returns EXIT_BLOCK from PreToolUse when bash command is blocked', () => { - const result = handleHookRoute( - makeDeps({ - readStdin: () => - makeHookStdin({ - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { command: 'git commit -m "test"' }, - }), - engineDeps: { - store: { sessionExists: () => true, readEvents: () => developingEvents() }, - }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_BLOCK) - }) - - it('returns empty output from PreToolUse when all checks pass', () => { - const result = handleHookRoute( - makeDeps({ - readStdin: () => makeHookStdin({ hook_event_name: 'PreToolUse' }), - engineDeps: { - store: { sessionExists: () => true, readEvents: () => planningEvents() }, - }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - expect(result.output).toStrictEqual('') - }) - - it('routes SubagentStart and returns EXIT_ALLOW with no session', () => { - const result = handleHookRoute( - makeDeps({ - readStdin: () => makeHookStdin({ hook_event_name: 'SubagentStart', agent_id: 'agt-1', agent_type: 'developer-1' }), - engineDeps: { store: { sessionExists: () => false } }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - }) - - it('SubagentStart appends agent-registered event when session exists', () => { - const appended: Array<{ sessionId: string; firstEventType: string }> = [] - const result = handleHookRoute( - makeDeps({ - readStdin: () => makeHookStdin({ - hook_event_name: 'SubagentStart', - session_id: 'parent-session', - agent_id: 'agt-1', - agent_type: 'developer-1', - }), - engineDeps: { - store: { - sessionExists: () => true, - readEvents: () => [], - appendEvents: (sessionId, events) => appended.push({ sessionId, firstEventType: events[0]?.type ?? '' }), - }, - }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - expect(appended[0]?.firstEventType).toStrictEqual('agent-registered') - }) - - it('routes TeammateIdle and returns EXIT_ALLOW with no session', () => { - const result = handleHookRoute( - makeDeps({ - readStdin: () => makeHookStdin({ hook_event_name: 'TeammateIdle' }), - engineDeps: { store: { sessionExists: () => false } }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - }) - - it('TeammateIdle allows unnamed agent', () => { - const result = handleHookRoute( - makeDeps({ - readStdin: () => makeHookStdin({ hook_event_name: 'TeammateIdle' }), - engineDeps: { - store: { sessionExists: () => true, readEvents: () => developingEvents() }, - }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - }) - - it('TeammateIdle allows non-lead agent in DEVELOPING state', () => { - const result = handleHookRoute( - makeDeps({ - readStdin: () => makeHookStdin({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer-1' }), - engineDeps: { - store: { sessionExists: () => true, readEvents: () => developingEvents() }, - }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - }) - - it('TeammateIdle blocks lead agent in non-BLOCKED state', () => { - const result = handleHookRoute( - makeDeps({ - readStdin: () => makeHookStdin({ hook_event_name: 'TeammateIdle', teammate_name: 'lead-1' }), - engineDeps: { - store: { sessionExists: () => true, readEvents: () => developingEvents() }, - }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_BLOCK) - expect(result.output).toContain('Lead cannot go idle') - }) - - it('throws when tool_input contains a non-string value', () => { - expect(() => handleHookRoute( - makeDeps({ - readStdin: () => makeHookStdin({ - hook_event_name: 'PreToolUse', - tool_name: 'Write', - tool_input: { file_path: 42 }, - }), - }), - )).toThrow('Expected string or undefined') - }) - - it('returns EXIT_ERROR for unrecognised hook event', () => { - const result = handleHookRoute( - makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'UnknownEvent' }) }), - ) - expect(result.exitCode).toStrictEqual(EXIT_ERROR) - expect(result.output).toContain('Unknown hook event: UnknownEvent') - }) - - it('PreToolUse records identity-verified event via engine prefix verification', () => { - const appended: Array<{ firstEventType: string }> = [] - const result = handleHookRoute( - makeDeps({ - readStdin: () => makeHookStdin({ hook_event_name: 'PreToolUse' }), - engineDeps: { - store: { - sessionExists: () => true, - readEvents: () => planningEvents(), - appendEvents: (_sessionId: string, events: readonly WorkflowEvent[]) => appended.push({ firstEventType: events[0]?.type ?? '' }), - }, - }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - expect(appended[0]?.firstEventType).toStrictEqual('identity-verified') - }) - - it('PreToolUse blocks when identity is lost', () => { - const result = handleHookRoute( - makeDeps({ - readStdin: () => makeHookStdin({ hook_event_name: 'PreToolUse' }), - engineDeps: { - transcriptReader: { - readMessages: () => [ - { id: '1', textContent: 'LEAD: PLANNING' }, - { id: '2', textContent: 'No prefix here' }, - ], - }, - store: { sessionExists: () => true, readEvents: () => planningEvents() }, - }, - }), - ) - expect(result.exitCode).toStrictEqual(EXIT_BLOCK) - }) -}) diff --git a/src/hook-routes.ts b/src/hook-routes.ts deleted file mode 100644 index fbd3163..0000000 --- a/src/hook-routes.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { WorkflowEngine } from '@ntcoding/agentic-workflow-builder/engine' -import type { RunnerResult } from '@ntcoding/agentic-workflow-builder/cli' -import { - HookCommonInputSchema, - PreToolUseInputSchema, - SubagentStartInputSchema, - TeammateIdleInputSchema, -} from '@ntcoding/agentic-workflow-builder/cli' -import { FeatureTeamWorkflowDefinition, BASH_FORBIDDEN, checkWriteAllowed } from './workflow-definition/index.js' -import type { Workflow, WorkflowDeps } from './workflow-definition/index.js' -import type { StateName, WorkflowOperation, WorkflowState } from './workflow-definition/domain/workflow-types.js' -import type { AdapterDeps } from './infra/composition-root.js' -import { - formatDenyDecision, - formatContextInjection, - EXIT_ALLOW, - EXIT_ERROR, - EXIT_BLOCK, -} from './infra/hook-io.js' -import { WorkflowError } from './infra/workflow-error.js' - -type Engine = WorkflowEngine<Workflow, WorkflowState, WorkflowDeps, StateName, WorkflowOperation> - -type HookHandler = (engine: Engine, deps: AdapterDeps) => RunnerResult - -const HOOK_HANDLERS: Readonly<Record<string, HookHandler>> = { - SessionStart: handleSessionStart, - PreToolUse: handlePreToolUse, - SubagentStart: handleSubagentStart, - TeammateIdle: handleTeammateIdle, -} - -export function handleHookRoute(deps: AdapterDeps): RunnerResult { - const engine = new WorkflowEngine(FeatureTeamWorkflowDefinition, deps.engineDeps, deps.workflowDeps) - const stdin = deps.readStdin() - const cachedDeps: AdapterDeps = { ...deps, readStdin: () => stdin } - const common = HookCommonInputSchema.parse(JSON.parse(stdin)) - const handler = HOOK_HANDLERS[common.hook_event_name] - if (!handler) { - return { output: `Unknown hook event: ${common.hook_event_name}`, exitCode: EXIT_ERROR } - } - if (common.hook_event_name !== 'SessionStart' && !engine.hasSession(common.session_id)) { - return { output: '', exitCode: EXIT_ALLOW } - } - return handler(engine, cachedDeps) -} - -function handleSessionStart(engine: Engine, deps: AdapterDeps): RunnerResult { - const hookInput = HookCommonInputSchema.parse(JSON.parse(deps.readStdin())) - engine.persistSessionId(hookInput.session_id) - return { output: '', exitCode: EXIT_ALLOW } -} - -function handlePreToolUse(engine: Engine, deps: AdapterDeps): RunnerResult { - const hookInput = PreToolUseInputSchema.parse(JSON.parse(deps.readStdin())) - - const filePath = resolveStringField(hookInput.tool_input['file_path']) - || resolveStringField(hookInput.tool_input['path']) - || resolveStringField(hookInput.tool_input['pattern']) - const command = resolveStringField(hookInput.tool_input['command']) - - const pluginCheck = engine.transaction(hookInput.session_id, 'hook-check', (w) => { - return w.checkPluginSourceRead(hookInput.tool_name, filePath, command) - }, hookInput.transcript_path) - if (pluginCheck.type === 'blocked') return { output: formatDenyDecision(pluginCheck.output), exitCode: EXIT_BLOCK } - - const writeCheck = engine.checkWrite(hookInput.session_id, hookInput.tool_name, filePath, checkWriteAllowed, hookInput.transcript_path) - if (writeCheck.type === 'blocked') return { output: formatDenyDecision(writeCheck.output), exitCode: EXIT_BLOCK } - - const bashCheck = engine.checkBash(hookInput.session_id, hookInput.tool_name, command, BASH_FORBIDDEN, hookInput.transcript_path) - if (bashCheck.type === 'blocked') return { output: formatDenyDecision(bashCheck.output), exitCode: EXIT_BLOCK } - - return { output: '', exitCode: EXIT_ALLOW } -} - -function handleSubagentStart(engine: Engine, deps: AdapterDeps): RunnerResult { - const hookInput = SubagentStartInputSchema.parse(JSON.parse(deps.readStdin())) - const result = engine.transaction(hookInput.session_id, 'register-agent', (w) => { - return w.registerAgent(hookInput.agent_type, hookInput.agent_id) - }) - /* v8 ignore next */ - const state = result.type === 'success' ? result.output : '' - return { output: formatContextInjection(state), exitCode: EXIT_ALLOW } -} - -function handleTeammateIdle(engine: Engine, deps: AdapterDeps): RunnerResult { - const hookInput = TeammateIdleInputSchema.parse(JSON.parse(deps.readStdin())) - const agentName = hookInput.teammate_name ?? '' - const result = engine.transaction(hookInput.session_id, 'check-idle', (w) => w.checkIdleAllowed(agentName)) - if (result.type === 'blocked') { - return { output: result.output, exitCode: EXIT_BLOCK } - } - return { output: '', exitCode: EXIT_ALLOW } -} - -function resolveStringField(value: unknown): string { - if (value === undefined || value === null) return '' - if (typeof value === 'string') return value - throw new WorkflowError(`Expected string or undefined. Got ${typeof value}: ${String(value)}`) -} diff --git a/src/infra/arg-parsing.spec.ts b/src/infra/arg-parsing.spec.ts new file mode 100644 index 0000000..ef96fae --- /dev/null +++ b/src/infra/arg-parsing.spec.ts @@ -0,0 +1,64 @@ +import { parseNumber, parseString, parseStringArray } from './arg-parsing.js' + +describe('parseNumber', () => { + it('returns number when given a number', () => { + expect(parseNumber(42)).toBe(42) + }) + + it('throws ZodError when given a string', () => { + expect(() => parseNumber('not-a-number')).toThrow('Expected number') + }) + + it('throws ZodError when given undefined', () => { + expect(() => parseNumber(undefined)).toThrow('Required') + }) + + it('returns zero', () => { + expect(parseNumber(0)).toBe(0) + }) + + it('returns negative numbers', () => { + expect(parseNumber(-5)).toBe(-5) + }) +}) + +describe('parseString', () => { + it('returns string when given a string', () => { + expect(parseString('hello')).toBe('hello') + }) + + it('throws ZodError when given a number', () => { + expect(() => parseString(42)).toThrow('Expected string') + }) + + it('throws ZodError when given undefined', () => { + expect(() => parseString(undefined)).toThrow('Required') + }) + + it('returns empty string', () => { + expect(parseString('')).toBe('') + }) +}) + +describe('parseStringArray', () => { + it('returns readonly string array when given string array', () => { + const result = parseStringArray(['a', 'b', 'c']) + expect(result).toStrictEqual(['a', 'b', 'c']) + }) + + it('returns empty readonly array', () => { + expect(parseStringArray([])).toStrictEqual([]) + }) + + it('throws ZodError when given a string', () => { + expect(() => parseStringArray('not-array')).toThrow('Expected array') + }) + + it('throws ZodError when array contains non-strings', () => { + expect(() => parseStringArray([1, 2])).toThrow('Expected string') + }) + + it('throws ZodError when given undefined', () => { + expect(() => parseStringArray(undefined)).toThrow('Required') + }) +}) diff --git a/src/infra/arg-parsing.ts b/src/infra/arg-parsing.ts new file mode 100644 index 0000000..d85567c --- /dev/null +++ b/src/infra/arg-parsing.ts @@ -0,0 +1,6 @@ +import { z } from 'zod' + +export const parseNumber = (v: unknown): number => z.number().parse(v) +export const parseString = (v: unknown): string => z.string().parse(v) +export const parseStringArray = (v: unknown): readonly string[] => + z.array(z.string()).readonly().parse(v) diff --git a/src/infra/composition-root.ts b/src/infra/composition-root.ts deleted file mode 100644 index 304d977..0000000 --- a/src/infra/composition-root.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { readFileSync, writeFileSync, appendFileSync, existsSync } from 'node:fs' -import { execSync } from 'node:child_process' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { WorkflowEngineDeps } from '@ntcoding/agentic-workflow-builder/engine' -import type { WorkflowDeps } from '../workflow-definition/domain/workflow.js' -import { - computeSessionSummary, - computeCrossSessionSummary, - computeEventContext, - formatSessionSummary, - formatCrossSessionSummary, - computeEnhancedSessionSummary, - buildSessionViewData, - assembleReportData, - generateReportHtml, - formatAnalysisContext, -} from '../workflow-analysis/index.js' -import { createStore, resolveSessionId } from '@ntcoding/agentic-workflow-builder/event-store' -import { WorkflowEventSchema } from '../workflow-definition/index.js' -import { getSessionId, getPluginRoot, getEnvFilePath, getDbPath } from './environment.js' -import { getGitInfo, getRepositoryName } from './git.js' -import { checkPrChecks, createDraftPr, appendIssueChecklist, tickFirstUncheckedIteration } from './github.js' -import { readStdinSync } from './stdin.js' -import { runEslintOnFiles } from './linter.js' - -export type AnalyticsDeps = { - readonly computeSession: (sessionId: string) => string - readonly computeAll: () => string - readonly computeEventContext: (sessionId: string) => string -} - -export type ReportResult = { - readonly path: string -} - -export type ReportDeps = { - readonly getAnalysisContext: (sessionId: string) => string - readonly generateReport: (sessionId: string, options?: { analysis?: string }) => ReportResult - readonly readAnalysisFile: (filePath: string) => string -} - -export type AdapterDeps = { - readonly getSessionId: () => string - readonly getRepositoryName: () => string | undefined - readonly readStdin: () => string - readonly engineDeps: WorkflowEngineDeps - readonly workflowDeps: WorkflowDeps - readonly analyticsDeps: AnalyticsDeps - readonly reportDeps: ReportDeps -} - -/* v8 ignore start */ -export function buildRealDeps(): AdapterDeps { - const store = createStore(getDbPath()) - - const engineDeps: WorkflowEngineDeps = { - store, - getPluginRoot, - getEnvFilePath, - readFile: (path) => readFileSync(path, 'utf8'), - appendToFile: (path, content) => appendFileSync(path, content), - now: () => new Date().toISOString(), - } - - const workflowDeps: WorkflowDeps = { - getGitInfo, - checkPrChecks, - createDraftPr, - appendIssueChecklist, - tickFirstUncheckedIteration, - runEslintOnFiles, - fileExists: existsSync, - getPluginRoot, - now: () => new Date().toISOString(), - } - - const analyticsDeps: AnalyticsDeps = { - computeSession: (sessionId) => formatSessionSummary(computeSessionSummary(createStore(getDbPath()), sessionId)), - computeAll: () => formatCrossSessionSummary(computeCrossSessionSummary(createStore(getDbPath()))), - computeEventContext: (sessionId) => computeEventContext(createStore(getDbPath()), sessionId), - } - - const reportDeps: ReportDeps = { - getAnalysisContext: (sessionId) => { - const eventStore = createStore(getDbPath()) - const resolvedId = resolveSessionId(eventStore, sessionId) - const rawEvents = eventStore.readEvents(resolvedId) - const events = rawEvents.map((e) => WorkflowEventSchema.parse(e)) - const baseSummary = computeSessionSummary(eventStore, resolvedId) - const viewData = buildSessionViewData(resolvedId, rawEvents) - const enhanced = computeEnhancedSessionSummary(baseSummary, viewData, events) - const enhancedWithRepo = { ...enhanced, repository: enhanced.repository ?? getRepositoryName() } - const data = assembleReportData(enhancedWithRepo, viewData, [], [], events) - return formatAnalysisContext(data) - }, - generateReport: (sessionId, options) => { - const eventStore = createStore(getDbPath()) - const resolvedId = resolveSessionId(eventStore, sessionId) - const rawEvents = eventStore.readEvents(resolvedId) - const events = rawEvents.map((e) => WorkflowEventSchema.parse(e)) - const baseSummary = computeSessionSummary(eventStore, resolvedId) - const viewData = buildSessionViewData(resolvedId, rawEvents) - const enhanced = computeEnhancedSessionSummary(baseSummary, viewData, events) - const enhancedWithRepo = { ...enhanced, repository: enhanced.repository ?? getRepositoryName() } - const data = assembleReportData(enhancedWithRepo, viewData, [], [], events) - const html = generateReportHtml(data, options?.analysis) - const htmlPath = join(tmpdir(), `session-report-${resolvedId}.html`) - writeFileSync(htmlPath, html) - try { execSync(`open ${JSON.stringify(htmlPath)}`) } catch { } - return { path: htmlPath } - }, - readAnalysisFile: (filePath) => readFileSync(filePath, 'utf8'), - } - - return { - getSessionId, - getRepositoryName, - readStdin: readStdinSync, - engineDeps, - workflowDeps, - analyticsDeps, - reportDeps, - } -} -/* v8 ignore stop */ diff --git a/src/infra/hook-io.spec.ts b/src/infra/hook-io.spec.ts deleted file mode 100644 index 13e5a4b..0000000 --- a/src/infra/hook-io.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - formatDenyDecision, - formatContextInjection, - EXIT_ALLOW, - EXIT_BLOCK, - EXIT_ERROR, -} from './hook-io.js' - -describe('formatDenyDecision', () => { - it('includes permissionDecision deny and reason', () => { - const output = formatDenyDecision('reason here') - expect(output).toContain('"permissionDecision":"deny"') - expect(output).toContain('reason here') - }) - - it('includes hookEventName PreToolUse', () => { - const output = formatDenyDecision('any reason') - expect(output).toContain('PreToolUse') - }) -}) - -describe('formatContextInjection', () => { - it('wraps context in additionalContext field', () => { - const output = formatContextInjection('ctx text') - expect(output).toContain('"additionalContext"') - expect(output).toContain('ctx text') - }) -}) - -describe('exit code constants', () => { - it('EXIT_ALLOW is 0, EXIT_BLOCK is 2, EXIT_ERROR is 1', () => { - expect(EXIT_ALLOW).toStrictEqual(0) - expect(EXIT_BLOCK).toStrictEqual(2) - expect(EXIT_ERROR).toStrictEqual(1) - }) -}) diff --git a/src/workflow-definition/domain/workflow-cr-review-to-complete-states.spec.ts b/src/workflow-definition/domain/workflow-cr-review-to-complete-states.spec.ts index 79c8833..fd747b3 100644 --- a/src/workflow-definition/domain/workflow-cr-review-to-complete-states.spec.ts +++ b/src/workflow-definition/domain/workflow-cr-review-to-complete-states.spec.ts @@ -119,13 +119,13 @@ describe('Workflow', () => { it('sets prNumber when recordPr succeeds', () => { const { result, state } = spec .given(...eventsToPrCreation()) - .when((wf) => wf.recordPr(99)) + .when((wf) => wf.executeRecording('record-pr', 99)) expect(result).toStrictEqual({ pass: true }) expect(state.prNumber).toBe(99) }) it('fails recordPr in non-PR_CREATION states', () => { - const { result } = spec.given().when((wf) => wf.recordPr(99)) + const { result } = spec.given().when((wf) => wf.executeRecording('record-pr', 99)) expect(result.pass).toBe(false) }) diff --git a/src/workflow-definition/domain/workflow-spawn-planning-states.spec.ts b/src/workflow-definition/domain/workflow-spawn-planning-states.spec.ts index fdcf437..2129f07 100644 --- a/src/workflow-definition/domain/workflow-spawn-planning-states.spec.ts +++ b/src/workflow-definition/domain/workflow-spawn-planning-states.spec.ts @@ -98,7 +98,7 @@ describe('Workflow', () => { }) it('sets githubIssue and emits event when recordIssue succeeds', () => { - const { result, state, events } = spec.given().when((wf) => wf.recordIssue(42)) + const { result, state, events } = spec.given().when((wf) => wf.executeRecording('record-issue', 42)) expect(result).toStrictEqual({ pass: true }) expect(state.githubIssue).toBe(42) expect(events).toStrictEqual( @@ -109,7 +109,7 @@ describe('Workflow', () => { it('fails recordIssue in non-SPAWN states', () => { const { result } = spec .given(...eventsToPlanning()) - .when((wf) => wf.recordIssue(42)) + .when((wf) => wf.executeRecording('record-issue', 42)) expect(result.pass).toBe(false) }) }) @@ -141,7 +141,7 @@ describe('Workflow', () => { it('sets featureBranch when recordBranch succeeds', () => { const { result, state, events } = spec .given(...eventsToPlanning()) - .when((wf) => wf.recordBranch('feature/x')) + .when((wf) => wf.executeRecording('record-branch', 'feature/x')) expect(result).toStrictEqual({ pass: true }) expect(state.featureBranch).toBe('feature/x') expect(events).toStrictEqual( @@ -150,14 +150,14 @@ describe('Workflow', () => { }) it('fails recordBranch in non-PLANNING states', () => { - const { result } = spec.given().when((wf) => wf.recordBranch('feature/x')) + const { result } = spec.given().when((wf) => wf.executeRecording('record-branch', 'feature/x')) expect(result.pass).toBe(false) }) it('sets userApprovedPlan when recordPlanApproval succeeds', () => { const { result, state, events } = spec .given(...eventsToPlanning()) - .when((wf) => wf.recordPlanApproval()) + .when((wf) => wf.executeRecording('record-plan-approval')) expect(result).toStrictEqual({ pass: true }) expect(state.userApprovedPlan).toBe(true) expect(events).toStrictEqual( @@ -166,7 +166,7 @@ describe('Workflow', () => { }) it('fails recordPlanApproval in non-PLANNING states', () => { - const { result } = spec.given().when((wf) => wf.recordPlanApproval()) + const { result } = spec.given().when((wf) => wf.executeRecording('record-plan-approval')) expect(result.pass).toBe(false) }) @@ -224,7 +224,7 @@ describe('Workflow', () => { it('pushes new iteration when assignIterationTask succeeds', () => { const { result, state } = spec .given(...eventsToRespawn()) - .when((wf) => wf.assignIterationTask('build feature')) + .when((wf) => wf.executeRecording('assign-iteration-task', 'build feature')) expect(result).toStrictEqual({ pass: true }) expect(state.iterations).toStrictEqual( expect.arrayContaining([expect.objectContaining({ task: 'build feature' })]) @@ -232,7 +232,7 @@ describe('Workflow', () => { }) it('fails assignIterationTask in non-RESPAWN states', () => { - const { result } = spec.given().when((wf) => wf.assignIterationTask('task')) + const { result } = spec.given().when((wf) => wf.executeRecording('assign-iteration-task', 'task')) expect(result.pass).toBe(false) }) }) diff --git a/src/workflow-definition/domain/workflow.ts b/src/workflow-definition/domain/workflow.ts index 141cf43..3f8f715 100644 --- a/src/workflow-definition/domain/workflow.ts +++ b/src/workflow-definition/domain/workflow.ts @@ -1,9 +1,10 @@ import type { PreconditionResult, GitInfo } from '@ntcoding/agentic-workflow-builder/dsl' -import { pass, fail } from '@ntcoding/agentic-workflow-builder/dsl' +import { pass, fail, defineRecordingOps } from '@ntcoding/agentic-workflow-builder/dsl' import { WorkflowStateError } from '@ntcoding/agentic-workflow-builder/engine' import type { BaseEvent } from '@ntcoding/agentic-workflow-builder/engine' -import type { WorkflowState, StateName } from './workflow-types.js' +import type { WorkflowState, StateName, WorkflowOperation } from './workflow-types.js' import { getStateDefinition } from './registry.js' +import { WORKFLOW_REGISTRY } from './registry.js' import { WorkflowStateSchema } from './workflow-types.js' import type { WorkflowEvent } from './workflow-events.js' import { WorkflowEventSchema } from './workflow-events.js' @@ -17,6 +18,14 @@ import { checkOperationGate, } from './workflow-predicates.js' +const RECORDING_OPS = defineRecordingOps<StateName, WorkflowState, WorkflowOperation>(WORKFLOW_REGISTRY, { + 'record-issue': { event: 'issue-recorded', payload: (n: number) => ({ issueNumber: n }) }, + 'record-branch': { event: 'branch-recorded', payload: (b: string) => ({ branch: b }) }, + 'record-plan-approval': { event: 'plan-approval-recorded', payload: () => ({}) }, + 'assign-iteration-task': { event: 'iteration-task-assigned', payload: (t: string) => ({ task: t }) }, + 'record-pr': { event: 'pr-recorded', payload: (n: number) => ({ prNumber: n }) }, +}) + export type WorkflowDeps = { readonly getGitInfo: () => GitInfo readonly checkPrChecks: (prNumber: number) => boolean @@ -78,31 +87,11 @@ export class Workflow { this.append({ type: 'session-started', at: this.deps.now(), ...(transcriptPath === undefined ? {} : { transcriptPath }), ...(repository === undefined ? {} : { repository }) }) } - recordIssue(issueNumber: number): PreconditionResult { - const gate = checkOperationGate('record-issue', this.state) - if (!gate.pass) return gate - this.append({ type: 'issue-recorded', at: this.deps.now(), issueNumber }) - return pass() - } - - recordBranch(branch: string): PreconditionResult { - const gate = checkOperationGate('record-branch', this.state) - if (!gate.pass) return gate - this.append({ type: 'branch-recorded', at: this.deps.now(), branch }) - return pass() - } - - recordPlanApproval(): PreconditionResult { - const gate = checkOperationGate('record-plan-approval', this.state) - if (!gate.pass) return gate - this.append({ type: 'plan-approval-recorded', at: this.deps.now() }) - return pass() - } - - assignIterationTask(task: string): PreconditionResult { - const gate = checkOperationGate('assign-iteration-task', this.state) - if (!gate.pass) return gate - this.append({ type: 'iteration-task-assigned', at: this.deps.now(), task }) + executeRecording(op: WorkflowOperation, ...args: readonly unknown[]): PreconditionResult { + const result = RECORDING_OPS.executeOp(op, this.state, this.deps.now(), args) + if (!result.pass) return fail(result.reason) + const parsed = WorkflowEventSchema.parse(result.event) + this.append(parsed) return pass() } @@ -119,13 +108,6 @@ export class Workflow { return pass() } - recordPr(prNumber: number): PreconditionResult { - const gate = checkOperationGate('record-pr', this.state) - if (!gate.pass) return gate - this.append({ type: 'pr-recorded', at: this.deps.now(), prNumber }) - return pass() - } - createPr(title: string, body: string): PreconditionResult { const gate = checkOperationGate('create-pr', this.state) if (!gate.pass) return gate diff --git a/src/analytics/cli-routes.spec.ts b/src/workflow-definition/entrypoint/analytics/cli-routes.spec.ts similarity index 98% rename from src/analytics/cli-routes.spec.ts rename to src/workflow-definition/entrypoint/analytics/cli-routes.spec.ts index 2101069..31f5b2a 100644 --- a/src/analytics/cli-routes.spec.ts +++ b/src/workflow-definition/entrypoint/analytics/cli-routes.spec.ts @@ -1,7 +1,7 @@ import { isAnalyticsCommand, routeAnalytics } from './cli-routes.js' import { EXIT_ERROR, EXIT_ALLOW } from '@ntcoding/agentic-workflow-builder/cli' -import { WorkflowError } from '../infra/workflow-error.js' -import type { AnalyticsDeps, ReportDeps } from '../infra/composition-root.js' +import { WorkflowError } from '../../../infra/workflow-error.js' +import type { AnalyticsDeps, ReportDeps } from '../entrypoint.js' function makeAnalyticsDeps(overrides?: Partial<AnalyticsDeps>): AnalyticsDeps { return { diff --git a/src/analytics/cli-routes.ts b/src/workflow-definition/entrypoint/analytics/cli-routes.ts similarity index 94% rename from src/analytics/cli-routes.ts rename to src/workflow-definition/entrypoint/analytics/cli-routes.ts index e9565b3..74a8fc0 100644 --- a/src/analytics/cli-routes.ts +++ b/src/workflow-definition/entrypoint/analytics/cli-routes.ts @@ -1,7 +1,7 @@ import type { RunnerResult } from '@ntcoding/agentic-workflow-builder/cli' import { EXIT_ALLOW, EXIT_ERROR } from '@ntcoding/agentic-workflow-builder/cli' -import type { AnalyticsDeps, ReportDeps } from '../infra/composition-root.js' -import { WorkflowError } from '../infra/workflow-error.js' +import type { AnalyticsDeps, ReportDeps } from '../entrypoint.js' +import { WorkflowError } from '../../../infra/workflow-error.js' const ANALYTICS_COMMANDS: ReadonlySet<string> = new Set(['analyze', 'view-report']) diff --git a/src/autonomous-claude-agent-team-workflow-argument-validation.spec.ts b/src/workflow-definition/entrypoint/argument-validation.spec.ts similarity index 96% rename from src/autonomous-claude-agent-team-workflow-argument-validation.spec.ts rename to src/workflow-definition/entrypoint/argument-validation.spec.ts index d113259..ab5c0ff 100644 --- a/src/autonomous-claude-agent-team-workflow-argument-validation.spec.ts +++ b/src/workflow-definition/entrypoint/argument-validation.spec.ts @@ -1,7 +1,7 @@ import { runWorkflow } from './entrypoint.js' -import { EXIT_ERROR, EXIT_ALLOW } from './infra/hook-io.js' -import type { WorkflowEvent } from './workflow-definition/index.js' -import { makeDeps } from './autonomous-claude-agent-team-workflow-cli-test-fixtures.js' +import { EXIT_ERROR, EXIT_ALLOW } from '@ntcoding/agentic-workflow-builder/cli' +import type { WorkflowEvent } from '../index.js' +import { makeDeps } from './cli-test-fixtures.js' const AT = '2026-01-01T00:00:00Z' diff --git a/src/autonomous-claude-agent-team-workflow-cli-commands.spec.ts b/src/workflow-definition/entrypoint/cli-commands.spec.ts similarity index 79% rename from src/autonomous-claude-agent-team-workflow-cli-commands.spec.ts rename to src/workflow-definition/entrypoint/cli-commands.spec.ts index 7821c72..255f0d2 100644 --- a/src/autonomous-claude-agent-team-workflow-cli-commands.spec.ts +++ b/src/workflow-definition/entrypoint/cli-commands.spec.ts @@ -1,5 +1,5 @@ import { runWorkflow } from './entrypoint.js' -import { EXIT_ALLOW, EXIT_BLOCK } from './infra/hook-io.js' +import { EXIT_ALLOW, EXIT_BLOCK } from '@ntcoding/agentic-workflow-builder/cli' import { makeDeps, makeHookStdin, @@ -10,10 +10,10 @@ import { committingEvents, crReviewEvents, prCreationEvents, -} from './autonomous-claude-agent-team-workflow-cli-test-fixtures.js' +} from './cli-test-fixtures.js' describe('runWorkflow - routing overview', () => { - it('delegates to hook handling when no command is given', () => { + it('delegates to platform runner hook handling when no command is given', () => { const result = runWorkflow( [], makeDeps({ readStdin: () => makeHookStdin({ hook_event_name: 'SessionStart' }) }), @@ -25,6 +25,56 @@ describe('runWorkflow - routing overview', () => { const result = runWorkflow(['analyze', '--all'], makeDeps()) expect(result.exitCode).toStrictEqual(EXIT_ALLOW) }) + + it('delegates SubagentStart to platform runner', () => { + const result = runWorkflow( + [], + makeDeps({ + readStdin: () => makeHookStdin({ + hook_event_name: 'SubagentStart', + agent_id: 'agt-1', + agent_type: 'developer-1', + }), + engineDeps: { store: { sessionExists: () => true, readEvents: () => [] } }, + }), + ) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + const parsed = JSON.parse(result.output) + expect(parsed).toHaveProperty('additionalContext') + }) + + it('delegates TeammateIdle to platform runner', () => { + const result = runWorkflow( + [], + makeDeps({ + readStdin: () => makeHookStdin({ + hook_event_name: 'TeammateIdle', + teammate_name: 'lead-1', + }), + engineDeps: { + store: { sessionExists: () => true, readEvents: () => developingEvents() }, + }, + }), + ) + expect(result.exitCode).toStrictEqual(EXIT_BLOCK) + }) + + it('delegates PreToolUse to platform runner via preToolUseHandler', () => { + const result = runWorkflow( + [], + makeDeps({ + readStdin: () => makeHookStdin({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + }), + engineDeps: { + store: { sessionExists: () => true, readEvents: () => planningEvents() }, + }, + }), + ) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + }) }) describe('runWorkflow - CLI command routing', () => { diff --git a/src/autonomous-claude-agent-team-workflow-cli-test-fixtures.ts b/src/workflow-definition/entrypoint/cli-test-fixtures.ts similarity index 97% rename from src/autonomous-claude-agent-team-workflow-cli-test-fixtures.ts rename to src/workflow-definition/entrypoint/cli-test-fixtures.ts index e89c517..ee04f4f 100644 --- a/src/autonomous-claude-agent-team-workflow-cli-test-fixtures.ts +++ b/src/workflow-definition/entrypoint/cli-test-fixtures.ts @@ -1,7 +1,7 @@ import type { AdapterDeps, AnalyticsDeps, ReportDeps } from './entrypoint.js' import type { WorkflowEngineDeps, WorkflowEventStore } from '@ntcoding/agentic-workflow-builder/engine' -import type { WorkflowDeps } from './workflow-definition/index.js' -import type { WorkflowEvent } from './workflow-definition/index.js' +import type { WorkflowDeps } from '../index.js' +import type { WorkflowEvent } from '../index.js' const AT = '2026-01-01T00:00:00Z' diff --git a/src/workflow-definition/entrypoint/entrypoint.ts b/src/workflow-definition/entrypoint/entrypoint.ts new file mode 100644 index 0000000..a4701c9 --- /dev/null +++ b/src/workflow-definition/entrypoint/entrypoint.ts @@ -0,0 +1,212 @@ +import type { EngineResult, WorkflowEngineDeps } from '@ntcoding/agentic-workflow-builder/engine' +import { WorkflowEngine } from '@ntcoding/agentic-workflow-builder/engine' +import { createWorkflowRunner, defineRoutes, defineHooks, arg, EXIT_ALLOW, EXIT_ERROR, EXIT_BLOCK } from '@ntcoding/agentic-workflow-builder/cli' +import type { RunnerResult } from '@ntcoding/agentic-workflow-builder/cli' +import { FeatureTeamWorkflowDefinition, StateNameSchema } from '../index.js' +import type { Workflow, WorkflowDeps } from '../index.js' +import type { StateName, WorkflowOperation, WorkflowState } from '../domain/workflow-types.js' +import { preToolUseHandler } from './pre-tool-use-handler.js' +import { isAnalyticsCommand, routeAnalytics } from './analytics/cli-routes.js' +import { parseNumber, parseString, parseStringArray } from '../../infra/arg-parsing.js' + +export type AnalyticsDeps = { + readonly computeSession: (sessionId: string) => string + readonly computeAll: () => string + readonly computeEventContext: (sessionId: string) => string +} + +export type ReportResult = { + readonly path: string +} + +export type ReportDeps = { + readonly getAnalysisContext: (sessionId: string) => string + readonly generateReport: (sessionId: string, options?: { analysis?: string }) => ReportResult + readonly readAnalysisFile: (filePath: string) => string +} + +export type AdapterDeps = { + readonly getSessionId: () => string + readonly getRepositoryName: () => string | undefined + readonly readStdin: () => string + readonly engineDeps: WorkflowEngineDeps + readonly workflowDeps: WorkflowDeps + readonly analyticsDeps: AnalyticsDeps + readonly reportDeps: ReportDeps +} + +const ROUTES = defineRoutes<Workflow, WorkflowState>({ + transition: { type: 'transition', args: [arg.state('STATE', StateNameSchema)] }, + 'record-issue': { type: 'transaction', args: [arg.number('number')], handler: (w, n) => w.executeRecording('record-issue', parseNumber(n)) }, + 'record-branch': { type: 'transaction', args: [arg.string('branch')], handler: (w, b) => w.executeRecording('record-branch', parseString(b)) }, + 'record-plan-approval': { type: 'transaction', args: [], handler: (w) => w.executeRecording('record-plan-approval') }, + 'assign-iteration-task': { type: 'transaction', args: [arg.string('task')], handler: (w, t) => w.executeRecording('assign-iteration-task', parseString(t)) }, + 'signal-done': { type: 'transaction', args: [], handler: (w) => w.signalDone() }, + 'record-pr': { type: 'transaction', args: [arg.number('number')], handler: (w, n) => w.executeRecording('record-pr', parseNumber(n)) }, + 'create-pr': { type: 'transaction', args: [arg.string('title'), arg.string('body')], handler: (w, t, b) => w.createPr(parseString(t), parseString(b)) }, + 'append-issue-checklist': { type: 'transaction', args: [arg.number('issue-number'), arg.string('checklist')], handler: (w, n, c) => w.appendIssueChecklist(parseNumber(n), parseString(c)) }, + 'tick-iteration': { type: 'transaction', args: [arg.number('issue-number')], handler: (w, n) => w.tickIteration(parseNumber(n)) }, + 'review-approved': { type: 'transaction', args: [], handler: (w) => w.reviewApproved() }, + 'review-rejected': { type: 'transaction', args: [], handler: (w) => w.reviewRejected() }, + 'coderabbit-feedback-addressed': { type: 'transaction', args: [], handler: (w) => w.coderabbitFeedbackAddressed() }, + 'coderabbit-feedback-ignored': { type: 'transaction', args: [], handler: (w) => w.coderabbitFeedbackIgnored() }, + 'shut-down': { type: 'transaction', args: [arg.string('agent-name')], handler: (w, name) => w.shutDown(parseString(name)) }, + 'write-journal': { type: 'transaction', args: [arg.string('agent-name'), arg.string('content')], handler: (w, a, c) => w.writeJournal(parseString(a), parseString(c)) }, + 'run-lint': { type: 'transaction', args: [arg.rest('files')], handler: (w, files) => w.runLint(parseStringArray(files)) }, + 'get-session-summary': { type: 'transaction', args: [arg.string('agent-name').optional()], handler: (w, name) => w.getSessionSummary(typeof name === 'string' ? name : '') }, +}) + +const HOOKS = defineHooks<Workflow>({ + subagentStart: { + register: (w, agentType, agentId) => w.registerAgent(agentType, agentId), + }, + teammateIdle: { + check: (w, agentName) => w.checkIdleAllowed(agentName), + }, +}) + +const platformRunner = createWorkflowRunner<Workflow, WorkflowState, WorkflowDeps, StateName, WorkflowOperation>({ + workflowDefinition: FeatureTeamWorkflowDefinition, + routes: ROUTES, + hooks: HOOKS, + preToolUseHandler, +}) + +export function runWorkflow(args: readonly string[], deps: AdapterDeps): RunnerResult { + const command = args[0] + if (command === 'init') return handleInit(deps) + if (command && isAnalyticsCommand(command)) return routeAnalytics(command, args, deps) + return platformRunner(command ? args : [], deps.engineDeps, deps.workflowDeps, { + readStdin: deps.readStdin, + getSessionId: deps.getSessionId, + }) +} + +function handleInit(deps: AdapterDeps): RunnerResult { + const engine = new WorkflowEngine(FeatureTeamWorkflowDefinition, deps.engineDeps, deps.workflowDeps) + return mapResult(engine.startSession(deps.getSessionId(), undefined, deps.getRepositoryName())) +} + +function mapResult(result: EngineResult): RunnerResult { + /* v8 ignore next */ + const exitCode = result.type === 'success' ? EXIT_ALLOW : result.type === 'blocked' ? EXIT_BLOCK : EXIT_ERROR + return { output: result.output, exitCode } +} + +/* v8 ignore start */ +import { fileURLToPath } from 'node:url' +import { readFileSync, appendFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { createStore } from '@ntcoding/agentic-workflow-builder/event-store' +import { getGitInfo, getRepositoryName } from '../../infra/git.js' +import { checkPrChecks, createDraftPr, appendIssueChecklist, tickFirstUncheckedIteration } from '../../infra/github.js' +import { runEslintOnFiles } from '../../infra/linter.js' +import { readStdinSync } from '../../infra/stdin.js' +import { getSessionId, getPluginRoot, getEnvFilePath, getDbPath } from '../../infra/environment.js' +import { + computeSessionSummary, + computeCrossSessionSummary, + computeEventContext, + formatSessionSummary, + formatCrossSessionSummary, + computeEnhancedSessionSummary, + buildSessionViewData, + assembleReportData, + generateReportHtml, + formatAnalysisContext, +} from '../../workflow-analysis/index.js' +import { WorkflowEventSchema } from '../index.js' +import { resolveSessionId } from '@ntcoding/agentic-workflow-builder/event-store' +import { writeFileSync } from 'node:fs' +import { execSync } from 'node:child_process' +import { tmpdir } from 'node:os' + +function buildRealDeps(): AdapterDeps { + const store = createStore(getDbPath()) + + const engineDeps: WorkflowEngineDeps = { + store, + getPluginRoot, + getEnvFilePath, + readFile: (path) => readFileSync(path, 'utf8'), + appendToFile: (path, content) => appendFileSync(path, content), + now: () => new Date().toISOString(), + } + + const workflowDeps: WorkflowDeps = { + getGitInfo, + checkPrChecks, + createDraftPr, + appendIssueChecklist, + tickFirstUncheckedIteration, + runEslintOnFiles, + fileExists: existsSync, + getPluginRoot, + now: () => new Date().toISOString(), + } + + const analyticsDeps: AnalyticsDeps = { + computeSession: (sessionId) => formatSessionSummary(computeSessionSummary(createStore(getDbPath()), sessionId)), + computeAll: () => formatCrossSessionSummary(computeCrossSessionSummary(createStore(getDbPath()))), + computeEventContext: (sessionId) => computeEventContext(createStore(getDbPath()), sessionId), + } + + const reportDeps: ReportDeps = { + getAnalysisContext: (sessionId) => { + const eventStore = createStore(getDbPath()) + const resolvedId = resolveSessionId(eventStore, sessionId) + const rawEvents = eventStore.readEvents(resolvedId) + const events = rawEvents.map((e) => WorkflowEventSchema.parse(e)) + const baseSummary = computeSessionSummary(eventStore, resolvedId) + const viewData = buildSessionViewData(resolvedId, rawEvents) + const enhanced = computeEnhancedSessionSummary(baseSummary, viewData, events) + const enhancedWithRepo = { ...enhanced, repository: enhanced.repository ?? getRepositoryName() } + const data = assembleReportData(enhancedWithRepo, viewData, [], [], events) + return formatAnalysisContext(data) + }, + generateReport: (sessionId, options) => { + const eventStore = createStore(getDbPath()) + const resolvedId = resolveSessionId(eventStore, sessionId) + const rawEvents = eventStore.readEvents(resolvedId) + const events = rawEvents.map((e) => WorkflowEventSchema.parse(e)) + const baseSummary = computeSessionSummary(eventStore, resolvedId) + const viewData = buildSessionViewData(resolvedId, rawEvents) + const enhanced = computeEnhancedSessionSummary(baseSummary, viewData, events) + const enhancedWithRepo = { ...enhanced, repository: enhanced.repository ?? getRepositoryName() } + const data = assembleReportData(enhancedWithRepo, viewData, [], [], events) + const html = generateReportHtml(data, options?.analysis) + const htmlPath = join(tmpdir(), `session-report-${resolvedId}.html`) + writeFileSync(htmlPath, html) + try { execSync(`open ${JSON.stringify(htmlPath)}`) } catch { } + return { path: htmlPath } + }, + readAnalysisFile: (filePath) => readFileSync(filePath, 'utf8'), + } + + return { + getSessionId, + getRepositoryName, + readStdin: readStdinSync, + engineDeps, + workflowDeps, + analyticsDeps, + reportDeps, + } +} + +function main(): void { + try { + const result = runWorkflow(process.argv.slice(2), buildRealDeps()) + process.stdout.write(result.output, () => process.exit(result.exitCode)) + } catch (error) { + const message = `[${new Date().toISOString()}] HOOK ERROR: ${String(error)}\n` + process.stderr.write(message) + appendFileSync('/tmp/feature-team-hook-errors.log', message) + process.exit(EXIT_ERROR) + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main() +} +/* v8 ignore stop */ diff --git a/src/workflow-definition/entrypoint/pre-tool-use-handler.spec.ts b/src/workflow-definition/entrypoint/pre-tool-use-handler.spec.ts new file mode 100644 index 0000000..2ab0dd7 --- /dev/null +++ b/src/workflow-definition/entrypoint/pre-tool-use-handler.spec.ts @@ -0,0 +1,153 @@ +import { WorkflowEngine } from '@ntcoding/agentic-workflow-builder/engine' +import { FeatureTeamWorkflowDefinition } from '../index.js' +import { preToolUseHandler } from './pre-tool-use-handler.js' +import { + makeDeps, + planningEvents, + developingEvents, +} from './cli-test-fixtures.js' + +const AT = '2026-01-01T00:00:00Z' + +function makeEngine(overrides?: Parameters<typeof makeDeps>[0]) { + const deps = makeDeps(overrides) + return new WorkflowEngine(FeatureTeamWorkflowDefinition, deps.engineDeps, deps.workflowDeps) +} + +describe('preToolUseHandler', () => { + it('returns success when all checks pass', () => { + const engine = makeEngine({ + engineDeps: { + store: { sessionExists: () => true, readEvents: () => planningEvents() }, + }, + }) + const result = preToolUseHandler(engine, 'test-session', 'Bash', { command: 'ls' }, '/tmp/transcript.jsonl') + expect(result.type).toBe('success') + }) + + it('returns blocked when plugin source read is blocked', () => { + const engine = makeEngine({ + engineDeps: { + store: { sessionExists: () => true, readEvents: () => [] }, + }, + workflowDeps: { + getPluginRoot: () => '/home/.claude/plugins/cache/myplugin', + }, + }) + const result = preToolUseHandler( + engine, 'test-session', 'Read', + { file_path: '/home/.claude/plugins/cache/myplugin/src/index.ts' }, + '/tmp/transcript.jsonl', + ) + expect(result.type).toBe('blocked') + }) + + it('returns blocked when write is blocked', () => { + const engine = makeEngine({ + engineDeps: { + store: { + sessionExists: () => true, + readEvents: () => [ + { type: 'transitioned', at: AT, from: 'SPAWN', to: 'RESPAWN' }, + ], + }, + }, + }) + const result = preToolUseHandler( + engine, 'test-session', 'Write', + { file_path: '/project/src/foo.ts' }, + '/tmp/transcript.jsonl', + ) + expect(result.type).toBe('blocked') + }) + + it('returns blocked when bash command is blocked', () => { + const engine = makeEngine({ + engineDeps: { + store: { sessionExists: () => true, readEvents: () => developingEvents() }, + }, + }) + const result = preToolUseHandler( + engine, 'test-session', 'Bash', + { command: 'git commit -m "test"' }, + '/tmp/transcript.jsonl', + ) + expect(result.type).toBe('blocked') + }) + + it('throws when tool_input contains a non-string value', () => { + const engine = makeEngine({ + engineDeps: { + store: { sessionExists: () => true, readEvents: () => planningEvents() }, + }, + }) + expect(() => preToolUseHandler( + engine, 'test-session', 'Write', + { file_path: 42 }, + '/tmp/transcript.jsonl', + )).toThrow('Expected string or undefined') + }) + + it('records identity-verified event via engine prefix verification', () => { + const appended: Array<{ firstEventType: string }> = [] + const engine = makeEngine({ + engineDeps: { + store: { + sessionExists: () => true, + readEvents: () => planningEvents(), + appendEvents: (_sessionId: string, events: ReadonlyArray<{ type: string }>) => + appended.push({ firstEventType: events[0]?.type ?? '' }), + }, + }, + }) + const result = preToolUseHandler(engine, 'test-session', 'Bash', {}, '/tmp/transcript.jsonl') + expect(result.type).toBe('success') + expect(appended[0]?.firstEventType).toBe('identity-verified') + }) + + it('blocks when identity is lost', () => { + const engine = makeEngine({ + engineDeps: { + transcriptReader: { + readMessages: () => [ + { id: '1', textContent: 'LEAD: PLANNING' }, + { id: '2', textContent: 'No prefix here' }, + ], + }, + store: { sessionExists: () => true, readEvents: () => planningEvents() }, + }, + }) + const result = preToolUseHandler(engine, 'test-session', 'Bash', {}, '/tmp/transcript.jsonl') + expect(result.type).toBe('blocked') + }) + + it('resolves path from file_path field', () => { + const engine = makeEngine({ + engineDeps: { + store: { sessionExists: () => true, readEvents: () => planningEvents() }, + }, + }) + const result = preToolUseHandler(engine, 'test-session', 'Read', { file_path: '/project/src/ok.ts' }, undefined) + expect(result.type).toBe('success') + }) + + it('resolves path from path field when file_path absent', () => { + const engine = makeEngine({ + engineDeps: { + store: { sessionExists: () => true, readEvents: () => planningEvents() }, + }, + }) + const result = preToolUseHandler(engine, 'test-session', 'Glob', { path: '/project/src' }, undefined) + expect(result.type).toBe('success') + }) + + it('resolves path from pattern field when file_path and path absent', () => { + const engine = makeEngine({ + engineDeps: { + store: { sessionExists: () => true, readEvents: () => planningEvents() }, + }, + }) + const result = preToolUseHandler(engine, 'test-session', 'Grep', { pattern: '*.ts' }, undefined) + expect(result.type).toBe('success') + }) +}) diff --git a/src/workflow-definition/entrypoint/pre-tool-use-handler.ts b/src/workflow-definition/entrypoint/pre-tool-use-handler.ts new file mode 100644 index 0000000..9df628c --- /dev/null +++ b/src/workflow-definition/entrypoint/pre-tool-use-handler.ts @@ -0,0 +1,36 @@ +import type { WorkflowEngine, EngineResult } from '@ntcoding/agentic-workflow-builder/engine' +import { BASH_FORBIDDEN, checkWriteAllowed } from '../index.js' +import type { Workflow, WorkflowDeps } from '../index.js' +import type { StateName, WorkflowOperation, WorkflowState } from '../domain/workflow-types.js' +import { WorkflowError } from '../../infra/workflow-error.js' + +type Engine = WorkflowEngine<Workflow, WorkflowState, WorkflowDeps, StateName, WorkflowOperation> + +export function preToolUseHandler( + engine: Engine, + sessionId: string, + toolName: string, + toolInput: Record<string, unknown>, + transcriptPath: string | undefined, +): EngineResult { + const filePath = resolveStringField(toolInput['file_path']) + || resolveStringField(toolInput['path']) + || resolveStringField(toolInput['pattern']) + const command = resolveStringField(toolInput['command']) + + const pluginCheck = engine.transaction(sessionId, 'hook-check', (w) => { + return w.checkPluginSourceRead(toolName, filePath, command) + }, transcriptPath) + if (pluginCheck.type === 'blocked') return pluginCheck + + const writeCheck = engine.checkWrite(sessionId, toolName, filePath, checkWriteAllowed, transcriptPath) + if (writeCheck.type === 'blocked') return writeCheck + + return engine.checkBash(sessionId, toolName, command, BASH_FORBIDDEN, transcriptPath) +} + +function resolveStringField(value: unknown): string { + if (value === undefined || value === null) return '' + if (typeof value === 'string') return value + throw new WorkflowError(`Expected string or undefined. Got ${typeof value}: ${String(value)}`) +} diff --git a/src/autonomous-claude-agent-team-workflow-view-report.spec.ts b/src/workflow-definition/entrypoint/view-report.spec.ts similarity index 96% rename from src/autonomous-claude-agent-team-workflow-view-report.spec.ts rename to src/workflow-definition/entrypoint/view-report.spec.ts index 3a21539..bf853c3 100644 --- a/src/autonomous-claude-agent-team-workflow-view-report.spec.ts +++ b/src/workflow-definition/entrypoint/view-report.spec.ts @@ -1,7 +1,7 @@ import { runWorkflow } from './entrypoint.js' -import { EXIT_ERROR, EXIT_ALLOW } from './infra/hook-io.js' -import { WorkflowError } from './infra/workflow-error.js' -import { makeDeps } from './autonomous-claude-agent-team-workflow-cli-test-fixtures.js' +import { EXIT_ERROR, EXIT_ALLOW } from '@ntcoding/agentic-workflow-builder/cli' +import { WorkflowError } from '../../infra/workflow-error.js' +import { makeDeps } from './cli-test-fixtures.js' describe('runWorkflow - view-report command', () => { it('returns EXIT_ERROR when no sessionId is given', () => { diff --git a/src/autonomous-claude-agent-team-workflow-workflow-state-commands.spec.ts b/src/workflow-definition/entrypoint/workflow-state-commands.spec.ts similarity index 95% rename from src/autonomous-claude-agent-team-workflow-workflow-state-commands.spec.ts rename to src/workflow-definition/entrypoint/workflow-state-commands.spec.ts index f3754bc..05d6908 100644 --- a/src/autonomous-claude-agent-team-workflow-workflow-state-commands.spec.ts +++ b/src/workflow-definition/entrypoint/workflow-state-commands.spec.ts @@ -1,11 +1,11 @@ import { runWorkflow } from './entrypoint.js' -import { EXIT_ERROR, EXIT_ALLOW } from './infra/hook-io.js' -import type { WorkflowEvent } from './workflow-definition/index.js' +import { EXIT_ERROR, EXIT_ALLOW } from '@ntcoding/agentic-workflow-builder/cli' +import type { WorkflowEvent } from '../index.js' import { makeDeps, planningEvents, developingEvents, -} from './autonomous-claude-agent-team-workflow-cli-test-fixtures.js' +} from './cli-test-fixtures.js' const AT = '2026-01-01T00:00:00Z' From 16e7bf6d029dd9f7c1b6cf2ed46c409c4cb4243c Mon Sep 17 00:00:00 2001 From: Nick Tune <nick@ntcoding.co.uk> Date: Sat, 7 Mar 2026 17:11:14 +0000 Subject: [PATCH 4/4] Enforce module boundaries: eliminate src/infra dumping ground - Move git, github, linter, arg-parsing, workflow-error to workflow-definition/infra/ - Move analytics CLI routes to workflow-analysis/entrypoint/ - Create src/shell.ts as process boundary and top-level router - Delete src/infra/ entirely (environment + stdin absorbed into shell, state-store deleted as dead code) - Decouple workflow-definition from workflow-analysis (shell.ts routes between them) - Export WorkflowError from workflow-definition barrel for cross-module use - Update depcruiser rules for new module structure - hooks.json now points to src/shell.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .dependency-cruiser.cjs | 29 ++- hooks/hooks.json | 8 +- src/infra/environment.spec.ts | 57 ------ src/infra/environment.ts | 34 ---- src/infra/state-store.spec.ts | 58 ------ src/infra/state-store.ts | 44 ----- src/infra/stdin.spec.ts | 13 -- src/infra/stdin.ts | 5 - src/shell.spec.ts | 140 +++++++++++++++ src/shell.ts | 170 ++++++++++++++++++ .../entrypoint/entrypoint.spec.ts} | 6 +- .../entrypoint/entrypoint.ts} | 19 +- src/workflow-analysis/workflow-analytics.ts | 2 +- .../entrypoint/cli-commands.spec.ts | 5 - .../entrypoint/cli-test-fixtures.ts | 22 +-- .../entrypoint/entrypoint.ts | 146 +-------------- .../entrypoint/pre-tool-use-handler.ts | 2 +- .../entrypoint/view-report.spec.ts | 139 -------------- src/workflow-definition/index.ts | 2 + .../infra/arg-parsing.spec.ts | 0 .../infra/arg-parsing.ts | 0 .../infra/git.spec.ts | 0 src/{ => workflow-definition}/infra/git.ts | 0 .../infra/github.spec.ts | 0 src/{ => workflow-definition}/infra/github.ts | 0 .../infra/linter.spec.ts | 0 src/{ => workflow-definition}/infra/linter.ts | 0 .../infra/workflow-error.spec.ts | 0 .../infra/workflow-error.ts | 0 29 files changed, 357 insertions(+), 544 deletions(-) delete mode 100644 src/infra/environment.spec.ts delete mode 100644 src/infra/environment.ts delete mode 100644 src/infra/state-store.spec.ts delete mode 100644 src/infra/state-store.ts delete mode 100644 src/infra/stdin.spec.ts delete mode 100644 src/infra/stdin.ts create mode 100644 src/shell.spec.ts create mode 100644 src/shell.ts rename src/{workflow-definition/entrypoint/analytics/cli-routes.spec.ts => workflow-analysis/entrypoint/entrypoint.spec.ts} (97%) rename src/{workflow-definition/entrypoint/analytics/cli-routes.ts => workflow-analysis/entrypoint/entrypoint.ts} (82%) delete mode 100644 src/workflow-definition/entrypoint/view-report.spec.ts rename src/{ => workflow-definition}/infra/arg-parsing.spec.ts (100%) rename src/{ => workflow-definition}/infra/arg-parsing.ts (100%) rename src/{ => workflow-definition}/infra/git.spec.ts (100%) rename src/{ => workflow-definition}/infra/git.ts (100%) rename src/{ => workflow-definition}/infra/github.spec.ts (100%) rename src/{ => workflow-definition}/infra/github.ts (100%) rename src/{ => workflow-definition}/infra/linter.spec.ts (100%) rename src/{ => workflow-definition}/infra/linter.ts (100%) rename src/{ => workflow-definition}/infra/workflow-error.spec.ts (100%) rename src/{ => workflow-definition}/infra/workflow-error.ts (100%) diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 23fba97..d628b1b 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -32,9 +32,9 @@ module.exports = { name: 'workflow-definition-module-privacy', severity: 'error', comment: - 'External code must import from workflow-definition/index.ts — domain internals (registry, states, operations) are private', - from: { pathNot: '^src/workflow-definition/' }, - to: { path: '^src/workflow-definition/domain/' }, + 'External code must import from workflow-definition/index.ts — domain and infra internals are private', + from: { pathNot: '^src/(workflow-definition/|shell\\.)' }, + to: { path: '^src/workflow-definition/(domain|infra)/' }, }, // Rule 3b: Module privacy — workflow-engine internals are private @@ -47,30 +47,27 @@ module.exports = { to: { path: '^src/workflow-engine/domain/' }, }, - // Rule 4: workflow-definition must not import from infra + // Rule 4: workflow-definition domain must not import from workflow-analysis { - name: 'workflow-definition-no-upward-deps', + name: 'workflow-definition-domain-isolation', severity: 'error', comment: - 'workflow-definition may depend on workflow-dsl and workflow-engine — not on infra', - from: { path: '^src/workflow-definition/' }, + 'workflow-definition domain must not depend on workflow-analysis', + from: { path: '^src/workflow-definition/domain/' }, to: { - path: '^src/infra/', + path: '^src/workflow-analysis/', dependencyTypesNot: ['type-only'], }, }, - // Rule 5: Entrypoint allowed imports — only workflow-definition/, workflow-engine/, and infra/ + // Rule 5: workflow-analysis must not import workflow-definition internals (only index.ts) { - name: 'entrypoint-allowed-imports', + name: 'workflow-analysis-no-definition-internals', severity: 'error', comment: - 'Entrypoint must import from workflow-definition/, workflow-engine/, or infra/ — never from internal domain/ paths or other modules directly', - from: { path: '^src/autonomous-claude-agent-team-workflow\\.ts$' }, - to: { - path: '^src/(?!workflow-definition/|workflow-engine/|infra/)', - pathNot: '^src/autonomous-claude-agent-team-workflow\\.ts$', - }, + 'workflow-analysis may import workflow-definition/index.ts and workflow-definition/infra/ (shared error types) — not domain internals', + from: { path: '^src/workflow-analysis/' }, + to: { path: '^src/workflow-definition/domain/' }, }, ], diff --git a/hooks/hooks.json b/hooks/hooks.json index ad197d9..4c071b8 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,8 +1,8 @@ { "hooks": { - "SessionStart": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/workflow-definition/entrypoint/entrypoint.ts", "timeout": 30 }] }], - "PreToolUse": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/workflow-definition/entrypoint/entrypoint.ts", "timeout": 30 }] }], - "SubagentStart": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/workflow-definition/entrypoint/entrypoint.ts", "timeout": 30 }] }], - "TeammateIdle": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/workflow-definition/entrypoint/entrypoint.ts", "timeout": 30 }] }] + "SessionStart": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/shell.ts", "timeout": 30 }] }], + "PreToolUse": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/shell.ts", "timeout": 30 }] }], + "SubagentStart": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/shell.ts", "timeout": 30 }] }], + "TeammateIdle": [{ "hooks": [{ "type": "command", "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/src/shell.ts", "timeout": 30 }] }] } } diff --git a/src/infra/environment.spec.ts b/src/infra/environment.spec.ts deleted file mode 100644 index bd497e1..0000000 --- a/src/infra/environment.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { homedir } from 'node:os' -import { getSessionId, getPluginRoot, getEnvFilePath, getStateFilePath, getDbPath } from './environment.js' - -describe('getSessionId', () => { - it('returns value when CLAUDE_SESSION_ID is set', () => { - process.env['CLAUDE_SESSION_ID'] = 'abc-123' - expect(getSessionId()).toStrictEqual('abc-123') - delete process.env['CLAUDE_SESSION_ID'] - }) - - it('throws WorkflowError when CLAUDE_SESSION_ID is absent', () => { - delete process.env['CLAUDE_SESSION_ID'] - expect(() => getSessionId()).toThrow('CLAUDE_SESSION_ID') - }) -}) - -describe('getPluginRoot', () => { - it('returns value when CLAUDE_PLUGIN_ROOT is set', () => { - process.env['CLAUDE_PLUGIN_ROOT'] = '/plugin' - expect(getPluginRoot()).toStrictEqual('/plugin') - delete process.env['CLAUDE_PLUGIN_ROOT'] - }) - - it('throws WorkflowError when CLAUDE_PLUGIN_ROOT is absent', () => { - delete process.env['CLAUDE_PLUGIN_ROOT'] - expect(() => getPluginRoot()).toThrow('CLAUDE_PLUGIN_ROOT') - }) -}) - -describe('getEnvFilePath', () => { - it('returns value when CLAUDE_ENV_FILE is set', () => { - process.env['CLAUDE_ENV_FILE'] = '/test/env' - expect(getEnvFilePath()).toStrictEqual('/test/env') - delete process.env['CLAUDE_ENV_FILE'] - }) - - it('throws WorkflowError when CLAUDE_ENV_FILE is absent', () => { - delete process.env['CLAUDE_ENV_FILE'] - expect(() => getEnvFilePath()).toThrow('CLAUDE_ENV_FILE') - }) -}) - -describe('getStateFilePath', () => { - it('interpolates session ID into path under /tmp/', () => { - expect(getStateFilePath('abc-123')).toStrictEqual('/tmp/feature-team-state-abc-123.json') - }) - - it('uses different session ID correctly', () => { - expect(getStateFilePath('xyz-789')).toStrictEqual('/tmp/feature-team-state-xyz-789.json') - }) -}) - -describe('getDbPath', () => { - it('returns path under home directory', () => { - expect(getDbPath()).toStrictEqual(`${homedir()}/.claude/workflow-events.db`) - }) -}) diff --git a/src/infra/environment.ts b/src/infra/environment.ts deleted file mode 100644 index 34379cf..0000000 --- a/src/infra/environment.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { homedir } from 'node:os' -import { WorkflowError } from './workflow-error.js' - -export function getSessionId(): string { - const value = process.env['CLAUDE_SESSION_ID'] - if (value === undefined) { - throw new WorkflowError('Missing required env var: CLAUDE_SESSION_ID') - } - return value -} - -export function getPluginRoot(): string { - const value = process.env['CLAUDE_PLUGIN_ROOT'] - if (value === undefined) { - throw new WorkflowError('Missing required env var: CLAUDE_PLUGIN_ROOT') - } - return value -} - -export function getEnvFilePath(): string { - const value = process.env['CLAUDE_ENV_FILE'] - if (value === undefined) { - throw new WorkflowError('Missing required env var: CLAUDE_ENV_FILE') - } - return value -} - -export function getStateFilePath(sessionId: string): string { - return `/tmp/feature-team-state-${sessionId}.json` -} - -export function getDbPath(): string { - return `${homedir()}/.claude/workflow-events.db` -} diff --git a/src/infra/state-store.spec.ts b/src/infra/state-store.spec.ts deleted file mode 100644 index d0abf71..0000000 --- a/src/infra/state-store.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { writeFileSync, existsSync, unlinkSync, mkdirSync } from 'node:fs' -import { join } from 'node:path' -import { readState, writeState, stateFileExists } from './state-store.js' -import { INITIAL_STATE } from '../workflow-definition/index.js' - -const PLUGIN_TEST_DIR = join(import.meta.dirname, '../../.claude/plugins/autonomous-claude-agent-team') -const testPath = join(PLUGIN_TEST_DIR, 'test-state-store-spec.json') - -beforeAll(() => { mkdirSync(PLUGIN_TEST_DIR, { recursive: true }) }) - -afterEach(() => { - if (existsSync(testPath)) unlinkSync(testPath) - if (existsSync(`${testPath}.tmp`)) unlinkSync(`${testPath}.tmp`) -}) - -describe('readState', () => { - it('parses valid state file', () => { - writeFileSync(testPath, JSON.stringify(INITIAL_STATE), 'utf-8') - expect(readState(testPath)).toStrictEqual(INITIAL_STATE) - }) - - it('throws when file does not exist', () => { - expect(() => readState(join(PLUGIN_TEST_DIR, 'nonexistent-state-abc123.json'))).toThrow('Cannot read state file') - }) - - it('throws when file contains invalid JSON', () => { - writeFileSync(testPath, 'not { valid json', 'utf-8') - expect(() => readState(testPath)).toThrow('Cannot parse state file') - }) - - it('throws when JSON does not match schema', () => { - writeFileSync(testPath, JSON.stringify({ invalid: true }), 'utf-8') - expect(() => readState(testPath)).toThrow('Invalid state file') - }) -}) - -describe('writeState', () => { - it('creates the state file', () => { - writeState(testPath, INITIAL_STATE) - expect(existsSync(testPath)).toStrictEqual(true) - }) - - it('round-trips state without data loss', () => { - writeState(testPath, INITIAL_STATE) - expect(readState(testPath)).toStrictEqual(INITIAL_STATE) - }) -}) - -describe('stateFileExists', () => { - it('returns false when file does not exist', () => { - expect(stateFileExists(join(PLUGIN_TEST_DIR, 'nonexistent-xyz-987.json'))).toStrictEqual(false) - }) - - it('returns true when file exists', () => { - writeFileSync(testPath, JSON.stringify(INITIAL_STATE), 'utf-8') - expect(stateFileExists(testPath)).toStrictEqual(true) - }) -}) diff --git a/src/infra/state-store.ts b/src/infra/state-store.ts deleted file mode 100644 index e080fff..0000000 --- a/src/infra/state-store.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { readFileSync, writeFileSync, renameSync, existsSync } from 'node:fs' -import type { WorkflowState as WorkflowStateType } from '../workflow-definition/index.js' -import { WorkflowStateSchema } from '../workflow-definition/index.js' -import { WorkflowError } from './workflow-error.js' - -export function readState(stateFilePath: string): WorkflowStateType { - const raw = readFileSafe(stateFilePath) - return parseStateSafe(stateFilePath, raw) -} - -export function writeState(stateFilePath: string, state: WorkflowStateType): void { - const tmpPath = `${stateFilePath}.tmp` - writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8') - renameSync(tmpPath, stateFilePath) -} - -export function stateFileExists(stateFilePath: string): boolean { - return existsSync(stateFilePath) -} - -function readFileSafe(stateFilePath: string): string { - try { - return readFileSync(stateFilePath, 'utf-8') - } catch (cause) { - throw new WorkflowError(`Cannot read state file at ${stateFilePath}: ${String(cause)}`) - } -} - -function parseStateSafe(stateFilePath: string, raw: string): WorkflowStateType { - const json = tryParseJson(stateFilePath, raw) - const result = WorkflowStateSchema.safeParse(json) - if (!result.success) { - throw new WorkflowError(`Invalid state file at ${stateFilePath}: ${result.error.message}`) - } - return result.data -} - -function tryParseJson(stateFilePath: string, raw: string): unknown { - try { - return JSON.parse(raw) - } catch (cause) { - throw new WorkflowError(`Cannot parse state file at ${stateFilePath}: ${String(cause)}`) - } -} diff --git a/src/infra/stdin.spec.ts b/src/infra/stdin.spec.ts deleted file mode 100644 index 007779f..0000000 --- a/src/infra/stdin.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { vi } from 'vitest' - -vi.mock('node:fs', () => ({ - readFileSync: vi.fn(() => 'mocked stdin content'), -})) - -import { readStdinSync } from './stdin.js' - -describe('readStdinSync', () => { - it('returns content read from stdin', () => { - expect(readStdinSync()).toStrictEqual('mocked stdin content') - }) -}) diff --git a/src/infra/stdin.ts b/src/infra/stdin.ts deleted file mode 100644 index 2d1fc05..0000000 --- a/src/infra/stdin.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { readFileSync } from 'node:fs' - -export function readStdinSync(): string { - return readFileSync(0, 'utf-8') -} diff --git a/src/shell.spec.ts b/src/shell.spec.ts new file mode 100644 index 0000000..5d3aa53 --- /dev/null +++ b/src/shell.spec.ts @@ -0,0 +1,140 @@ +import { route, getSessionId, getPluginRoot, getEnvFilePath, getDbPath } from './shell.js' +import type { ShellDeps } from './shell.js' +import { EXIT_ALLOW, EXIT_ERROR } from '@ntcoding/agentic-workflow-builder/cli' +import { homedir } from 'node:os' +import type { WorkflowEventStore } from '@ntcoding/agentic-workflow-builder/engine' +import type { WorkflowDeps } from './workflow-definition/index.js' + +const AT = '2026-01-01T00:00:00Z' + +function makeShellDeps(overrides?: Partial<ShellDeps>): ShellDeps { + return { + getSessionId: () => 'test-session', + getRepositoryName: () => undefined, + readStdin: () => JSON.stringify({ + session_id: 'test-session', + transcript_path: '/test/transcript.jsonl', + cwd: '/project', + permission_mode: 'default', + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: {}, + tool_use_id: 'tool-1', + }), + engineDeps: { + store: { + readEvents: () => [], + appendEvents: () => undefined, + sessionExists: () => true, + } satisfies WorkflowEventStore, + getPluginRoot: () => '/plugin', + getEnvFilePath: () => '/test/claude.env', + readFile: () => '', + appendToFile: () => undefined, + now: () => AT, + transcriptReader: { readMessages: () => [] }, + }, + workflowDeps: { + getGitInfo: () => ({ + currentBranch: 'main', + workingTreeClean: true, + headCommit: 'abc123', + changedFilesVsDefault: [], + hasCommitsVsDefault: false, + }), + checkPrChecks: () => true, + createDraftPr: () => 99, + appendIssueChecklist: () => undefined, + tickFirstUncheckedIteration: () => undefined, + runEslintOnFiles: () => true, + fileExists: () => false, + getPluginRoot: () => '/plugin', + now: () => AT, + } satisfies WorkflowDeps, + analyticsDeps: { + computeSession: () => 'Session: test\n===', + computeAll: () => 'Total Sessions: 0', + computeEventContext: () => 'Context', + }, + reportDeps: { + getAnalysisContext: () => '# Context', + generateReport: () => ({ path: '/tmp/report.html' }), + readAnalysisFile: () => '# Analysis', + }, + ...overrides, + } +} + +describe('route', () => { + it('dispatches init to workflow entrypoint', () => { + const result = route(['init'], makeShellDeps()) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + }) + + it('dispatches analyze to analytics entrypoint', () => { + const result = route(['analyze', '--all'], makeShellDeps()) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + expect(result.output).toStrictEqual('Total Sessions: 0') + }) + + it('dispatches view-report to analytics entrypoint', () => { + const result = route(['view-report', 'abc'], makeShellDeps()) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + expect(result.output).toStrictEqual('# Context') + }) + + it('dispatches workflow commands to workflow entrypoint', () => { + const result = route(['record-issue', '42'], makeShellDeps()) + expect(result.exitCode).toStrictEqual(EXIT_ALLOW) + }) + + it('dispatches unknown commands to workflow entrypoint which returns error', () => { + const result = route(['unknown-cmd'], makeShellDeps()) + expect(result.exitCode).toStrictEqual(EXIT_ERROR) + }) +}) + +describe('getSessionId', () => { + it('returns value when CLAUDE_SESSION_ID is set', () => { + process.env['CLAUDE_SESSION_ID'] = 'abc-123' + expect(getSessionId()).toStrictEqual('abc-123') + delete process.env['CLAUDE_SESSION_ID'] + }) + + it('throws when CLAUDE_SESSION_ID is absent', () => { + delete process.env['CLAUDE_SESSION_ID'] + expect(() => getSessionId()).toThrow('CLAUDE_SESSION_ID') + }) +}) + +describe('getPluginRoot', () => { + it('returns value when CLAUDE_PLUGIN_ROOT is set', () => { + process.env['CLAUDE_PLUGIN_ROOT'] = '/plugin' + expect(getPluginRoot()).toStrictEqual('/plugin') + delete process.env['CLAUDE_PLUGIN_ROOT'] + }) + + it('throws when CLAUDE_PLUGIN_ROOT is absent', () => { + delete process.env['CLAUDE_PLUGIN_ROOT'] + expect(() => getPluginRoot()).toThrow('CLAUDE_PLUGIN_ROOT') + }) +}) + +describe('getEnvFilePath', () => { + it('returns value when CLAUDE_ENV_FILE is set', () => { + process.env['CLAUDE_ENV_FILE'] = '/test/env' + expect(getEnvFilePath()).toStrictEqual('/test/env') + delete process.env['CLAUDE_ENV_FILE'] + }) + + it('throws when CLAUDE_ENV_FILE is absent', () => { + delete process.env['CLAUDE_ENV_FILE'] + expect(() => getEnvFilePath()).toThrow('CLAUDE_ENV_FILE') + }) +}) + +describe('getDbPath', () => { + it('returns path under home directory', () => { + expect(getDbPath()).toStrictEqual(`${homedir()}/.claude/workflow-events.db`) + }) +}) diff --git a/src/shell.ts b/src/shell.ts new file mode 100644 index 0000000..dbded1e --- /dev/null +++ b/src/shell.ts @@ -0,0 +1,170 @@ +import type { RunnerResult } from '@ntcoding/agentic-workflow-builder/cli' +import { EXIT_ERROR } from '@ntcoding/agentic-workflow-builder/cli' +import { runWorkflow } from './workflow-definition/entrypoint/entrypoint.js' +import type { WorkflowEntrypointDeps } from './workflow-definition/entrypoint/entrypoint.js' +import { isAnalyticsCommand, routeAnalytics } from './workflow-analysis/entrypoint/entrypoint.js' +import type { AnalyticsDeps, ReportDeps } from './workflow-analysis/entrypoint/entrypoint.js' +import { WorkflowError } from './workflow-definition/index.js' + +export type ShellDeps = WorkflowEntrypointDeps & { + readonly analyticsDeps: AnalyticsDeps + readonly reportDeps: ReportDeps +} + +export function route(args: readonly string[], deps: ShellDeps): RunnerResult { + const command = args[0] + if (command === 'init') return runWorkflow(args, deps) + if (command && isAnalyticsCommand(command)) return routeAnalytics(command, args, deps) + return runWorkflow(args, deps) +} + +export function getSessionId(): string { + const value = process.env['CLAUDE_SESSION_ID'] + if (value === undefined) { + throw new WorkflowError('Missing required env var: CLAUDE_SESSION_ID') + } + return value +} + +export function getPluginRoot(): string { + const value = process.env['CLAUDE_PLUGIN_ROOT'] + if (value === undefined) { + throw new WorkflowError('Missing required env var: CLAUDE_PLUGIN_ROOT') + } + return value +} + +export function getEnvFilePath(): string { + const value = process.env['CLAUDE_ENV_FILE'] + if (value === undefined) { + throw new WorkflowError('Missing required env var: CLAUDE_ENV_FILE') + } + return value +} + +export function getDbPath(): string { + return `${homedir()}/.claude/workflow-events.db` +} + +/* v8 ignore start */ +import { fileURLToPath } from 'node:url' +import { readFileSync, appendFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' +import { createStore } from '@ntcoding/agentic-workflow-builder/event-store' +import type { WorkflowEngineDeps } from '@ntcoding/agentic-workflow-builder/engine' +import type { WorkflowDeps } from './workflow-definition/index.js' +import { getGitInfo, getRepositoryName } from './workflow-definition/infra/git.js' +import { checkPrChecks, createDraftPr, appendIssueChecklist, tickFirstUncheckedIteration } from './workflow-definition/infra/github.js' +import { runEslintOnFiles } from './workflow-definition/infra/linter.js' +import { + computeSessionSummary, + computeCrossSessionSummary, + computeEventContext, + formatSessionSummary, + formatCrossSessionSummary, + computeEnhancedSessionSummary, + buildSessionViewData, + assembleReportData, + generateReportHtml, + formatAnalysisContext, +} from './workflow-analysis/index.js' +import { WorkflowEventSchema } from './workflow-definition/index.js' +import { resolveSessionId } from '@ntcoding/agentic-workflow-builder/event-store' +import { writeFileSync } from 'node:fs' +import { execSync } from 'node:child_process' +import { tmpdir } from 'node:os' + +function readStdinSync(): string { + return readFileSync(0, 'utf-8') +} + +function buildRealDeps(): ShellDeps { + const store = createStore(getDbPath()) + + const engineDeps: WorkflowEngineDeps = { + store, + getPluginRoot, + getEnvFilePath, + readFile: (path) => readFileSync(path, 'utf8'), + appendToFile: (path, content) => appendFileSync(path, content), + now: () => new Date().toISOString(), + } + + const workflowDeps: WorkflowDeps = { + getGitInfo, + checkPrChecks, + createDraftPr, + appendIssueChecklist, + tickFirstUncheckedIteration, + runEslintOnFiles, + fileExists: existsSync, + getPluginRoot, + now: () => new Date().toISOString(), + } + + const analyticsDeps: AnalyticsDeps = { + computeSession: (sessionId) => formatSessionSummary(computeSessionSummary(createStore(getDbPath()), sessionId)), + computeAll: () => formatCrossSessionSummary(computeCrossSessionSummary(createStore(getDbPath()))), + computeEventContext: (sessionId) => computeEventContext(createStore(getDbPath()), sessionId), + } + + const reportDeps: ReportDeps = { + getAnalysisContext: (sessionId) => { + const eventStore = createStore(getDbPath()) + const resolvedId = resolveSessionId(eventStore, sessionId) + const rawEvents = eventStore.readEvents(resolvedId) + const events = rawEvents.map((e) => WorkflowEventSchema.parse(e)) + const baseSummary = computeSessionSummary(eventStore, resolvedId) + const viewData = buildSessionViewData(resolvedId, rawEvents) + const enhanced = computeEnhancedSessionSummary(baseSummary, viewData, events) + const enhancedWithRepo = { ...enhanced, repository: enhanced.repository ?? getRepositoryName() } + const data = assembleReportData(enhancedWithRepo, viewData, [], [], events) + return formatAnalysisContext(data) + }, + generateReport: (sessionId, options) => { + const eventStore = createStore(getDbPath()) + const resolvedId = resolveSessionId(eventStore, sessionId) + const rawEvents = eventStore.readEvents(resolvedId) + const events = rawEvents.map((e) => WorkflowEventSchema.parse(e)) + const baseSummary = computeSessionSummary(eventStore, resolvedId) + const viewData = buildSessionViewData(resolvedId, rawEvents) + const enhanced = computeEnhancedSessionSummary(baseSummary, viewData, events) + const enhancedWithRepo = { ...enhanced, repository: enhanced.repository ?? getRepositoryName() } + const data = assembleReportData(enhancedWithRepo, viewData, [], [], events) + const html = generateReportHtml(data, options?.analysis) + const htmlPath = join(tmpdir(), `session-report-${resolvedId}.html`) + writeFileSync(htmlPath, html) + try { execSync(`open ${JSON.stringify(htmlPath)}`) } catch { } + return { path: htmlPath } + }, + readAnalysisFile: (filePath) => readFileSync(filePath, 'utf8'), + } + + return { + getSessionId, + getRepositoryName, + readStdin: readStdinSync, + engineDeps, + workflowDeps, + analyticsDeps, + reportDeps, + } +} + +function main(): void { + try { + const result = route(process.argv.slice(2), buildRealDeps()) + process.stdout.write(result.output, () => process.exit(result.exitCode)) + } catch (error) { + const message = `[${new Date().toISOString()}] HOOK ERROR: ${String(error)}\n` + process.stderr.write(message) + appendFileSync('/tmp/feature-team-hook-errors.log', message) + process.exit(EXIT_ERROR) + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main() +} +/* v8 ignore stop */ diff --git a/src/workflow-definition/entrypoint/analytics/cli-routes.spec.ts b/src/workflow-analysis/entrypoint/entrypoint.spec.ts similarity index 97% rename from src/workflow-definition/entrypoint/analytics/cli-routes.spec.ts rename to src/workflow-analysis/entrypoint/entrypoint.spec.ts index 31f5b2a..6dbb3b6 100644 --- a/src/workflow-definition/entrypoint/analytics/cli-routes.spec.ts +++ b/src/workflow-analysis/entrypoint/entrypoint.spec.ts @@ -1,7 +1,7 @@ -import { isAnalyticsCommand, routeAnalytics } from './cli-routes.js' +import { isAnalyticsCommand, routeAnalytics } from './entrypoint.js' import { EXIT_ERROR, EXIT_ALLOW } from '@ntcoding/agentic-workflow-builder/cli' -import { WorkflowError } from '../../../infra/workflow-error.js' -import type { AnalyticsDeps, ReportDeps } from '../entrypoint.js' +import { WorkflowError } from '../../workflow-definition/index.js' +import type { AnalyticsDeps, ReportDeps } from './entrypoint.js' function makeAnalyticsDeps(overrides?: Partial<AnalyticsDeps>): AnalyticsDeps { return { diff --git a/src/workflow-definition/entrypoint/analytics/cli-routes.ts b/src/workflow-analysis/entrypoint/entrypoint.ts similarity index 82% rename from src/workflow-definition/entrypoint/analytics/cli-routes.ts rename to src/workflow-analysis/entrypoint/entrypoint.ts index 74a8fc0..656f7a1 100644 --- a/src/workflow-definition/entrypoint/analytics/cli-routes.ts +++ b/src/workflow-analysis/entrypoint/entrypoint.ts @@ -1,7 +1,22 @@ import type { RunnerResult } from '@ntcoding/agentic-workflow-builder/cli' import { EXIT_ALLOW, EXIT_ERROR } from '@ntcoding/agentic-workflow-builder/cli' -import type { AnalyticsDeps, ReportDeps } from '../entrypoint.js' -import { WorkflowError } from '../../../infra/workflow-error.js' +import { WorkflowError } from '../../workflow-definition/index.js' + +export type AnalyticsDeps = { + readonly computeSession: (sessionId: string) => string + readonly computeAll: () => string + readonly computeEventContext: (sessionId: string) => string +} + +export type ReportResult = { + readonly path: string +} + +export type ReportDeps = { + readonly getAnalysisContext: (sessionId: string) => string + readonly generateReport: (sessionId: string, options?: { analysis?: string }) => ReportResult + readonly readAnalysisFile: (filePath: string) => string +} const ANALYTICS_COMMANDS: ReadonlySet<string> = new Set(['analyze', 'view-report']) diff --git a/src/workflow-analysis/workflow-analytics.ts b/src/workflow-analysis/workflow-analytics.ts index 33d363a..3d98a96 100644 --- a/src/workflow-analysis/workflow-analytics.ts +++ b/src/workflow-analysis/workflow-analytics.ts @@ -2,7 +2,7 @@ import type { SqliteEventStore } from '@ntcoding/agentic-workflow-builder/event- import type { BaseEvent } from '@ntcoding/agentic-workflow-builder/engine' import type { WorkflowEvent } from '../workflow-definition/index.js' import { applyEvents, WorkflowEventSchema } from '../workflow-definition/index.js' -import { WorkflowError } from '../infra/workflow-error.js' +import { WorkflowError } from '../workflow-definition/index.js' export function renderBar(ratio: number, width = 40): string { const filled = Math.round(Math.max(0, Math.min(1, ratio)) * width) diff --git a/src/workflow-definition/entrypoint/cli-commands.spec.ts b/src/workflow-definition/entrypoint/cli-commands.spec.ts index 255f0d2..8d841d6 100644 --- a/src/workflow-definition/entrypoint/cli-commands.spec.ts +++ b/src/workflow-definition/entrypoint/cli-commands.spec.ts @@ -21,11 +21,6 @@ describe('runWorkflow - routing overview', () => { expect(result.exitCode).toStrictEqual(EXIT_ALLOW) }) - it('delegates to analytics when command is analyze', () => { - const result = runWorkflow(['analyze', '--all'], makeDeps()) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - }) - it('delegates SubagentStart to platform runner', () => { const result = runWorkflow( [], diff --git a/src/workflow-definition/entrypoint/cli-test-fixtures.ts b/src/workflow-definition/entrypoint/cli-test-fixtures.ts index ee04f4f..2b4eb86 100644 --- a/src/workflow-definition/entrypoint/cli-test-fixtures.ts +++ b/src/workflow-definition/entrypoint/cli-test-fixtures.ts @@ -1,4 +1,4 @@ -import type { AdapterDeps, AnalyticsDeps, ReportDeps } from './entrypoint.js' +import type { WorkflowEntrypointDeps } from './entrypoint.js' import type { WorkflowEngineDeps, WorkflowEventStore } from '@ntcoding/agentic-workflow-builder/engine' import type { WorkflowDeps } from '../index.js' import type { WorkflowEvent } from '../index.js' @@ -121,37 +121,19 @@ function makeWorkflowDeps(overrides?: Partial<WorkflowDeps>): WorkflowDeps { } } -function makeAnalyticsDeps(overrides?: Partial<AnalyticsDeps>): AnalyticsDeps { - return { - computeSession: (_sessionId: string) => 'Session: test-session\n===', - computeAll: () => 'Total Sessions: 0', - computeEventContext: (_sessionId: string) => 'Session: test-session\nState: SPAWN (iteration: 0)', - ...overrides, - } -} - export type MakeDepsOverrides = { engineDeps?: EngineDepsOverrides workflowDeps?: Partial<WorkflowDeps> - analyticsDeps?: Partial<AnalyticsDeps> - reportDeps?: Partial<ReportDeps> getSessionId?: () => string readStdin?: () => string } -export function makeDeps(overrides?: MakeDepsOverrides): AdapterDeps { +export function makeDeps(overrides?: MakeDepsOverrides): WorkflowEntrypointDeps { return { getSessionId: overrides?.getSessionId ?? (() => 'test-session'), getRepositoryName: () => undefined, readStdin: overrides?.readStdin ?? (() => makeHookStdin()), engineDeps: makeEngineDeps(overrides?.engineDeps), workflowDeps: makeWorkflowDeps(overrides?.workflowDeps), - analyticsDeps: makeAnalyticsDeps(overrides?.analyticsDeps), - reportDeps: { - getAnalysisContext: () => '# Session Analysis Context\ntest data', - generateReport: () => ({ path: '/tmp/session-report-test.html' }), - readAnalysisFile: () => '# Analysis\ntest', - ...overrides?.reportDeps, - }, } } diff --git a/src/workflow-definition/entrypoint/entrypoint.ts b/src/workflow-definition/entrypoint/entrypoint.ts index a4701c9..dd9c1e7 100644 --- a/src/workflow-definition/entrypoint/entrypoint.ts +++ b/src/workflow-definition/entrypoint/entrypoint.ts @@ -6,33 +6,14 @@ import { FeatureTeamWorkflowDefinition, StateNameSchema } from '../index.js' import type { Workflow, WorkflowDeps } from '../index.js' import type { StateName, WorkflowOperation, WorkflowState } from '../domain/workflow-types.js' import { preToolUseHandler } from './pre-tool-use-handler.js' -import { isAnalyticsCommand, routeAnalytics } from './analytics/cli-routes.js' -import { parseNumber, parseString, parseStringArray } from '../../infra/arg-parsing.js' +import { parseNumber, parseString, parseStringArray } from '../infra/arg-parsing.js' -export type AnalyticsDeps = { - readonly computeSession: (sessionId: string) => string - readonly computeAll: () => string - readonly computeEventContext: (sessionId: string) => string -} - -export type ReportResult = { - readonly path: string -} - -export type ReportDeps = { - readonly getAnalysisContext: (sessionId: string) => string - readonly generateReport: (sessionId: string, options?: { analysis?: string }) => ReportResult - readonly readAnalysisFile: (filePath: string) => string -} - -export type AdapterDeps = { +export type WorkflowEntrypointDeps = { readonly getSessionId: () => string readonly getRepositoryName: () => string | undefined readonly readStdin: () => string readonly engineDeps: WorkflowEngineDeps readonly workflowDeps: WorkflowDeps - readonly analyticsDeps: AnalyticsDeps - readonly reportDeps: ReportDeps } const ROUTES = defineRoutes<Workflow, WorkflowState>({ @@ -72,17 +53,16 @@ const platformRunner = createWorkflowRunner<Workflow, WorkflowState, WorkflowDep preToolUseHandler, }) -export function runWorkflow(args: readonly string[], deps: AdapterDeps): RunnerResult { +export function runWorkflow(args: readonly string[], deps: WorkflowEntrypointDeps): RunnerResult { const command = args[0] if (command === 'init') return handleInit(deps) - if (command && isAnalyticsCommand(command)) return routeAnalytics(command, args, deps) return platformRunner(command ? args : [], deps.engineDeps, deps.workflowDeps, { readStdin: deps.readStdin, getSessionId: deps.getSessionId, }) } -function handleInit(deps: AdapterDeps): RunnerResult { +function handleInit(deps: WorkflowEntrypointDeps): RunnerResult { const engine = new WorkflowEngine(FeatureTeamWorkflowDefinition, deps.engineDeps, deps.workflowDeps) return mapResult(engine.startSession(deps.getSessionId(), undefined, deps.getRepositoryName())) } @@ -92,121 +72,3 @@ function mapResult(result: EngineResult): RunnerResult { const exitCode = result.type === 'success' ? EXIT_ALLOW : result.type === 'blocked' ? EXIT_BLOCK : EXIT_ERROR return { output: result.output, exitCode } } - -/* v8 ignore start */ -import { fileURLToPath } from 'node:url' -import { readFileSync, appendFileSync, existsSync } from 'node:fs' -import { join } from 'node:path' -import { createStore } from '@ntcoding/agentic-workflow-builder/event-store' -import { getGitInfo, getRepositoryName } from '../../infra/git.js' -import { checkPrChecks, createDraftPr, appendIssueChecklist, tickFirstUncheckedIteration } from '../../infra/github.js' -import { runEslintOnFiles } from '../../infra/linter.js' -import { readStdinSync } from '../../infra/stdin.js' -import { getSessionId, getPluginRoot, getEnvFilePath, getDbPath } from '../../infra/environment.js' -import { - computeSessionSummary, - computeCrossSessionSummary, - computeEventContext, - formatSessionSummary, - formatCrossSessionSummary, - computeEnhancedSessionSummary, - buildSessionViewData, - assembleReportData, - generateReportHtml, - formatAnalysisContext, -} from '../../workflow-analysis/index.js' -import { WorkflowEventSchema } from '../index.js' -import { resolveSessionId } from '@ntcoding/agentic-workflow-builder/event-store' -import { writeFileSync } from 'node:fs' -import { execSync } from 'node:child_process' -import { tmpdir } from 'node:os' - -function buildRealDeps(): AdapterDeps { - const store = createStore(getDbPath()) - - const engineDeps: WorkflowEngineDeps = { - store, - getPluginRoot, - getEnvFilePath, - readFile: (path) => readFileSync(path, 'utf8'), - appendToFile: (path, content) => appendFileSync(path, content), - now: () => new Date().toISOString(), - } - - const workflowDeps: WorkflowDeps = { - getGitInfo, - checkPrChecks, - createDraftPr, - appendIssueChecklist, - tickFirstUncheckedIteration, - runEslintOnFiles, - fileExists: existsSync, - getPluginRoot, - now: () => new Date().toISOString(), - } - - const analyticsDeps: AnalyticsDeps = { - computeSession: (sessionId) => formatSessionSummary(computeSessionSummary(createStore(getDbPath()), sessionId)), - computeAll: () => formatCrossSessionSummary(computeCrossSessionSummary(createStore(getDbPath()))), - computeEventContext: (sessionId) => computeEventContext(createStore(getDbPath()), sessionId), - } - - const reportDeps: ReportDeps = { - getAnalysisContext: (sessionId) => { - const eventStore = createStore(getDbPath()) - const resolvedId = resolveSessionId(eventStore, sessionId) - const rawEvents = eventStore.readEvents(resolvedId) - const events = rawEvents.map((e) => WorkflowEventSchema.parse(e)) - const baseSummary = computeSessionSummary(eventStore, resolvedId) - const viewData = buildSessionViewData(resolvedId, rawEvents) - const enhanced = computeEnhancedSessionSummary(baseSummary, viewData, events) - const enhancedWithRepo = { ...enhanced, repository: enhanced.repository ?? getRepositoryName() } - const data = assembleReportData(enhancedWithRepo, viewData, [], [], events) - return formatAnalysisContext(data) - }, - generateReport: (sessionId, options) => { - const eventStore = createStore(getDbPath()) - const resolvedId = resolveSessionId(eventStore, sessionId) - const rawEvents = eventStore.readEvents(resolvedId) - const events = rawEvents.map((e) => WorkflowEventSchema.parse(e)) - const baseSummary = computeSessionSummary(eventStore, resolvedId) - const viewData = buildSessionViewData(resolvedId, rawEvents) - const enhanced = computeEnhancedSessionSummary(baseSummary, viewData, events) - const enhancedWithRepo = { ...enhanced, repository: enhanced.repository ?? getRepositoryName() } - const data = assembleReportData(enhancedWithRepo, viewData, [], [], events) - const html = generateReportHtml(data, options?.analysis) - const htmlPath = join(tmpdir(), `session-report-${resolvedId}.html`) - writeFileSync(htmlPath, html) - try { execSync(`open ${JSON.stringify(htmlPath)}`) } catch { } - return { path: htmlPath } - }, - readAnalysisFile: (filePath) => readFileSync(filePath, 'utf8'), - } - - return { - getSessionId, - getRepositoryName, - readStdin: readStdinSync, - engineDeps, - workflowDeps, - analyticsDeps, - reportDeps, - } -} - -function main(): void { - try { - const result = runWorkflow(process.argv.slice(2), buildRealDeps()) - process.stdout.write(result.output, () => process.exit(result.exitCode)) - } catch (error) { - const message = `[${new Date().toISOString()}] HOOK ERROR: ${String(error)}\n` - process.stderr.write(message) - appendFileSync('/tmp/feature-team-hook-errors.log', message) - process.exit(EXIT_ERROR) - } -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - main() -} -/* v8 ignore stop */ diff --git a/src/workflow-definition/entrypoint/pre-tool-use-handler.ts b/src/workflow-definition/entrypoint/pre-tool-use-handler.ts index 9df628c..b8a3ab1 100644 --- a/src/workflow-definition/entrypoint/pre-tool-use-handler.ts +++ b/src/workflow-definition/entrypoint/pre-tool-use-handler.ts @@ -2,7 +2,7 @@ import type { WorkflowEngine, EngineResult } from '@ntcoding/agentic-workflow-bu import { BASH_FORBIDDEN, checkWriteAllowed } from '../index.js' import type { Workflow, WorkflowDeps } from '../index.js' import type { StateName, WorkflowOperation, WorkflowState } from '../domain/workflow-types.js' -import { WorkflowError } from '../../infra/workflow-error.js' +import { WorkflowError } from '../infra/workflow-error.js' type Engine = WorkflowEngine<Workflow, WorkflowState, WorkflowDeps, StateName, WorkflowOperation> diff --git a/src/workflow-definition/entrypoint/view-report.spec.ts b/src/workflow-definition/entrypoint/view-report.spec.ts deleted file mode 100644 index bf853c3..0000000 --- a/src/workflow-definition/entrypoint/view-report.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { runWorkflow } from './entrypoint.js' -import { EXIT_ERROR, EXIT_ALLOW } from '@ntcoding/agentic-workflow-builder/cli' -import { WorkflowError } from '../../infra/workflow-error.js' -import { makeDeps } from './cli-test-fixtures.js' - -describe('runWorkflow - view-report command', () => { - it('returns EXIT_ERROR when no sessionId is given', () => { - const result = runWorkflow(['view-report'], makeDeps()) - expect(result.exitCode).toStrictEqual(EXIT_ERROR) - expect(result.output).toContain('missing required argument') - }) - - describe('phase 1 — analysis context output', () => { - it('outputs analysisContext from getAnalysisContext when no flags are given', () => { - const result = runWorkflow(['view-report', 'my-session'], makeDeps({ - reportDeps: { - getAnalysisContext: (sessionId) => `# Context for ${sessionId}`, - }, - })) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - expect(result.output).toStrictEqual('# Context for my-session') - }) - - it('does not call generateReport in phase 1', () => { - const calls: string[] = [] - runWorkflow(['view-report', 'abc'], makeDeps({ - reportDeps: { - getAnalysisContext: () => 'context', - generateReport: () => { calls.push('called'); return { path: '/tmp/report.html' } }, - }, - })) - expect(calls).toStrictEqual([]) - }) - }) - - describe('phase 2 — render with analysis file', () => { - it('reads analysis file and passes content to generateReport', () => { - const capturedAnalysis: Array<string | undefined> = [] - const result = runWorkflow(['view-report', 'abc-123', '--render', '/tmp/analysis.md'], makeDeps({ - reportDeps: { - readAnalysisFile: (path) => `analysis from ${path}`, - generateReport: (_id, options) => { - capturedAnalysis.push(options?.analysis) - return { path: '/tmp/session-report.html' } - }, - }, - })) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - expect(result.output).toStrictEqual('/tmp/session-report.html') - expect(capturedAnalysis[0]).toStrictEqual('analysis from /tmp/analysis.md') - }) - - it('handles --render before sessionId in args', () => { - const calledWith: string[] = [] - const result = runWorkflow(['view-report', '--render', '/tmp/a.md', 'abc-123'], makeDeps({ - reportDeps: { - readAnalysisFile: () => 'content', - generateReport: (sessionId) => { - calledWith.push(sessionId) - return { path: '/tmp/report.html' } - }, - }, - })) - expect(calledWith[0]).toStrictEqual('abc-123') - expect(result.output).toStrictEqual('/tmp/report.html') - }) - }) - - describe('simple mode — no analysis', () => { - it('generates report without analysis and outputs only link', () => { - const capturedOptions: Array<{ analysis?: string } | undefined> = [] - const result = runWorkflow(['view-report', 'abc-123', '--simple'], makeDeps({ - reportDeps: { - generateReport: (_id, options) => { - capturedOptions.push(options) - return { path: '/tmp/session-report-abc-123.html' } - }, - }, - })) - expect(result.exitCode).toStrictEqual(EXIT_ALLOW) - expect(result.output).toStrictEqual('/tmp/session-report-abc-123.html') - expect(capturedOptions[0]).toStrictEqual(undefined) - }) - - it('handles --simple before sessionId in args', () => { - const calledWith: string[] = [] - const result = runWorkflow(['view-report', '--simple', 'abc-123'], makeDeps({ - reportDeps: { - generateReport: (sessionId) => { - calledWith.push(sessionId) - return { path: '/tmp/report.html' } - }, - }, - })) - expect(calledWith[0]).toStrictEqual('abc-123') - expect(result.output).toStrictEqual('/tmp/report.html') - }) - }) - - describe('error handling', () => { - it('returns EXIT_ERROR with message when getAnalysisContext throws WorkflowError', () => { - const result = runWorkflow(['view-report', 'bad-id'], makeDeps({ - reportDeps: { - getAnalysisContext: () => { throw new WorkflowError('No events found for session "bad-id"') }, - }, - })) - expect(result.exitCode).toStrictEqual(EXIT_ERROR) - expect(result.output).toContain('view-report: No events found for session "bad-id"') - }) - - it('returns EXIT_ERROR with message when generateReport throws WorkflowError', () => { - const result = runWorkflow(['view-report', 'bad-id', '--simple'], makeDeps({ - reportDeps: { - generateReport: () => { throw new WorkflowError('No events found for session "bad-id"') }, - }, - })) - expect(result.exitCode).toStrictEqual(EXIT_ERROR) - expect(result.output).toContain('view-report: No events found for session "bad-id"') - }) - - it('returns EXIT_ERROR with message when readAnalysisFile throws WorkflowError', () => { - const result = runWorkflow(['view-report', 'abc', '--render', '/tmp/missing.md'], makeDeps({ - reportDeps: { - readAnalysisFile: () => { throw new WorkflowError('File not found') }, - }, - })) - expect(result.exitCode).toStrictEqual(EXIT_ERROR) - expect(result.output).toContain('view-report: File not found') - }) - - it('re-throws non-WorkflowError exceptions', () => { - expect(() => runWorkflow(['view-report', 'bad-id'], makeDeps({ - reportDeps: { - getAnalysisContext: () => { throw new TypeError('unexpected') }, - }, - }))).toThrow('unexpected') - }) - }) -}) diff --git a/src/workflow-definition/index.ts b/src/workflow-definition/index.ts index 8d28763..4c29f38 100644 --- a/src/workflow-definition/index.ts +++ b/src/workflow-definition/index.ts @@ -22,3 +22,5 @@ export { applyEvents } from './domain/fold.js' export { checkWriteAllowed } from './domain/workflow-predicates.js' export { BASH_FORBIDDEN } from './domain/registry.js' + +export { WorkflowError } from './infra/workflow-error.js' diff --git a/src/infra/arg-parsing.spec.ts b/src/workflow-definition/infra/arg-parsing.spec.ts similarity index 100% rename from src/infra/arg-parsing.spec.ts rename to src/workflow-definition/infra/arg-parsing.spec.ts diff --git a/src/infra/arg-parsing.ts b/src/workflow-definition/infra/arg-parsing.ts similarity index 100% rename from src/infra/arg-parsing.ts rename to src/workflow-definition/infra/arg-parsing.ts diff --git a/src/infra/git.spec.ts b/src/workflow-definition/infra/git.spec.ts similarity index 100% rename from src/infra/git.spec.ts rename to src/workflow-definition/infra/git.spec.ts diff --git a/src/infra/git.ts b/src/workflow-definition/infra/git.ts similarity index 100% rename from src/infra/git.ts rename to src/workflow-definition/infra/git.ts diff --git a/src/infra/github.spec.ts b/src/workflow-definition/infra/github.spec.ts similarity index 100% rename from src/infra/github.spec.ts rename to src/workflow-definition/infra/github.spec.ts diff --git a/src/infra/github.ts b/src/workflow-definition/infra/github.ts similarity index 100% rename from src/infra/github.ts rename to src/workflow-definition/infra/github.ts diff --git a/src/infra/linter.spec.ts b/src/workflow-definition/infra/linter.spec.ts similarity index 100% rename from src/infra/linter.spec.ts rename to src/workflow-definition/infra/linter.spec.ts diff --git a/src/infra/linter.ts b/src/workflow-definition/infra/linter.ts similarity index 100% rename from src/infra/linter.ts rename to src/workflow-definition/infra/linter.ts diff --git a/src/infra/workflow-error.spec.ts b/src/workflow-definition/infra/workflow-error.spec.ts similarity index 100% rename from src/infra/workflow-error.spec.ts rename to src/workflow-definition/infra/workflow-error.spec.ts diff --git a/src/infra/workflow-error.ts b/src/workflow-definition/infra/workflow-error.ts similarity index 100% rename from src/infra/workflow-error.ts rename to src/workflow-definition/infra/workflow-error.ts