Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "workflow-conductor",
"version": "0.4.2",
"version": "0.4.3",
"description": "Terminal UI for planning, running and understanding complex agent workflows inside Claude Code.",
"author": {
"name": "Artur Turdiev"
Expand Down
1 change: 1 addition & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ scripts/render-graph.ts layout + paint as text with no session (--list names th
- `match` (labels or phase titles; a trailing `*` is a prefix) ties a block to a script that existed before the project.
- `replay-*` projects live in state only: never written, listed or handed off.
- A template is saved by its first edit or by a hand-off, not by opening it.
- A save first reads the file: where it is no longer what this session last read or wrote (another session sharing the root saved it), nothing is written, the board takes the file, and an edit Claude makes from an older read is denied until it reads the project again. On the board, one undo brings back the person's version with the refused edit made on it, and saves that; text typed in a field stays in it, to be sent onto the other session's.

## A run and its project

Expand Down
180 changes: 148 additions & 32 deletions hooks/project.tsx

Large diffs are not rendered by default.

13 changes: 9 additions & 4 deletions src/project/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,8 +581,13 @@ export function resultLine(text: string, isError: boolean): string {
}

/** Sent, unseen, with a prompt when the board changed by hand and the change itself cannot be said (`changedNote`). */
export function staleNote(name: string, revision: number): string {
return `Conductor: the user changed the "${name}" workflow project on the board by hand since you last read or edited it (it is now revision ${revision}). This note cannot say what changed, so what you know of it is out of date: call ${TOOL_FULL_NAME} with action "read" before you describe, review or edit it.`
/** `elsewhere`: the change came from another session saving the file, not from the user's hand on this board. */
function changedLine(name: string, elsewhere: boolean): string {
return elsewhere ? `the "${name}" workflow project was changed in another session` : `the user changed the "${name}" workflow project on the board by hand`
}

export function staleNote(name: string, revision: number, elsewhere = false): string {
return `Conductor: ${changedLine(name, elsewhere)} since you last read or edited it (it is now revision ${revision}). This note cannot say what changed, so what you know of it is out of date: call ${TOOL_FULL_NAME} with action "read" before you describe, review or edit it.`
}

function blockOf(node: ProjectNode, deep: boolean): Record<string, unknown> {
Expand Down Expand Up @@ -645,12 +650,12 @@ export function boardChanges(seen: Project, now: Project): string[] {
}

/** In place of `staleNote` when the project as Claude last saw it is at hand. Null when saying the change takes more than the project does: a read is cheaper. */
export function changedNote(name: string, revision: number, seen: Project, now: Project, selectedId: string | null): string | null {
export function changedNote(name: string, revision: number, seen: Project, now: Project, selectedId: string | null, elsewhere = false): string | null {
const lines = boardChanges(seen, now)
if (lines.join('\n').length > serializeProject(now).length) return null
const selected = selectedId === null ? undefined : findNode(now, selectedId)
return [
`Conductor: the user changed the "${name}" workflow project on the board by hand since you last read or edited it (it is now revision ${revision}). ${lines.length > 0 ? 'What follows is everything that changed' : 'Nothing in it differs from what you last saw'}: with it you know the board as it is, so this is the note that stands in for a read: none is needed. An edit with ${TOOL_FULL_NAME} from here is accepted.`,
`Conductor: ${changedLine(name, elsewhere)} since you last read or edited it (it is now revision ${revision}). ${lines.length > 0 ? 'What follows is everything that changed' : 'Nothing in it differs from what you last saw'}: with it you know the board as it is, so this is the note that stands in for a read: none is needed. An edit with ${TOOL_FULL_NAME} from here is accepted.`,
...lines,
`Selected block ("this block"): ${selected ? `${selected.id} (${typeName(selected)} "${selected.name}")` : 'none'}.`,
].join('\n')
Expand Down
10 changes: 10 additions & 0 deletions src/project/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,16 @@ export function serializeProject(project: Project): string {
return `${JSON.stringify(out, null, 2)}\n`
}

/** Whether two projects are one, whatever names they carry: a file is named by its path. Both go through the parser, so a project laid out by hand matches. */
export function sameProject(a: Project, b: Project): boolean {
const plain = (project: Project): string | null => {
const parsed = parseProject(serializeProject({ ...project, name: 'same' }))
return parsed.ok ? serializeProject(parsed.project) : null
}
const one = plain(a)
return one !== null && one === plain(b)
}

export function walk(project: Project, fn: (node: ProjectNode, info: WalkInfo) => void): void {
const visit = (nodes: readonly ProjectNode[], parent: ProjectNode | undefined, depth: number): void => {
nodes.forEach((node, index) => {
Expand Down
79 changes: 79 additions & 0 deletions tests/project-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,82 @@ test('the minimized strip keeps what is drawn beneath it in the band', async ($,
await ui.unmount()
}
})

test("an edit is refused, not saved, once another session saved the project since this one loaded it", async ($, on) => {
const files = new Map<string, string>()
const path = '/work/.claude/workflow-conductor/demo.json'
on('session.start', async (_$, e) => ({ cwd: e.cwd }))
on('command.register', async (_$, e) => ({ value: { command: e.name } }))
on('ui.panes', async () => ({ value: [] }))
on('fs.exists', async (_$, e) => ({ value: files.has(e.path) }))
on('fs.read', async (_$, e) => {
const text = files.get(e.path)
if (text === undefined) throw new Error(`no file ${e.path}`)

return { value: text }
})
on('fs.write', async (_$, e) => {
files.set(e.path, e.text)

return { value: undefined }
})
on('fs.list', async () => ({ value: [] }))
on('session.root', async () => ({ value: '/work' }))
on('tool.register', async (_$, e) => ({ value: { tool: e.name } }))

await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true })
const apply = (name: string) => $.tool.call({ tool: 'mcp__workflow-conductor__project', action: 'apply', name: 'demo', ops: [{ op: 'add', type: 'agent', name }] })

expect(await apply('first')).toHaveProperty('result')
// The project is open on the board now; another session sharing the root saves a block of its own.
const other = JSON.parse(files.get(path) ?? '{}')
other.nodes.push({ id: 'n90', type: 'agent', name: 'from another session' })
files.set(path, JSON.stringify(other))

expect(await apply('second')).toHaveProperty('deny')
expect(files.get(path)).toBe(JSON.stringify(other))
// Read again, the next edit is made on what the other session saved, and keeps it.
await $.tool.call({ tool: 'mcp__workflow-conductor__project', action: 'read', name: 'demo' })
expect(await apply('third')).toHaveProperty('result')
const names = JSON.parse(files.get(path) ?? '{}').nodes.map((node: { name: string }) => node.name)
expect(names).toContain('from another session')
expect(names).toContain('third')
expect(names).not.toContain('second')
})

test('a write Claude made from an older read is refused once another session saved the project, with the board closed', async ($, on) => {
const files = new Map<string, string>()
const path = '/work/.claude/workflow-conductor/shared.json'
on('session.start', async (_$, e) => ({ cwd: e.cwd }))
on('command.register', async (_$, e) => ({ value: { command: e.name } }))
on('ui.panes', async () => ({ value: [] }))
on('fs.exists', async (_$, e) => ({ value: files.has(e.path) }))
on('fs.read', async (_$, e) => {
const text = files.get(e.path)
if (text === undefined) throw new Error(`no file ${e.path}`)

return { value: text }
})
on('fs.write', async (_$, e) => {
files.set(e.path, e.text)

return { value: undefined }
})
on('fs.list', async () => ({ value: [] }))
on('session.root', async () => ({ value: '/work' }))
on('tool.register', async (_$, e) => ({ value: { tool: e.name } }))

files.set(path, JSON.stringify({ version: 1, name: 'shared', nodes: [{ id: 'n1', type: 'agent', name: 'first' }] }))
await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true })
const call = (input: Record<string, unknown>) => $.tool.call({ tool: 'mcp__workflow-conductor__project', name: 'shared', ...input })
const write = (name: string) => call({ action: 'write', nodes: [{ type: 'agent', name }] })

expect(await call({ action: 'read' })).toHaveProperty('result')
const other = JSON.stringify({ version: 1, name: 'shared', nodes: [{ id: 'n1', type: 'agent', name: 'first' }, { id: 'n2', type: 'agent', name: 'from another session' }] })
files.set(path, other)

expect(await write('mine')).toHaveProperty('deny')
expect(files.get(path)).toBe(other)
await call({ action: 'read' })
expect(await write('mine')).toHaveProperty('result')
})
10 changes: 9 additions & 1 deletion tests/project-model.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from 'claude-code/testing'

import { KINDS, NODE_TYPES, canHoldChildren, emptyProject, findNode, flatten, kindsOf, parseProject, readType, serializeProject, startOf, walk } from '../src/project/model'
import { KINDS, NODE_TYPES, canHoldChildren, emptyProject, findNode, flatten, kindsOf, parseProject, readType, sameProject, serializeProject, startOf, walk } from '../src/project/model'
import type { Project } from '../src/project/model'
import { TEMPLATES, review } from '../src/project/templates'

Expand Down Expand Up @@ -215,3 +215,11 @@ test('walk visits depth-first with the parent, and findNode reaches nested nodes
expect(findNode(project, 'n4')).toMatchObject({ type: 'agent', kind: 'verify' })
expect(findNode(project, 'n99')).toBeUndefined()
})

test('two projects are one whatever their names or how their file is laid out', () => {
const one = review()
const laidOut = parsed(JSON.stringify(JSON.parse(serializeProject(one)), null, 8))
expect(sameProject(one, laidOut)).toBe(true)
expect(sameProject(one, { ...one, name: 'copied' })).toBe(true)
expect(sameProject(one, { ...one, nodes: one.nodes.slice(0, 1) })).toBe(false)
})
Loading