From 85a7c726af69489926707f2466150c8862b9a05e Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 7 Sep 2026 09:51:42 +0900 Subject: [PATCH 1/2] Normalize workflow naming across runtime and authoring contracts --- Makefile | 6 +- ProwlCLI/Commands/WorkflowCommand.swift | 14 +- ProwlCLI/Output/OutputRenderer+Workflow.swift | 14 +- .../Resources/action-definition-schema.json | 2 +- .../Resources/cli-output-schema.json | 26 +-- .../Resources/workflow-definition-schema.json | 6 +- ProwlCLITests/ProwlCLIIntegrationTests.swift | 34 ++-- .../WorkflowActionContractTests.swift | 2 +- .../WorkflowBundleValidatorTests.swift | 18 +- .../WorkflowCommandParsingTests.swift | 44 ++--- ProwlCLITests/WorkflowContentTests.swift | 4 +- .../WorkflowDocumentParserTests.swift | 20 +- ProwlCLITests/WorkflowFixtures.swift | 22 +-- ProwlCLITests/WorkflowNamingTests.swift | 132 +++++++++++++ ProwlCLITests/WorkflowSchemaTests.swift | 28 +-- ProwlCLITests/WorkflowValidatorTests.swift | 56 +++--- .../workflow.yaml | 2 +- docs-ai/013-prowl-cli/contracts/workflow.md | 80 ++++---- docs-ai/063-agent-workflows/000-plan.md | 73 +++++--- .../004-pane-identity-env.md | 2 +- .../063-agent-workflows/006-b1-definitions.md | 20 +- .../063-agent-workflows/007-b2-runner-core.md | 30 +-- .../008-b3-runner-wiring.md | 52 +++--- .../010-c1-workflow-status-center.md | 8 +- .../012-v1-boundary-observations.md | 6 +- .../015-action-bundles-and-control-flow.md | 28 +-- .../017-action-bundle-implementation.md | 4 +- .../018-history-storage-plan.md | 6 +- .../019-workflow-naming.md | 64 +++++++ docs-ai/063-agent-workflows/dsl-spec.md | 70 ++++--- docs-ai/063-agent-workflows/release-plan.md | 10 +- .../064-agent-completion-signals/000-plan.md | 4 +- .../001-action.md | 2 +- .../002-s1-work-note.md | 2 +- .../003-s2-dispatch-wait-design.md | 4 +- .../012-cli-evidence-semantics.md | 2 +- docs-ai/README.md | 2 +- docs/components/cli.md | 30 +-- docs/components/workflows.md | 20 +- scripts/check_workflow_naming.py | 145 +++++++++++++++ scripts/test_workflow_naming.py | 72 ++++++++ skills/prowl-cli/SKILL.md | 12 +- skills/prowl-workflow/SKILL.md | 8 +- skills/prowl-workflow/references/actions.md | 36 ++-- skills/prowl-workflow/references/authoring.md | 46 +++-- skills/prowl-workflow/references/runbook.md | 16 +- supacode/CLIService/Shared/InputModels.swift | 10 +- .../WorkflowActionDefinitionSchema.swift | 2 +- .../Shared/WorkflowActionRegistry.swift | 12 +- .../Shared/WorkflowCommandPayload.swift | 50 ++--- .../Shared/WorkflowControlCursor.swift | 6 +- .../Shared/WorkflowDefinition.swift | 38 ++-- .../Shared/WorkflowDocumentParser.swift | 8 +- .../Shared/WorkflowExpression.swift | 12 ++ .../Shared/WorkflowHistoryMetadata.swift | 2 +- .../Shared/WorkflowJSONSchema.swift | 6 +- .../Shared/WorkflowPreparedBundle.swift | 2 +- .../Shared/WorkflowScriptAction.swift | 13 +- .../Shared/WorkflowTaskContent.swift | 2 +- .../CLIService/Shared/WorkflowTemplate.swift | 6 +- .../CLIService/Shared/WorkflowValidator.swift | 73 ++++---- .../CLIService/WorkflowCommandHandler.swift | 8 +- .../CLIService/WorkflowRunAdmission.swift | 2 +- .../CLIService/WorkflowRunPayload+App.swift | 14 +- .../WorkflowRuntimeCoordinator.swift | 26 +-- .../Workflow/WorkflowDeliveryValidator.swift | 4 +- .../Workflow/WorkflowExecutionContext.swift | 18 +- .../Workflow/WorkflowLineRenderer.swift | 12 +- .../Workflow/WorkflowNativeActions.swift | 24 +-- supacode/Domain/Workflow/WorkflowRun.swift | 39 ++-- .../Domain/Workflow/WorkflowRunMachine.swift | 84 ++++----- .../Domain/Workflow/WorkflowRunStore.swift | 28 +-- .../Workflow/WorkflowStarterTemplate.swift | 8 +- .../Workflow/WorkflowTemplateRenderer.swift | 173 ------------------ .../Features/App/Reducer/AppFeature.swift | 2 +- .../Help/WorkflowAuthoringPrompt.swift | 36 ++-- .../Views/WorkflowStatusPopoverButton.swift | 4 +- .../Workflow/Models/WorkflowRunNotice.swift | 10 +- .../WorkflowStatusCenterPresentation.swift | 8 +- .../Reducer/WorkflowRunsFeature.swift | 12 +- .../Workflow/Views/WorkflowHistoryView.swift | 4 +- .../AgentDispatchCommandHandlerTests.swift | 7 +- .../AgentProfileHookCarrierTests.swift | 2 +- .../AppFeatureWorkflowNoticeTests.swift | 4 +- supacodeTests/CLISocketServerTests.swift | 3 +- .../WorkflowAuthoringPromptTests.swift | 3 +- .../WorkflowBundleRunMachineTests.swift | 8 +- .../WorkflowDeliveryValidatorTests.swift | 6 +- .../WorkflowExecutionContextTests.swift | 56 ++++++ supacodeTests/WorkflowLineRendererTests.swift | 34 ++-- .../WorkflowNativeActionsTests.swift | 21 ++- supacodeTests/WorkflowRunAdmissionTests.swift | 20 +- supacodeTests/WorkflowRunHarnessTests.swift | 37 ++-- supacodeTests/WorkflowRunMachineTests.swift | 95 +++++----- supacodeTests/WorkflowRunStoreTests.swift | 8 +- supacodeTests/WorkflowRunsFeatureTests.swift | 29 +-- .../WorkflowRuntimeCoordinatorTests.swift | 125 ++++++------- .../WorkflowSettingsCatalogTests.swift | 2 +- supacodeTests/WorkflowStartContextTests.swift | 4 +- supacodeTests/WorkflowStartFeatureTests.swift | 6 +- ...orkflowStatusCenterPresentationTests.swift | 21 ++- .../WorkflowTemplateRendererTests.swift | 94 ---------- 102 files changed, 1473 insertions(+), 1154 deletions(-) create mode 100644 ProwlCLITests/WorkflowNamingTests.swift create mode 100644 docs-ai/063-agent-workflows/019-workflow-naming.md create mode 100644 scripts/check_workflow_naming.py create mode 100644 scripts/test_workflow_naming.py delete mode 100644 supacode/Domain/Workflow/WorkflowTemplateRenderer.swift create mode 100644 supacodeTests/WorkflowExecutionContextTests.swift delete mode 100644 supacodeTests/WorkflowTemplateRendererTests.swift diff --git a/Makefile b/Makefile index 203ad325d..634c5f253 100644 --- a/Makefile +++ b/Makefile @@ -535,7 +535,11 @@ format-lint: # Check Swift formatting without rewriting files lint: # Lint code with swiftlint mise exec -- swiftlint lint --quiet --config .swiftlint.yml -check: format-changed format-lint lint test-scripts # Format changed Swift files, then run swift-format lint, SwiftLint, and the script tests +.PHONY: check-workflow-naming +check-workflow-naming: # Check maintained workflow source and references for retired names + python3 scripts/check_workflow_naming.py + +check: format-changed format-lint lint test-scripts check-workflow-naming # Format changed Swift files, then run linters and checks log-stream: # Stream logs from the app via log stream log stream --predicate 'subsystem == "com.onevcat.prowl"' --style compact --color always diff --git a/ProwlCLI/Commands/WorkflowCommand.swift b/ProwlCLI/Commands/WorkflowCommand.swift index 56d5edcfd..43fd290d8 100644 --- a/ProwlCLI/Commands/WorkflowCommand.swift +++ b/ProwlCLI/Commands/WorkflowCommand.swift @@ -20,7 +20,7 @@ struct WorkflowCommand: ParsableCommand { WorkflowRunCommand.self, WorkflowTestActionCommand.self, WorkflowStatusCommand.self, - WorkflowDoneCommand.self, + WorkflowDeliverCommand.self, WorkflowCancelCommand.self, WorkflowValidateCommand.self, WorkflowSchemaCommand.self, @@ -105,9 +105,9 @@ struct WorkflowStatusCommand: ParsableCommand { } } -struct WorkflowDoneCommand: ParsableCommand { +struct WorkflowDeliverCommand: ParsableCommand { static let configuration = CommandConfiguration( - commandName: "done", + commandName: "deliver", abstract: "Deliver one workflow step's output from stdin or a UTF-8 file." ) @@ -134,7 +134,7 @@ struct WorkflowDoneCommand: ParsableCommand { output: options.outputMode, command: .workflow( WorkflowInput( - action: .done, + action: .deliver, runID: runID, stepID: step, body: body, @@ -182,7 +182,7 @@ struct WorkflowDoneCommand: ParsableCommand { guard isatty(fileno(stdin)) == 0 else { throw ExitError( code: CLIErrorCode.emptyInput, - message: "workflow done - reads the output body from piped stdin.") + message: "workflow deliver - reads the output body from piped stdin.") } data = (try? FileHandle.standardInput.readToEnd()) ?? Data() } @@ -235,7 +235,7 @@ struct WorkflowValidateCommand: ParsableCommand { } } - @Argument(help: "Path to a workflow YAML file.") var file: String + @Argument(help: "Path to a .pwlworkflow bundle directory.") var file: String @Option(name: .long, help: "Source scope (bundle, user, repo); inferred when omitted.") var scope: Scope? @OptionGroup var options: GlobalOptions @@ -318,7 +318,7 @@ struct WorkflowTestActionCommand: ParsableCommand { abstract: "Run one action from an installed workflow bundle with the same native approval policy.") @Argument(help: "Workflow id or unique name.") var workflow: String - @Argument(help: "builtin:git.context or local:.") var action: String + @Argument(help: "builtin:collect-worktree-context or local:.") var action: String @Argument(help: "Source worktree or pane.") var source: String? @Option(name: .long, help: "JSON object supplied to the action.") var inputJSON = "{}" @OptionGroup var selector: SelectorOptions diff --git a/ProwlCLI/Output/OutputRenderer+Workflow.swift b/ProwlCLI/Output/OutputRenderer+Workflow.swift index d7524bcfa..285526b6d 100644 --- a/ProwlCLI/Output/OutputRenderer+Workflow.swift +++ b/ProwlCLI/Output/OutputRenderer+Workflow.swift @@ -23,8 +23,8 @@ extension OutputRenderer { print(workflowListText(list)) case .run(let run), .status(let run), .cancel(let run): print(workflowRunText(run)) - case .done(let done): - print(workflowDoneText(done)) + case .deliver(let deliver): + print(workflowDeliverText(deliver)) case .validate(let validate): print(workflowValidateText(validate)) case .schema(let schema): @@ -95,9 +95,9 @@ extension OutputRenderer { lines.append(parts.joined(separator: " ")) } } - if !payload.outputs.isEmpty { - lines.append("Outputs:") - for (name, output) in payload.outputs.sorted(by: { $0.key < $1.key }) { + if !payload.deliveries.isEmpty { + lines.append("Deliveries:") + for (name, output) in payload.deliveries.sorted(by: { $0.key < $1.key }) { let verdict = output.verdict.map { " verdict \($0)" } ?? "" lines.append(" \(name.bold) \(output.latestPath.dim)\(verdict)") } @@ -109,7 +109,7 @@ extension OutputRenderer { return lines.joined(separator: "\n") } - static func workflowDoneText(_ payload: WorkflowDonePayload) -> String { + static func workflowDeliverText(_ payload: WorkflowDeliverPayload) -> String { let delivery = payload.delivery var lines: [String] = [] switch delivery.state { @@ -140,7 +140,7 @@ extension OutputRenderer { case "skipped": let detail = [status.step, status.dependent].compactMap { $0 }.joined(separator: " → ") return "skipped".yellow + (detail.isEmpty ? "" : " (\(detail))") - case "cancelled", "interrupted", "max_rounds_reached": + case "cancelled", "interrupted", "iteration_limit_reached": return status.state.replacing("_", with: " ").red default: return status.state } diff --git a/ProwlCLIContracts/Resources/action-definition-schema.json b/ProwlCLIContracts/Resources/action-definition-schema.json index b1789feef..2af6f0b6c 100644 --- a/ProwlCLIContracts/Resources/action-definition-schema.json +++ b/ProwlCLIContracts/Resources/action-definition-schema.json @@ -71,7 +71,7 @@ "type": "string" } }, - "environment": { + "inherit_env": { "type": "array", "items": { "type": "string", diff --git a/ProwlCLIContracts/Resources/cli-output-schema.json b/ProwlCLIContracts/Resources/cli-output-schema.json index b94969dac..c97d59a17 100644 --- a/ProwlCLIContracts/Resources/cli-output-schema.json +++ b/ProwlCLIContracts/Resources/cli-output-schema.json @@ -3830,7 +3830,7 @@ "$ref": "#/$defs/workflowStatusData" }, { - "$ref": "#/$defs/workflowDoneData" + "$ref": "#/$defs/workflowDeliverData" }, { "$ref": "#/$defs/workflowCancelData" @@ -3981,7 +3981,7 @@ "worktree", "run_directory", "bindings", - "outputs", + "deliveries", "started_at", "updated_at" ], @@ -4032,7 +4032,7 @@ "activation": { "$ref": "#/$defs/workflowActivation" }, - "outputs": { + "deliveries": { "type": "object", "additionalProperties": { "$ref": "#/$defs/workflowOutput" @@ -4068,7 +4068,7 @@ "worktree", "run_directory", "bindings", - "outputs", + "deliveries", "started_at", "updated_at" ], @@ -4119,7 +4119,7 @@ "activation": { "$ref": "#/$defs/workflowActivation" }, - "outputs": { + "deliveries": { "type": "object", "additionalProperties": { "$ref": "#/$defs/workflowOutput" @@ -4155,7 +4155,7 @@ "worktree", "run_directory", "bindings", - "outputs", + "deliveries", "started_at", "updated_at" ], @@ -4206,7 +4206,7 @@ "activation": { "$ref": "#/$defs/workflowActivation" }, - "outputs": { + "deliveries": { "type": "object", "additionalProperties": { "$ref": "#/$defs/workflowOutput" @@ -4229,7 +4229,7 @@ } } }, - "workflowDoneData": { + "workflowDeliverData": { "type": "object", "additionalProperties": false, "required": [ @@ -4239,7 +4239,7 @@ ], "properties": { "action": { - "const": "done" + "const": "deliver" }, "run": { "$ref": "#/$defs/workflowRun" @@ -4261,7 +4261,7 @@ "worktree", "run_directory", "bindings", - "outputs", + "deliveries", "started_at", "updated_at" ], @@ -4309,7 +4309,7 @@ "activation": { "$ref": "#/$defs/workflowActivation" }, - "outputs": { + "deliveries": { "type": "object", "additionalProperties": { "$ref": "#/$defs/workflowOutput" @@ -4346,7 +4346,7 @@ "completed", "cancelled", "skipped", - "max_rounds_reached", + "iteration_limit_reached", "interrupted" ] }, @@ -4577,7 +4577,7 @@ "type": "string" } }, - "verdict": { + "verdicts": { "type": "array", "items": { "type": "string" diff --git a/ProwlCLIContracts/Resources/workflow-definition-schema.json b/ProwlCLIContracts/Resources/workflow-definition-schema.json index a3371cc7d..22792ec92 100644 --- a/ProwlCLIContracts/Resources/workflow-definition-schema.json +++ b/ProwlCLIContracts/Resources/workflow-definition-schema.json @@ -393,7 +393,7 @@ }, "action": { "type": "string", - "pattern": "^(builtin:git\\.context|local:[a-z0-9][a-z0-9_-]{0,63})$" + "pattern": "^(builtin:collect-worktree-context|local:(?=[a-z0-9-]{1,64}$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$" }, "with": { "type": "object", @@ -446,7 +446,7 @@ "type": "object", "additionalProperties": false, "properties": { - "output": { + "delivery": { "$ref": "#/$defs/slug" }, "format": { @@ -463,7 +463,7 @@ "minLength": 1 } }, - "verdict": { + "verdicts": { "type": "array", "minItems": 2, "maxItems": 4, diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index ad73b69f5..729dd84cd 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -1285,7 +1285,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertTrue(text.stdout.contains("1 warning(s)"), text.stdout) } - func testWorkflowDoneAcceptsSixteenMiBWithJSONEscaping() throws { + func testWorkflowDeliverAcceptsSixteenMiBWithJSONEscaping() throws { let file = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) defer { try? FileManager.default.removeItem(at: file) } let body = Data(repeating: 10, count: WorkflowSizeLimits.payload) @@ -1295,7 +1295,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { error: .init(code: "STEP_NOT_EXPECTING", message: "No assigned task.")) let (request, result) = try runWithMockServer( socketPath: temporarySocketPath(suffix: "workflow-large-done"), response: response, - args: ["workflow", "done", "--file", file.path, "--json"]) + args: ["workflow", "deliver", "--file", file.path, "--json"]) XCTAssertEqual(result.exitCode, 1) XCTAssertGreaterThan(request.count, 32 * 1024 * 1024) let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: request) @@ -1307,7 +1307,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { let runID = UUID().uuidString let payload = WorkflowCommandPayload.read(WorkflowContentPayload( run: runID, invocation: 3, role: "author", step: "brief", resource: "resource-1", - body: "AA==", encoding: "base64", resources: [.init(id: "resource-1", name: "outputs/brief.md")], + body: "AA==", encoding: "base64", resources: [.init(id: "resource-1", name: "deliveries/brief.md")], offset: 4, nextOffset: 5, totalBytes: 8)) let response = try CommandResponse( ok: true, command: "workflow", schemaVersion: "prowl.cli.workflow.v1", data: RawJSON(encoding: payload)) @@ -1332,9 +1332,9 @@ final class ProwlCLIIntegrationTests: XCTestCase { } func testWorkflowRunAndDoneRoundTripThroughTheSocket() throws { - let output = WorkflowOutputPayload( - name: "brief", ordinal: 1, path: "/Projects/App/.prowl/workflow-runs/R/outputs/brief.1.md", - latestPath: "/Projects/App/.prowl/workflow-runs/R/outputs/brief.md", verdict: nil, + let output = WorkflowDeliveryRecordPayload( + name: "brief", ordinal: 1, path: "/Projects/App/.prowl/workflow-runs/R/deliveries/brief.1.md", + latestPath: "/Projects/App/.prowl/workflow-runs/R/deliveries/brief.md", verdict: nil, deliveredAt: "2026-08-30T01:02:03.000Z") let run = WorkflowRunPayload( id: "0BADCAFE-0000-4000-8000-000000000042", @@ -1357,17 +1357,17 @@ final class ProwlCLIIntegrationTests: XCTestCase { activation: WorkflowActivationPayload( ordinal: 1, step: "brief", role: "author", state: "waiting", dispatchID: "d-1", output: "brief", expect: WorkflowExpectationPayload( - format: .markdown, sections: ["## Scope"], verdict: nil, strict: false, - completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"]), + format: .markdown, sections: ["## Scope"], verdicts: nil, strict: false, + completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow deliver -"]), deadline: nil), - outputs: [:], + deliveries: [:], startedAt: "2026-08-30T01:00:00.000Z", updatedAt: "2026-08-30T01:00:00.000Z", finishedAt: nil, selfInitiated: WorkflowSelfInitiatedPayload( - line: "[Prowl] Read /Projects/App/.prowl/workflow-runs/R/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=T prowl workflow done -", + line: "[Prowl] Read /Projects/App/.prowl/workflow-runs/R/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=T prowl workflow deliver -", instructionPath: "/Projects/App/.prowl/workflow-runs/R/instructions/brief.1.md", - completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"])) + completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow deliver -"])) let runResponse = try CommandResponse( ok: true, command: "workflow", schemaVersion: "prowl.cli.workflow.v1", data: RawJSON(encoding: WorkflowCommandPayload.run(run))) @@ -1405,8 +1405,8 @@ final class ProwlCLIIntegrationTests: XCTestCase { let doneResponse = try CommandResponse( ok: true, command: "workflow", schemaVersion: "prowl.cli.workflow.v1", data: RawJSON( - encoding: WorkflowCommandPayload.done( - WorkflowDonePayload( + encoding: WorkflowCommandPayload.deliver( + WorkflowDeliverPayload( run: run, delivery: WorkflowDeliveryPayload( state: .provisional, ordinal: 1, step: "brief", role: "author", output: output, @@ -1414,13 +1414,13 @@ final class ProwlCLIIntegrationTests: XCTestCase { )))) let (doneRequest, doneResult) = try runWithMockServer( socketPath: temporarySocketPath(suffix: "workflow-done"), response: doneResponse, - args: ["workflow", "done", "-", "--verdict", "clean", "--json"], + args: ["workflow", "deliver", "-", "--verdict", "clean", "--json"], stdinData: Data("## Scope\nOnly the scope.\n".utf8), environment: [WorkflowSchema.tokenEnvironmentKey: "T"]) XCTAssertEqual(doneResult.exitCode, 0, doneResult.stderr) let doneEnvelope = try JSONDecoder().decode(CommandEnvelope.self, from: doneRequest) guard case .workflow(let doneInput) = doneEnvelope.command else { return XCTFail("Expected a workflow envelope") } - XCTAssertEqual(doneInput.action, .done) + XCTAssertEqual(doneInput.action, .deliver) XCTAssertEqual(doneInput.body, "## Scope\nOnly the scope.\n") XCTAssertEqual(doneInput.verdict, "clean") XCTAssertEqual(doneInput.token, "T", "the token comes from the environment the step handed out") @@ -1428,12 +1428,12 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertFalse(doneInput.force) let doneText = try runWithMockServer( socketPath: temporarySocketPath(suffix: "workflow-done-text"), response: doneResponse, - args: ["workflow", "done", "-", "--no-color"], stdinData: Data("x".utf8)).1 + args: ["workflow", "deliver", "-", "--no-color"], stdinData: Data("x".utf8)).1 XCTAssertEqual(doneText.exitCode, 0, doneText.stderr) XCTAssertTrue(doneText.stdout.contains("Provisional"), doneText.stdout) XCTAssertTrue(doneText.stdout.contains("missing_sections"), doneText.stdout) - let noStdin = try runProwl(args: ["workflow", "done", "-"], environment: [ProwlSocket.environmentKey: "/nonexistent.sock"]) + let noStdin = try runProwl(args: ["workflow", "deliver", "-"], environment: [ProwlSocket.environmentKey: "/nonexistent.sock"]) XCTAssertNotEqual(noStdin.exitCode, 0) } diff --git a/ProwlCLITests/WorkflowActionContractTests.swift b/ProwlCLITests/WorkflowActionContractTests.swift index db630f3af..1d2b23ae8 100644 --- a/ProwlCLITests/WorkflowActionContractTests.swift +++ b/ProwlCLITests/WorkflowActionContractTests.swift @@ -27,7 +27,7 @@ struct WorkflowActionContractTests { @Test func scriptEnvironmentDisablesBytecodeAndKeepsOnlyAllowedValues() throws { let source = yaml.replacing("entrypoint: main.py", with: - "entrypoint: main.py\n environment: [SELECTED_VALUE, PYTHONDONTWRITEBYTECODE]") + "entrypoint: main.py\n inherit_env: [SELECTED_VALUE, PYTHONDONTWRITEBYTECODE]") let action = try WorkflowScriptAction.parse(source, id: "count") let environment = WorkflowPreparedBundle.environment(for: action, inherited: [ "PATH": "/usr/bin:/bin", "SELECTED_VALUE": "included", "UNSELECTED_VALUE": "excluded", diff --git a/ProwlCLITests/WorkflowBundleValidatorTests.swift b/ProwlCLITests/WorkflowBundleValidatorTests.swift index e79481f04..e5a4f83b6 100644 --- a/ProwlCLITests/WorkflowBundleValidatorTests.swift +++ b/ProwlCLITests/WorkflowBundleValidatorTests.swift @@ -44,7 +44,7 @@ struct WorkflowBundleValidatorTests { - id: branch \(control) - id: snapshot - action: builtin:git.context + action: builtin:collect-worktree-context - id: consume notify: '{{ actions.snapshot.output.path }}' """) @@ -58,7 +58,7 @@ struct WorkflowBundleValidatorTests { if: 'true' then: - id: snapshot - action: builtin:git.context + action: builtin:collect-worktree-context else: - id: consume notify: '{{ actions.snapshot.output.path }}' @@ -68,7 +68,7 @@ struct WorkflowBundleValidatorTests { @Test func outerOutputsRemainAvailableInsideNestedLoops() { #expect(codes(""" - id: snapshot - action: builtin:git.context + action: builtin:collect-worktree-context - id: outer while: 'true' max_iterations: 2 @@ -124,12 +124,12 @@ struct WorkflowBundleValidatorTests { } @Test func executionContextOnlyExistsForActionInputs() { - #expect(codes(" - id: invalid\n notify: '{{ context.execution.id }}'") == ["unknown_variable"]) - #expect(codes(" - id: snapshot\n action: builtin:git.context\n with: {root: '{{ context.execution.cwd }}'}").isEmpty) + #expect(codes(" - id: invalid\n notify: '{{ context.action.execution_id }}'") == ["unknown_variable"]) + #expect(codes(" - id: snapshot\n action: builtin:collect-worktree-context\n with: {root: '{{ context.action.working_directory }}'}").isEmpty) } @Test func builtinInputTypesAndRemovedActionsAreRejected() { - #expect(codes(" - id: snapshot\n action: builtin:git.context\n with: {root: 3}") == ["action_input_type"]) + #expect(codes(" - id: snapshot\n action: builtin:collect-worktree-context\n with: {root: 3}") == ["action_input_type"]) #expect(codes(" - id: old\n action: handoff.checkpoint") == ["unknown_action"]) } @Test func aBranchCannotOverwriteAnOuterOutputBinding() { @@ -137,16 +137,16 @@ struct WorkflowBundleValidatorTests { - id: outer message: author text: Write. - expect: {output: report} + expect: {delivery: report} - id: branch if: 'true' then: - id: inner message: author text: Write again. - expect: {output: report} + expect: {delivery: report} - id: after - notify: '{{ outputs.report.path }}' + notify: '{{ deliveries.report.path }}' """, state: "roles: {author: {source: current}}") #expect(diagnostics.contains("output_shadowing")) } diff --git a/ProwlCLITests/WorkflowCommandParsingTests.swift b/ProwlCLITests/WorkflowCommandParsingTests.swift index 281d4ee28..a49a605c7 100644 --- a/ProwlCLITests/WorkflowCommandParsingTests.swift +++ b/ProwlCLITests/WorkflowCommandParsingTests.swift @@ -8,7 +8,7 @@ final class WorkflowCommandParsingTests: XCTestCase { XCTAssertTrue(try ProwlCommand.parseAsRoot(["workflow", "list"]) is WorkflowListCommand) XCTAssertTrue(try ProwlCommand.parseAsRoot(["workflow", "run", "demo"]) is WorkflowRunCommand) XCTAssertTrue(try ProwlCommand.parseAsRoot(["workflow", "status"]) is WorkflowStatusCommand) - XCTAssertTrue(try ProwlCommand.parseAsRoot(["workflow", "done", "-"]) is WorkflowDoneCommand) + XCTAssertTrue(try ProwlCommand.parseAsRoot(["workflow", "deliver", "-"]) is WorkflowDeliverCommand) XCTAssertTrue( try ProwlCommand.parseAsRoot(["workflow", "cancel", "00000000-0000-0000-0000-000000000000"]) is WorkflowCancelCommand) @@ -48,35 +48,35 @@ final class WorkflowCommandParsingTests: XCTestCase { } func testDoneParsesItsDeliveryOptions() throws { - let done = try XCTUnwrap( + let deliver = try XCTUnwrap( ProwlCommand.parseAsRoot([ - "workflow", "done", "-", "--verdict", "clean", "--token", "T", "--run", + "workflow", "deliver", "-", "--verdict", "clean", "--token", "T", "--run", "0BADCAFE-0000-4000-8000-000000000042", "--step", "review", "--force", - ]) as? WorkflowDoneCommand) - XCTAssertEqual(done.input, "-") - XCTAssertEqual(done.verdict, "clean") - XCTAssertEqual(done.token, "T") - XCTAssertEqual(done.runID, "0BADCAFE-0000-4000-8000-000000000042") - XCTAssertEqual(done.step, "review") - XCTAssertTrue(done.force) + ]) as? WorkflowDeliverCommand) + XCTAssertEqual(deliver.input, "-") + XCTAssertEqual(deliver.verdict, "clean") + XCTAssertEqual(deliver.token, "T") + XCTAssertEqual(deliver.runID, "0BADCAFE-0000-4000-8000-000000000042") + XCTAssertEqual(deliver.step, "review") + XCTAssertTrue(deliver.force) let file = try XCTUnwrap( - ProwlCommand.parseAsRoot(["workflow", "done", "--file", "/tmp/out.md"]) - as? WorkflowDoneCommand) + ProwlCommand.parseAsRoot(["workflow", "deliver", "--file", "/tmp/out.md"]) + as? WorkflowDeliverCommand) XCTAssertEqual(file.file, "/tmp/out.md") XCTAssertNil(file.input) } func testDoneRejectsMissingOrDoubledBodiesAndHalfManualTargets() { - XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "done"])) + XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "deliver"])) XCTAssertThrowsError( - try ProwlCommand.parseAsRoot(["workflow", "done", "-", "--file", "/tmp/out.md"])) - XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "done", "out.md"])) - XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "done", "-", "--run", "id"])) - XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "done", "-", "--step", "s"])) - XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "done", "-", "--force"])) + try ProwlCommand.parseAsRoot(["workflow", "deliver", "-", "--file", "/tmp/out.md"])) + XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "deliver", "out.md"])) + XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "deliver", "-", "--run", "id"])) + XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "deliver", "-", "--step", "s"])) + XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "deliver", "-", "--force"])) XCTAssertNoThrow( try ProwlCommand.parseAsRoot([ - "workflow", "done", "-", "--run", "id", "--step", "s", "--force", + "workflow", "deliver", "-", "--run", "id", "--step", "s", "--force", ])) } @@ -94,19 +94,19 @@ final class WorkflowCommandParsingTests: XCTestCase { XCTAssertEqual(run.skip, ["brief"]) } - func testWorkflowDoneEnvelopeCarriesTheBodyAndToken() throws { + func testWorkflowDeliverEnvelopeCarriesTheBodyAndToken() throws { let envelope = CommandEnvelope( output: .json, command: .workflow( WorkflowInput( - action: .done, runID: "r", stepID: "s", body: "# Out\n", verdict: "clean", token: "T", + action: .deliver, runID: "r", stepID: "s", body: "# Out\n", verdict: "clean", token: "T", force: true))) let decoded = try JSONDecoder().decode( CommandEnvelope.self, from: try JSONEncoder().encode(envelope)) guard case .workflow(let input) = decoded.command else { return XCTFail("Expected a workflow envelope") } - XCTAssertEqual(input.action, .done) + XCTAssertEqual(input.action, .deliver) XCTAssertEqual(input.body, "# Out\n") XCTAssertEqual(input.token, "T") XCTAssertEqual(input.runID, "r") diff --git a/ProwlCLITests/WorkflowContentTests.swift b/ProwlCLITests/WorkflowContentTests.swift index 185932b6a..9e8f9a368 100644 --- a/ProwlCLITests/WorkflowContentTests.swift +++ b/ProwlCLITests/WorkflowContentTests.swift @@ -6,8 +6,8 @@ import Testing struct WorkflowContentTests { @Test func taskReferencesGrantOnlyExplicitKnownResources() throws { let root = URL(filePath: "/history/run") - let granted = root.appending(path: "outputs/review.1.md") - let other = root.appending(path: "outputs/private.2.md") + let granted = root.appending(path: "deliveries/review.1.md") + let other = root.appending(path: "deliveries/private.2.md") let content = WorkflowTaskContent.make( text: "Read \(granted.path)", task: (UUID(), 1), runDirectory: root, knownPaths: [granted.path, other.path, "/etc/passwd"], skill: nil) diff --git a/ProwlCLITests/WorkflowDocumentParserTests.swift b/ProwlCLITests/WorkflowDocumentParserTests.swift index f7301cba2..c7e5a752c 100644 --- a/ProwlCLITests/WorkflowDocumentParserTests.swift +++ b/ProwlCLITests/WorkflowDocumentParserTests.swift @@ -7,8 +7,8 @@ final class WorkflowDocumentParserTests: XCTestCase { func testExpectStrictParsesAndDefaultsToFalse() throws { let yaml = WorkflowFixtures.minimal( - extraSteps: " - id: a\n message: author\n text: x\n expect: { output: a, strict: true }\n" - + " - id: b\n message: author\n text: y\n expect: { output: b }") + extraSteps: " - id: a\n message: author\n text: x\n expect: { delivery: a, strict: true }\n" + + " - id: b\n message: author\n text: y\n expect: { delivery: b }") let workflow = try WorkflowFixtures.parse(yaml) XCTAssertEqual(workflow.steps[1].action.expect?.strict, true) XCTAssertEqual(workflow.steps[2].action.expect?.strict, false) @@ -61,18 +61,18 @@ final class WorkflowDocumentParserTests: XCTestCase { } XCTAssertEqual(role, "author") XCTAssertTrue(instruction.hasPrefix("Write a short brief")) - XCTAssertEqual(briefExpect?.output, "brief") + XCTAssertEqual(briefExpect?.delivery, "brief") XCTAssertEqual(briefExpect?.sections, ["## Scope", "## Claims"]) XCTAssertEqual(briefExpect?.timeoutSeconds, 600) XCTAssertEqual(briefExpect?.format, .markdown) - XCTAssertNil(briefExpect?.verdict) - XCTAssertEqual(workflow.steps[0].outputName, "brief") + XCTAssertNil(briefExpect?.verdicts) + XCTAssertEqual(workflow.steps[0].deliveryName, "brief") guard case .launch("reviewer", let prompt, "prowl.adversarial-reviewer", let launchExpect) = workflow.steps[1].action else { return XCTFail("launch should target reviewer with a skill") } - XCTAssertTrue(prompt.contains("{{ outputs.brief.path }}")) - XCTAssertEqual(launchExpect?.verdict, ["clean", "issues"]) + XCTAssertTrue(prompt.contains("{{ deliveries.brief.path }}")) + XCTAssertEqual(launchExpect?.verdicts, ["clean", "issues"]) XCTAssertEqual(launchExpect?.timeoutSeconds, 1800) guard case .control(.loop(let condition, let maximum, let body)) = workflow.steps[3].action else { @@ -81,7 +81,7 @@ final class WorkflowDocumentParserTests: XCTestCase { XCTAssertEqual(condition, "state.verdict != 'clean'") XCTAssertEqual(maximum, 10) XCTAssertEqual(body.map(\.id), ["fix", "rereview", "retain"]) - guard case .action("builtin:git.context", let inputs) = workflow.steps[4].action else { + guard case .action("builtin:collect-worktree-context", let inputs) = workflow.steps[4].action else { return XCTFail("context should be a built-in action") } XCTAssertEqual(inputs, ["root": "{{ context.worktree.path }}"]) @@ -105,7 +105,7 @@ final class WorkflowDocumentParserTests: XCTestCase { steps: - id: a notify: hi - expect: { output: x } + expect: { delivery: x } """ let diagnostics = WorkflowDocumentParser.parse(yaml).diagnostics XCTAssertEqual(diagnostics.map(\.code), ["unknown_key", "expect_not_allowed"]) @@ -180,7 +180,7 @@ final class WorkflowDocumentParserTests: XCTestCase { func testExpectIsRejectedOnActionNotifyAndClose() { for verb in ["action: git.context", "notify: hi", "close: author"] { - let yaml = WorkflowFixtures.minimal(extraSteps: " - id: b\n \(verb)\n expect: { output: o }") + let yaml = WorkflowFixtures.minimal(extraSteps: " - id: b\n \(verb)\n expect: { delivery: o }") XCTAssertEqual(WorkflowFixtures.parseCodes(yaml), ["expect_not_allowed"], verb) } } diff --git a/ProwlCLITests/WorkflowFixtures.swift b/ProwlCLITests/WorkflowFixtures.swift index b735ee59a..3beba8fe2 100644 --- a/ProwlCLITests/WorkflowFixtures.swift +++ b/ProwlCLITests/WorkflowFixtures.swift @@ -43,19 +43,19 @@ enum WorkflowFixtures { instruction: | Write a short brief for an adversarial reviewer: ## Scope, ## Claims, ## How to verify. Deliver it with the generated completion command. - expect: { output: brief, sections: ["## Scope", "## Claims"], timeout: 10m } + expect: { delivery: brief, sections: ["## Scope", "## Claims"], timeout: 10m } - id: launch title: "Reviewer starting round 1" launch: reviewer - prompt: "Read {{ outputs.brief.path }} and the bundled reviewer skill, then review. Focus: {{ inputs.focus }}" + prompt: "Read {{ deliveries.brief.path }} and the bundled reviewer skill, then review. Focus: {{ inputs.focus }}" skill: prowl.adversarial-reviewer - expect: { output: findings, sections: ["## Findings", "## Verdict"], verdict: [clean, issues], timeout: 30m } + expect: { delivery: findings, sections: ["## Findings", "## Verdict"], verdicts: [clean, issues], timeout: 30m } - id: remember set: - verdict: outputs.findings.verdict - findings_path: outputs.findings.path + verdict: deliveries.findings.verdict + findings_path: deliveries.findings.path - id: rounds while: state.verdict != 'clean' @@ -65,18 +65,18 @@ enum WorkflowFixtures { title: "Round {{ context.step.iteration }}: author addressing findings" message: author text: "Findings: {{ state.findings_path }}. Fix or rebut each item." - expect: { output: disposition, timeout: 30m } + expect: { delivery: disposition, timeout: 30m } - id: rereview message: reviewer - text: "Disposition: {{ outputs.disposition.path }}. Re-review." - expect: { output: round_findings, verdict: [clean, issues], timeout: 30m } + text: "Disposition: {{ deliveries.disposition.path }}. Re-review." + expect: { delivery: round_findings, verdicts: [clean, issues], timeout: 30m } - id: retain set: - verdict: outputs.round_findings.verdict - findings_path: outputs.round_findings.path + verdict: deliveries.round_findings.verdict + findings_path: deliveries.round_findings.path - id: context - action: builtin:git.context + action: builtin:collect-worktree-context with: { root: "{{ context.worktree.path }}" } - id: done diff --git a/ProwlCLITests/WorkflowNamingTests.swift b/ProwlCLITests/WorkflowNamingTests.swift new file mode 100644 index 000000000..ce0f06998 --- /dev/null +++ b/ProwlCLITests/WorkflowNamingTests.swift @@ -0,0 +1,132 @@ +import Testing +@testable import ProwlCLIShared +@testable import prowl + +struct WorkflowNamingTests { + @Test func optionalExpressionsStillValidateNamespaceSpelling() throws { + for (expression, accepted) in [ + ("context.run.directory ?? 'missing'", false), + ("exists(outputs.findings.path)", false), + ("exists(context.source) && context.source.pane_id != ''", false), + ("!exists(context.execution) || context.execution.id == ''", false), + ("context['run']['workflow_id'] ?? 'missing'", false), + ("context.roles['author']['pane'] ?? ''", false), + ("context.run.path ?? ''", true), + ("exists(deliveries.findings.path)", true), + ("exists(context.initiator) && context.initiator.pane_id != ''", true), + ] { + let definition = WorkflowDefinition( + id: "naming", name: "Naming", roles: [.init(name: "author", source: .current)], + steps: [.init(id: "condition", action: .control(.conditional(condition: expression, then: [], else: [])))]) + let diagnostics = WorkflowValidator.validate(definition, context: .init(scope: .user)) + #expect(diagnostics.contains { $0.code == "unknown_variable" } != accepted) + } + } + + @Test func verdictDiagnosticsNameTheDeclarationKey() throws { + for values in ["[clean]", "[clean, clean]"] { + let parsed = WorkflowDocumentParser.parse(""" + schema: prowl.workflow/v1 + id: naming + name: Naming + roles: {author: {source: current}} + steps: [{id: report, message: author, text: Review., expect: {delivery: report, verdicts: \(values)}}] + """) + let definition = try #require(parsed.definition) + let diagnostics = WorkflowValidator.validate(definition, context: .init(scope: .user)) + #expect(diagnostics.contains { $0.message.contains("'verdicts'") }) + } + } + + @Test func localActionIDsUseKebabCase() { + let yaml = """ + schema: prowl.action/v1 + name: Write report + input_schema: {type: object} + output_schema: {type: object} + backend: {type: script, interpreter: /bin/sh, entrypoint: main.sh} + """ + #expect(throws: (any Error).self) { try WorkflowScriptAction.parse(yaml, id: "write_report") } + } + + @Test func retiredExpectationKeysAreRejected() { + for fields in ["output: brief", "verdict: [ready, blocked]"] { + let parsed = WorkflowDocumentParser.parse(""" + schema: prowl.workflow/v1 + id: naming + name: Naming + roles: {author: {source: current}} + steps: [{id: brief, message: author, text: Write., expect: {\(fields)}}] + """) + #expect(parsed.definition == nil) + #expect(parsed.diagnostics.contains { $0.code == "unknown_key" }) + } + } + + @Test func currentNamespacesValidateAndOldNamespacesDoNot() throws { + for (reference, accepted) in [ + ("context.workflow.id", true), ("context.workflow.name", true), ("context.run.path", true), + ("context.initiator.pane_id", true), ("context.roles.author.display_name", true), + ("context.roles.author.pane_id", true), ("context.run.workflow_id", false), + ("context.run.directory", false), ("context.source.pane_id", false), + ("context.roles.author.name", false), ("context.roles.author.pane", false), + ] { + let parsed = WorkflowDocumentParser.parse(""" + schema: prowl.workflow/v1 + id: naming + name: Naming + roles: {author: {source: current}} + steps: [{id: report, notify: '{{ \(reference) }}'}] + """) + let definition = try #require(parsed.definition) + let diagnostics = WorkflowValidator.validate(definition, context: .init(scope: .user)) + #expect(diagnostics.contains { $0.code == "unknown_variable" } != accepted) + } + } + + @Test func inheritedEnvironmentHasAnExplicitName() throws { + let source = """ + schema: prowl.action/v1 + name: Read configuration + input_schema: {type: object} + output_schema: {type: object} + backend: + type: script + interpreter: /bin/sh + entrypoint: main.sh + inherit_env: [SELECTED_VALUE] + """ + _ = try WorkflowScriptAction.parse(source, id: "read-configuration") + #expect(throws: (any Error).self) { + try WorkflowScriptAction.parse(source.replacing("inherit_env", with: "environment"), id: "read-configuration") + } + } + + @Test func acceptsDeliveryAndPluralVerdictDeclaration() { + let parsed = WorkflowDocumentParser.parse(""" + schema: prowl.workflow/v1 + id: naming + name: Naming + roles: {author: {source: current}} + steps: + - id: brief + message: author + text: Write a briefing. + expect: {delivery: briefing, verdicts: [ready, blocked]} + """) + #expect(parsed.diagnostics.isEmpty) + #expect(parsed.definition != nil) + } + + @Test func routesDeliverAndRejectsRetiredCommand() throws { + _ = try ProwlCommand.parseAsRoot(["workflow", "deliver", "-"]) + #expect(throws: (any Error).self) { + _ = try ProwlCommand.parseAsRoot(["workflow", "done", "-"]) + } + } + + @Test func collectorUsesVerbFirstName() { + #expect(WorkflowActionRegistry.schema(for: "builtin:collect-worktree-context") != nil) + #expect(WorkflowActionRegistry.schema(for: "builtin:git.context") == nil) + } +} diff --git a/ProwlCLITests/WorkflowSchemaTests.swift b/ProwlCLITests/WorkflowSchemaTests.swift index 1353d7c81..6baf3e79c 100644 --- a/ProwlCLITests/WorkflowSchemaTests.swift +++ b/ProwlCLITests/WorkflowSchemaTests.swift @@ -30,7 +30,7 @@ final class WorkflowSchemaTests: XCTestCase { let cancel = ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"cancel",\###(Self.runFields)}}"### let done = - ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"done","run":{\###(Self.runFields)},"delivery":{"state":"provisional","ordinal":1,"step":"brief","role":"author","output":\###(Self.output),"warnings":[{"code":"missing_sections","message":"missing ## Claims"}]}}}"### + ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"deliver","run":{\###(Self.runFields)},"delivery":{"state":"provisional","ordinal":1,"step":"brief","role":"author","output":\###(Self.output),"warnings":[{"code":"missing_sections","message":"missing ## Claims"}]}}}"### let read = #"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"read","run":"0BADCAFE-0000-4000-8000-000000000042","invocation":1,"role":"author","step":"brief","resource":"instruction","body":"Read","encoding":"utf-8","resources":[],"offset":0,"next_offset":4,"total_bytes":8}}"# for instance in [list, listWithoutWorktree, validate, schema, error, run, status, cancel, done, read] { @@ -40,7 +40,7 @@ final class WorkflowSchemaTests: XCTestCase { func testOutputSchemaRejectsMalformedRuntimePayloads() throws { let badDeliveryState = - ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"done","run":{\###(Self.runFields)},"delivery":{"state":"accepted","ordinal":1,"step":"brief","role":"author","output":\###(Self.output),"warnings":[]}}}"### + ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"deliver","run":{\###(Self.runFields)},"delivery":{"state":"accepted","ordinal":1,"step":"brief","role":"author","output":\###(Self.output),"warnings":[]}}}"### let badBindingSource = ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"run",\###(Self.runFields.replacingOccurrences(of: #""source":"current""#, with: #""source":"remote""#))}}"### let badState = @@ -73,8 +73,8 @@ final class WorkflowSchemaTests: XCTestCase { func testRuntimePayloadsRoundTripThroughCodable() throws { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] - let output = WorkflowOutputPayload( - name: "brief", ordinal: 1, path: "/r/outputs/brief.1.md", latestPath: "/r/outputs/brief.md", verdict: nil, + let output = WorkflowDeliveryRecordPayload( + name: "brief", ordinal: 1, path: "/r/deliveries/brief.1.md", latestPath: "/r/deliveries/brief.md", verdict: nil, deliveredAt: "2026-08-30T01:02:03Z") let run = WorkflowRunPayload( id: "0BADCAFE-0000-4000-8000-000000000042", @@ -104,19 +104,19 @@ final class WorkflowSchemaTests: XCTestCase { activation: WorkflowActivationPayload( ordinal: 1, step: "brief", role: "author", state: "provisional", dispatchID: "dispatch-1", output: "brief", expect: WorkflowExpectationPayload( - format: .markdown, sections: ["## Scope"], verdict: nil, strict: false, - completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"]), + format: .markdown, sections: ["## Scope"], verdicts: nil, strict: false, + completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow deliver -"]), deadline: nil), - outputs: ["brief": output], + deliveries: ["brief": output], startedAt: "2026-08-30T01:00:00Z", updatedAt: "2026-08-30T01:02:03Z", finishedAt: nil, selfInitiated: WorkflowSelfInitiatedPayload( - line: "[Prowl] Read /r/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=T prowl workflow done -", + line: "[Prowl] Read /r/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=T prowl workflow deliver -", instructionPath: "/r/instructions/brief.1.md", - completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"])) - let done = WorkflowCommandPayload.done( - WorkflowDonePayload( + completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow deliver -"])) + let done = WorkflowCommandPayload.deliver( + WorkflowDeliverPayload( run: run, delivery: WorkflowDeliveryPayload( state: .provisional, ordinal: 1, step: "brief", role: "author", output: output, @@ -132,13 +132,13 @@ final class WorkflowSchemaTests: XCTestCase { } private static let output = - #"{"name":"brief","ordinal":1,"path":"/r/outputs/brief.1.md","latest_path":"/r/outputs/brief.md","delivered_at":"2026-08-30T01:02:03Z"}"# + #"{"name":"brief","ordinal":1,"path":"/r/deliveries/brief.1.md","latest_path":"/r/deliveries/brief.md","delivered_at":"2026-08-30T01:02:03Z"}"# private static let runFields = - ###""id":"0BADCAFE-0000-4000-8000-000000000042","workflow":{"id":"prowl.adversarial-review","name":"Adversarial Review"},"scope":"repo","definition_path":"/Projects/App/.prowl/workflows/review.yaml","source":"live","status":{"state":"running"},"step":"brief","role":"author","worktree":{"id":"wt","name":"feature","branch":"feat/x","path":"/Projects/App"},"run_directory":"/Projects/App/.prowl/workflow-runs/0BADCAFE-0000-4000-8000-000000000042","bindings":{"author":{"source":"current","pane":{"id":"00000000-0000-0000-0000-000000000001","tab_id":"00000000-0000-0000-0000-000000000011","handle":"p1","display_name":"Claude Code","agent":"claude"}},"reviewer":{"source":"launch","profile":{"id":"00000000-0000-0000-0000-000000000009","name":"Pi Reviewer","agent":"pi"}}},"activation":{"ordinal":1,"step":"brief","role":"author","state":"waiting","dispatch_id":"dispatch-1","output":"brief","expect":{"format":"markdown","sections":["## Scope","## Claims"],"strict":false,"completion":["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"]},"deadline":"2026-08-30T01:10:00Z"},"outputs":{},"started_at":"2026-08-30T01:00:00Z","updated_at":"2026-08-30T01:00:00Z","self_initiated":{"line":"[Prowl] Read /r/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=T prowl workflow done -","instruction_path":"/r/instructions/brief.1.md","completion":["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"]}"### + ###""id":"0BADCAFE-0000-4000-8000-000000000042","workflow":{"id":"prowl.adversarial-review","name":"Adversarial Review"},"scope":"repo","definition_path":"/Projects/App/.prowl/workflows/review.yaml","source":"live","status":{"state":"running"},"step":"brief","role":"author","worktree":{"id":"wt","name":"feature","branch":"feat/x","path":"/Projects/App"},"run_directory":"/Projects/App/.prowl/workflow-runs/0BADCAFE-0000-4000-8000-000000000042","bindings":{"author":{"source":"current","pane":{"id":"00000000-0000-0000-0000-000000000001","tab_id":"00000000-0000-0000-0000-000000000011","handle":"p1","display_name":"Claude Code","agent":"claude"}},"reviewer":{"source":"launch","profile":{"id":"00000000-0000-0000-0000-000000000009","name":"Pi Reviewer","agent":"pi"}}},"activation":{"ordinal":1,"step":"brief","role":"author","state":"waiting","dispatch_id":"dispatch-1","output":"brief","expect":{"format":"markdown","sections":["## Scope","## Claims"],"strict":false,"completion":["PROWL_WORKFLOW_TOKEN=T prowl workflow deliver -"]},"deadline":"2026-08-30T01:10:00Z"},"deliveries":{},"started_at":"2026-08-30T01:00:00Z","updated_at":"2026-08-30T01:00:00Z","self_initiated":{"line":"[Prowl] Read /r/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=T prowl workflow deliver -","instruction_path":"/r/instructions/brief.1.md","completion":["PROWL_WORKFLOW_TOKEN=T prowl workflow deliver -"]}"### private static let recordFields = - ###""id":"0BADCAFE-0000-4000-8000-000000000042","workflow":{"id":"prowl.handoff","name":"Hand Off"},"scope":"bundle","source":"record","status":{"state":"interrupted"},"worktree":{"id":"wt","name":"feature","branch":"feat/x","path":"/Projects/App"},"run_directory":"/Projects/App/.prowl/workflow-runs/0BADCAFE-0000-4000-8000-000000000042","bindings":{"source":{"source":"current","pane":{"id":"00000000-0000-0000-0000-000000000001","handle":"p1","display_name":"shell"}}},"outputs":{"brief":\###(WorkflowSchemaTests.output)},"started_at":"2026-08-30T01:00:00Z","updated_at":"2026-08-30T01:05:00Z","finished_at":"2026-08-30T01:05:00Z""### + ###""id":"0BADCAFE-0000-4000-8000-000000000042","workflow":{"id":"prowl.handoff","name":"Hand Off"},"scope":"bundle","source":"record","status":{"state":"interrupted"},"worktree":{"id":"wt","name":"feature","branch":"feat/x","path":"/Projects/App"},"run_directory":"/Projects/App/.prowl/workflow-runs/0BADCAFE-0000-4000-8000-000000000042","bindings":{"source":{"source":"current","pane":{"id":"00000000-0000-0000-0000-000000000001","handle":"p1","display_name":"shell"}}},"deliveries":{"brief":\###(WorkflowSchemaTests.output)},"started_at":"2026-08-30T01:00:00Z","updated_at":"2026-08-30T01:05:00Z","finished_at":"2026-08-30T01:05:00Z""### func testOutputSchemaRejectsUnknownFieldsBadScopesAndCrossActionFields() throws { let unknownField = diff --git a/ProwlCLITests/WorkflowValidatorTests.swift b/ProwlCLITests/WorkflowValidatorTests.swift index 8fbb0dc25..9f4ecc7d4 100644 --- a/ProwlCLITests/WorkflowValidatorTests.swift +++ b/ProwlCLITests/WorkflowValidatorTests.swift @@ -49,7 +49,7 @@ final class WorkflowValidatorTests: XCTestCase { WorkflowFixtures.codes(minimal(steps: " - id: Fix It\n notify: hi")), ["step_id_slug"]) XCTAssertEqual( WorkflowFixtures.codes( - minimal(steps: " - id: b\n message: author\n text: hi\n expect: { output: Bad.Name }")), + minimal(steps: " - id: b\n message: author\n text: hi\n expect: { delivery: Bad.Name }")), ["output_name_slug"]) XCTAssertEqual( WorkflowFixtures.codes(minimal() + "inputs:\n Max: { type: integer }\n"), ["input_name_slug"]) @@ -75,7 +75,7 @@ final class WorkflowValidatorTests: XCTestCase { ["message_before_launch"]) let twice = " - id: l1\n launch: r\n prompt: go\n - id: l2\n launch: r\n prompt: again" XCTAssertEqual(WorkflowFixtures.codes(minimal(steps: twice, roles: role)), ["launch_twice"]) - let ordered = " - id: l1\n launch: r\n prompt: go\n - id: m\n message: r\n text: \"pane {{ context.roles.r.pane }}\"" + let ordered = " - id: l1\n launch: r\n prompt: go\n - id: m\n message: r\n text: \"pane {{ context.roles.r.pane_id }}\"" XCTAssertEqual(WorkflowFixtures.codes(minimal(steps: ordered, roles: role)), []) } @@ -117,13 +117,13 @@ final class WorkflowValidatorTests: XCTestCase { func codes(_ text: String, roles: String = "") -> [String] { WorkflowFixtures.codes(minimal(steps: " - id: b\n notify: \"\(text)\"", roles: roles)) } - XCTAssertEqual(codes("{{ context.run.id }} {{ context.run.directory }} {{ context.worktree.path }} {{ context.worktree.branch }}"), []) - XCTAssertEqual(codes("{{ context.roles.author.name }} {{ context.roles.author.agent }} {{ context.roles.author.pane }}"), []) + XCTAssertEqual(codes("{{ context.run.id }} {{ context.run.path }} {{ context.worktree.path }} {{ context.worktree.branch }}"), []) + XCTAssertEqual(codes("{{ context.roles.author.display_name }} {{ context.roles.author.agent }} {{ context.roles.author.pane_id }}"), []) XCTAssertEqual(codes("{{ nope.x }}"), ["unknown_variable"]) XCTAssertEqual(codes("{{ context.worktree.owner }}"), ["unknown_variable"]) XCTAssertEqual(codes("{{ inputs.missing }}"), ["unknown_variable"]) - XCTAssertEqual(codes("{{ outputs.brief.path }}"), ["unknown_variable"], "no producer yet") - XCTAssertEqual(codes("{{ context.roles.r.pane }}", roles: role), [], "unlaunched pane is explicitly null") + XCTAssertEqual(codes("{{ deliveries.brief.path }}"), ["unknown_variable"], "no producer yet") + XCTAssertEqual(codes("{{ context.roles.r.pane_id }}", roles: role), [], "unlaunched pane is explicitly null") XCTAssertEqual(codes("{{ context.step.iteration }}"), [], "outside a loop iteration is explicitly null") XCTAssertEqual(codes("{{ loop.count }}"), ["unknown_variable"], "before any loop") XCTAssertEqual(codes("{{ open"), ["template_syntax"]) @@ -135,13 +135,13 @@ final class WorkflowValidatorTests: XCTestCase { - id: b message: author text: hi - expect: { output: brief } + expect: { delivery: brief } - id: ctx - action: builtin:git.context + action: builtin:collect-worktree-context - id: n - notify: "{{ outputs.brief.path }} {{ actions.ctx.output.path }} {{ actions.ctx.output.branch }}" + notify: "{{ deliveries.brief.path }} {{ actions.ctx.output.path }} {{ actions.ctx.output.branch }}" - id: v - notify: "{{ outputs.brief.verdict }}" + notify: "{{ deliveries.brief.verdict }}" - id: k notify: "{{ actions.ctx.nope }}" """ @@ -154,7 +154,7 @@ final class WorkflowValidatorTests: XCTestCase { while: "true" steps: - id: ctx - action: builtin:git.context + action: builtin:collect-worktree-context - id: inside notify: "{{ actions.ctx.output.path }} round {{ context.step.iteration }}" - id: after @@ -168,7 +168,7 @@ final class WorkflowValidatorTests: XCTestCase { func testActionInputsFollowTheRegistry() { XCTAssertEqual(WorkflowFixtures.codes(minimal(steps: " - id: b\n action: fs.delete")), ["unknown_action"]) XCTAssertEqual( - WorkflowFixtures.codes(minimal(steps: " - id: b\n action: builtin:git.context\n with: { depth: 3 }")), + WorkflowFixtures.codes(minimal(steps: " - id: b\n action: builtin:collect-worktree-context\n with: { depth: 3 }")), ["unknown_action_input"]) XCTAssertEqual( WorkflowFixtures.codes(minimal(steps: " - id: b\n action: handoff.transition\n with: { from: author }")), @@ -182,7 +182,7 @@ final class WorkflowValidatorTests: XCTestCase { func testVerdictRules() { func expect(_ verdict: String) -> String { - minimal(steps: " - id: b\n message: author\n text: hi\n expect: { verdict: \(verdict) }") + minimal(steps: " - id: b\n message: author\n text: hi\n expect: { verdicts: \(verdict) }") } XCTAssertEqual(WorkflowFixtures.codes(expect("[clean]")), ["verdict_count"]) XCTAssertEqual(WorkflowFixtures.codes(expect("[a, b, c, d, e]")), ["verdict_count"]) @@ -203,7 +203,7 @@ final class WorkflowValidatorTests: XCTestCase { func testWarnings() { let long = minimal(steps: " - id: b\n message: author\n text: hi\n expect: { timeout: 3h }") XCTAssertEqual(WorkflowFixtures.codes(long), ["timeout_long"]) - let spelled = minimal(steps: " - id: b\n message: author\n text: \"finish with prowl workflow done -\"") + let spelled = minimal(steps: " - id: b\n message: author\n text: \"finish with prowl workflow deliver -\"") XCTAssertEqual(WorkflowFixtures.codes(spelled), ["spells_completion_command"]) XCTAssertEqual(WorkflowFixtures.diagnostics(spelled).first?.severity, .warning) } @@ -213,19 +213,19 @@ final class WorkflowValidatorTests: XCTestCase { - id: b message: author text: hi - expect: { output: brief, timeout: 5m, on_timeout: skip } + expect: { delivery: brief, timeout: 5m, on_timeout: skip } - id: n - notify: "{{ outputs.brief.path }}" + notify: "{{ deliveries.brief.path }}" """ XCTAssertEqual(WorkflowFixtures.codes(minimal(steps: blocking)), ["skip_ends_run"]) let optional = """ - id: b message: author text: hi - expect: { output: brief, timeout: 5m, on_timeout: skip } + expect: { delivery: brief, timeout: 5m, on_timeout: skip } - id: t - action: builtin:git.context - with: { root: "{{ outputs.brief.path ?? context.worktree.path }}" } + action: builtin:collect-worktree-context + with: { root: "{{ deliveries.brief.path ?? context.worktree.path }}" } """ XCTAssertEqual(WorkflowFixtures.codes(minimal(steps: optional)), []) } @@ -256,26 +256,26 @@ final class WorkflowValidatorTests: XCTestCase { - id: first message: author text: First - expect: { output: result, verdict: [clean, issues] } + expect: { delivery: result, verdicts: [clean, issues] } - id: second message: author text: Second - expect: { output: result } + expect: { delivery: result } - id: report - notify: "{{ outputs.result.verdict }}" + notify: "{{ deliveries.result.verdict }}" """ XCTAssertEqual(WorkflowFixtures.codes(minimal(steps: stale)), ["unknown_variable"]) let refreshed = """ - id: first message: author text: First - expect: { output: result } + expect: { delivery: result } - id: second message: author text: Second - expect: { output: result, verdict: [clean, issues] } + expect: { delivery: result, verdicts: [clean, issues] } - id: report - notify: "{{ outputs.result.verdict }}" + notify: "{{ deliveries.result.verdict }}" """ XCTAssertEqual(WorkflowFixtures.codes(minimal(steps: refreshed)), []) } @@ -312,13 +312,13 @@ final class WorkflowValidatorTests: XCTestCase { - id: first message: author text: First - expect: { output: brief } + expect: { delivery: brief } - id: use - notify: "{{ outputs.brief.path }}" + notify: "{{ deliveries.brief.path }}" - id: second message: author text: Second - expect: { output: brief, timeout: 5m, on_timeout: skip } + expect: { delivery: brief, timeout: 5m, on_timeout: skip } """ XCTAssertEqual(WorkflowFixtures.codes(minimal(steps: consumedBefore)), [], "nothing after the skip depends on it") } diff --git a/Resources/workflows/repository-context.pwlworkflow/workflow.yaml b/Resources/workflows/repository-context.pwlworkflow/workflow.yaml index 2c6ef3429..dfdfa72dd 100644 --- a/Resources/workflows/repository-context.pwlworkflow/workflow.yaml +++ b/Resources/workflows/repository-context.pwlworkflow/workflow.yaml @@ -5,6 +5,6 @@ description: Save the selected worktree's branch, status, and diff summary in a icon: doc.text.magnifyingglass steps: - id: snapshot - action: builtin:git.context + action: builtin:collect-worktree-context - id: done notify: 'Repository context saved: {{ actions.snapshot.output.path }}' diff --git a/docs-ai/013-prowl-cli/contracts/workflow.md b/docs-ai/013-prowl-cli/contracts/workflow.md index a2b79cb16..0843a2d68 100644 --- a/docs-ai/013-prowl-cli/contracts/workflow.md +++ b/docs-ai/013-prowl-cli/contracts/workflow.md @@ -3,10 +3,10 @@ ## Status Current version: `prowl.cli.workflow.v1` (docs-ai 063 B1 for `list` / `validate` / `schema`, -063 B3 for `run` / `status` / `done` / `cancel`). +063 B3 for `run` / `status` / `deliver` / `cancel`). `workflow` is the surface of Agent Workflows: it discovers, validates, and describes -`prowl.workflow/v1` YAML files, and runs them. `list`, `run`, `status`, `done`, and `cancel` +`prowl.workflow/v1` workflow bundles, and runs them. `list`, `run`, `status`, `deliver`, and `cancel` cross the socket; `validate` and `schema` are **local-only** and work with Prowl closed. Every response uses `command: "workflow"` and one closed `data` object discriminated by `action`. The run protocol itself (roles, activations, tokens, the two-phase delivery) is specified in @@ -20,16 +20,16 @@ prowl workflow list [target] [--target|--worktree|--tab|--pane ] [--js prowl workflow run [source] [--role =]... [--input =]... [--skip ]... [--json] prowl workflow read [resource-id] --run --invocation [--offset ] [--json] prowl workflow status [run-id] [--json] -prowl workflow done (-|--file ) [--verdict ] [--token ] [--run --step ] [--force] [--json] +prowl workflow deliver (-|--file ) [--verdict ] [--token ] [--run --step ] [--force] [--json] prowl workflow cancel [--json] prowl workflow validate [--scope bundle|user|repo] [--json] prowl workflow schema [--json] ``` -Wire request: `command: "workflow"` with `action` (`list` | `run` | `status` | `done` | +Wire request: `command: "workflow"` with `action` (`list` | `run` | `status` | `deliver` | `cancel` | `read`), `target` (060 selector), and the action's fields — `workflow`, `roleBindings[]`, -`inputValues[]`, `skippedSteps[]` (`run`); `runID` (`status`, `cancel`, `done`); `stepID`, -`body`, `verdict`, `token`, `force` (`done`). The CLI reads the `done` body itself (stdin or +`inputValues[]`, `skippedSteps[]` (`run`); `runID` (`status`, `cancel`, `deliver`); `stepID`, +`body`, `verdict`, `token`, `force` (`deliver`). The CLI reads the `deliver` body itself (stdin or `--file`, UTF-8, at most 16 MiB → `OUTPUT_TOO_LARGE` client-side) and fills `token` from `--token` or `$PROWL_WORKFLOW_TOKEN`. @@ -66,15 +66,14 @@ before a socket request. Reading never completes a task or delivers an output. | Scope | Directory | Notes | | --- | --- | --- | -| `bundle` | `Prowl.app/Contents/Resources/workflows/` | ids `prowl.*` are reserved for this source; absent until the first built-in ships | +| `bundle` | `Prowl.app/Contents/Resources/workflows/` | ids `prowl.*` are reserved for this source; includes Repository Context | | `user` | `~/.prowl/workflows/` | | | `repo` | `/.prowl/workflows/` | resolved per worktree | -Files with extension `.yaml` or `.yml` directly inside a source directory are read in -file-name order; hidden files and other extensions are ignored. A **valid** file (parses and -validates without errors) shadows valid files with the same id in lower-precedence sources -(`repo` > `user` > `bundle`) and later files in the same source. Invalid files never shadow -and are never shadowed; a file that does not parse is listed without an `id`. +Directories ending in `.pwlworkflow` contain `workflow.yaml` and optional local actions, +helpers, and schemas. Discovery reads bundles in file-name order; loose YAML is not a workflow. +Valid bundles shadow valid bundles by ID (`repo` > `user` > `bundle`). Invalid bundles remain +visible with diagnostics and do not shadow valid definitions. ### `list` worktree resolution @@ -123,7 +122,7 @@ be valid (`WORKFLOW_INVALID`, `details` = the validate payload) and enabled activation record exists (or its opening failed and the run sits in attention), so the returned completion command is attributable the moment the caller runs it; nothing was typed. -### `done` attribution (decision W3) +### `deliver` attribution (decision W3) 1. The caller pane (socket peer ancestry) and its pending dispatch record identify the activation; the machine then checks the token (`TOKEN_REQUIRED`, `TOKEN_INVALID`) and the @@ -140,22 +139,21 @@ The response is sent only after the output reached the run directory (decision W or skip that lands while it is being written answers `STEP_NOT_EXPECTING`, a write failure `WORKFLOW_FAILED`; a client that disconnects first sees `REQUEST_CANCELLED` while the run continues. `agents dispatch-complete` from a pane that owes a workflow delivery is refused with -`WORKFLOW_DELIVERY_REQUIRED` whose message carries the replacement `done` command. +`WORKFLOW_DELIVERY_REQUIRED` whose message carries the replacement `deliver` command. ### `status` (decision W5) Without a run id the calling pane must belong to an active run (`SOURCE_REQUIRED` outside a pane, `RUN_NOT_FOUND` otherwise). With one, a live run is reported from the app; a run the app -no longer holds is read from its `run.json` in any known worktree (`source: "record"`); neither +no longer holds is read from its `run.json` in personal workflow history (`source: "record"`); neither is `RUN_NOT_FOUND`. Runs an earlier app instance left `running` / `needs_attention` are marked -`interrupted` when their worktree loads; nothing is resumed. +`interrupted` when unoccupied runs are recovered; nothing is resumed. ### `validate` scope -`--scope` decides whether a `prowl.*` id is allowed. When omitted it is inferred from the -file's directory: `~/.prowl/workflows` → `user`, any other `…/.prowl/workflows` → `repo`, -anything else → `user`. The path must be an existing file (`PATH_NOT_FOUND`; a directory is -`INVALID_ARGUMENT`). +`--scope` decides whether a `prowl.*` ID is allowed. When omitted, the bundle location +selects user or repository scope. The path must be an existing `.pwlworkflow` directory +containing `workflow.yaml`; a loose YAML file is not accepted. ### Bundle resolution for skills @@ -224,7 +222,7 @@ valid; the app-side `list` always has the bundle. "step": "brief", "role": "author", "worktree": { "id": "…", "name": "feature", "branch": "feat/x", "path": "/Projects/App" }, - "run_directory": "/Projects/App/.prowl/workflow-runs/0BADCAFE-0000-4000-8000-000000000042", + "run_directory": "/Users/example/.prowl/logs/workflow-runs/App-/2026-08/0BADCAFE-0000-4000-8000-000000000042", "bindings": { "author": { "source": "current", "pane": { "id": "…", "tab_id": "…", "handle": "p1", "display_name": "Claude Code", "agent": "claude" } }, "reviewer": { "source": "launch", "profile": { "id": "…", "name": "Codex", "agent": "codex" } } @@ -232,16 +230,16 @@ valid; the app-side `list` always has the bundle. "activation": { "ordinal": 1, "step": "brief", "role": "author", "state": "waiting", "dispatch_id": "…", "output": "brief", "expect": { "format": "markdown", "sections": ["## Scope", "## Claims"], "strict": false, - "completion": ["PROWL_WORKFLOW_TOKEN=… prowl workflow done -"] }, + "completion": ["PROWL_WORKFLOW_TOKEN=… prowl workflow deliver -"] }, "deadline": "2026-08-30T01:10:00.000Z" }, - "outputs": {}, + "deliveries": {}, "started_at": "2026-08-30T01:00:00.000Z", "updated_at": "2026-08-30T01:00:00.000Z", "self_initiated": { - "line": "[Prowl] Read …/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=… prowl workflow done -", + "line": "[Prowl] Read …/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=… prowl workflow deliver -", "instruction_path": "…/instructions/brief.1.md", - "completion": ["PROWL_WORKFLOW_TOKEN=… prowl workflow done -"] + "completion": ["PROWL_WORKFLOW_TOKEN=… prowl workflow deliver -"] } } } @@ -250,7 +248,7 @@ valid; the app-side `list` always has the bundle. - `source` is `live` (the app holds the run) or `record` (read from `run.json`; then `activation` and `self_initiated` are absent and no token is spelled anywhere). - `status.state` is `running` | `needs_attention` | `completed` | `cancelled` | `skipped` - (with `step` and `dependent`) | `max_rounds_reached` | `interrupted`; `status.attention` + (with `step` and `dependent`) | `iteration_limit_reached` | `interrupted`; `status.attention` carries `reason` (`needs_input`, `idle_without_delivery`, `blocked`, `agent_gone:`, `injection_failed:`, `launch_failed`, `rendered_text_invalid`, `action_failed`, `persist_failed`, `delivery_issues`, `timeout`), `message`, `step`, `role`, `ordinal`, @@ -258,18 +256,18 @@ valid; the app-side `list` always has the bundle. - `step` is the step in progress; absent once the run ended. `role` is the *verified* calling pane's role when it is bound in the run; only when that pane owns the current activation does `activation.expect.completion` spell the completion commands (they carry the token) — a - worktree-started run, a manual or forced `done`, and any other role's pane get an empty list. `activation` is the activation - waiting for, persisting, or holding a provisional delivery — the one `done` can address; a + worktree-started run, a manual or forced `deliver`, and any other role's pane get an empty list. `activation` is the activation + waiting for, persisting, or holding a provisional delivery — the one `deliver` can address; a step stuck in an injection or launch attention reports none. - `bindings..profile` is the frozen profile (id, name, agent) of a `launch` role; `bindings..pane` is the role's pane (`launch` roles gain it once launched). -- `outputs` is the latest delivered output per name (`name`, `ordinal`, `path`, +- `deliveries` is the latest delivered output per name (`name`, `ordinal`, `path`, `latest_path`, `verdict`, `delivered_at`). - `self_initiated` appears on `run` only, when the run started from the pane that is its `current` role and the first step messages that role: the runner typed nothing. - `cancel` returns the run after cancellation (`status.state: "cancelled"`). -### `done` +### `deliver` ```json { @@ -277,12 +275,12 @@ valid; the app-side `list` always has the bundle. "command": "workflow", "schema_version": "prowl.cli.workflow.v1", "data": { - "action": "done", + "action": "deliver", "run": { "…": "the run object above, after the delivery" }, "delivery": { "state": "provisional", "ordinal": 1, "step": "brief", "role": "author", - "output": { "name": "brief", "ordinal": 1, "path": "…/outputs/brief.1.md", "latest_path": "…/outputs/brief.md", "delivered_at": "…" }, + "output": { "name": "brief", "ordinal": 1, "path": "…/deliveries/brief.1.md", "latest_path": "…/deliveries/brief.md", "delivered_at": "…" }, "warnings": [{ "code": "missing_sections", "message": "missing section(s) ## Claims" }] } } @@ -343,15 +341,15 @@ schema is printed alone, pretty-printed. | `WORKFLOW_INVALID` | `validate` found at least one error, or `run` named a definition with errors. `details` carries the full validate payload. Exit status 1. | | `WORKFLOW_NOT_FOUND` / `WORKFLOW_DISABLED` | `run`: no unshadowed definition with that id or unique name; or it is switched off. | | `WORKFLOW_FAILED` | A source directory could not be read, the run directory could not be created, a profile could not be planned, an accepted output could not be saved, or a payload could not be encoded. | -| `SOURCE_REQUIRED` | `run` of a workflow with a `current` role outside a pane (or with a worktree target); `status` without a run id and `done` without `--run --step` outside a pane. | +| `SOURCE_REQUIRED` | `run` of a workflow with a `current` role outside a pane (or with a worktree target); `status` without a run id and `deliver` without `--run --step` outside a pane. | | `PANE_BUSY` / `DISPATCH_PENDING` / `AGENT_NOT_FOUND` / `PROFILE_NOT_FOUND` / `PROFILE_NOT_UNIQUE` / `UNSAFE_PATH` | `run` preflight, see above. | -| `RUN_NOT_FOUND` | `cancel` / manual `done` of a run that is not live; `status ` of a run neither live nor recorded; `status` from a pane outside any active run. | -| `STEP_NOT_EXPECTING` / `TOKEN_REQUIRED` / `TOKEN_INVALID` / `ROLE_MISMATCH` | `done` attribution, see above. | -| `OUTPUT_INVALID` / `OUTPUT_TOO_LARGE` / `VERDICT_REQUIRED` | `done` body validation (dsl-spec §5): empty body, above the cap, or a `strict` step's requirements. | +| `RUN_NOT_FOUND` | `cancel` / manual `deliver` of a run that is not live; `status ` of a run neither live nor recorded; `status` from a pane outside any active run. | +| `STEP_NOT_EXPECTING` / `TOKEN_REQUIRED` / `TOKEN_INVALID` / `ROLE_MISMATCH` | `deliver` attribution, see above. | +| `OUTPUT_INVALID` / `OUTPUT_TOO_LARGE` / `VERDICT_REQUIRED` | `deliver` body validation (dsl-spec §5): empty body, above the cap, or a `strict` step's requirements. | | `WORKFLOW_DELIVERY_REQUIRED` | `agents dispatch-complete` from a pane whose pending record is a workflow activation. | -| `REQUEST_CANCELLED` / `REQUEST_CONFLICT` | The socket peer disconnected while `done` waited for persistence; an in-app request id collision (never expected). | +| `REQUEST_CANCELLED` / `REQUEST_CONFLICT` | The socket peer disconnected while `deliver` waited for persistence; an in-app request id collision (never expected). | | `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE` | Selector resolution (`list`, `run`). | -| `PATH_NOT_FOUND` / `INVALID_ARGUMENT` | `validate` path is missing or a directory; malformed `--role` / `--input` / `--skip`, conflicting selectors, a non-UUID run id, half a manual target. | +| `PATH_NOT_FOUND` / `INVALID_ARGUMENT` | `validate` path is missing or is not a workflow bundle; malformed `--role` / `--input` / `--skip`, conflicting selectors, a non-UUID run id, half a manual target. | | `APP_NOT_RUNNING` | Any socket action without a reachable app. Never raised by `validate` or `schema`. | ## Verification @@ -360,8 +358,8 @@ schema is printed alone, pretty-printed. `WorkflowDiscoveryTests`, `WorkflowSchemaTests` (output contract for every action + definition schema pinned to `WorkflowJSONSchema.definitionSchemaJSON`), `WorkflowCommandParsingTests`, `WorkflowCommandExecutorTests`, and the `workflow` cases in `ProwlCLIIntegrationTests` -(real `prowl` process for `validate`/`schema`, mock socket for `list` and `done`); +(real `prowl` process for `validate`/`schema`, mock socket for `list` and `deliver`); `supacodeTests/WorkflowCommandHandlerTests` (worktree / source resolution, enabled set), -`WorkflowRunAdmissionTests` (preflight), `WorkflowRuntimeCoordinatorTests` (`done` +`WorkflowRunAdmissionTests` (preflight), `WorkflowRuntimeCoordinatorTests` (`deliver` attribution, `status`, `cancel`), `WorkflowCLIRendezvousTests`, and `WorkflowRunsFeatureTests` -(the reducer: ordered effects, the two-phase `done` answer, late launches, restart scan). +(the reducer: ordered effects, the two-phase `deliver` answer, late launches, restart scan). diff --git a/docs-ai/063-agent-workflows/000-plan.md b/docs-ai/063-agent-workflows/000-plan.md index e6b0c5f2e..aa7669ee9 100644 --- a/docs-ai/063-agent-workflows/000-plan.md +++ b/docs-ai/063-agent-workflows/000-plan.md @@ -1,3 +1,8 @@ +> **Current naming (2026-09-07):** [019](019-workflow-naming.md) and the +> [DSL specification](dsl-spec.md) define the current authoring contract. Earlier +> slice descriptions below record design history; loose YAML, repeat/until, and +> dedicated handoff actions are superseded. D3 remains future work. + # 063 — Agent Workflows: Plan | | | @@ -65,7 +70,7 @@ contract governance of 060. - Surface runs in the toolbar's central status slot (`Adversarial Review · 3/6 · Round 2: reviewer re-checking`) with a popover for steps, role panes, and controls; keep every existing entry point (Agents capsule popover, Command Palette, Active Agents context menu). -- Let agents participate through the `prowl` CLI only (`prowl workflow done`), so every +- Let agents participate through the `prowl` CLI only (`prowl workflow deliver`), so every recognized runtime can play any interactive role, and keep the pure-CLI route (an agent orchestrating others by hand) first-class by shipping the missing primitives. - Ship two built-in workflows — `prowl.handoff` (replacing the current implementation) and @@ -97,7 +102,7 @@ contract governance of 060. | Role | A participant: `source: current` (the pane the run was started from; it must host a detected agent only if the runner will actually deliver a `message` to it — steps skipped at start via `--skip` / the start sheet do not count — so a bare shell can still be the source of a context-only handoff), `pick` (an existing detected agent pane in the same worktree, chosen at start), or `launch` (a new agent Prowl starts). V1 launch roles are interactive (TUI in a tab/split); `kind: headless` is reserved for V2 (see Alternatives). | | Binding | Role → concrete Agent Profile (or, for `pick`, an existing pane), resolved at start and frozen into the run. | | Step | One verb: `message` (say something to a live role), `launch` (start a launch role), `action` (built-in Swift action), `notify`, `close`; plus `repeat` blocks. Each step has a `title` for the status slot and an optional `expect`. | -| Expect | Only on `message` / `launch` steps: what must happen before the run advances — a named `output` delivered by the step's target role via the generated `prowl workflow done` command, optional `sections`/`format` validation, optional `verdict` enum (safe slugs), optional `timeout` / `on_timeout`. | +| Expect | Only on `message` / `launch` steps: what must happen before the run advances — a named `delivery` submitted by the step's target role via the generated `prowl workflow deliver` command, optional `sections`/`format` validation, optional `verdicts` list (safe slugs), optional `timeout` / `on_timeout`. | | Run | One execution: state snapshot + artifacts under `/.prowl/workflow-runs//`. | ### Execution model: Prowl runs, agents participate @@ -124,11 +129,11 @@ that advances one step at a time through existing terminal boundaries: rendered and injected (the injected text carries it); every `repeat` iteration and every Retry/Relaunch is a new invocation. One delivery per activation. The token is placed in the generated completion command - (`PROWL_WORKFLOW_TOKEN= prowl workflow done -`, the same env-prefix technique as + (`PROWL_WORKFLOW_TOKEN= prowl workflow deliver -`, the same env-prefix technique as today's `PROWL_HANDOFF_REQUEST_ID`; `--token ` is the explicit form). The entry - is claimable exactly once; a `done` that arrives without the token, with a revoked token + is claimable exactly once; a `deliver` that arrives without the token, with a revoked token (Skip / Cancel / Relaunch revoke), or from a pane other than the role's is rejected — so - a delayed or duplicated `done` from a pane that has since moved on to another step can + a delayed or duplicated `deliver` from a pane that has since moved on to another step can never be misattributed. Tokens are never written into YAML, and the **generated command is the only spelling agents ever see**: one completion-command renderer produces the initial hint, every nudge, and every re-delivery (token always present; for verdict @@ -136,7 +141,7 @@ that advances one step at a time through existing terminal boundaries: line, materialized instruction, and `prowl workflow status` — never a placeholder); built-ins and examples say "finish with the generated completion command"; the validator warns when - `text`/`instruction` spells out `prowl workflow done`. `expect` is valid only on + `text`/`instruction` spells out `prowl workflow deliver`. `expect` is valid only on `message` and `launch` (their target role delivers); native actions return typed outputs synchronously. Skipping a step whose expected output is referenced by a later template ends the run as `skipped` (the panel says which step depends on it) — V1 has @@ -144,7 +149,7 @@ that advances one step at a time through existing terminal boundaries: tolerated consumer is a `with` input declared optional by the action's schema: the key is simply absent, which is how skipping the brief turns `prowl.handoff` into a context-only transition (the old HUD's "Context Only" fallback, now a generic rule). -- `action` → a registry of native Swift actions (`handoff.transition`, `git.context`). +- `action` → native or bundle-local actions (`builtin:collect-worktree-context`, `local:`). - `notify`/`close` → the existing bell pipeline and protected close path. **Data channels.** Inbound to an agent is always *file + short pointer*: long @@ -152,7 +157,7 @@ that advances one step at a time through existing terminal boundaries: invocation ordinal — the DSL spec §§5/8 are normative for run-directory layout) and one line is typed (or passed as the kickoff prompt); short `text` is typed verbatim (single line). -Outbound is `prowl workflow done [--verdict v] -` (stdin): the caller pane identifies the +Outbound is `prowl workflow deliver [--verdict v] -` (stdin): the caller pane identifies the run/role, the delivery token identifies the awaited step — the YAML itself carries nothing machine-specific. Transcript observation (`agents read`) and headless adapter capture are V2 channels (see Alternatives). @@ -188,13 +193,13 @@ cancellation removes the subscriber, and `surfaceClosed` terminates the stream. subscriber receives an explicit `bufferOverflow` error instead of silently losing signal or lifecycle evidence; S2's `agents wait` re-subscribes and evaluates the newer snapshot before surfacing an error. `agents wait` maps `removed` / -`surfaceClosed` to a terminal `AGENT_GONE` error (not to `done`) unless `--until exit` +`surfaceClosed` to a terminal `AGENT_GONE` error (not to `deliver`) unless `--until exit` was requested. The runner's watchdog likewise reads the role's *current* state first and schedules cancellable grace deadlines on the injected clock; it never relies on a later event alone. **Data bus.** `/.prowl/workflow-runs//` holds `run.json`, `log.md`, -`instructions/` and `outputs/` (both versioned by the run-global invocation ordinal, latest +`instructions/` and `deliveries/` (both versioned by the run-global invocation ordinal, latest output view replaced atomically — layout normative in the DSL spec §8), `skills/` (materialized from the embedded skill registry only — `skill:` ids are safe slugs that must resolve to a bundled skill). @@ -234,20 +239,20 @@ the existing detection events (`agentEntryChanged` / `agentEntryRemoved`, produc periodic detection schedule) with grace periods, because detection is heuristic and a wrong guess must be harmless: a role `blocked` for ≥ `blocked_grace` (default 30 s) → `needsAttention` (Focus pane / Cancel); a role `idle`/`done` for ≥ `idle_grace` (default -3 min) without `done` → Prowl **auto-nudges once** (types `[Prowl] When your work for this +3 min) without `deliver` → Prowl **auto-nudges once** (types `[Prowl] When your work for this step is fully complete, finish with: `, harmless if the agent was in fact still working — the runtime just queues the line) and escalates to `needsAttention` (Nudge again / Keep waiting / Skip / Cancel) only after another `idle_grace`; the role's agent process disappearing → `needsAttention` (Relaunch role / Skip / Cancel). `needsAttention` is a UI -state, never a deadline: a late `done` is still accepted. Grace values are global settings +state, never a deadline: a late `deliver` is still accepted. Grace values are global settings (Settings › Workflows); an author may still add an explicit `expect.timeout` with `on_timeout: attention|skip|cancel` for hard caps. **Invariants** (carried from 047/053/#651): a pane belongs to at most one run at a time (`PANE_BUSY`); injection only into panes bound to the run; roles and their plans are frozen -at start; `done` is accepted only from the bound pane with a live delivery token, unless an +at start; `deliver` is accepted only from the bound pane with a live delivery token, unless an explicit `--run/--step` (manual, logged) or `--force` is given; attention states wait for a person, they never discard delivered outputs; cancel never closes a pane; Prowl-originated metadata (requests, payloads, `run.json`, logs) never carries extra arguments, environment @@ -324,7 +329,7 @@ writes. ### CLI (per 060's four-layer rule) `prowl workflow list | run [source] [--role r=…] [--input k=v] [--skip ] | status -[run] | done [-|--file] [--verdict v] [--token t] [--run --step] [--force] | cancel | +[run] | deliver [-|--file] [--verdict v] [--token t] [--run --step] [--force] | cancel | validate | schema` — `[source]` is 060's `GenericTarget` (`pN`, `tN`, UUID, worktree ref); omitted, the source is the caller pane when the workflow has a `current` role, and a worktree @@ -342,13 +347,19 @@ run …` replacement, then removal (see Built-ins). ### Built-ins and distribution +> The design below predates action bundles and is historical. The current distribution +> unit is a `.pwlworkflow` directory. D3 will compose general-purpose actions and agent +> steps; dedicated handoff actions are no longer planned. See [015](015-action-bundles-and-control-flow.md) +> and [019](019-workflow-naming.md) before implementing a built-in. + + - `Resources/workflows/*.yaml` are embedded like `docs/` (`Makefile` `embed-docs` pattern); `Resources/skills/` and the bundled-skill registry are owned by [065-bundled-agent-skills](../065-bundled-agent-skills/000-plan.md) (`embed-skills`, `ProwlSkills`); `skill:` references resolve through that registry and are materialized into the run directory so sandboxed agents can read them. - `prowl.adversarial-review`: interactive reviewer in a right split (transparency and user - trust outweigh headless precision), `repeat … until outputs.findings.verdict == clean` + trust outweigh headless precision), `repeat … until deliveries.findings.verdict == clean` with `max_rounds`. - `prowl.handoff`: `message source` (brief) → `action handoff.transition` (keeps the `.prowl/handoff/` artifact contract; outputs `kickoff_prompt`, `artifact_path`, @@ -364,7 +375,7 @@ run …` replacement, then removal (see Built-ins). stubs returning `HANDOFF_RETIRED` with the copy-pasteable replacement (`prowl workflow run prowl.handoff [--role receiver=…] [--skip brief]` / `prowl workflow run prowl.handoff-checkpoint`, briefing delivered with the returned - `prowl workflow done -`); afterwards the commands, `HandoffCommandHandler`, + `prowl workflow deliver -`); afterwards the commands, `HandoffCommandHandler`, `HandoffHudFeature`, `HandoffRequestRegistry`, and the `prowl.cli.handoff.v2` contract are deleted. A self-initiated run returns the first step's instruction and completion command in its response instead of typing them into the caller's own pane, so an agent's @@ -428,16 +439,16 @@ attaches hooks through A2's launch boundary. | --- | --- | --- | --- | | **C0** | C | — | Settings IA: `Section("Agents")` with **Profiles** (today's Agents page, renamed) and **Command Line Tool** (moved from Advanced); the Workflows page comes with D1. Independent, small; decides where everything lands. | | **A1** | A | 060 | `prowl create pane` (#699) + target-surface split primitive returning the surface id; CLI four layers. Foundation for every `launch` into a split. | -| **A1b** | A | A1 | `PROWL_PANE_ID` injected into every pane's environment (beside `PROWL_WORKTREE_PATH` / `PROWL_ROOT_PATH`), documented in `docs/components/cli.md`, and the `prowl-cli` skill's self-identification rewritten around it. Convenience identity only — trusted attribution (064 `agents signal`, `workflow done`) stays on caller-PID resolution. | +| **A1b** | A | A1 | `PROWL_PANE_ID` injected into every pane's environment (beside `PROWL_WORKTREE_PATH` / `PROWL_ROOT_PATH`), documented in `docs/components/cli.md`, and the `prowl-cli` skill's self-identification rewritten around it. Convenience identity only — trusted attribution (064 `agents signal`, `workflow deliver`) stays on caller-PID resolution. | | **A2** | A | A1 | Profile launch boundary (`.prompt`, placement override, anchor, background, synchronous `LaunchedSurface` result) + `prowl create tab/pane --profile --prompt -` + `prowl profiles list`; exposes the seam 064-S3 uses for launch-scoped hooks. Unlocks the CLI-driven route; the runner's `launch` boundary. | | **B1** | B | — | Definitions: Yams, `AgentWorkflow` model + validator + JSON Schema, three-source discovery, `prowl workflow list/validate/schema`. Makes the DSL concrete and authorable (no user-facing surface until R2). Lives in `ProwlCLIShared` so `validate`/`schema` run without the app; `list` goes through the socket and reads a hidden enabled set (`@Shared`, all enabled until D1's page). Record: [006](006-b1-definitions.md). | | **B2** | B | B1 | Runner core (pure): run state machine incl. `repeat`, run store, template renderer, action registry, watchdog with injected clock that consumes exact signals first (064-S5's watchdog part, moved here 2026-08-29) — tested against fake boundaries. Activations live in the shared dispatch store; there is no separate `WorkflowRequestRegistry` (decision 2026-08-29). | -| **B3** | B | A2, 064-S1, B2, #733 | Runner wiring: reducer-owned `WorkflowRunsFeature` effects, per-activation `observeAgentDispatch` + `observeAgentState` watchdog streams, CLI admission preflight, `prowl workflow run/status/done/cancel` + contracts. Engine first powered on. | +| **B3** | B | A2, 064-S1, B2, #733 | Runner wiring: reducer-owned `WorkflowRunsFeature` effects, per-activation `observeAgentDispatch` + `observeAgentState` watchdog streams, CLI admission preflight, `prowl workflow run/status/deliver/cancel` + contracts. Engine first powered on. | | **C1** | C | B3 | Status center fifth state + run panel + attention triggers + notifications (061 visual verification). Runs become visible. | | **C2** | C | B3 | Start sheet (bindings, suggestion-based profile creation, don't-ask-again, `--skip` equivalent) + entry points (capsule popover, palette, Active Agents context menu). GUI-initiated runs. | | **D1** | D | B1, C2, 065-K1 | `prowl-workflow` authoring skill (registered by adding it to `skills/`; embedding and the registry come from [065](../065-bundled-agent-skills/000-plan.md)), `docs/components/workflows.md`, Settings › Workflows page (enable/validate/Reveal/New/Ask-agent/per-workflow auto) added to the Agents group. Distribution and docs. | | **D2** | D | D3 acceptance / R2b shipped; A2, C2, D1, 064-S3 wave 1, #733, #726 T1 | `prowl.adversarial-review` built-in + reviewer skill + loop/verdict/watchdog E2E. Deferred to R3 after handoff validates the first built-in path. | -| **D3** | D | A2, C2, D1, 064-S3 wave 1, #733, #726 T1 | `prowl.handoff` + `prowl.handoff-checkpoint` built-ins + `handoff.transition`/`handoff.checkpoint` actions; `prowl handoff to\|save` → `HANDOFF_RETIRED` stubs; remove `HandoffHudFeature`, `HandoffCommandHandler`, `HandoffRequestRegistry`; rewrite `docs/components/handoff.md` and the `prowl-cli` skill. First built-in workflow and Debug E2E in R2b; release candidate after handoff/checkpoint acceptance. | +| **D3** | D | A2, C2, D1, 064-S3 wave 1, #733, #726 T1 | `prowl.handoff` + `prowl.handoff-checkpoint` built-in bundles composed from general-purpose actions and agent steps; `prowl handoff to\|save` → `HANDOFF_RETIRED` stubs; remove `HandoffHudFeature`, `HandoffCommandHandler`, `HandoffRequestRegistry`; rewrite `docs/components/handoff.md` and the `prowl-cli` skill. First built-in workflow and Debug E2E in R2b; release candidate after handoff/checkpoint acceptance. | | **V2** | — | — | observe mode (`expect.status` + `agents read` / hook `last_assistant_message`), `on_attention: ask `, fan-out (`count`, `wait all`), run persistence/resume, retention, cross-worktree roles, GUI editor. | ## Alternatives & decisions @@ -450,7 +461,7 @@ attaches hooks through A2's launch boundary. - **YAML (Yams) as the source of truth; Mermaid render-only.** Multi-line prompts are the bulk of a workflow; block scalars are essential. JSON remains valid input. Parsing Mermaid into stable orchestration semantics is fragile and was rejected. -- **`done`-first outbound channel, not transcript observation.** `prowl workflow done` is +- **`deliver`-first outbound channel, not transcript observation.** `prowl workflow deliver` is runtime-agnostic, validated, correlated by caller pane + delivery token, and proven by the inline brief. `agents read` covers only Claude/Codex and depends on intermittent session attribution; it becomes a V2 assist. @@ -472,9 +483,9 @@ attaches hooks through A2's launch boundary. `--verdict`, never prose. `max` is mandatory. `until` is evaluated **before entering and after every iteration** (while-loop semantics), so a first-round `clean` verdict skips the loop entirely. -- **Step completion is `prowl workflow done`, not `submit `.** Prowl knows which +- **Step completion is `prowl workflow deliver`, not `submit `.** Prowl knows which step awaits which pane, so the agent names nothing; output names live in YAML - (`expect.output`). + (`expect.delivery`). - **Run directory under the target root**, mirroring `.prowl/handoff/`: sandboxed agents read cwd-relative files most reliably; definitions live beside it in `/.prowl/workflows/` so a repo can ship its workflows. @@ -509,7 +520,7 @@ attaches hooks through A2's launch boundary. - **Completion signals split out as 064 (2026-08-22)**: the layered signal bus, `prowl agents signal`, launch-scoped hooks, and `prowl agents wait` with `source`/`confidence` are an independent entry. 063 V1 does not depend on it (steps - complete on `done`; the heuristic watchdog is harmless by design); 064-S1 delivers the + complete on `deliver`; the heuristic watchdog is harmless by design); 064-S1 delivers the `ObservedAgentState` observer that B3 consumes, 064-S3 builds on the launch boundary (A2), and in return 064 sharpens the watchdog and enables 063's V2 observe mode / `on_attention: ask `. @@ -517,7 +528,7 @@ attaches hooks through A2's launch boundary. Tool pages (see Design / UI); the CLI install leaves Advanced. - **No default wall-clock timeout; state-driven watchdog with grace periods** (see Design / Execution model). Detection is heuristic, so every trigger is designed to be harmless - when wrong: grace before acting, a nudge that only asks the agent to finish with `done` + when wrong: grace before acting, a nudge that only asks the agent to finish with `deliver` when it is truly complete, and attention states that never discard a late delivery. - **PR order / releases** (revised 2026-09-05): R1 CLI/signals and R2a workflow engine/CLI have shipped. R2b = C2, D1, T1, then D3 handoff/checkpoint migration and first built-in E2E; @@ -530,7 +541,7 @@ attaches hooks through A2's launch boundary. do not receive Prowl-managed hooks. - **Review round (2026-08-22)** — accepted corrections: runner as an `AppFeature` child fed by the single event subscription + a per-surface multicast observer for CLI waits; - opaque per-step delivery tokens for `done`; `LegacyHandoffAdapter` with a full parameter + opaque per-step delivery tokens for `deliver`; `LegacyHandoffAdapter` with a full parameter map instead of a "byte-compatible" claim; binding memory scoped by definition source + repository and re-validated; `pick` restricted to the source worktree; `kind: headless` moved to V2; output size caps, slug-safe ids, run-directory containment, and the @@ -557,10 +568,10 @@ attaches hooks through A2's launch boundary. bare-shell `current`, privacy phrasing, atomic observer registration, slug patterns). - **Review round 4 (2026-08-22; verified item by item before adopting)** — the renderer now emits one complete executable command per verdict value on every transport (no - placeholders); run-global monotonic activation ordinals make `outputs/..md` + placeholders); run-global monotonic activation ordinals make `deliveries/..md` and `instructions/..md` collision-free, with atomic "latest" replacement; native actions declare typed input/output schemas and `actions..` is validated - like `outputs.*` (known action, declared key, producer dominates consumer); `repeat.max` + like `deliveries.*` (known action, declared key, producer dominates consumer); `repeat.max` is a positive integer literal or exactly one integer-input template, resolved at start, bounded `1…20`; verdict values are unique safe slugs and `until` literals must be declared; optional fixes (`UNSAFE_PATH` listed, `tN` in the source grammar, concepts @@ -587,7 +598,7 @@ attaches hooks through A2's launch boundary. join the parity matrix; Retry revokes/re-mints a token only when the step has `expect`. - **Review round 8 (2026-08-22; verified before adopting)** — internal-only *seeded outputs* give a pre-delivered legacy brief a legal run-store identity (run-global - ordinal, `outputs/brief..md`, `seeded` record, no token/pane), preserving the + ordinal, `deliveries/brief..md`, `seeded` record, no token/pane), preserving the invalid-brief-before-any-artifact property; the destination-only binding is cross-referenced from the binding model, the `run` response, and `run.json`. - **Review round 9 (2026-08-22; verified before adopting, mechanism chosen differently)** — @@ -627,13 +638,13 @@ attaches hooks through A2's launch boundary. Relaunch is offered for `launch` roles only; a Skip resolves its §5 consequence immediately; binding resolution is a pure resolver (memory storage and the sheet stay with B3/C2). The spec's §4/§5/§8/§10 were clarified accordingly — see [007-b2-runner-core.md](007-b2-runner-core.md). -- Updated 2026-08-29 (B2, H14): `prowl workflow done` validation became a review gate — a +- Updated 2026-08-29 (B2, H14): `prowl workflow deliver` validation became a review gate — a delivery that misses `sections` / `format` / `verdict` is kept as provisional and the run asks the user (Accept / Accept with verdict / Ask again / Skip); `expect.strict: true` restores the hard rejection. Spec §5/§9/§10 amended; see [007-b2-runner-core.md](007-b2-runner-core.md). - Updated 2026-08-29 (B1 kickoff, grilled): the DSL spec was aligned with what R1 shipped. (1) `expect` activations are records in the shared dispatch store — `launch` via the S2 - prompted-launch path, `message` via #733's re-dispatch — and `workflow done` is the + prompted-launch path, `message` via #733's re-dispatch — and `workflow deliver` is the body-validating completion of that record; the per-activation token stays for correlation only, and the `WorkflowRequestRegistry` of the execution-model section is not built. (2) Model, validator, JSON Schema, and discovery live in `ProwlCLIShared`; `validate`/`schema` @@ -680,3 +691,5 @@ attaches hooks through A2's launch boundary. - Updated 2026-09-05: Added the process-scoped workflow UI release gate in [016](016-workflow-ui-release-gate.md); the CLI/runtime remain available and future workflow slices do not block this release. - Updated 2026-09-06: Personal workflow history and fixed retention — see [018-history-storage-plan.md](018-history-storage-plan.md). + +- Updated 2026-09-07: Normalize workflow naming before D3; no aliases or migration — see [019](019-workflow-naming.md). diff --git a/docs-ai/063-agent-workflows/004-pane-identity-env.md b/docs-ai/063-agent-workflows/004-pane-identity-env.md index 6472fd6fa..e0adeae07 100644 --- a/docs-ai/063-agent-workflows/004-pane-identity-env.md +++ b/docs-ai/063-agent-workflows/004-pane-identity-env.md @@ -13,7 +13,7 @@ The A1 review (PR #710) showed that the `prowl-cli` skill taught agents to find ## Decisions - One variable only. Tab and worktree identity are derivable from `prowl list --json` by `pane.id`; `PROWL_WORKTREE_PATH` already exists. -- Convenience identity, not attribution. The value is inherited and forgeable; `handoff` (and later `workflow done` / `agents signal`) keep resolving the calling pane from process ancestry. +- Convenience identity, not attribution. The value is inherited and forgeable; `handoff` (and later `workflow deliver` / `agents signal`) keep resolving the calling pane from process ancestry. - `create pane` keeps its explicit anchor (no caller-pane default); the recipe is `prowl create pane "$PROWL_PANE_ID" --direction right`. ## Verification diff --git a/docs-ai/063-agent-workflows/006-b1-definitions.md b/docs-ai/063-agent-workflows/006-b1-definitions.md index c475d96ef..318fc2c02 100644 --- a/docs-ai/063-agent-workflows/006-b1-definitions.md +++ b/docs-ai/063-agent-workflows/006-b1-definitions.md @@ -1,3 +1,7 @@ +> Historical slice record. For executable definitions and current names, use the +> [DSL specification](dsl-spec.md) and [019 naming contract](019-workflow-naming.md). +> The old substitution renderer and dedicated handoff actions described below have been removed. + # 063.006 — Workflow Definitions (B1): Plan ## Status @@ -13,7 +17,7 @@ R1 delivered the primitives the runner needs (A1/A2 launch boundary, 064-S1 obse dispatch receipts, S3 hooks on all eight tier-A runtimes, 065 bundled skills), but the DSL exists only as a spec. B1 makes it concrete: a file can be parsed, validated, and listed, and authoring agents get a machine-readable schema. Nothing runs yet — B2 (runner core) and B3 -(wiring, `run/status/done/cancel`) follow, with #733 re-dispatch landing before B3. +(wiring, `run/status/deliver/cancel`) follow, with #733 re-dispatch landing before B3. The spec was written before S2/S3 shipped. Re-reading it against `main` surfaced one structural overlap (the `expect` completion channel duplicated the dispatch model) and a few @@ -23,7 +27,7 @@ stale rules; those are settled here so B2/B3 do not inherit them. | # | Decision | Alternatives rejected | | --- | --- | --- | -| G1 | **Activation = dispatch.** A workflow activation is a record in `AgentDispatchStore`: `launch` steps create it through the S2 prompted-launch path, `message` steps through #733's re-dispatch into an existing surface. `prowl workflow done` resolves the caller pane to its current pending record (peer PID + ancestry, as `dispatch-complete`), requires the activation token to match (correlation only, never trust), validates the body, persists the output, completes the record. `agents wait --dispatch` works on activations. No `WorkflowRequestRegistry`. | A parallel registry + token protocol as first drafted (two "one pending task per pane" mechanisms with drifting semantics). | +| G1 | **Activation = dispatch.** A workflow activation is a record in `AgentDispatchStore`: `launch` steps create it through the S2 prompted-launch path, `message` steps through #733's re-dispatch into an existing surface. `prowl workflow deliver` resolves the caller pane to its current pending record (peer PID + ancestry, as `dispatch-complete`), requires the activation token to match (correlation only, never trust), validates the body, persists the output, completes the record. `agents wait --dispatch` works on activations. No `WorkflowRequestRegistry`. | A parallel registry + token protocol as first drafted (two "one pending task per pane" mechanisms with drifting semantics). | | G2 | **Definitions live in `ProwlCLIShared`.** Model, Yams decoding, validator, JSON Schema, and three-source discovery are compiled into both the CLI and the app; `prowl workflow validate` / `schema` run without the app; `list` needs the app (enabled state, worktree-scoped repo source). | App-only parsing with every subcommand over the socket (authoring agents and CI could not validate without a running Prowl; Settings and CLI could not share one validator). | | G3 | **Watchdog is exact-signal-first and ships in B2** (064-S5's watchdog part moves from D2). `needs-input` → attention immediately; `turn-ended` without delivery → `turn_grace` 15 s (floor 5 s, re-check at expiry) → one nudge → `idle_grace` 3 min → attention; heuristic rules only without a channel. | Heuristic-only watchdog in B2, exact signals retrofitted in D2 (pure rework: hooks already cover all tier-A runtimes). Shorter `turn_grace`: the detector still needs 2 s of heuristic stability plus poll/event skew, and OpenCode can fire `session.idle` twice. | | G4 | **`launch.prompt` may be multi-line** (A2's `PROWL_LAUNCH_PROMPT` carrier; NUL rejected; 32 KiB cap → `PROMPT_TOO_LARGE`); materialization stays for `message` only. The runner appends the workflow protocol block instead of S2's dispatch block; `dispatch-complete` against an activation fails with `WORKFLOW_DELIVERY_REQUIRED` carrying the exact replacement command. | Keeping the one-line rule; treating a stray `dispatch-complete` as a body-less completion (the step would advance with a missing output). | @@ -45,10 +49,10 @@ Owned by 063; nothing here changes 064 code. `close`, `repeat`), `expect`, inputs (`integer`, `string`, `enum`). Decoding through Yams into `Codable` types; unknown keys are errors (spec §7). - **Validator** (`WorkflowValidator`): every error and warning listed in spec §7, including - template-variable whitelist checks, producer-dominates-consumer for `outputs.*` / + template-variable whitelist checks, producer-dominates-consumer for `deliveries.*` / `actions.*` / `roles..pane`, `repeat` rules, verdict/`until` consistency, slug patterns, `skill:` resolution against the bundled registry (`ProwlSkills`, 065), and the - "spells `prowl workflow done`" warning. Diagnostics carry YAML line/column where Yams + "spells `prowl workflow deliver`" warning. Diagnostics carry YAML line/column where Yams provides them. - **Action registry (schemas only)**: the V1 native actions `handoff.transition`, `handoff.checkpoint`, `git.context` declared with their typed `with` inputs and output keys @@ -71,12 +75,12 @@ Owned by 063; nothing here changes 064 code. new file is enabled by default; no UI (D1). - **Docs**: `docs/components/cli.md` gains the three commands; contract page `docs-ai/013-prowl-cli/contracts/workflow.md`; `cli-output-schema.json` updated. The - `prowl-cli` skill is not taught these commands until B3 makes `run`/`done` real (same rule as + `prowl-cli` skill is not taught these commands until B3 makes `run`/`deliver` real (same rule as 064.012 B1: never name unshipped commands to agents). ### Non-goals -`prowl workflow run/status/done/cancel`, the runner and watchdog (B2/B3), Settings › Workflows +`prowl workflow run/status/deliver/cancel`, the runner and watchdog (B2/B3), Settings › Workflows (D1), bundled definitions and skills (`prowl.handoff`, `prowl.adversarial-review`; D2/D3), the `Resources/workflows` staging in the Makefile (ships with the first bundled definition). @@ -143,7 +147,7 @@ the `Resources/workflows` staging in the Makefile (ships with the first bundled (`kind: headless`, `expect` on `action`/`notify`/`close`, nested `repeat`, `launch` inside `repeat`, missing `max`) are parser diagnostics, so a file with them yields no definition. - **Validator** (`WorkflowValidator.swift`): one `Walker` pass in document order with a - `repeat`-scoped action table; `outputs.*` references need an earlier producer anywhere + `repeat`-scoped action table; `deliveries.*` references need an earlier producer anywhere (loop bodies included), `actions.*` references need a producer in the same or an enclosing sequence, `roles..pane` needs the role launched, `loop.index` needs a loop, `loop.count` a loop before or around. `until` accepts a producer before the loop or inside its body (the @@ -195,7 +199,7 @@ the app builds. `multipliedReportingOverflow` → `timeout_syntax`). P1: `handoff.transition`'s `from`/`to` were free strings (now `WorkflowActionInput.Kind.role`, literal declared roles only). P1: output metadata accumulated monotonically — a later producer without a verdict still let - `{{ outputs.x.verdict }}` validate, and outputs first produced inside a loop with `until` + `{{ deliveries.x.verdict }}` validate, and outputs first produced inside a loop with `until` stayed visible after a loop that may run zero times (now per-producer tracking with `latestVerdicts`, and `foldSkippableLoopOutputs` after a skippable loop). P2: `steps: []` and `id: 1` passed the parser but not the published schema (`steps_empty`, `strictText`); a tab diff --git a/docs-ai/063-agent-workflows/007-b2-runner-core.md b/docs-ai/063-agent-workflows/007-b2-runner-core.md index 047f4a053..9adeacb01 100644 --- a/docs-ai/063-agent-workflows/007-b2-runner-core.md +++ b/docs-ai/063-agent-workflows/007-b2-runner-core.md @@ -1,3 +1,7 @@ +> Historical slice record. For executable definitions and current names, use the +> [DSL specification](dsl-spec.md) and [019 naming contract](019-workflow-naming.md). +> The old substitution renderer and dedicated handoff actions described below have been removed. + # 063.007 — Workflow Runner Core (B2): Plan ## Status @@ -14,7 +18,7 @@ dispatch store a re-dispatch into an existing pane; #726 T0 (#739) added the ver B2 is the engine that turns a validated `WorkflowDefinition` into a run: the state machine, the run directory, the renderers, delivery validation, the exact-signal-first watchdog, the native actions, and pure binding resolution. It ships no user-facing surface and stays dormant on `main`: -B3 wires it into TCA and the CLI (`prowl workflow run/status/done/cancel`), C1 makes runs visible. +B3 wires it into TCA and the CLI (`prowl workflow run/status/deliver/cancel`), C1 makes runs visible. Decisions G1–G6 ([006](006-b1-definitions.md)) and the 2026-08-29 spec amendments are inputs, not subjects: activation = dispatch record, no `WorkflowRequestRegistry`, `message` injects only into an @@ -27,11 +31,11 @@ Every row was either a default derivable from the code base (H1, H3, H9, H13) or | # | Decision | Alternatives rejected | | --- | --- | --- | -| H1 | **B2 lives in `supacode/Domain/Workflow/` (app target), tests in `supacodeTests/`.** It depends on app types (`AgentSignal`, `ObservedAgentState`, `AgentProfile`, `HandoffStore`, dispatch records) that never enter `ProwlCLIShared`. Two additions go to Shared: `WorkflowSchema.tokenEnvironmentKey` (`PROWL_WORKFLOW_TOKEN`, read by B3's CLI `done`) and the markdown fence/preamble normalizer that `HandoffStore.validatedBriefing` and the delivery validator share. | Putting the text-level helpers (template renderer, completion command, delivery validation) in Shared so `swift test` covers them: nothing in the CLI consumes them in V1 and Shared carries the `nonisolated` / name-collision tax; revisit if B3 wants a local pre-validation in `done`. | +| H1 | **B2 lives in `supacode/Domain/Workflow/` (app target), tests in `supacodeTests/`.** It depends on app types (`AgentSignal`, `ObservedAgentState`, `AgentProfile`, `HandoffStore`, dispatch records) that never enter `ProwlCLIShared`. Two additions go to Shared: `WorkflowSchema.tokenEnvironmentKey` (`PROWL_WORKFLOW_TOKEN`, read by B3's CLI `deliver`) and the markdown fence/preamble normalizer that `HandoffStore.validatedBriefing` and the delivery validator share. | Putting the text-level helpers (template renderer, completion command, delivery validation) in Shared so `swift test` covers them: nothing in the CLI consumes them in V1 and Shared carries the `nonisolated` / name-collision tax; revisit if B3 wants a local pre-validation in `deliver`. | | H2 | **Reducer-style core.** `WorkflowRunMachine.apply(_ event) -> [WorkflowRunEffect]` is a pure, synchronous transition over a `WorkflowRun` value; every transport concern (idle wait, injection, launch, native action, notify, close, persistence, watchdog arming) is an *effect* B3's `WorkflowRunsFeature` interprets with the terminal boundaries. B2 tests drive the machine directly and, for the composition, through a test harness that interprets effects against fakes (store on a temp directory, fake bridge, `TestClock`). The harness is the executable specification of how B3 must interpret each effect. | A stateful `WorkflowRunner` class in B2 that owns state and calls the boundaries itself (B3 would either wrap it as an `@Observable` store beside TCA or discard it; the plan says the runner is reducer-owned). Async methods on the machine (transitions entangled with I/O; the Retry/Relaunch/Skip/Cancel × phase matrix becomes hard to pin). | | H3 | **Activation bridge = one protocol, `WorkflowActivationBridge`**, with exactly the dispatch-store operations the effects need: open a message activation (issue + bind to the pane's current epoch, #733's `issueAgentDispatch(boundTo:)`), abandon with a reason, observe a record, complete a record on delivery. The launch activation is opened by the launch boundary itself (S2's issue → attach → launch → bind), so the `.launch` effect carries the token and the environment values and receives the dispatch id back in `.launched`. B2 tests use a fake. | Folding the operations into `TerminalClient` closures now (that is B3's wiring); a second registry (rejected in G1). | -| H4 | **Token check lives in the machine, not in the store.** Each activation holds its token in `WorkflowRun` state; `done` in B3 resolves the caller pane → its pending dispatch id → the run and activation (through `WorkflowRun.activation(forDispatchID:)`) → `machine.deliver(ordinal:token:body:verdict:)`, which answers `STEP_NOT_EXPECTING` / `TOKEN_REQUIRED` / `TOKEN_INVALID` / `OUTPUT_*` / `VERDICT_REQUIRED` itself. `dispatch-complete` against a workflow activation is refused by B3's handler from the same index (`WORKFLOW_DELIVERY_REQUIRED` with the replacement command rendered by B2). The dispatch store (064 code) is untouched. | Storing the token in `AgentDispatchBinding` and checking it in `AgentDispatchStore.complete` (the store would learn workflow semantics; every 064 completion path would grow a branch). | -| H5 | **`run.json` is `WorkflowRunRecord` v1**: top-level `version: 1`; `run` (id, workflow id/name, scope, definition path, status, started/updated/finished), `worktree` (id, name, branch, path), `inputs`, `bindings` (per role: source; launch → profile id/name/agent, pane id/handle once launched; current/pick → pane id/handle, detected agent), `invocations` (ordinal, step, iteration, role, kind, instruction path, activation → dispatch id + state `waiting/delivered/skipped/revoked` + output name/path/verdict), `outputs` (latest per name), `actions` (step → declared keys), `loop` (count), `steps` (entered step records with state). **Delivery tokens are not persisted** (correlation only, useless after restart, and the file then carries nothing a reader could replay); dispatch ids are (needed by `status` / `agents wait --dispatch`). No environment values, extra arguments, home paths, or credentials — the frozen launch *plan* stays in memory with B3, only profile id/name/agent reach the file. Readers tolerate unknown keys; the launch-time `interrupted` scan reads only `version` and `run.status`. | Persisting the token (no consumer); persisting the launch plan (its surface environment carries override values); a schemaless dictionary. | +| H4 | **Token check lives in the machine, not in the store.** Each activation holds its token in `WorkflowRun` state; `deliver` in B3 resolves the caller pane → its pending dispatch id → the run and activation (through `WorkflowRun.activation(forDispatchID:)`) → `machine.deliver(ordinal:token:body:verdict:)`, which answers `STEP_NOT_EXPECTING` / `TOKEN_REQUIRED` / `TOKEN_INVALID` / `OUTPUT_*` / `VERDICT_REQUIRED` itself. `dispatch-complete` against a workflow activation is refused by B3's handler from the same index (`WORKFLOW_DELIVERY_REQUIRED` with the replacement command rendered by B2). The dispatch store (064 code) is untouched. | Storing the token in `AgentDispatchBinding` and checking it in `AgentDispatchStore.complete` (the store would learn workflow semantics; every 064 completion path would grow a branch). | +| H5 | **`run.json` is `WorkflowRunRecord` v1**: top-level `version: 1`; `run` (id, workflow id/name, scope, definition path, status, started/updated/finished), `worktree` (id, name, branch, path), `inputs`, `bindings` (per role: source; launch → profile id/name/agent, pane id/handle once launched; current/pick → pane id/handle, detected agent), `invocations` (ordinal, step, iteration, role, kind, instruction path, activation → dispatch id + state `waiting/delivered/skipped/revoked` + output name/path/verdict), `deliveries` (latest per name), `actions` (step → declared keys), `loop` (count), `steps` (entered step records with state). **Delivery tokens are not persisted** (correlation only, useless after restart, and the file then carries nothing a reader could replay); dispatch ids are (needed by `status` / `agents wait --dispatch`). No environment values, extra arguments, home paths, or credentials — the frozen launch *plan* stays in memory with B3, only profile id/name/agent reach the file. Readers tolerate unknown keys; the launch-time `interrupted` scan reads only `version` and `run.status`. | Persisting the token (no consumer); persisting the launch plan (its surface environment carries override values); a schemaless dictionary. | | H6 | **Watchdog observes per activation**: `observeAgentDispatch(id)` (already epoch-gated by the store: `.needsInput`, `.incomplete` = coalesced `turn-ended`, `.changed(gone)`) for exact evidence and `observeAgentState(surfaceID)` for detector levels, `removed`, `surfaceClosed`, plus a `snapshot(surfaceID:)` re-read at every grace expiry. Two streams and an injected clock per waiting activation, torn down with it. | One bus through `AppFeature`'s single `eventStream` subscription (the 2026-08-22 topology, written before 064-S1 shipped the multicast observer; the spec §10 already names `observeAgentState` / `observeAgentDispatch`). | | H7 | **Attention and nudge copy** (the only strings agents and users see from B2): nudge `[Prowl] When your work for this step is fully complete, finish with: `; attention reasons (panel text, C1 renders): *needs input* "The reviewer is waiting for input in its pane" (Focus pane / Cancel), *idle without delivery* "The reviewer has been idle for 3 min without delivering findings — nudged once" (Nudge again / Keep waiting / Skip / Cancel), *blocked* (heuristic) "The reviewer looks blocked (screen) for 30 s" (Focus pane / Cancel), *agent gone* "The reviewer's agent session ended" / "…pane was closed" / "…process is gone" (Relaunch / Skip / Cancel for launch roles; Skip / Cancel otherwise), *injection failed* "The line could not be typed into the author's pane" with the unsubmitted-line hint when the insert succeeded (Retry / Skip / Cancel), *launch failed* (Retry / Skip / Cancel), *rendered text invalid* (Skip / Cancel), *action failed* (Retry / Cancel), *timeout* (`on_timeout: attention`: Nudge / Keep waiting / Skip / Cancel). | Free-form strings composed in C1 (two places to keep in sync). | | H8 | **Relaunch is offered for `launch` roles only.** It abandons the current activation, mints a new invocation for the *current* step, and re-delivers it as the kickoff prompt of a fresh launch of the frozen profile (message content + workflow protocol block); the role's pane is rebound on `.launched`. A `current` / `pick` role that is gone offers Skip / Cancel (a "Rebind pane" action is V2). | Relaunching a `pick` role by re-running the picker (needs C2's sheet; not a runner concern). | @@ -56,7 +60,7 @@ All new types are `nonisolated` where the app target's MainActor default would o display name, agent token), `WorkflowActivation` (ordinal, step, role, token, dispatch id?, expect, state), `WorkflowAttention` (reason + `Set`), `WorkflowRunStatus` (`running`, `needsAttention`, `completed`, `cancelled`, `skipped`, - `maxRoundsReached`, `interrupted`). + `iterationLimitReached`, `interrupted`). - `WorkflowRunMachine.swift` — `WorkflowRunEvent` (`roleIdle`, `injectionSucceeded/Failed`, `launched/launchFailed`, `actionCompleted/Failed`, `watchdog(ordinal, verdict)`, `timeout`, `user(action)`), `WorkflowRunEffect` (`awaitRoleIdle`, `materializeInstruction`, @@ -78,8 +82,8 @@ All new types are `nonisolated` where the app target's MainActor default would o `OUTPUT_TOO_LARGE`). - `WorkflowRunStore.swift` — `/.prowl/workflow-runs//` layout, self-ignoring `.gitignore`, `WorkflowRunRecord` (Codable, `version` 1), append-only `log.md`, - `instructions/..md`, `outputs/..md` + atomically replaced - `outputs/.md`, `skills//` copied from the bundle, every path from validated slugs and + `instructions/..md`, `deliveries/..md` + atomically replaced + `deliveries/.md`, `skills//` copied from the bundle, every path from validated slugs and the run UUID under `AgentProfileHomeProvisioner.validatePhysicalContainment`, and `markInterruptedRuns()` for launch. - `WorkflowWatchdog.swift` — `WorkflowWatchdogSettings` (`turnGrace` 15 s floor 5 s, `idleGrace` @@ -100,7 +104,7 @@ All new types are `nonisolated` where the app target's MainActor default would o - Machine: linear run through message → launch → repeat → action → notify → close; `until` before entry (satisfied → loop skipped, `loop.count` 0) and after each iteration; `max` - reached → `maxRoundsReached`; latest-wins outputs across steps with the same name; `until` + reached → `iterationLimitReached`; latest-wins outputs across steps with the same name; `until` reading the body's final producer; Skip consequences (template / `until` / required input end the run, optional input continues with the key absent, `--skip` at start); every attention reason with its action set; Retry mints a new ordinal and token; Relaunch re-delivers the @@ -135,7 +139,7 @@ All new types are `nonisolated` where the app target's MainActor default would o ## What B3 verifies live (B2 has no surface) Idle wait and re-dispatch against real Claude Code / Codex panes; the typed line reaching the -composer as one entry with the token prefix; a real `prowl workflow done -` resolving through +composer as one entry with the token prefix; a real `prowl workflow deliver -` resolving through caller ancestry to the activation; `agents wait --dispatch` on an activation id; the launch protocol block and `PROWL_WORKFLOW_TOKEN` in the child environment only; the watchdog's nudge and attention timings on hooked and unhooked runtimes; `dispatch-complete` refused with @@ -196,13 +200,13 @@ Everything lives in `supacode/Domain/Workflow/` (app target) plus three Shared t ### Behaviors worth knowing (beyond the spec text) -- A `repeat` without `until` ends the run as `max_rounds_reached` after `max` iterations, as +- A `repeat` without `until` ends the run as `iteration_limit_reached` after `max` iterations, as §4 says; it never falls through to the steps after it. - `roleBusy` from the bridge (the #733 refusal) is not attention: the step returns to `waitingForRole` with the same invocation and token. - A delivery is two-phase: `deliver` validates and emits `.persistOutput`; the run advances and the dispatch record completes only on `.outputPersisted`. While `persisting`, a second - `done` is `STEP_NOT_EXPECTING`; Skip / Cancel abandon the record as they would a waiting one. + `deliver` is `STEP_NOT_EXPECTING`; Skip / Cancel abandon the record as they would a waiting one. - A delivery with issues (H14) becomes `provisional` after persistence: the dispatch record stays pending, `outputs[name]` is not set, and the run waits for Accept / Accept with verdict / Ask again. Ask again returns the same activation to `waiting` and re-arms the watchdog with @@ -237,7 +241,7 @@ Everything lives in `supacode/Domain/Workflow/` (app target) plus three Shared t carriers (like `attachingDispatch`), issue the dispatch when `expectsDelivery`, then `.launched`; `runAction` → `WorkflowNativeActionRunner`; `armWatchdog` → one `WorkflowWatchdog` per request, `disarmWatchdog` → `cancel()`; `persist` / `log` → `WorkflowRunStore`; `persistOutput` → `WorkflowRunStore.writeOutput` -then `.outputPersisted(ordinal)` (or `.outputPersistFailed`), and the CLI `done` response is +then `.outputPersisted(ordinal)` (or `.outputPersistFailed`), and the CLI `deliver` response is sent only after that event; `abandonActivation` / `completeActivation` → the bridge; `cancelRoleWait` → stop the idle wait; `finished` → tear down. A `.launched` that arrives after the run ended must abandon its @@ -339,5 +343,5 @@ SwiftPM-only so it could run beside the app builds; briefs and findings under (the pre-existing handoff contract); moving it to descriptor-rooted I/O would close the remaining check-then-use window that `git.context` inherits from it. Recorded as a follow-up for D3, when the handoff actions become the shipped path. -- B3 must answer `prowl workflow done` only after `.outputPersisted`, cancel a run's watchdog +- B3 must answer `prowl workflow deliver` only after `.outputPersisted`, cancel a run's watchdog on acceptance, and abandon the dispatch of a `.launched` that arrives after the run ended. diff --git a/docs-ai/063-agent-workflows/008-b3-runner-wiring.md b/docs-ai/063-agent-workflows/008-b3-runner-wiring.md index f1c31d296..fc87d0419 100644 --- a/docs-ai/063-agent-workflows/008-b3-runner-wiring.md +++ b/docs-ai/063-agent-workflows/008-b3-runner-wiring.md @@ -16,7 +16,7 @@ start sheet and interactive binding picker. - The normative CLI protocol is [dsl-spec §9](dsl-spec.md#9-cli-participant-protocol). B3 must not reopen B2 decisions H1–H14. - B2's `WorkflowRunHarness` is the interpreter reference. In particular, delivery is - validate → persist output → `.outputPersisted` → complete dispatch / advance; a CLI `done` + validate → persist output → `.outputPersisted` → complete dispatch / advance; a CLI `deliver` response must not report success before persistence. ## Scope @@ -34,7 +34,7 @@ start sheet and interactive binding picker. - output persistence, logging, native actions, close/notify, and terminal-run cleanup. 3. Extend the workflow CLI across all four governed layers: parser/input envelope, app handler, versioned payload/output renderer plus executable schema, contracts/manual/skill. Add - `run`, `status`, `done`, and `cancel` while preserving B1's `list`, `validate`, and `schema`. + `run`, `status`, `deliver`, and `cancel` while preserving B1's `list`, `validate`, and `schema`. 4. Resolve a run request from the discovered effective definition and freeze bindings. CLI binding resolution uses explicit `--role` overrides, remembered bindings, suggestion, and Recommended. A `pick` role without an explicit pane, or a launch role whose resolver reaches @@ -50,9 +50,9 @@ start sheet and interactive binding picker. | # | Decision | Reason | | --- | --- | --- | -| W1 | CLI commands enter the reducer through a request/response rendezvous that owns continuations only, never runner state. `run` replies after preflight, layout, and the initial record persist; `done` replies when its activation leaves `persisting` — delivered/provisional succeeds, persist-failed/revoked/terminal fails — not merely when an `.outputPersisted` event arrives. | The reducer remains the single owner of active runs; a socket handler must nevertheless await `done` persistence before replying. Cancel and store-failure can race a queued output-persist event, whose machine guard intentionally ignores terminal transitions; resolving only on that event would leak a CLI continuation. | +| W1 | CLI commands enter the reducer through a request/response rendezvous that owns continuations only, never runner state. `run` replies after preflight, layout, and the initial record persist; `deliver` replies when its activation leaves `persisting` — delivered/provisional succeeds, persist-failed/revoked/terminal fails — not merely when an `.outputPersisted` event arrives. | The reducer remains the single owner of active runs; a socket handler must nevertheless await `deliver` persistence before replying. Cancel and store-failure can race a queued output-persist event, whose machine guard intentionally ignores terminal transitions; resolving only on that event would leak a CLI continuation. | | W2 | B3's preflight means workflow discovery/validity/enabled state, source/worktree resolution, binding legality, pane ownership, and run-directory setup. It does **not** add CLI installation or socket-health UI. | A reachable socket is already a prerequisite of any CLI command; the observable installation/socket status belongs to D1. This resolves the ambiguous “CLI preflight” wording in the slice table. | -| W3 | `workflow done` identifies an activation from the caller pane's pending dispatch first, then checks the machine token. Explicit `--run --step` is the documented manual path; mismatched caller and explicit target is `ROLE_MISMATCH` unless `--force`. `agents dispatch-complete` is intercepted before the normal handler can complete a workflow activation. | Preserves the B1/B2 trust boundary: tokens correlate but do not authenticate. | +| W3 | `workflow deliver` identifies an activation from the caller pane's pending dispatch first, then checks the machine token. Explicit `--run --step` is the documented manual path; mismatched caller and explicit target is `ROLE_MISMATCH` unless `--force`. `agents dispatch-complete` is intercepted before the normal handler can complete a workflow activation. | Preserves the B1/B2 trust boundary: tokens correlate but do not authenticate. | | W4 | A non-strict delivery with validation issues persists and becomes `needsAttention`; B3 reports `delivery.state = provisional`, warnings, and the attention vocabulary through `status`, but does not silently accept it. | H14 requires a user decision. C1 supplies Accept / Ask again / Skip / Retry / Relaunch controls; B3 intentionally has only `status` and `cancel` as public lifecycle controls. Before C1, a provisional delivery (and every other attention state) is cancel-only; it cannot be re-delivered because the activation is no longer waiting. R2a is not released with B3 but without C1. | | W5 | `status` reads an active run from reducer state when available and otherwise reads a v1 record from its indexed worktree root. No run is reconstructed from disk. | Status and `agents wait --dispatch` remain useful after an app restart without accidentally implementing V2 resume. | | W6 | CLI launch roles use only frozen profile launch plans. `PROWL_WORKFLOW_TOKEN`, `PROWL_WORKFLOW_RUN`, and `PROWL_WORKFLOW_ROLE` are child-only surface environment values, not `run.json` or response data. | Retains B2's privacy rule and prevents a workflow token from leaking to unrelated processes or persisted metadata. | @@ -62,8 +62,8 @@ start sheet and interactive binding picker. Follow test-first development for the deterministic routing and contract layers. Add reducer/handler coverage for source resolution, every binding source and override, one-run-per-pane rejection, -`done` attribution/token/force mismatch, two-phase persistence (including Cancel or persistence -failure while a `done` rendezvous waits), dispatch-complete interception, late launch cleanup, +`deliver` attribution/token/force mismatch, two-phase persistence (including Cancel or persistence +failure while a `deliver` rendezvous waits), dispatch-complete interception, late launch cleanup, watchdog lifecycle, restart interruption, and structured payload/schema validation. Keep B2's pure suites unchanged except for seams required by real wiring. Run the CLI unit, smoke, and socket integration targets as required for CLI work; run `make check` @@ -71,10 +71,10 @@ and `make build-app`. Then use an isolated Debug app and matching CLI to drive a - message into an idle existing Claude Code or Codex pane, then a second #733 re-dispatch; - launch with the protocol block and workflow token visible only to the child; -- `done -` resolved by caller ancestry and followed by `agents wait --dispatch`; +- `deliver -` resolved by caller ancestry and followed by `agents wait --dispatch`; - refused `agents dispatch-complete` with `WORKFLOW_DELIVERY_REQUIRED`; - watchdog nudge/attention behavior on both hooked and unhooked runtimes; -- a deliberately provisional delivery: verify `done` reports `provisional`, `status` exposes +- a deliberately provisional delivery: verify `deliver` reports `provisional`, `status` exposes its attention, and document that C1 is required to resolve it rather than treating it as a happy-path completion; - output/run-directory inspection and restart interruption. @@ -98,7 +98,7 @@ unchanged except for one seam. its batch and raises the injection / launch attention. Late (`run` ended) and stale (machine no longer expects it) `.launched` events abandon their dispatch record and close the pane. A successful launch remembers its profile under B2's digest key (`UserGlobalSettings.workflowBindings`). -- The `done` rendezvous (`WorkflowCLIRendezvous`, `WorkflowCLIResponderClient`): the reducer +- The `deliver` rendezvous (`WorkflowCLIRendezvous`, `WorkflowCLIResponderClient`): the reducer keeps `pendingDeliveries[requestID]` and answers when the addressed activation leaves `persisting` — `delivered` / `provisional` succeed, `persist_failed` answers `WORKFLOW_FAILED`, a revoked / skipped activation or an ended run answers @@ -117,7 +117,7 @@ unchanged except for one seam. before the reply. - The coordinator (`WorkflowRuntimeCoordinator`): `run` (a self-initiated first step is answered through the rendezvous once its activation record exists) / `status` (W5: live - session, else a `run.json` from any known worktree root, else `RUN_NOT_FOUND`) / `done` (W3: + session, else a `run.json` from any known worktree root, else `RUN_NOT_FOUND`) / `deliver` (W3: caller pane's pending dispatch → activation; explicit `--run --step` manual; disagreement `ROLE_MISMATCH` unless `--force`) / `cancel`. Completion commands are spelled only for the verified caller pane's own activation. `agents dispatch-complete` is intercepted before the @@ -137,24 +137,24 @@ unchanged except for one seam. `PROWL_DISPATCH_ID`) → launch → bind → focus unless `background`; every later failure cancels the issuance and closes the pane. `notify` logs and, when system notifications are enabled, posts a banner titled `Workflow · `. -- CLI (`prowl workflow run/status/done/cancel`), payload `prowl.cli.workflow.v1` actions - `run` / `status` / `cancel` (run object) and `done` (`run` + `delivery`), executable schema, +- CLI (`prowl workflow run/status/deliver/cancel`), payload `prowl.cli.workflow.v1` actions + `run` / `status` / `cancel` (run object) and `deliver` (`run` + `delivery`), executable schema, text renderers, `docs/components/cli.md`, the contract page, and the `prowl-cli` skill - recipe. `done` reads its body client-side (stdin or `--file`, 4 MiB cap). + recipe. `deliver` reads its body client-side (stdin or `--file`, 4 MiB cap). ## Verification - Red first for the routing and contract layers: the reducer suite (`WorkflowRunsFeatureTests`: - ordered execution with the instruction file on disk before the pointer is typed, the `done` + ordered execution with the instruction file on disk before the pointer is typed, the `deliver` rendezvous through delivered / provisional / persist-failed / cancelled-while-persisting, late and stale launches, the idle-wait outcomes, the restart scan), `WorkflowRunAdmissionTests` (definition selection, source rules, every binding source and override, one run per pane, the - pending-dispatch refusal, start-time validation), `WorkflowRuntimeCoordinatorTests` (`done` + pending-dispatch refusal, start-time validation), `WorkflowRuntimeCoordinatorTests` (`deliver` attribution incl. `ROLE_MISMATCH` / `--force`, `status` live / record / who-am-I, `cancel`), `WorkflowCLIRendezvousTests` (buffered early answers, cancellation), the launch-plan carrier test, the dispatch-complete interception test, the `.roleUnavailable` machine tests; CLI parser, schema (`WorkflowSchemaTests` for every action + Codable round trip), and the mock-socket - `run` / `done` round trip. `make check`, `make build-cli`, `make test-cli-unit` (233), + `run` / `deliver` round trip. `make check`, `make build-cli`, `make test-cli-unit` (233), `make test-cli-smoke`, `make test-cli-integration` (110), `make build-app` (0 warnings in the changed files), the workflow app suites (121 at PR time; 135 with the review rounds' tests), and `make test` — 2892 passed, 0 failed at the final head. Note for the next slice: run the @@ -167,7 +167,7 @@ unchanged except for one seam. - `b3-review` started from a launched Claude Code pane (`prowl workflow run b3-review --json` typed by the agent itself): the response carried `self_initiated.line` with the instruction path and the token-bearing completion command, nothing was typed into the caller; the brief - was delivered with `done -` resolved by caller ancestry (`delivered`); the reviewer profile + was delivered with `deliver -` resolved by caller ancestry (`delivered`); the reviewer profile launched in a split with the protocol block in its prompt and — checked from inside the child with a masked `env` — only `PROWL_WORKFLOW_TOKEN` / `_RUN` / `_ROLE` in its environment (no `PROWL_DISPATCH_*` / `PROWL_LAUNCH_*`); `agents wait --dispatch` on the @@ -177,18 +177,18 @@ unchanged except for one seam. `clean`, `notify` fired, `close` removed the reviewer pane; `run.json` and `log.md` carry dispatch ids and paths but no token. - `agents dispatch-complete` from the author while it owed the `fix` delivery was refused - with `WORKFLOW_DELIVERY_REQUIRED` and the replacement `done` command. - - A second run delivered a brief without `## Claims`: `done --json` answered + with `WORKFLOW_DELIVERY_REQUIRED` and the replacement `deliver` command. + - A second run delivered a brief without `## Claims`: `deliver --json` answered `delivery.state: provisional` with `missing_sections`, `status` showed `needs_attention` / `delivery_issues` with the H14 actions, and `cancel` ended it (activation `revoked`, - `outputs/brief.md` kept on disk, `outputs` in the record empty). B3 offers no accept / + `deliveries/brief.md` kept on disk, `deliveries` in the record empty). B3 offers no accept / ask-again control; that is C1 (decision W4). - Found and fixed live: a launched author that never ran `dispatch-complete` still held its launch record, so the self-initiated activation failed `roleBusy`, the machine fell back to the idle wait, and the run ended in an injection attention while `status` still advertised a - `waiting` activation `done` could not address. Admission now refuses such panes with + `waiting` activation `deliver` could not address. Admission now refuses such panes with `DISPATCH_PENDING`, the idle wait ends as `dispatchPending` attention instead of spinning, - and `status` reports only the activation `done` can address (`WorkflowRun.currentActivation`). + and `status` reports only the activation `deliver` can address (`WorkflowRun.currentActivation`). - `b3-idle` (no `current` role, started from a worktree target) launched its worker, which answered "OK" within three seconds; the exact `turn-ended` reached the dispatch record (`agents wait --dispatch` → `DISPATCH_INCOMPLETE`) but no nudge followed. Cause, in B2's @@ -200,7 +200,7 @@ unchanged except for one seam. the re-arm, plus a regression test for the late first detection. Re-verified live after the fix: the worker answered "OK" within seconds of its launch, the nudge (`[Prowl] When your work for this step is fully complete, finish with: PROWL_WORKFLOW_TOKEN=… prowl workflow - done -`) was typed 41 s after the run started, the worker answered "OK" again, and 3 min + deliver -`) was typed 41 s after the run started, the worker answered "OK" again, and 3 min later the run entered `needs_attention` / `idle_without_delivery` with the H7 copy ("… has been idle without delivering report; Prowl nudged it once"). The heuristic (unhooked) watchdog path was not exercised live; B2's policy tests cover it. @@ -224,7 +224,7 @@ whose app does not accept `agents.dispatch`; briefs were sent with `prowl send` - **Round 1 — 8 findings (1 P0, 6 P1, 1 P2), all accepted and fixed.** P0: `run` spelled the current activation's completion command whenever it included the self-initiated line — a workflow whose first awaited step is a `launch` handed the launcher the reviewer's token — and a - manual or forced `done` was answered as if the caller were the delivering role (it could learn + manual or forced `deliver` was answered as if the caller were the delivering role (it could learn the next activation's token); completion commands are now spelled only for the *verified* caller pane's own activation (`callerRole` travels with the request; `includeSelfInitiated` only adds the self-initiated line). P1: work queued before a cancel / skip / retry still ran @@ -241,8 +241,8 @@ whose app does not accept `agents.dispatch`; briefs were sent with `prowl send` detector-idle stabilizer (it is checked first now); a launch without `expect` left its new pane unreserved until `.launched` reached the reducer (`WorkflowPaneReservations` now holds it, admission counts it busy); a self-initiated `run` replied before its activation record - existed, so an immediate `done` could be `STEP_NOT_EXPECTING` (the reply now waits for the - record through the same rendezvous as `done`). P2: a duplicate request id was registered + existed, so an immediate `deliver` could be `STEP_NOT_EXPECTING` (the reply now waits for the + record through the same rendezvous as `deliver`). P2: a duplicate request id was registered twice (refused with `REQUEST_CONFLICT` now). - **Round 2 — 5 findings (0 P0, 4 P1, 1 P2), all accepted and fixed; round-1 fixes verified.** P1: the batch-wide fence also dropped the *bookkeeping* of transitions the diff --git a/docs-ai/063-agent-workflows/010-c1-workflow-status-center.md b/docs-ai/063-agent-workflows/010-c1-workflow-status-center.md index c765b40f9..e0f1fada7 100644 --- a/docs-ai/063-agent-workflows/010-c1-workflow-status-center.md +++ b/docs-ai/063-agent-workflows/010-c1-workflow-status-center.md @@ -126,7 +126,7 @@ running toolbar item and pinned run panel were inspected in Normal, Shelf, and C normal window size and at macOS half-width. The current title, full instruction, role chip, document-order steps, elapsed state, and footer controls remained legible and usable; the fixed 580-point single-run panel fit the constrained window without clipping. The workflow also completed -through `prowl workflow done -`, after which the active item disappeared as designed. Temporary +through `prowl workflow deliver -`, after which the active item disappeared as designed. Temporary workflow input was removed; persisted local run records remain under the self-ignored run store. ### Adversarial review record @@ -140,7 +140,7 @@ workflow input was removed; persisted local run records remain under the self-ig - interacting with panel controls pins the hover-open panel so confirmation menus cannot vanish on pointer exit; - the workflow popover stays mounted while a toast overlays it, preserving pinned panel state; - - selected-worktree `skipped` and `maxRoundsReached` outcomes now receive warning toasts, while + - selected-worktree `skipped` and `iterationLimitReached` outcomes now receive warning toasts, while successful completion keeps the success toast. - The duplicate-edge reducer test now keeps exhaustive TestStore checking enabled through the next action, so an unexpected duplicate notice cannot be discarded before the assertion boundary. @@ -166,7 +166,7 @@ workflow input was removed; persisted local run records remain under the self-ig surface, ran a captured shell command successfully, and launched a real Codex profile. This directly covers the display-sleep/locked-session surface path; the later final delta only changes toolbar view lifetime and received its own focused review and rebuilt-app E2E. -- A real launch-role happy run completed through the generated `prowl workflow done -` command and +- A real launch-role happy run completed through the generated `prowl workflow deliver -` command and persisted its output with verdict `clean`. - Concurrent provisional runs exercised all delivery decisions: `Ask Again` injected the generated remediation prompt and accepted a corrected re-delivery; `Accept as Delivered` persisted a valid @@ -174,7 +174,7 @@ workflow input was removed; persisted local run records remain under the self-ig panel and persisted the selected `clean` verdict. - Gone-role recovery replaced the dead p22 binding with a new p30 pane and resumed the step. A separate run exercised the destructive Cancel confirmation and finished `cancelled` while keeping - its pane and outputs. + its pane and deliveries. - The watchdog reached attention through its real automatic nudge and idle grace. `Nudge Again` delivered another completion reminder, `Keep Waiting` re-armed the grace period, and the next attention state was actually skipped after confirming the displayed consequence. diff --git a/docs-ai/063-agent-workflows/012-v1-boundary-observations.md b/docs-ai/063-agent-workflows/012-v1-boundary-observations.md index 55548cc43..51d092365 100644 --- a/docs-ai/063-agent-workflows/012-v1-boundary-observations.md +++ b/docs-ai/063-agent-workflows/012-v1-boundary-observations.md @@ -21,7 +21,7 @@ Evidence runs referenced below (all in `Prowl/.prowl/workflow-runs/`): ### F1 — By-reference output passing costs agents nothing; the real gap is human-facing surfaces -Agent→agent handover via `{{ outputs..path }}` was frictionless in every experiment: +Agent→agent handover via `{{ deliveries..path }}` was frictionless in every experiment: a receiving agent reads the file as its first action, and the guess-number players demonstrably consumed each other's rounds through the shared file. The one place the "no inlined output text" rule (§6) actually bites is a surface where nobody can @@ -29,7 +29,7 @@ dereference a path: the `notify` bell. "Put the computed number in the notificat is inexpressible. **V2 input:** if this is ever opened, open it narrowly — e.g. a sanitized, length-capped -`outputs..firstline` allowed in `notify` only, passed through the existing +`deliveries..firstline` allowed in `notify` only, passed through the existing rendered-text validation. The agent→agent case needs nothing. ### F2 — A poor-man's `if` already exists: `repeat { max: 1, until: }` @@ -103,7 +103,7 @@ watchdog/timeout machinery is the only backstop. ### F7 — Smaller observations - **Pre-loop seed delivery.** A loop body whose first step references - `{{ outputs..path }}` needs a producer before the loop to satisfy dominance + `{{ deliveries..path }}` needs a producer before the loop to satisfy dominance checking; guess-number seeded it with an opening delivery (verdict `ready`) that also burned one of the four verdict slots. Worth documenting as an authoring pattern (D1 skill), and worth remembering as pressure on the 2–4 verdict cap. diff --git a/docs-ai/063-agent-workflows/015-action-bundles-and-control-flow.md b/docs-ai/063-agent-workflows/015-action-bundles-and-control-flow.md index e098a8bae..fe773d24e 100644 --- a/docs-ai/063-agent-workflows/015-action-bundles-and-control-flow.md +++ b/docs-ai/063-agent-workflows/015-action-bundles-and-control-flow.md @@ -80,7 +80,7 @@ handoff.pwlworkflow/ `workflow.yaml` is the only workflow manifest; no additional package manifest. The package is an ordinary directory suitable for source control. Discovery retains app/user/repository scope and the reserved `prowl.*` workflow namespace. Local action IDs derive from action directories; -references are explicit: `local:collect-context` versus `builtin:git.context`. +references are explicit: `local:collect-context` versus `builtin:collect-worktree-context`. Use `prowl.workflow/v1` for the bundle workflow and `prowl.action/v1` for action declarations. The owner confirmed on 2026-09-06 that workflows have never shipped: define the bundle format @@ -133,10 +133,11 @@ not part of the initial protocol. The following is illustrative, not a finalized "protocol": "prowl.action/v1", "input": { "include_untracked": true }, "context": { - "run": { "id": "...", "workflow_id": "prowl.handoff" }, - "execution": { - "id": "...", "step_id": "snapshot", "attempt": 1, - "cwd": "...", "artifact_dir": "..." + "workflow": { "id": "prowl.handoff", "name": "Handoff" }, + "run": { "id": "...", "path": "..." }, + "action": { + "execution_id": "...", "step_id": "snapshot", "attempt": 1, + "working_directory": "...", "artifacts_directory": "..." }, "worktree": { "id": "...", "path": "...", "name": "..." } } @@ -149,7 +150,7 @@ manual retries. Proposed run layout: ```text / definition/ # approved package copy - outputs/ # existing agent delivery files + deliveries/ # existing agent delivery files actions/// request.json result.json @@ -208,12 +209,13 @@ redaction policy must say what it actually covers. `context` is workflow-wide, not an action-only API. The same step snapshot feeds condition and template evaluation and the action request. Proposed groups: -- `context.run`: run/workflow identity and run directory; frozen for the run. +- `context.workflow`: definition identity and name; frozen for the run. +- `context.run`: run identity and path; frozen for the run. - `context.worktree`: fixed target identity/path plus timestamped dynamic branch observations. -- `context.source`: original initiating pane/tab identity, nullable for worktree-only starts. +- `context.initiator`: original initiating pane/tab identity, nullable for worktree-only starts. - `context.roles`: frozen profile bindings plus current pane existence and agent observations. - `context.step`: current step/invocation/loop position and snapshot capture time. -- `context.execution`: action-specific execution ID, cwd, and artifact directory, only available +- `context.action`: action-specific `execution_id`, `step_id`, `attempt`, `working_directory`, and `artifacts_directory`, only available during an action invocation; other steps cannot reference it as though it existed. The action process has no terminal pane. Never impersonate a source/role pane as the script's @@ -224,8 +226,8 @@ expensive data remains an explicit action/CLI query. Final field names and the e observation set require a schema review before implementation. Replace V1's parallel `run.*`, `worktree.*`, `roles.*`, and loop references with their documented -`context.*` equivalents. Keep `inputs.*`, `outputs.*`, `actions.*`, and `state.*` distinct. -An action exposes `actions..output` (typed JSON) and `actions..result_path` (a file). +`context.*` equivalents. Keep `inputs.*`, `deliveries.*`, `actions.*`, and `state.*` distinct. +An action exposes `actions..output` (typed JSON) and `actions..output_path` (a file). A whole-value reference in `with` preserves JSON type. String interpolation accepts scalars, not implicit serialization of objects/arrays. Complex agent inputs should use result files. @@ -257,7 +259,7 @@ run bindings. Repeated work uses `message` to an already launched role; launchin an error. Dynamic role creation is outside this first design. Strict result lifetime is accepted: an unexecuted branch has no outputs, and a new loop iteration -cannot inherit the prior iteration's outputs. Cross-iteration retention requires `set`. Outer +cannot inherit the prior iteration's deliveries. Cross-iteration retention requires `set`. Outer scope results remain available to inner scopes; retain per-invocation history on disk even when values leave evaluation scope. Static validation rejects definite unavailable references, while runtime checks handle branch-dependent availability. Never inject an empty string or reuse stale @@ -295,7 +297,7 @@ and visible interruption diagnostics need explicit tests; durable recovery is ex Remove the two handoff action declarations, dispatcher branches, JSON schema entries, and associated workflow-only fixtures/docs. Do not delete HandoffStore/HandoffCoordinator code still used by the shipped legacy HUD/CLI. Extract reusable repository snapshot collection for -`builtin:git.context`; save to the current invocation's artifacts, never shared handoff paths. +`builtin:collect-worktree-context`; save to the current invocation's artifacts, never shared handoff paths. Old loose YAML and removed-action definitions receive specific unsupported-format/action errors, not silent fallback or a compatibility runner. No migration command is planned. diff --git a/docs-ai/063-agent-workflows/017-action-bundle-implementation.md b/docs-ai/063-agent-workflows/017-action-bundle-implementation.md index 350ab70b1..9ad5f78e9 100644 --- a/docs-ai/063-agent-workflows/017-action-bundle-implementation.md +++ b/docs-ai/063-agent-workflows/017-action-bundle-implementation.md @@ -57,7 +57,7 @@ This amendment resolves the specification details left open in 015. permissions. Review and approve are explicit actions. Approval does not start a run. - Cancellation and timeout terminate the owned process group, then force termination after a bounded grace period. Already performed side effects are not rolled back. -- `builtin:git.context` writes to invocation artifacts. Legacy handoff CLI remains intact; +- `builtin:collect-worktree-context` writes to invocation artifacts. Legacy handoff CLI remains intact; workflow-only handoff actions are removed. Restarted runs are inspection-only/interrupted. ## Validation and delivery @@ -83,7 +83,7 @@ regressions fixed red-to-green and summarized in PR comments. - `test-action --input-json` transports literal JSON; only workflow `with` uses expressions. - Track owned subprocess groups in app-private storage with PID and process start time. On startup, recover groups whose owner no longer exists; never trust a repository PID. -- Keep the existing handoff collector intact. Native `git.context` uses the same cancellable +- Keep the existing handoff collector intact. Native `builtin:collect-worktree-context` uses the same cancellable process transport as scripts so workflow cancellation does not block on synchronous git. ## Delivery and acceptance diff --git a/docs-ai/063-agent-workflows/018-history-storage-plan.md b/docs-ai/063-agent-workflows/018-history-storage-plan.md index 51250f7a0..d26ef9f9a 100644 --- a/docs-ai/063-agent-workflows/018-history-storage-plan.md +++ b/docs-ai/063-agent-workflows/018-history-storage-plan.md @@ -22,7 +22,7 @@ The owner confirmed these fixed product rules: - Keep Run exempts a run. Live, recent, unknown, and corrupt records are protected. - Only terminal runs can be exported as complete ZIP files. Exports are independent of retention. - Agent reads require current task attribution and expose only assigned instructions, skills, - and explicitly passed prior outputs/action artifacts. A UUID alone grants no content access. + and explicitly passed prior deliveries/action artifacts. A UUID alone grants no content access. ## Storage and safety @@ -43,7 +43,7 @@ remain visible when the soft budget cannot be met. Cleanup never stops a script. ## Agent content transfer -Keep `workflow done -` as the ordinary text/JSON delivery boundary. Add and test a scoped +Keep `workflow deliver -` as the ordinary text/JSON delivery boundary. Add and test a scoped CLI read contract, including pane/run/invocation attribution, bounded content, and resource identifiers issued by Prowl. Instructions must teach this route without home-directory grants. Do not infer every runtime blocks external reads. Inspect actual sandbox behavior before adding @@ -100,7 +100,7 @@ The owner approved using existing task identity instead of introducing read cred `workflow read --run --invocation ` checks the caller pane against its current assignment. There is no read token, token environment injection, or separate credential lifecycle. Normal completion keeps the last assigned task readable until -reassignment, cleanup, or app exit. Cancellation revokes access. The existing `done` +reassignment, cleanup, or app exit. Cancellation revokes access. The existing `deliver` delivery protocol retains its own validation. This first release uses fixed retention settings and the existing filesystem/reducer diff --git a/docs-ai/063-agent-workflows/019-workflow-naming.md b/docs-ai/063-agent-workflows/019-workflow-naming.md new file mode 100644 index 000000000..89e40dc55 --- /dev/null +++ b/docs-ai/063-agent-workflows/019-workflow-naming.md @@ -0,0 +1,64 @@ +# 063.019 — Workflow Naming + +Status: implemented and verified 2026-09-07. + +## Scope + +Normalize the unreleased workflow language, action protocol, CLI, schemas, examples, +skills, and design references before D3. No compatibility aliases or data migration. +This change does not implement handoff migration, new context collectors, or review workflows. + +## Contract + +- Workflow identifies a definition; run identifies one execution; worktree identifies its target. +- `context.workflow.id` and `name` describe the definition. `context.run.id` and `path` + describe the run. Keep `context.worktree` and `context.step`. +- Rename `context.source` to `context.initiator`. Role bindings expose `display_name` + and `pane_id`; retain `source`, `agent`, and `observed`. +- Action-only context is `context.action`, with `execution_id`, `step_id`, `attempt`, + `working_directory`, and `artifacts_directory`. +- Agent submissions are deliveries: `expect.delivery`, `expect.verdicts`, and + `deliveries..path|verdict`. Actual submitted verdicts stay singular. +- Actions return `actions..output` and `output_path`. Script manifests retain + `input_schema` and `output_schema`; inherited environment names use `backend.inherit_env`. +- Action identifiers use `builtin:` or `local:`. + Rename the existing collector to `builtin:collect-worktree-context`; its current + single-Git-directory behavior remains unchanged. Broader collection belongs to D3. +- Participant submission is `prowl workflow deliver` and socket `command: workflow` with `action: deliver`. + Retain `run`, `status`, `read`, `cancel`, and `test-action`. +- The terminal loop-limit state is `iteration_limit_reached`. + +## Verification + +Add parser, expression/runtime, command/schema, and retired-name rejection coverage. +Check active source, shipped resources, documentation, and skills for stale names. +Keep deliberate rejection fixtures distinguishable from supported examples. +Run CLI build/smoke/unit/integration, relevant App workflow tests, `make check`, and +`make build-app`. Complete three sequential reviews with the neighboring Pi agent; +fix confirmed findings before each next review. Publish a non-draft fork PR. + +## Outcome + +Implemented the contract across runtime contexts, native action requests, expression +validation, delivery persistence, CLI routing/payloads, published schemas, bundled +examples, localized authoring prompts, documentation, and shipped skills. Removed the +unused legacy template renderer; tests now inspect the context produced by the runner. + +Three sequential adversarial reviews were completed. Confirmed corrections included +the actual script-request execution ID, obsolete names hidden in optional expressions, +local action ID syntax, stale reference examples, and naming-check false positives on +custom data. Each confirmed issue received a fix and relevant regression coverage. +The third round's optional quoted-key/whitespace coverage was also added. + +Validation passed: `make check` (150 script tests), CLI build and smoke checks, 291 CLI +unit tests, 112 CLI integration tests, 3147 App tests, and `make build-app`. Native-action +tests decode the actual `request.json`; the expression tests distinguish optional +availability from valid namespace spelling. + +`make check-workflow-naming` is a lightweight source/reference regression check, not +a complete YAML parser or semantic validator. It exempts explicit historical records +and the old-to-new mapping in this document. Runtime parsing and bundle validation +remain authoritative; custom input and output schemas do not reserve retired words. + +D3 remains separate: the renamed collector still requires one Git directory, and no +handoff migration or additional collector was implemented. diff --git a/docs-ai/063-agent-workflows/dsl-spec.md b/docs-ai/063-agent-workflows/dsl-spec.md index e2d0bc0af..f3b32aec3 100644 --- a/docs-ai/063-agent-workflows/dsl-spec.md +++ b/docs-ai/063-agent-workflows/dsl-spec.md @@ -76,25 +76,32 @@ run. CLI overrides are source-specific (§9): `--role =` | binding `source`, `name`, `agent`, `pane` | +| `context.initiator` | original source pane identity, or null for a worktree-only start | +| `context.roles.` | binding `source`, `display_name`, `agent`, `pane_id`, and live `observed` | | `context.step` | `id`, `iteration` (null outside loops), `captured_at` | -| `context.execution` | action-only `id`, `step_id`, `attempt`, `cwd`, `artifact_dir` | +| `context.action` | action-only `execution_id`, `step_id`, `attempt`, `working_directory`, `artifacts_directory` | | `inputs.` | typed start-time inputs | -| `outputs.` | agent delivery `path` and nullable `verdict` | -| `actions.` | action `output` object and `result_path` | +| `deliveries.` | agent delivery `path` and nullable `verdict` | +| `actions.` | action `output` object and `output_path` | | `state.` | explicitly declared, mutable typed state | Use `{{ expression }}` in text and action inputs. A complete expression in an action input retains its type; text interpolation accepts scalars, not arrays/objects. No implicit string-to-number or string-to-boolean conversion occurs. Missing fields are errors; -`exists(outputs.optional.path)` and `outputs.optional.path ?? ''` handle absence explicitly. +`exists(deliveries.optional.path)` and `deliveries.optional.path ?? ''` handle absence explicitly. `exists` does not hide arithmetic/type errors. `&&`, `||`, and `??` short-circuit. Expressions support null, booleans, numbers, single/double quoted strings, arrays, @@ -136,7 +143,7 @@ No step implicitly changes state from an action result. `max_iterations`. Conditions must be boolean. The loop tests its condition before each iteration. In a `while` condition, `context.step.id` is the loop ID and `context.step.iteration` is the number of completed iterations (0 on the first check). -Inside the body, iteration numbers start at 1. If the condition stays true at the cap, the run ends as `max_rounds_reached`; it does not +Inside the body, iteration numbers start at 1. If the condition stays true at the cap, the run ends as `iteration_limit_reached`; it does not report success or execute later steps. For an ordinary counted loop, express the count in its condition. Omit the cap when the task calls for an unlimited loop. @@ -155,13 +162,16 @@ iteration's output being implicitly visible ; use the declared state and `while` ## Step verbs -Each step has `id`, optional templated `title`, and one verb. +Each step has `id`, optional templated `title`, and one verb. Action IDs use +`builtin:` or `local:`: for example, +`builtin:collect-worktree-context` and `local:persist-handoff`. Use verb-first +kebab-case names for actions; dot-separated expressions address data, not actions. | Verb | Payload and behavior | | --- | --- | | `message: role` | `text` (one line) or `instruction` (file-backed multiline); waits for the role to be idle before injection; optional `expect` | | `launch: role` | `prompt`, optional bundled `skill`, optional `expect`; at most once per persistent role | -| `action: builtin:git.context` or `local:id` | typed `with` object; awaits validated result; no `expect`; see [actions](../../skills/prowl-workflow/references/actions.md) | +| `action: builtin:collect-worktree-context` or `local:id` | typed `with` object; awaits validated result; no `expect`; see [actions](../../skills/prowl-workflow/references/actions.md) | | `notify: text` | notification | | `close: role` | closes a launch role's pane; use only when the requested workflow needs cleanup | | `set` | atomic state assignments | @@ -174,10 +184,10 @@ Each step has `id`, optional templated `title`, and one verb. ```yaml expect: - output: findings # output name; default = step id; the same name may be produced by several steps (latest wins) + delivery: findings # output name; default = step id; the same name may be produced by several steps (latest wins) format: markdown # markdown (default) | text | json (parseable) sections: ["## Findings"] # markdown: required headings (fence/preamble stripped before checking, as HandoffStore.validatedBriefing) - verdict: [clean, issues] # declares 2–4 allowed values (safe slugs); the rendered completion command then carries `--verdict ` and it becomes mandatory + verdicts: [clean, issues] # declares 2–4 allowed values (safe slugs); the rendered completion command then carries `--verdict ` and it becomes mandatory timeout: 2h # optional hard cap; NO default — without it Prowl waits as long as the agent works on_timeout: attention # only with `timeout`: attention (default) | skip | cancel strict: false # default false: a delivery that misses sections/format/verdict is kept as @@ -185,10 +195,10 @@ expect: ``` - **Validation is a review gate, not a wall (decision 2026-08-29, [007](007-b2-runner-core.md) H14).** - `prowl workflow done` always rejects what the pipeline cannot use at all — a missing or + `prowl workflow deliver` always rejects what the pipeline cannot use at all — a missing or wrong token, an empty body, a body above the size cap. Everything the *author* declared — - `sections`, `format`, `verdict` — is checked too, but by default a delivery that misses - them is **persisted as provisional** (`outputs/..md` is written, the CLI + `sections`, `format`, `verdicts` — is checked too, but by default a delivery that misses + them is **persisted as provisional** (`deliveries/..md` is written, the CLI answers `ok` with `warnings`), the dispatch record stays pending, and the run enters `needsAttention` with **Accept as delivered** (or **Accept with verdict …** when a declared verdict is missing or not one of the declared values — the user picks one), **Ask again** @@ -204,7 +214,7 @@ expect: prompted-launch path, a `message` step through #733's re-dispatch into an existing surface. One pending record per surface; it is created only while the role is idle (§4), so exactly one runtime turn belongs to it and a `turn-ended` without a delivery is the `incomplete` - evidence the watchdog consumes (§10). `prowl workflow done` resolves the caller pane to that + evidence the watchdog consumes (§10). `prowl workflow deliver` resolves the caller pane to that pane's current pending record (kernel peer PID + process ancestry, as `dispatch-complete` does) and additionally requires the activation token to match — correlation, not trust — then validates the body, persists the output, and completes the record. Skip / Cancel / @@ -218,12 +228,12 @@ expect: (`instructions/..md`). When the step has an `expect`, the same invocation is also its *activation* `(run id, step id, ordinal, delivery role)`: the runner mints a fresh delivery token for it, and the previous activation of the same step (if any) is - terminal. Exactly one successful `done` is accepted per activation, identified by its + terminal. Exactly one successful `deliver` is accepted per activation, identified by its token; a later, stale, or token-less delivery gets `STEP_NOT_EXPECTING` / `TOKEN_REQUIRED` / `TOKEN_INVALID`. Skip / Cancel / Relaunch revoke the *current* activation's token (Relaunch then mints a new invocation/activation). Every delivery is - persisted as `outputs/..md` (collision-free by construction, even when - several steps produce the same output name); `outputs/.md` is the "latest" view, + persisted as `deliveries/..md` (collision-free by construction, even when + several steps produce the same output name); `deliveries/.md` is the "latest" view, replaced atomically (temp file + rename); `run.json` records the invocation → step / iteration / activation / file mapping. - Output bodies are capped (16 MiB in both the CLI and App → `OUTPUT_TOO_LARGE`). @@ -245,20 +255,20 @@ prowl workflow run [source] [--role r=]... [--input k=v]... [ # when the workflow has a `current` role (SOURCE_REQUIRED outside a pane), a # worktree reference otherwise prowl workflow status [run-id] [--json] # no args: "who am I" — caller pane's run, role, awaited step and its requirements -prowl workflow done [-|--file ] [--verdict ] [--token ] [--run --step ] [--force] [--json] +prowl workflow deliver [-|--file ] [--verdict ] [--token ] [--run --step ] [--force] [--json] prowl workflow cancel [--json] prowl workflow validate [--json] prowl workflow schema # JSON Schema / reference for authoring agents ``` -**Resolution of `done`.** Two independent facts must agree: the **caller pane** (socket +**Resolution of `deliver`.** Two independent facts must agree: the **caller pane** (socket peer PID → process ancestry → shell PID → pane) identifies the run and role, and the **delivery token** (`PROWL_WORKFLOW_TOKEN` — set in the launch environment for a `launch` step's activation exactly like `PROWL_DISPATCH_ID`, carried as the typed line's environment prefix for a `message` step's activation exactly like `PROWL_HANDOFF_REQUEST_ID` today, or `--token`) identifies the awaited step. Prowl mints the token when the step starts waiting, embeds it in the typed hint, and -revokes it on Skip / Cancel / Relaunch; a stale or duplicated `done` from a pane that has +revokes it on Skip / Cancel / Relaunch; a stale or duplicated `deliver` from a pane that has moved on to another step is therefore rejected instead of misattributed. A pane belongs to at most one run at a time, so no run/step ids are needed in the typed command. Explicit `--run --step` is required when no caller pane exists (manual delivery, logged as @@ -293,7 +303,7 @@ role and the first step is a `message` to that role, the response carries that s rendered instruction (or scoped `workflow read` command) and its completion command, and the runner does **not** also type them into the caller's pane — the caller already has them. For an agent this makes a self-handoff two commands: `prowl workflow run prowl.handoff`, then the returned -`… prowl workflow done -` with its briefing on stdin. +`… prowl workflow deliver -` with its briefing on stdin. Error codes: `WORKFLOW_NOT_FOUND`, `WORKFLOW_INVALID`, `RUN_NOT_FOUND`, `PANE_BUSY`, `ROLE_MISMATCH`, `STEP_NOT_EXPECTING`, `TOKEN_REQUIRED`, `TOKEN_INVALID`, @@ -303,7 +313,7 @@ Error codes: `WORKFLOW_NOT_FOUND`, `WORKFLOW_INVALID`, `RUN_NOT_FOUND`, `PANE_BU (a rendered `text`/pointer/`--input` value would not survive as one terminal line), `UNSAFE_PATH`, `PROMPT_TOO_LARGE` (a rendered `launch` prompt above 128 KiB), `WORKFLOW_DELIVERY_REQUIRED` (`agents dispatch-complete` from a pane whose pending record is a -workflow activation; the message carries the exact `prowl workflow done` replacement). +workflow activation; the message carries the exact `prowl workflow deliver` replacement). (`AGENT_GONE` and `WAIT_TIMEOUT` belong to the `agents wait` contract, not to `prowl workflow`.) @@ -327,7 +337,7 @@ changed` / `exit` was requested. `prowl.action/v1` manifests declare a name, object-root `input_schema`/`output_schema`, a script backend (`interpreter`, action-relative `entrypoint`, optional literal `arguments` and inherited -`environment` names), and optional timeout (1s...86400s; default 30s). JSON Schema Draft 2020-12 +`inherit_env` names), and optional timeout (1s...86400s; default 30s). JSON Schema Draft 2020-12 validates the schema, effective input, and result. Bundle-relative JSON/YAML schema resources and fragments are supported without network fetching. `$id` overrides are rejected; use local references and anchors. Defaults remain annotations. `prowl workflow schema --action` exports @@ -348,7 +358,7 @@ approved. Changed content or location needs approval again. Runs use fixed copie integrity before actions; an invalid copy offers cancel only. Retry keeps the approved copy, creates a new UUID/attempt, and can repeat side effects. It is never automatic. -`builtin:git.context` takes optional `root` within the selected worktree and returns an object +`builtin:collect-worktree-context` takes optional `root` within the selected worktree and returns an object with `path` and `branch`. It writes invocation artifacts and has no dependency on shared handoff files. `local:` selects only an action from this bundle. @@ -359,8 +369,8 @@ definition/ Fixed bundle copy run.json Status, bindings, typed state, attempts, output references log.md Timeline instructions/..md Materialized agent instructions -outputs/..md Accepted/provisional agent delivery -outputs/.md Latest file view +deliveries/..md Accepted/provisional agent delivery +deliveries/.md Latest file view skills// Materialized bundled skill actions/// request.json @@ -376,7 +386,7 @@ creation. Global UUID lookup does not depend on open projects. See [personal history and retention](018-history-storage-plan.md) for the fixed 30-day policy, 5 GiB soft budget, 24-hour protection, Keep Run, and complete ZIP export. Agents use `workflow read` with pane/task attribution for instructions and explicitly -granted output/action resources; ordinary delivery remains `workflow done -` on stdin. +granted output/action resources; ordinary delivery remains `workflow deliver -` on stdin. Only the current execution UUID may publish an action result. Cancel terminates the owned process group, escalating after a bounded grace period. App-private process ownership records @@ -391,7 +401,7 @@ are authored from roles, deliveries, typed state, and control flow. There is no registry, remote action download, parallel DSL branch, automatic retry, rollback, or resume. Headless roles and additional action backends are outside this contract. -`context.source` preserves the initiating `pane_id` and `tab_id` (null for worktree-only starts). +`context.initiator` preserves the initiating `pane_id` and `tab_id` (null for worktree-only starts). `exists(value) && predicate` and `!exists(value) || predicate` support optional data without requiring a missing value on the short-circuited path. diff --git a/docs-ai/063-agent-workflows/release-plan.md b/docs-ai/063-agent-workflows/release-plan.md index b8c383696..cfcb5d3ca 100644 --- a/docs-ai/063-agent-workflows/release-plan.md +++ b/docs-ai/063-agent-workflows/release-plan.md @@ -1,3 +1,7 @@ +> **2026-09-07 naming slice:** Action bundles (#774) and personal history (#775) are +> merged. Normalize the unreleased contract before D3; see [019](019-workflow-naming.md). +> This slice does not implement handoff or adversarial review and does not publish a release. + > **2026-09-06 action bundle implementation:** Workflow UI is enabled by default in this > change. `PROWL_WORKFLOW_UI=0` remains available to hide it. The earlier intervening-release > opt-in gate below is historical; see [017](017-action-bundle-implementation.md). @@ -32,7 +36,7 @@ as the slice name; both belong to 064. Cross-entry couplings (only these two): 064-S1 delivers the `ObservedAgentState` observer that 063-B3 consumes; 064-S3 attaches launch-scoped hooks through 063-A2's launch boundary. -063 V1 does not otherwise depend on 064 (steps complete on `prowl workflow done`). +063 V1 does not otherwise depend on 064 (steps complete on `prowl workflow deliver`). ## Releases @@ -129,7 +133,7 @@ User-visible result: onevcat's daily CLI-driven orchestration is first-class | 1 | **#733** `prowl agents dispatch --prompt -`: a new pending dispatch bound to an existing surface, one pending per surface, `dispatch-complete` resolved from the caller's ancestry to the pane's current record, refused while the agent is working or blocked | 064 | S2, 064.012 | a reviewer launched once takes N assignments, each with its own receipt — the transport B3's `message` + `expect` rides on (decision 2026-08-29), and usable from the CLI recipe as soon as it merges | | 1 | **#726 T0** version attestation: per-runtime attested version record beside the research matrix + `make agent-versions` | 064 | S3 wave 1 | an installed runtime newer than its attested contract warns before a release | | 2 | **B2** runner core (pure state machine, run store, templates, registry, watchdog) — record [063.007](007-b2-runner-core.md) | 063 | B1 | watchdog on exact signals (064-S5 watchdog part, moved from D2); dormant until B3 | -| 3 | **B3** runner wiring + `workflow run/status/done/cancel` | 063 | A2, S1, B2, #733 | engine powered on | +| 3 | **B3** runner wiring + `workflow run/status/deliver/cancel` | 063 | A2, S1, B2, #733 | engine powered on | | 4 | **C1** status center + run panel + notifications | 063 | B3 | runs visible | User-visible result: a workflow file runs from the CLI (`prowl workflow run`), its steps and @@ -246,7 +250,7 @@ R3+: V2 / S5 rest name is superseded). Before merge it was driven end to end from fresh agents that saw only the skill and the CLI: one ran an existing demo workflow, one authored and ran a new two-agent workflow (validated first try, `close:` steps included), and a launched participant - loaded the skill from the typed `[Prowl] …` line. That pass surfaced the `max_rounds_reached` + loaded the skill from the typed `[Prowl] …` line. That pass surfaced the `iteration_limit_reached` trap (a loop is only left through a satisfied `until`; the "poor-man's if" is not an `if`), now spelled out in the skill with a gave-up-verdict pattern. D1's remaining scope (Settings › Workflows page, `docs/components/workflows.md`, CLI reachability) is unchanged. diff --git a/docs-ai/064-agent-completion-signals/000-plan.md b/docs-ai/064-agent-completion-signals/000-plan.md index 86f572ddc..9080bd00a 100644 --- a/docs-ai/064-agent-completion-signals/000-plan.md +++ b/docs-ai/064-agent-completion-signals/000-plan.md @@ -33,7 +33,7 @@ at launch and have the agent report to Prowl through the bundled `prowl` binary. - Introduce one **agent signal bus** per pane that merges four layers of evidence, each tagged with `source` and `confidence`: - 0. cooperative signals — `prowl agents signal` (and 063's `prowl workflow done`); + 0. cooperative signals — `prowl agents signal` (and 063's `prowl workflow deliver`); 1. native hooks installed by Prowl at launch (agent-reported, exact); 2. deterministic observations — native transcript turn-end markers (059), agent process exit, OSC progress/notification sequences the CLI emits itself; @@ -60,7 +60,7 @@ at launch and have the agent report to Prowl through the bundled `prowl` binary. Hooks are attached only through launch-scoped flags/config the adapter has verified; a runtime without such a channel simply stays at layers 2–3. - Waiting semantics inside 063 workflows: the runner still completes steps only on - `prowl workflow done`; this entry improves its watchdog and enables 063's V2 observe mode. + `prowl workflow deliver`; this entry improves its watchdog and enables 063's V2 observe mode. ## Design / Approach diff --git a/docs-ai/064-agent-completion-signals/001-action.md b/docs-ai/064-agent-completion-signals/001-action.md index 6931a7013..63f8d8679 100644 --- a/docs-ai/064-agent-completion-signals/001-action.md +++ b/docs-ai/064-agent-completion-signals/001-action.md @@ -17,7 +17,7 @@ S1 does not add `agents wait`, launch-scoped runtime hooks, workflow completion, ## Owner decisions fixed before implementation -- Continue the 064 path before the 063 workflow runner. `prowl workflow done` remains the only command that completes a workflow step; agent signals are observation/control-plane evidence. +- Continue the 064 path before the 063 workflow runner. `prowl workflow deliver` remains the only command that completes a workflow step; agent signals are observation/control-plane evidence. - Rename the runtime edge from ambiguous `turn-complete` to `turn-ended`. A runtime hook can prove that a turn ended, not that an assigned task completed. - Reserve `dispatch-complete` for S2's paired dispatch protocol. S1 recorded the provisional shape; the final owner-reviewed command and receipt contract is in diff --git a/docs-ai/064-agent-completion-signals/002-s1-work-note.md b/docs-ai/064-agent-completion-signals/002-s1-work-note.md index ad08d6604..6f62ca52b 100644 --- a/docs-ai/064-agent-completion-signals/002-s1-work-note.md +++ b/docs-ai/064-agent-completion-signals/002-s1-work-note.md @@ -61,7 +61,7 @@ create --profile --prompt The dispatch receipt survives agent/pane closure but not app restart. A later dispatch cannot be satisfied by an older receipt. Internal surface generations remain only an unpaired -observation fallback. Full workflow output continues through `prowl workflow done -`; large +observation fallback. Full workflow output continues through `prowl workflow deliver -`; large ad-hoc results continue through `agents read` or a future `agents wait --include-result`. ## Progress log diff --git a/docs-ai/064-agent-completion-signals/003-s2-dispatch-wait-design.md b/docs-ai/064-agent-completion-signals/003-s2-dispatch-wait-design.md index a308051ad..b804b3d3d 100644 --- a/docs-ai/064-agent-completion-signals/003-s2-dispatch-wait-design.md +++ b/docs-ai/064-agent-completion-signals/003-s2-dispatch-wait-design.md @@ -64,7 +64,7 @@ S2 ships three connected surfaces in one PR: heuristics otherwise. S2 does not install runtime hooks, watch transcript files, infer completion with an LLM, -persist receipts across app restarts, or change `prowl workflow done` semantics. Those +persist receipts across app restarts, or change `prowl workflow deliver` semantics. Those remain owned by S3/S4, the orchestrating skill, and 063 respectively. ## Two planes, one observer context @@ -109,7 +109,7 @@ that needs byte-for-byte prompt delivery can create an interactive pane and use This rule is deliberately scoped to the CLI create request, not every internal consumer of the shared profile-launch boundary. The lifecycle request passes an explicit dispatch context into the shared launch seam. 063 workflow launches keep their separate -`prowl workflow done` activation protocol, and future prompt launchers must choose their own +`prowl workflow deliver` activation protocol, and future prompt launchers must choose their own completion contract rather than inheriting dispatch behavior accidentally. The id must be passed through the launch plan's child-process command environment, not the diff --git a/docs-ai/064-agent-completion-signals/012-cli-evidence-semantics.md b/docs-ai/064-agent-completion-signals/012-cli-evidence-semantics.md index bb2516250..37f20fe93 100644 --- a/docs-ai/064-agent-completion-signals/012-cli-evidence-semantics.md +++ b/docs-ai/064-agent-completion-signals/012-cli-evidence-semantics.md @@ -28,7 +28,7 @@ modes. Four behaviors, however, made the documented flows unreliable in practice the signal `unbound` (plain shell pane; caller outside the agent's process tree), so the caller could not tell that the signal would never count as evidence. -Documentation also referenced a `prowl workflow done` command that does not exist yet (063 +Documentation also referenced a `prowl workflow deliver` command that does not exist yet (063 R2), and omitted `AGENT_NOT_FOUND` (wait), `DISPATCH_NOT_FOUND`, `DISPATCH_CONTEXT_REQUIRED`, and `DISPATCH_ALREADY_TERMINAL`. diff --git a/docs-ai/README.md b/docs-ai/README.md index 6dd26ef4b..6cadaa00d 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -120,7 +120,7 @@ agent-facing manual for that). | 060 | [prowl-cli-targeting-and-contract-governance](060-prowl-cli-targeting-and-contract-governance/000-plan.md) | 2026-08-16 | Unified target grammar, CLI contract rebaseline, and durable documentation governance | | 061 | [native-toolbar-controls](061-native-toolbar-controls/000-plan.md) | 2026-08-17 | Native macOS toolbar grouping, Liquid Glass ownership, and review standards | | 062 | [workspace-child-diff](062-workspace-child-diff/000-plan.md) | 2026-08-19 | Per-repository diff for workspace children via unified DiffTarget routing | -| 063 | [agent-workflows](063-agent-workflows/000-plan.md) | 2026-08-21 | Agent Workflows: YAML-declared, profile-bound multi-agent orchestration (runner, `prowl workflow` CLI, status center, built-in handoff/adversarial review); successor to 047's fixed handoff flow; [action bundles and control-flow design](063-agent-workflows/015-action-bundles-and-control-flow.md), [implementation contract](063-agent-workflows/017-action-bundle-implementation.md), [personal history and retention](063-agent-workflows/018-history-storage-plan.md) | +| 063 | [agent-workflows](063-agent-workflows/000-plan.md) | 2026-08-21 | Agent Workflows: YAML-declared, profile-bound multi-agent orchestration (runner, `prowl workflow` CLI, status center, built-in handoff/adversarial review); successor to 047's fixed handoff flow; [action bundles and control-flow design](063-agent-workflows/015-action-bundles-and-control-flow.md), [implementation contract](063-agent-workflows/017-action-bundle-implementation.md), [personal history and retention](063-agent-workflows/018-history-storage-plan.md), [naming contract](063-agent-workflows/019-workflow-naming.md) | | 064 | [agent-completion-signals](064-agent-completion-signals/000-plan.md) | 2026-08-22 | Layered agent signal bus (cooperative / launch-scoped hooks / transcript+process+OSC / heuristic), `prowl agents signal` + `agents wait` with source/confidence, per-runtime hook research, and [T1 contract-test plan](064-agent-completion-signals/016-t1-contract-test-plan.md) | | 065 | [bundled-agent-skills](065-bundled-agent-skills/000-plan.md) | 2026-08-22 | Bundle Prowl's official agent skills into the app, `prowl skills` install/uninstall via symlinks into agent skill folders, Agent Skills section on Settings › CLI & Skills, shared registry for 063 | | 066 | [agent-island](066-agent-island/000-plan.md) | 2026-09-01 | Notch-aware Active Agents island, global keyboard entry, Agents Display settings, and hover-revealed floating placement | diff --git a/docs/components/cli.md b/docs/components/cli.md index f372db6c9..3f4702033 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -486,7 +486,7 @@ prowl workflow list [target] [--json] # every definition visib prowl workflow run [source] [--role r=]... [--input k=v]... [--skip ]... [--json] prowl workflow test-action [source] --input-json '' [--json] prowl workflow status [run-id] [--json] # no args: the calling pane's run, role, awaited step -prowl workflow done [-|--file ] [--verdict ] [--token ] [--run --step ] [--force] [--json] +prowl workflow deliver [-|--file ] [--verdict ] [--token ] [--run --step ] [--force] [--json] prowl workflow cancel [--json] prowl workflow validate [--scope bundle|user|repo] [--json] # validate a bundle; exit 1 on errors prowl workflow schema [--action] [--json] # workflow or action manifest JSON Schema (Draft 2020-12) @@ -532,10 +532,10 @@ prowl workflow schema [--action] [--json] # workflow or include only assigned skills and explicitly passed workflow inputs/artifacts. A granted artifact directory returns a JSON list of its contained file IDs; read each ID separately. Reading content never delivers an output or completes a step. -- `done` delivers the output of the step this pane is working on: the body comes from piped +- `deliver` delivers the output of the step this pane is working on: the body comes from piped stdin (`-`) or `--file`; `--verdict` supplies the declared verdict when the step requires one. Prowl attributes the delivery by the **caller pane** (its pending workflow activation) and - checks the token the step handed out (`PROWL_WORKFLOW_TOKEN=… prowl workflow done -` for a + checks the token the step handed out (`PROWL_WORKFLOW_TOKEN=… prowl workflow deliver -` for a typed step, the child environment of a launched role, or `--token`): a stale or wrong token is `TOKEN_INVALID`, a missing one `TOKEN_REQUIRED`, a pane whose step moved on `STEP_NOT_EXPECTING`. `--run --step ` is the manual path from outside the role's @@ -547,7 +547,7 @@ prowl workflow schema [--action] [--json] # workflow or for a decision in Prowl (accept, ask again, skip). Empty bodies are `OUTPUT_INVALID`, bodies above the step's cap `OUTPUT_TOO_LARGE`, and `strict: true` steps reject issues outright. `agents dispatch-complete` from a pane that owes a workflow delivery is refused with - `WORKFLOW_DELIVERY_REQUIRED` and the exact `done` command to run instead. + `WORKFLOW_DELIVERY_REQUIRED` and the exact `deliver` command to run instead. - `status` without an argument answers "who am I": the calling pane's active run, its role, the step in progress, and — for the role that owes it — the awaited output with its requirements and completion commands (`.data.activation`). With a run UUID it reports that @@ -581,7 +581,7 @@ prowl workflow schema [--action] [--json] # workflow or `skill_unchecked` warnings instead of errors. - `schema` prints the machine-readable definition schema for editors and authoring agents (`--json` wraps it as `.data.schema`). Structural rules live in the schema; cross-reference - rules (undefined roles, premature `{{ outputs.* }}`, `until` verdicts, …) are enforced by + rules (undefined roles, premature `{{ deliveries.* }}`, loop verdicts, …) are enforced by `validate` only. ```bash @@ -590,19 +590,19 @@ prowl workflow list --json | jq '.data.workflows[] | select(.valid) | .id' run="$(prowl workflow run review --role reviewer=Codex --input max_rounds=3 --json)" printf '%s\n' "$run" | jq -r '.data.self_initiated.line' # what to do now, when this pane is the current role prowl workflow status --json | jq '.data.activation' # what this pane owes, and how to deliver it -PROWL_WORKFLOW_TOKEN=… prowl workflow done - <<'EOF' +PROWL_WORKFLOW_TOKEN=… prowl workflow deliver - <<'EOF' ## Scope … EOF prowl workflow cancel "$(printf '%s\n' "$run" | jq -r '.data.id')" ``` -JSON is `prowl.cli.workflow.v1` with `data.action` = `list` | `run` | `status` | `done` | -`cancel` | `read` | `validate` | `schema`; `run`, `status`, and `cancel` share the run shape, `done` +JSON is `prowl.cli.workflow.v1` with `data.action` = `list` | `run` | `status` | `deliver` | +`cancel` | `read` | `validate` | `schema`; `run`, `status`, and `cancel` share the run shape, `deliver` nests it under `.data.run` beside `.data.delivery`. `prowl workflow test-action [source] --input-json '' [--json]` -starts a real single-action run from a discovered bundle. Use `builtin:git.context` or +starts a real single-action run from a discovered bundle. Use `builtin:collect-worktree-context` or `local:`. Script bundles require prior native approval in Settings > Agents > Workflows; `WORKFLOW_APPROVAL_REQUIRED` tells you to review the bundle. This command cannot grant approval. It uses the same worktree, fixed bundle copy, process limits, cancellation, and action records @@ -903,7 +903,7 @@ artifacts and terminal excerpts do not appear in `git status`. | `DISPATCH_TARGET_BUSY` | `agents dispatch` refused: the pane's agent is working or blocked (`.error.details.observation`, `.signals`). Wait for `--until idle`, then retry. | | `DISPATCH_ALREADY_TERMINAL` | `dispatch-abandon` targeted a record that already completed, was abandoned, or is gone. | | `DISPATCH_FAILED` / `DISPATCH_ABANDONED` / `DISPATCH_NEEDS_INPUT` / `DISPATCH_INCOMPLETE` | `agents wait --dispatch` structured outcomes; `.error.details` retains the record, target, and evidence (see **Dispatch completion and waiting**). | -| `SOURCE_REQUIRED` | A caller-owned command such as `agents signal`, selector-free `handoff`, `workflow run` of a workflow with a `current` role, `workflow status` without a run id, or `workflow done` without `--run --step` could not map the socket peer ancestry to a Prowl pane. Run it inside the source pane without tmux/detached wrappers, or use an explicit selector where that command permits one. | +| `SOURCE_REQUIRED` | A caller-owned command such as `agents signal`, selector-free `handoff`, `workflow run` of a workflow with a `current` role, `workflow status` without a run id, or `workflow deliver` without `--run --step` could not map the socket peer ancestry to a Prowl pane. Run it inside the source pane without tmux/detached wrappers, or use an explicit selector where that command permits one. | | `AGENT_GONE` | The meaning is mode-specific: a signal caller disappeared, a dispatch worker became terminal, or a generic condition target closed. Inspect `.error.details.mode`; dispatch details retain a record, while condition details retain the requested condition and exact surface observation. | | `BLOCKER_UNREADABLE` | A blocked screen was detected but Prowl could not safely extract its current interaction text. Re-run `agents read` or inspect with `read`. | | `SESSION_UNRESOLVED` / `RESULT_NOT_FOUND` / `RESULT_INCOMPLETE` / `RESULT_TOO_LARGE` | `agents read --result-only` could not provide one trustworthy complete result. Drop `--result-only` to retain the live snapshot and inspect `.data.result`. | @@ -914,13 +914,13 @@ artifacts and terminal excerpts do not appear in `git status`. | `INVALID_SKILL_FRONTMATTER` | A bundled (or `PROWL_SKILLS_DIR`) skill's `SKILL.md` frontmatter is malformed — fix the override skill, or reinstall Prowl if the bundle itself is damaged. | | `WORKFLOW_INVALID` | `workflow validate` found errors, or `workflow run` named a definition with errors; the full diagnostics are in `.error.details` (JSON) or on stdout (text). Fix the file and re-run. | | `WORKFLOW_NOT_FOUND` / `WORKFLOW_DISABLED` | No workflow definition with that id or unique name is visible to the worktree, or it is switched off — re-run `workflow list`. | -| `RUN_NOT_FOUND` | No live run with that UUID (`cancel`, manual `done`), no record of it in any known worktree (`status`), or the calling pane is not part of an active run (`status` without arguments). | +| `RUN_NOT_FOUND` | No live run with that UUID (`cancel`, manual `deliver`), no record of it in any known worktree (`status`), or the calling pane is not part of an active run (`status` without arguments). | | `PANE_BUSY` / `DISPATCH_PENDING` (`workflow run`) | The source pane or a `--role` pane already belongs to another active run, or still holds a pending dispatch record that must be completed or abandoned first. | | `PROFILE_NOT_FOUND` / `PROFILE_NOT_UNIQUE` (`workflow run`) | No enabled Profile satisfies a `launch` role (pass `--role =`), or the given name matches several. | -| `STEP_NOT_EXPECTING` / `TOKEN_REQUIRED` / `TOKEN_INVALID` | `workflow done`: the calling pane holds no waiting activation (the step moved on, was skipped, or the run ended before the output was saved), the completion command was run without its token, or the token belongs to an earlier step. Re-run the latest completion command Prowl typed. | -| `ROLE_MISMATCH` | `workflow done --run --step` named a step other than the one the calling pane is waiting for; pass `--force` to deliver there anyway. | -| `OUTPUT_INVALID` / `OUTPUT_TOO_LARGE` / `VERDICT_REQUIRED` | `workflow done`: empty body (or, for a `strict` step, missing sections / bad format / bad verdict), body above the step's size cap, or a strict step that declares verdicts got none. Non-strict issues are accepted as `delivery.state = provisional` instead. | -| `WORKFLOW_DELIVERY_REQUIRED` | `agents dispatch-complete` ran in a pane whose pending dispatch is a workflow activation; the message carries the exact `prowl workflow done` command to run instead. | +| `STEP_NOT_EXPECTING` / `TOKEN_REQUIRED` / `TOKEN_INVALID` | `workflow deliver`: the calling pane holds no waiting activation (the step moved on, was skipped, or the run ended before the output was saved), the completion command was run without its token, or the token belongs to an earlier step. Re-run the latest completion command Prowl typed. | +| `ROLE_MISMATCH` | `workflow deliver --run --step` named a step other than the one the calling pane is waiting for; pass `--force` to deliver there anyway. | +| `OUTPUT_INVALID` / `OUTPUT_TOO_LARGE` / `VERDICT_REQUIRED` | `workflow deliver`: empty body (or, for a `strict` step, missing sections / bad format / bad verdict), body above the step's size cap, or a strict step that declares verdicts got none. Non-strict issues are accepted as `delivery.state = provisional` instead. | +| `WORKFLOW_DELIVERY_REQUIRED` | `agents dispatch-complete` ran in a pane whose pending dispatch is a workflow activation; the message carries the exact `prowl workflow deliver` command to run instead. | | `REQUEST_CANCELLED` | The CLI disconnected before an in-app workflow request completed; the run itself was not affected. | | `NO_ACTIVE_PANE` | No pane for focused-target; pass an explicit `--pane`. | | `EMPTY_INPUT` | `send` got neither argv nor stdin (or both). | diff --git a/docs/components/workflows.md b/docs/components/workflows.md index 719f21ecf..6d029a377 100644 --- a/docs/components/workflows.md +++ b/docs/components/workflows.md @@ -46,7 +46,7 @@ instructions), `launch` (start a launch role with a kickoff prompt), `set`, nested `if`/`else`, `while`, `break`/`continue`, `action` (built-in or local script), `notify`, and `close`. A `message` or `launch` step may `expect` an output: the role finishes by running the exact -`prowl workflow done …` command Prowl typed into its pane, with the body on +`prowl workflow deliver …` command Prowl typed into its pane, with the body on stdin. The full DSL, validator rules, and authoring patterns are the `prowl-workflow` skill's job (`prowl skills install prowl-workflow`); `prowl workflow schema` prints the workflow JSON Schema; `prowl workflow schema --action` prints the action manifest schema. @@ -57,7 +57,7 @@ Three sources, later ones winning for the same `id`: | Source | Location | Notes | |---|---|---| -| Built-in | `Prowl.app/Contents/Resources/workflows/` | ids `prowl.*` are reserved for it. Includes Repository Context (`builtin:git.context`). | +| Built-in | `Prowl.app/Contents/Resources/workflows/` | ids `prowl.*` are reserved for it. Includes Repository Context (`builtin:collect-worktree-context`). | | Your workflows | `~/.prowl/workflows/*.pwlworkflow` | personal; not tied to a repository | | Repository | `/.prowl/workflows/*.pwlworkflow` | travels with the repo; seen only from that repository's worktrees | @@ -145,7 +145,7 @@ The creation month stays fixed. No project-local runtime files or Git-ignore rul are created. Personal and team workflow definitions keep their existing locations. Each run contains `run.json`, `log.md`, its frozen bundle in `definition/`, -`instructions/`, `skills/`, `outputs/`, and `actions/`. Metadata records the original +`instructions/`, `skills/`, `deliveries/`, and `actions/`. Metadata records the original execution root. `prowl workflow status ` finds saved history even after that root is closed, moved, or deleted. A moved folder does not inherit the old history. @@ -168,7 +168,7 @@ deleting each complete run. Old project-local data is neither migrated nor delet Agents retrieve assigned instructions and explicit input resources with `prowl workflow read`, using the run ID, invocation number, and assigned pane. Prowl owns -persistence; deliver text or JSON through `prowl workflow done -` on stdin. Run +persistence; deliver text or JSON through `prowl workflow deliver -` on stdin. Run paths are temporary artifact locations, not durable downstream references. ## Settings → Agents → Workflows @@ -227,7 +227,7 @@ is not listening on its socket. The same status appears under Settings → Agent - A remembered binding is keyed by the role's requirements (`agents`, `suggest`, …): editing those in the file forgets the binding; editing prompts keeps it. -- Participants must deliver with the exact `prowl workflow done …` command Prowl +- Participants must deliver with the exact `prowl workflow deliver …` command Prowl typed (token included); `prowl agents dispatch-complete` is refused inside a workflow activation with `WORKFLOW_DELIVERY_REQUIRED`. - Nothing is closed automatically; a launched reviewer pane stays open after the @@ -236,9 +236,9 @@ is not listening on its socket. The same status appears under Settings → Agent ## Script actions and bundles Local actions live under `actions//action.yaml` in a `.pwlworkflow` bundle, with scripts, -helpers, and assets beside them. Steps reference `local:` or `builtin:git.context`. +helpers, and assets beside them. Steps reference `local:` or `builtin:collect-worktree-context`. Action inputs and results are typed JSON; validated results appear at -`actions..output` and `actions..result_path`. The built-in repository context +`actions..output` and `actions..output_path`. The built-in repository context writes per-invocation artifacts, not shared handoff files. Legacy `prowl handoff` remains a separate CLI feature. These actions are also distinct from shell-command Custom Actions. @@ -257,7 +257,7 @@ owned script process group; neither operation rolls back completed work. Typed `state` retains values explicitly. Branch and loop iteration results leave scope on exit. Use state to carry a verdict/path to the next iteration. A loop with no cap is permitted; -a loop whose condition remains true at `max_iterations` ends as `max_rounds_reached`. +a loop whose condition remains true at `max_iterations` ends as `iteration_limit_reached`. In the condition, `context.step.id` names the loop and `context.step.iteration` is the completed count, starting at 0. Steps in the body use iteration numbers starting at 1. A role stays bound for the run: launch it once, then send messages for repeated work. @@ -270,7 +270,7 @@ and keeps Run disabled. Approval returns to the same start screen; it does not s ## Sandbox access -`workflow read` and `workflow done` use the existing Prowl Unix socket. File-read +`workflow read` and `workflow deliver` use the existing Prowl Unix socket. File-read access and socket access are separate permissions. If the runtime returns `SOCKET_PERMISSION_DENIED`, use its native approval mechanism for the specific Prowl command or socket; do not disable sandboxing or grant home-directory access. @@ -288,7 +288,7 @@ matching `PROWL_CLI_SOCKET`. Release builds ignore this directory override. | Action input and `test-action --input-json` | 16 MiB | | Action stdout | 16 MiB | | Action stderr | 4 MiB | -| `workflow done` body (CLI and App) | 16 MiB of UTF-8 | +| `workflow deliver` body (CLI and App) | 16 MiB of UTF-8 | | `workflow read` page | 256 KiB; continue with `next_offset` | | Complete launch prompt, including protocol text | 128 KiB | | Frozen workflow bundle | 64 MiB / 8192 entries | diff --git a/scripts/check_workflow_naming.py b/scripts/check_workflow_naming.py new file mode 100644 index 000000000..3be319d70 --- /dev/null +++ b/scripts/check_workflow_naming.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Reject retired workflow vocabulary in maintained source and documentation.""" + +from pathlib import Path +import json +import re +import subprocess +import sys + + +RULES = ( + (r"\bworkflow\s+done\b", "Use workflow deliver."), + (r"\bWorkflowDone\w*\b", "Use WorkflowDeliver types."), + (r"builtin:(?:git\.context|worktree\.context|agent\.context)\b", "Use a verb-first action ID."), + (r"(?-]+\.(?:name|pane)\b", "Use display_name and pane_id."), + (r"(?-]+\.result_path\b|\bmax_rounds_reached\b", "Use output_path and iteration_limit_reached."), + (r"(? list[tuple[int, str]]: + findings = [] + if re.search(r"\bworkflow\s+done\b", text): + findings.append((1, "Use workflow deliver.")) + block = None + block_indent = 0 + custom_indent = None + for number, line in enumerate(text.splitlines(), 1): + indent = len(line) - len(line.lstrip()) + if custom_indent is not None and line.strip() and indent <= custom_indent: + custom_indent = None + # Custom action inputs and schemas are not workflow declarations. + yaml_line = re.sub(r"""['"]([a-z_]+)['"]\s*:""", r"\1:", line) + custom_opener = re.match(r"\s*(?:with|input_schema|output_schema):", yaml_line) + custom_data = custom_indent is not None or custom_opener is not None + if custom_opener: + custom_indent = indent + # Swift property names are internal implementation, not expression namespaces. + public_text = line + if swift: + public_text = " ".join(re.findall(r'"([^"\n]*)"', line)) + if line.lstrip().startswith("//"): + public_text = line + for pattern, message in RULES: + if pattern.startswith(r"^\s*(?:-\s*)?"): + if custom_data: + continue + candidate = yaml_line + else: + candidate = line if pattern.startswith(r"\bWorkflowDone") else public_text + candidate = candidate.replace("\\", "") + candidate = re.sub(r"""\[\s*['"]([a-zA-Z_][\w-]*)['"]\s*\]""", r".\1", candidate) + if re.search(pattern, candidate): + findings.append((number, message)) + if block and line.strip() and indent <= block_indent: + block = None + if block and not custom_data and re.match(r"\s*(?:output|verdict):" if block == "expect" else r"\s*environment:", yaml_line): + findings.append((number, "Use the current expect/backend declaration keys.")) + match = None if custom_data else re.match(r"\s*(expect|backend):\s*(?:#.*)?$", yaml_line) + if match: + block, block_indent = match[1], indent + if not swift: + candidates = [text] + re.findall(r"```json\s*\n(.*?)```", text, re.DOTALL) + for candidate in candidates: + try: + value = json.loads(candidate) + except (ValueError, TypeError): + continue + def check_declarations(node): + if isinstance(node, list): + for item in node: + check_declarations(item) + elif isinstance(node, dict): + expect = node.get("expect") + if isinstance(expect, dict) and set(expect) & {"output", "verdict"}: + findings.append((1, "Use delivery and verdicts in expect.")) + backend = node.get("backend") + if isinstance(backend, dict) and backend.get("type") == "script" and "environment" in backend: + findings.append((1, "Use inherit_env in script backend.")) + # Descend only through workflow control-flow containers, never custom action data. + for key in ("steps", "body", "then", "else"): + if key in node: + check_declarations(node[key]) + + check_declarations(value) + if (isinstance(value, dict) and value.get("protocol") == "prowl.action/v1" + and isinstance(value.get("context"), dict)): + context = value["context"] + invalid = set(context) & {"source", "execution"} + run = context.get("run", {}) + action = context.get("action", {}) + if isinstance(run, dict): + invalid |= set(run) & {"workflow_id", "directory"} + if isinstance(action, dict): + invalid |= set(action) & {"id", "cwd", "artifact_dir"} + roles = context.get("roles", {}) + if isinstance(roles, dict): + for role in roles.values(): + if isinstance(role, dict): + invalid |= set(role) & {"name", "pane"} + if invalid: + findings.append((1, "Retired context JSON keys: " + ", ".join(sorted(invalid)))) + return findings + + +def main() -> int: + root = Path(__file__).resolve().parent.parent + names = subprocess.check_output(["git", "ls-files", "--cached", "--others", "--exclude-standard"], cwd=root, text=True) + errors = [] + for name in sorted(set(names.splitlines())): + if not (name.startswith(ROOTS + ("docs-ai/063-agent-workflows/",)) or name in REFERENCES): + continue + path = root / name + if path.is_symlink() or not path.is_file() or path.suffix not in {".swift", ".md", ".json", ".yaml", ".yml"}: + continue + text = path.read_text() + # The naming decision records old-to-new mappings; historical slices are explicitly marked. + if name.endswith("019-workflow-naming.md") or text.startswith("> Historical slice record."): + continue + errors.extend(f"{name}:{line}: {message}" for line, message in violations(text, swift=path.suffix == ".swift")) + for error in errors: + print(error) + if not errors: + print("Workflow naming checks passed.") + return bool(errors) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_workflow_naming.py b/scripts/test_workflow_naming.py new file mode 100644 index 000000000..d1eeb3f87 --- /dev/null +++ b/scripts/test_workflow_naming.py @@ -0,0 +1,72 @@ +import unittest + +from check_workflow_naming import violations + + +class WorkflowNamingTests(unittest.TestCase): + def test_rejects_stale_public_vocabulary(self): + for text in ( + "prowl workflow done -", "builtin:git.context", "context.source.pane_id", + "context.execution.id", "context.run.workflow_id", "context.roles.author.pane", + "context.action.cwd", "actions.snapshot.result_path", "max_rounds_reached", + "backend.environment", "expect.verdict", "{{ outputs.brief.path }}", + "expect: {output: findings, verdict: [clean, issues]}", + "backend: {type: script, environment: [MY_KEY]}", + "if: exists(outputs.findings.path)", + '{"protocol":"prowl.action/v1","context":{"run":{"workflow_id":"review"},"action":{"id":"x","cwd":"/repo"}}}', + "`workflow list|run|status|done|cancel`", + ): + with self.subTest(text=text): + self.assertTrue(violations(text)) + + def test_alternative_protocol_syntax(self): + for text in ( + '{"steps":[{"id":"review","expect":{"output":"findings","verdict":["clean","issues"]}}]}', + '{"backend":{"type":"script","environment":["MY_KEY"]}}', + '{"protocol":"prowl.action/v1","context":{"roles":{"author":{"name":"Pi","pane":"p1"}}}}', + "expect: # submission\n output: findings\n verdict: [clean, issues]", + "finish with: prowl workflow\n done -", + "{{ context['source']['pane_id'] ?? '' }}", + "{{ context[ 'run' ][ 'directory' ] ?? '' }}", + "expect: {'output': findings, 'verdict': [clean, issues]}", + 'expect:\n "output": findings\n "verdict": [clean, issues]', + ): + with self.subTest(text=text): + self.assertTrue(violations(text)) + + def test_custom_data_is_not_reserved(self): + for text in ( + "{{ inputs.result_path }}", + "{{ actions.summarize.output.result_path }}", + "{{ actions.summarize.output.outputs.path }}", + '{"context":{"source":"git"}}', + '{"input":{"expect":{"output":"report"},"backend":{"type":"script","environment":[]}}}', + + "{{ actions.snapshot.output.context.source }}", + "{{ actions.snapshot.output.context.run.directory }}", + "{{ actions.snapshot.output.expect.verdict }}", + "{{ actions.snapshot.output.backend.environment }}", + "steps:\n - id: snapshot\n action: local:write-report\n with:\n" + " expect: {output: report}\n backend: {type: script, environment: [MY_KEY]}", + "with: {expect: {output: report}, backend: {environment: [MY_KEY]}}", + "input_schema:\n properties:\n expect: {output: report}", + "output_schema:\n properties:\n backend: {environment: [MY_KEY]}", + + ): + with self.subTest(text=text): + self.assertFalse(violations(text)) + + def test_accepts_current_contract_and_internal_swift_properties(self): + self.assertFalse(violations(""" +prowl workflow deliver - +builtin:collect-worktree-context +context.workflow.id context.run.path context.initiator.pane_id +context.roles.author.pane_id context.action.execution_id +deliveries.brief.path actions.snapshot.output_path backend.inherit_env expect.verdicts +""")) + self.assertFalse(violations("let source = context.source", swift=True)) + self.assertTrue(violations('let text = "{{ context.source.pane_id }}"', swift=True)) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 1b129c2f6..a9073d31e 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -150,8 +150,8 @@ record is in `.error.details.record`) until you wait for or `dispatch-abandon` t one. The prompt is piped stdin (multi-line is fine; it arrives as one message), and the reviewer completes with the usual `agents dispatch-complete` — from its own pane, no id needed. -Run a workflow file instead of scripting the rounds yourself (`workflow list` shows what is -visible to your worktree; `workflow validate ` checks a new one locally): +Run a workflow bundle instead of scripting the rounds yourself (`workflow list` shows what is +visible to your worktree; `workflow validate ` checks a new one locally): ```bash run="$(prowl workflow run review --role reviewer=Codex --input max_rounds=3 --json)" @@ -168,16 +168,16 @@ delivers your output. Every step Prowl types into a pane ends with the exact com completes it — run it with the output on stdin when your work for that step is fully done: ```bash -PROWL_WORKFLOW_TOKEN=… prowl workflow done - <<'EOF' # the token Prowl handed you; launched roles have it in $PROWL_WORKFLOW_TOKEN +PROWL_WORKFLOW_TOKEN=… prowl workflow deliver - <<'EOF' # the token Prowl handed you; launched roles have it in $PROWL_WORKFLOW_TOKEN ## Findings … EOF prowl workflow status --json | jq '.data.activation' # what this pane still owes, with its requirements ``` -`done` answers after the output is saved: `.data.delivery.state` is `delivered`, or +`deliver` answers after the output is saved: `.data.delivery.state` is `delivered`, or `provisional` when the body had issues a non-strict step tolerates (`.data.delivery.warnings[]`) -— then the run waits for the user, not for another `done`. A pane whose step moved on gets +— then the run waits for the user, not for another `deliver`. A pane whose step moved on gets `STEP_NOT_EXPECTING`; a wrong token `TOKEN_INVALID`; `agents dispatch-complete` in a workflow pane is refused with `WORKFLOW_DELIVERY_REQUIRED` and the command to run instead. `prowl workflow cancel ` ends a run and keeps every pane and output; `status ` reads a @@ -328,4 +328,4 @@ Required sections are `## Objective`, `## Current State`, and `## Next Steps`; o ## Command Set -`list`, `agents`, `agents read`, `agents signal`, `agents dispatch`, `agents dispatch-complete`, `agents dispatch-abandon`, `agents wait`, `profiles list`, `skills list|install|uninstall|path` (local-only), `workflow list|run|status|done|cancel` (`workflow validate|schema` local-only), `read`, `send`, `key`, `focus`, `create tab`, `create pane`, `close`, `handoff to`, `handoff save`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with an explicit `close`. `tab create`, `tab close`, and `pane close` remain deprecated aliases for one release. +`list`, `agents`, `agents read`, `agents signal`, `agents dispatch`, `agents dispatch-complete`, `agents dispatch-abandon`, `agents wait`, `profiles list`, `skills list|install|uninstall|path` (local-only), `workflow list|run|status|deliver|cancel` (`workflow validate|schema` local-only), `read`, `send`, `key`, `focus`, `create tab`, `create pane`, `close`, `handoff to`, `handoff save`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with an explicit `close`. `tab create`, `tab close`, and `pane close` remain deprecated aliases for one release. diff --git a/skills/prowl-workflow/SKILL.md b/skills/prowl-workflow/SKILL.md index b99764ca2..c729311d6 100644 --- a/skills/prowl-workflow/SKILL.md +++ b/skills/prowl-workflow/SKILL.md @@ -9,7 +9,7 @@ description: >- other", "add an input to the guessing-game workflow"), wants one executed ("use Prowl's adversarial-review workflow on this branch", "run the count-files workflow", "跑一下 xxx workflow"), asks about a run's progress or its result files, or when a `[Prowl] …` - line with a `prowl workflow done` command appears in this pane — that means this agent is + line with a `prowl workflow deliver` command appears in this pane — that means this agent is a participant in a run and must deliver through the workflow protocol. Not for driving individual panes directly (use prowl-cli) and not for Prowl settings/UI questions. metadata: @@ -94,12 +94,12 @@ An active task delivered by Prowl in this pane, a launched role's kickoff protoc looks like this: ``` -[Prowl] — finish with: PROWL_WORKFLOW_TOKEN= prowl workflow done [--verdict ] - +[Prowl] — finish with: PROWL_WORKFLOW_TOKEN= prowl workflow deliver [--verdict ] - ``` 1. Do the work the instruction asks for, completely, before delivering. 2. Deliver by running the **exact rendered command** with the body on stdin as markdown - (`printf '…' | PROWL_WORKFLOW_TOKEN=… prowl workflow done -`). When verdict variants are + (`printf '…' | PROWL_WORKFLOW_TOKEN=… prowl workflow deliver -`). When verdict variants are offered, pick exactly one and run that variant. 3. Include the declared sections, format, and verdict. Empty bodies are rejected; other contract mismatches are provisional by default or rejected under `strict: true`. Check @@ -127,7 +127,7 @@ instructions. Read returned resource IDs with the same run ID and invocation num `workflow-resource:` references are handles, not filesystem paths. Use `--json` for byte-preserving reads, decode each chunk by its `encoding`, and continue with `--offset ` until `next_offset` is absent. Only the assigned pane can read this content; reads do not require a token. -Deliver ordinary text/JSON on stdin with `prowl workflow done -`; no project-local +Deliver ordinary text/JSON on stdin with `prowl workflow deliver -`; no project-local temporary output file is needed. Explicit delivery is required. Run artifacts expire with their run: 30 days for unpinned terminal runs, with a diff --git a/skills/prowl-workflow/references/actions.md b/skills/prowl-workflow/references/actions.md index 9cf7f7e77..b05a8d272 100644 --- a/skills/prowl-workflow/references/actions.md +++ b/skills/prowl-workflow/references/actions.md @@ -2,7 +2,7 @@ Use an action for deterministic file, repository, or tool work. Use an agent role for tasks that need judgment. Actions await a result; they cannot declare `expect` or call -`prowl workflow done`. They are separate from the app's shell-command Custom Actions. +`prowl workflow deliver`. They are separate from the app's shell-command Custom Actions. ## Package layout @@ -10,14 +10,14 @@ that need judgment. Actions await a result; they cannot declare `expect` or call report.pwlworkflow/ workflow.yaml actions/ - summarize/ + summarize-files/ action.yaml main.py helpers.py ``` -Use `action: local:summarize` for that package or `action: builtin:git.context` for Prowl's -repository collector. Local IDs are slugs. There is no global script registry. Helpers, +Use `action: local:summarize-files` for that package or `action: builtin:collect-worktree-context` for Prowl's +repository collector. Local IDs use lowercase kebab-case (up to 64 ASCII characters). There is no global script registry. Helpers, schemas, and assets must live inside the workflow bundle. Symlinks and special files are rejected. Pass the bundle directory to `prowl workflow validate`, not `workflow.yaml`. @@ -43,7 +43,7 @@ backend: interpreter: python3 entrypoint: main.py arguments: [] - environment: [] + inherit_env: [] timeout: 30s ``` @@ -62,12 +62,12 @@ The script receives one JSON object on stdin: "protocol": "prowl.action/v1", "input": {"paths": ["README.md"]}, "context": { - "execution": { - "id": "", + "action": { + "execution_id": "", "step_id": "summarize", "attempt": 1, - "cwd": "/repo", - "artifact_dir": "/Users/example/.prowl/logs/workflow-runs/repo-/2026-09//actions/summarize//artifacts" + "working_directory": "/repo", + "artifacts_directory": "/Users/example/.prowl/logs/workflow-runs/repo-/2026-09//actions/summarize//artifacts" } } } @@ -89,7 +89,7 @@ The workflow supplies typed values: ```yaml - id: summarize - action: local:summarize + action: local:summarize-files with: paths: [README.md, CHANGELOG.md] - id: report @@ -98,7 +98,7 @@ The workflow supplies typed values: A complete `{{ expression }}` retains its JSON type in `with`; interpolation in larger text accepts scalars only. Results appear at `actions..output` and the invocation's -JSON file at `actions..result_path`. Retain results in typed state before leaving a +JSON file at `actions..output_path`. Retain results in typed state before leaving a branch or loop iteration if later steps need them. ## Approval and testing @@ -116,7 +116,7 @@ Cancel an invalidated run and start a newly reviewed version. After validation and approval, test one action through the normal runner: ```bash -prowl workflow test-action report local:summarize --input-json '{"paths":["README.md"]}' --json +prowl workflow test-action report local:summarize-files --input-json '{"paths":["README.md"]}' --json prowl workflow status --json ``` @@ -133,14 +133,14 @@ already done or stop independent agent tasks. Default timeout: 30 seconds. Input and stdout: 16 MiB each. Stderr: 4 MiB. The JSON request envelope has separate transport headroom. JSON depth: 64. Bundle: 64 MiB and 8192 entries. Interpreter environment starts with PATH, HOME, TMPDIR, LANG, -and LC_ALL when present. `backend.environment` names extra inherited variables; `PROWL_*` +and LC_ALL when present. `backend.inherit_env` names extra inherited variables; `PROWL_*` control variables are always stripped. Prowl sets `PYTHONDONTWRITEBYTECODE=1` so Python helper imports do not add cache files to the fixed bundle. Environment values are not recorded in the request. -## Built-in repository context +## Collect worktree context -`builtin:git.context` takes optional `root`, restricted to the selected worktree, and returns +`builtin:collect-worktree-context` takes optional `root`, restricted to the selected worktree, and returns `output.path` plus `output.branch`. It writes the Markdown repository summary into this invocation's `artifacts/context.md`. It does not write shared handoff files. Workflow `handoff.transition` and `handoff.checkpoint` were removed; the separate `prowl handoff` CLI @@ -153,3 +153,9 @@ so strings containing `{{` are not evaluated as workflow expressions. When a script bundle needs approval, the workflow start screen provides **Review Bundle…** and keeps Run disabled. Approval returns to the same start screen; it does not start a run. + +The current collector requires a Git directory and collects that directory only. Its +worktree-oriented name describes the workflow target; multi-repository workspace and +plain-directory collection are future work. `builtin:collect-agent-context` is planned, +not registered. Use verb-first kebab-case names for local actions, such as +`local:write-report`; the runner does not infer behavior or permissions from the name. diff --git a/skills/prowl-workflow/references/authoring.md b/skills/prowl-workflow/references/authoring.md index 77f72d17c..cba89743c 100644 --- a/skills/prowl-workflow/references/authoring.md +++ b/skills/prowl-workflow/references/authoring.md @@ -19,9 +19,9 @@ steps: message: author instruction: | Inspect the current changes and write a concise summary. - expect: {output: summary, sections: ["## Summary"]} + expect: {delivery: summary, sections: ["## Summary"]} - id: done - notify: "Summary saved to {{ outputs.summary.path }}" + notify: "Summary saved to {{ deliveries.summary.path }}" ``` Do not add runtime constraints, loops, deadlines, or automatic pane closure unless they @@ -30,25 +30,32 @@ launch/injection success advances the workflow while the agent may still be work ## Typed values and expressions +A workflow is a reusable definition; a run is one execution of it. The worktree is +its execution target (a Git worktree, Prowl workspace, or plain directory). Role keys +are workflow participant names; `display_name` identifies the bound profile or pane. +`observed` contains `exists` and `state`, refreshed for the step. `context.initiator` +contains `pane_id` and nullable `tab_id`, or is null for a worktree-only start. + Read-only namespaces: | Namespace | Meaning | | --- | --- | -| `context.run` | `id`, `workflow_id`, `directory` | +| `context.workflow` | definition `id`, `name` | +| `context.run` | execution `id`, `path` | | `context.worktree` | target `id`, `path`, `name`, `branch`, `captured_at` | -| `context.source` | original source pane identity, or null for a worktree-only start | -| `context.roles.` | binding `source`, `name`, `agent`, `pane` | +| `context.initiator` | original source pane identity, or null for a worktree-only start | +| `context.roles.` | binding `source`, `display_name`, `agent`, `pane_id`, and live `observed` | | `context.step` | `id`, `iteration` (null outside loops), `captured_at` | -| `context.execution` | action-only `id`, `step_id`, `attempt`, `cwd`, `artifact_dir` | +| `context.action` | action-only `execution_id`, `step_id`, `attempt`, `working_directory`, `artifacts_directory` | | `inputs.` | typed start-time inputs | -| `outputs.` | agent delivery `path` and nullable `verdict` | -| `actions.` | action `output` object and `result_path` | +| `deliveries.` | agent delivery `path` and nullable `verdict` | +| `actions.` | action `output` object and `output_path` | | `state.` | explicitly declared, mutable typed state | Use `{{ expression }}` in text and action inputs. A complete expression in an action input retains its type; text interpolation accepts scalars, not arrays/objects. No implicit string-to-number or string-to-boolean conversion occurs. Missing fields are errors; -`exists(outputs.optional.path)` and `outputs.optional.path ?? ''` handle absence explicitly. +`exists(deliveries.optional.path)` and `deliveries.optional.path ?? ''` handle absence explicitly. `exists` does not hide arithmetic/type errors. `&&`, `||`, and `??` short-circuit. Expressions support null, booleans, numbers, single/double quoted strings, arrays, @@ -90,7 +97,7 @@ No step implicitly changes state from an action result. `max_iterations`. Conditions must be boolean. The loop tests its condition before each iteration. In a `while` condition, `context.step.id` is the loop ID and `context.step.iteration` is the number of completed iterations (0 on the first check). -Inside the body, iteration numbers start at 1. If the condition stays true at the cap, the run ends as `max_rounds_reached`; it does not +Inside the body, iteration numbers start at 1. If the condition stays true at the cap, the run ends as `iteration_limit_reached`; it does not report success or execute later steps. For an ordinary counted loop, express the count in its condition. Omit the cap when the task calls for an unlimited loop. @@ -109,13 +116,16 @@ iteration's output being implicitly visible ; use the declared state and `while` ## Step verbs -Each step has `id`, optional templated `title`, and one verb. +Each step has `id`, optional templated `title`, and one verb. Action IDs use +`builtin:` or `local:`: for example, +`builtin:collect-worktree-context` and `local:persist-handoff`. Use verb-first +kebab-case names for actions; dot-separated expressions address data, not actions. | Verb | Payload and behavior | | --- | --- | | `message: role` | `text` (one line) or `instruction` (file-backed multiline); waits for the role to be idle before injection; optional `expect` | | `launch: role` | `prompt`, optional bundled `skill`, optional `expect`; at most once per persistent role | -| `action: builtin:git.context` or `local:id` | typed `with` object; awaits validated result; no `expect`; see [actions](actions.md) | +| `action: builtin:collect-worktree-context` or `local:id` | typed `with` object; awaits validated result; no `expect`; see [actions](actions.md) | | `notify: text` | notification | | `close: role` | closes a launch role's pane; use only when the requested workflow needs cleanup | | `set` | atomic state assignments | @@ -173,15 +183,15 @@ satisfies, is a validation warning. `kind` may be omitted (`interactive` is the ## `expect` — waiting for a delivery A `message` or `launch` step with `expect` waits until the target agent explicitly delivers -via `prowl workflow done`; without one the step is fire-and-forget — the run advances the +via `prowl workflow deliver`; without one the step is fire-and-forget — the run advances the moment injection/launch succeeds, and there is no "wait without delivery" . ```yaml expect: - output: findings # name for the delivery; default = the step id + delivery: findings # name for the delivery; default = the step id format: markdown # markdown (default) | text | json sections: ["## Findings"] # required headings (case/level-forgiving; fenced code ignored) - verdict: [clean, issues] # 2–4 slugs; makes --verdict mandatory for expressions + verdicts: [clean, issues] # 2–4 slugs; makes --verdict mandatory for expressions timeout: 30m # optional hard cap as s|m|h (90s, 10m, 2h); NO default — omit to wait as long as the agent works on_timeout: attention # only together with timeout; attention (default) | skip | cancel strict: false # false: a delivery missing sections/format/verdict is kept as @@ -189,8 +199,8 @@ expect: ``` Prowl appends the completion command itself — the typed line or kickoff prompt ends with -the exact `PROWL_WORKFLOW_TOKEN=… prowl workflow done [--verdict v] -` to run. **Never -write `prowl workflow done` into your own `text`/`instruction`/`prompt`** (the validator +the exact `PROWL_WORKFLOW_TOKEN=… prowl workflow deliver [--verdict v] -` to run. **Never +write `prowl workflow deliver` into your own `text`/`instruction`/`prompt`** (the validator warns); the runner's renderer is the only source of that command. @@ -202,7 +212,7 @@ when it must survive the branch or iteration. contains the observed `exists` and `state` fields, or is null when unavailable. Observations are a step snapshot, not a guarantee that an agent will remain idle. -`context.source` preserves the initiating `pane_id` and `tab_id` (null for worktree-only starts). +`context.initiator` preserves the initiating `pane_id` and `tab_id` (null for worktree-only starts). `exists(value) && predicate` and `!exists(value) || predicate` support optional data without requiring a missing value on the short-circuited path. diff --git a/skills/prowl-workflow/references/runbook.md b/skills/prowl-workflow/references/runbook.md index b0d255e4d..49a58f41b 100644 --- a/skills/prowl-workflow/references/runbook.md +++ b/skills/prowl-workflow/references/runbook.md @@ -50,7 +50,7 @@ perform it and deliver; there is no separate injected first message to wait for. explicit `close:` step (authored by the workflow) closed it. Cancelling stops orchestration; it does not stop already-running agent work or undo edits. - Run states (`status.state`): `running`, `needs_attention` (the panel waits for the user), - then one terminal state — `completed`, `max_rounds_reached` (a `while` condition stayed + then one terminal state — `completed`, `iteration_limit_reached` (a `while` condition stayed true at `max_iterations`; later steps do not execute), `skipped` (required output skipped), `cancelled`, or `interrupted` (unfinished in an earlier app instance; never resumed). @@ -62,7 +62,7 @@ future starts and require a new grant. An invalidated run copy must be cancelled ## Watching a run -For a `done --json` response, inspect `.data.delivery.state`: `delivered` is accepted, +For a `deliver --json` response, inspect `.data.delivery.state`: `delivered` is accepted, whereas `provisional` may still return `ok` and an output path but leaves the run in `needs_attention`. The user can Accept, Ask again, Skip, or Cancel. **Ask again** returns the activation to waiting so the participant can submit a corrected delivery; repeated @@ -76,7 +76,7 @@ delivery is rejected instead and the participant can correct it while the step i - `.data.step` — the step in progress; `.data.activation` — the awaited delivery (`step`, `role`, `output`, `state` `waiting` | `persisting` | `provisional`, `ordinal`, `deadline`, and `expect.completion[]`, the exact commands that complete it). -- `.data.outputs.` — the latest accepted delivery (`path`, `latest_path`, `ordinal`, +- `.data.deliveries.` — the latest accepted delivery (`path`, `latest_path`, `ordinal`, `verdict`); `.data.bindings` and `.data.run_directory` are frozen at start. - `.data.started_at` / `.data.finished_at` carry milliseconds; `log.md` and `run.json` round to seconds. @@ -91,8 +91,8 @@ Runtime data lives in personal history, outside the execution root: ``` ~/.prowl/logs/workflow-runs/-/YYYY-MM// ├── log.md # timestamped timeline (start here) -├── run.json # machine record: bindings, invocations, step states, outputs -├── outputs/ +├── run.json # machine record: bindings, invocations, step states, deliveries +├── deliveries/ │ ├── ..md # output for an invocation; corrected submissions can replace it │ └── .md # "latest" view, replaced atomically on each delivery ├── definition/ # frozen workflow bundle @@ -118,7 +118,7 @@ Runtime data lives in personal history, outside the execution root: replace both files, so this is not an immutable history of every submission. Use the delivery receipt and run state to distinguish persisted content from accepted results. - Output bodies are capped (16 MiB in both the CLI and App). -- To summarize or debug a finished run: read `log.md`, then walk `outputs/` in ordinal +- To summarize or debug a finished run: read `log.md`, then walk `deliveries/` in ordinal order; `run.json` maps each ordinal to its step and loop iteration. ## Where the delivery token travels @@ -126,7 +126,7 @@ Runtime data lives in personal history, outside the execution root: Every awaited step mints a fresh token for its activation; Skip/Cancel/Relaunch revoke it. - **Launched roles**: the token is in the pane's environment as `PROWL_WORKFLOW_TOKEN`, and - the kickoff prompt's protocol block spells the bare `prowl workflow done [--verdict v] -`. + the kickoff prompt's protocol block spells the bare `prowl workflow deliver [--verdict v] -`. - **Messaged panes** (`current`/`pick`): the token rides the typed line as an environment prefix — the command in the `[Prowl] …` line is complete and directly executable. - Delivery requires the caller pane and the token to agree; a stale, duplicated, or @@ -146,5 +146,5 @@ Every awaited step mints a fresh token for its activation; Skip/Cancel/Relaunch | `PROFILE_NOT_FOUND` / `PROFILE_NOT_UNIQUE` | a `--role` override doesn't match exactly one enabled profile | | `TOKEN_REQUIRED` / `TOKEN_INVALID` / `STEP_NOT_EXPECTING` | delivering without/with a stale token, or the step has moved on — check `prowl workflow status` | | `OUTPUT_INVALID` / `VERDICT_REQUIRED` / `OUTPUT_TOO_LARGE` | empty body / missing mandatory verdict under `strict` / body over the cap | -| `WORKFLOW_DELIVERY_REQUIRED` | `dispatch-complete` was used inside a workflow activation — run the `prowl workflow done` command the error echoes | +| `WORKFLOW_DELIVERY_REQUIRED` | `dispatch-complete` was used inside a workflow activation — run the `prowl workflow deliver` command the error echoes | | `PROMPT_TOO_LARGE` / `RENDERED_TEXT_INVALID` | a rendered launch prompt over 128 KiB / a rendered line that isn't one clean terminal line — shorten, or move content into an `instruction` | diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index 46b88e907..9c3d87d65 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -693,7 +693,7 @@ nonisolated public enum WorkflowInputAction: String, Codable, Sendable { case list case run case status - case done + case deliver case cancel } @@ -717,15 +717,15 @@ nonisolated public struct WorkflowInput: Codable, Sendable { public let inputValues: [String] /// `run`: step ids skipped at start. public let skippedSteps: [String] - /// `status` / `cancel`: the run; `done`: the manual target together with `stepID`. + /// `status` / `cancel`: the run; `deliver`: the manual target together with `stepID`. public let runID: String? public let stepID: String? - /// `done`: the delivered output body (already read by the CLI). + /// `deliver`: the delivered output body (already read by the CLI). public let body: String? public let verdict: String? - /// `done`: `--token` or `$PROWL_WORKFLOW_TOKEN`; correlation only, never authentication. + /// `deliver`: `--token` or `$PROWL_WORKFLOW_TOKEN`; correlation only, never authentication. public let token: String? - /// `done`: deliver to the explicit target even when the caller pane belongs to another step. + /// `deliver`: deliver to the explicit target even when the caller pane belongs to another step. public let force: Bool public init( diff --git a/supacode/CLIService/Shared/WorkflowActionDefinitionSchema.swift b/supacode/CLIService/Shared/WorkflowActionDefinitionSchema.swift index 62ffb906f..94a209f7c 100644 --- a/supacode/CLIService/Shared/WorkflowActionDefinitionSchema.swift +++ b/supacode/CLIService/Shared/WorkflowActionDefinitionSchema.swift @@ -73,7 +73,7 @@ nonisolated public enum WorkflowActionDefinitionSchema { "type": "string" } }, - "environment": { + "inherit_env": { "type": "array", "items": { "type": "string", diff --git a/supacode/CLIService/Shared/WorkflowActionRegistry.swift b/supacode/CLIService/Shared/WorkflowActionRegistry.swift index ef140f6b6..918b44f4f 100644 --- a/supacode/CLIService/Shared/WorkflowActionRegistry.swift +++ b/supacode/CLIService/Shared/WorkflowActionRegistry.swift @@ -60,17 +60,17 @@ nonisolated public struct WorkflowActionSchema: Equatable, Sendable { } nonisolated public enum WorkflowActionRegistry { - public static var gitContextInput: WorkflowActionJSONSchema { + public static var worktreeContextInput: WorkflowActionJSONSchema { get throws { try WorkflowActionJSONSchema( .object([ "type": "object", "properties": .object(["root": .object(["type": "string"])]), "additionalProperties": .boolean(false), - ]), path: "builtin/git-context-input.json") + ]), path: "builtin/collect-worktree-context-input.json") } } - public static var gitContextOutput: WorkflowActionJSONSchema { + public static var worktreeContextOutput: WorkflowActionJSONSchema { get throws { try WorkflowActionJSONSchema( .object([ @@ -78,13 +78,13 @@ nonisolated public enum WorkflowActionRegistry { "properties": .object([ "path": .object(["type": "string"]), "branch": .object(["type": "string"]), ]), "required": .array(["path", "branch"]), "additionalProperties": .boolean(false), - ]), path: "builtin/git-context-output.json") + ]), path: "builtin/collect-worktree-context-output.json") } } public static let all: [WorkflowActionSchema] = [ WorkflowActionSchema( - id: "builtin:git.context", + id: "builtin:collect-worktree-context", description: "Save repository status and diff summary to this action's artifacts.", inputs: [ WorkflowActionInput( @@ -93,7 +93,7 @@ nonisolated public enum WorkflowActionRegistry { ], outputs: [ WorkflowActionOutput(name: "output", description: "JSON object containing path and branch"), - WorkflowActionOutput(name: "result_path", description: "Path to this invocation's result.json"), + WorkflowActionOutput(name: "output_path", description: "Path to this invocation's result.json"), ]) ] diff --git a/supacode/CLIService/Shared/WorkflowCommandPayload.swift b/supacode/CLIService/Shared/WorkflowCommandPayload.swift index 075182bfa..1b1b5751f 100644 --- a/supacode/CLIService/Shared/WorkflowCommandPayload.swift +++ b/supacode/CLIService/Shared/WorkflowCommandPayload.swift @@ -1,6 +1,6 @@ // ProwlShared/WorkflowCommandPayload.swift // `prowl workflow` response data (`prowl.cli.workflow.v1`), discriminated by `action`. -// `list`, `run`, `status`, `done`, and `cancel` cross the socket; `validate` and `schema` are +// `list`, `run`, `status`, `deliver`, and `cancel` cross the socket; `validate` and `schema` are // produced locally by the CLI. import Foundation @@ -13,7 +13,7 @@ nonisolated public enum WorkflowCommandPayload: Codable, Equatable, Sendable { case list(WorkflowListPayload) case run(WorkflowRunPayload) case status(WorkflowRunPayload) - case done(WorkflowDonePayload) + case deliver(WorkflowDeliverPayload) case cancel(WorkflowRunPayload) case validate(WorkflowValidatePayload) case schema(WorkflowSchemaPayload) @@ -24,7 +24,7 @@ nonisolated public enum WorkflowCommandPayload: Codable, Equatable, Sendable { case .list: .list case .run: .run case .status: .status - case .done: .done + case .deliver: .deliver case .cancel: .cancel case .validate: .validate case .schema: .schema @@ -42,7 +42,7 @@ nonisolated public enum WorkflowCommandPayload: Codable, Equatable, Sendable { case .list: self = .list(try WorkflowListPayload(from: decoder)) case .run: self = .run(try WorkflowRunPayload(from: decoder)) case .status: self = .status(try WorkflowRunPayload(from: decoder)) - case .done: self = .done(try WorkflowDonePayload(from: decoder)) + case .deliver: self = .deliver(try WorkflowDeliverPayload(from: decoder)) case .cancel: self = .cancel(try WorkflowRunPayload(from: decoder)) case .validate: self = .validate(try WorkflowValidatePayload(from: decoder)) case .schema: self = .schema(try WorkflowSchemaPayload(from: decoder)) @@ -57,7 +57,7 @@ nonisolated public enum WorkflowCommandPayload: Codable, Equatable, Sendable { case .list(let payload): try payload.encode(to: encoder) case .run(let payload), .status(let payload), .cancel(let payload): try payload.encode(to: encoder) - case .done(let payload): try payload.encode(to: encoder) + case .deliver(let payload): try payload.encode(to: encoder) case .validate(let payload): try payload.encode(to: encoder) case .schema(let payload): try payload.encode(to: encoder) } @@ -69,7 +69,7 @@ nonisolated public enum WorkflowCommandAction: String, Codable, Equatable, Senda case list case run case status - case done + case deliver case cancel case validate case schema @@ -201,7 +201,7 @@ nonisolated public struct WorkflowRunPayload: Codable, Equatable, Sendable { /// The activation currently waiting for (or persisting) a delivery. public let activation: WorkflowActivationPayload? /// Latest delivered output per name. - public let outputs: [String: WorkflowOutputPayload] + public let deliveries: [String: WorkflowDeliveryRecordPayload] public let startedAt: String public let updatedAt: String public let finishedAt: String? @@ -222,7 +222,7 @@ nonisolated public struct WorkflowRunPayload: Codable, Equatable, Sendable { case runDirectory = "run_directory" case bindings case activation - case outputs + case deliveries case startedAt = "started_at" case updatedAt = "updated_at" case finishedAt = "finished_at" @@ -242,7 +242,7 @@ nonisolated public struct WorkflowRunPayload: Codable, Equatable, Sendable { runDirectory: String, bindings: [String: WorkflowBindingPayload], activation: WorkflowActivationPayload?, - outputs: [String: WorkflowOutputPayload], + deliveries: [String: WorkflowDeliveryRecordPayload], startedAt: String, updatedAt: String, finishedAt: String?, @@ -260,7 +260,7 @@ nonisolated public struct WorkflowRunPayload: Codable, Equatable, Sendable { self.runDirectory = runDirectory self.bindings = bindings self.activation = activation - self.outputs = outputs + self.deliveries = deliveries self.startedAt = startedAt self.updatedAt = updatedAt self.finishedAt = finishedAt @@ -274,7 +274,7 @@ nonisolated public enum WorkflowRunPayloadSource: String, Codable, Equatable, Se } nonisolated public struct WorkflowRunStatusPayload: Codable, Equatable, Sendable { - /// `running`, `needs_attention`, `completed`, `cancelled`, `skipped`, `max_rounds_reached`, `interrupted`. + /// `running`, `needs_attention`, `completed`, `cancelled`, `skipped`, `iteration_limit_reached`, `interrupted`. public let state: String /// The step that ended a `skipped` run, or the step in attention. public let step: String? @@ -437,31 +437,31 @@ nonisolated public struct WorkflowActivationPayload: Codable, Equatable, Sendabl /// What a waiting activation requires of its delivery (dsl-spec §5). nonisolated public struct WorkflowExpectationPayload: Codable, Equatable, Sendable { - public let format: WorkflowOutputFormat + public let format: WorkflowDeliveryFormat public let sections: [String] - public let verdict: [String]? + public let verdicts: [String]? public let strict: Bool - /// The exact `prowl workflow done` commands that complete the step, one per allowed verdict. + /// The exact `prowl workflow deliver` commands that complete the step, one per allowed verdict. public let completion: [String] public init( - format: WorkflowOutputFormat, sections: [String], verdict: [String]?, strict: Bool, + format: WorkflowDeliveryFormat, sections: [String], verdicts: [String]?, strict: Bool, completion: [String] ) { self.format = format self.sections = sections - self.verdict = verdict + self.verdicts = verdicts self.strict = strict self.completion = completion } } -nonisolated public struct WorkflowOutputPayload: Codable, Equatable, Sendable { +nonisolated public struct WorkflowDeliveryRecordPayload: Codable, Equatable, Sendable { public let name: String public let ordinal: Int - /// `outputs/..md`. + /// `deliveries/..md`. public let path: String - /// `outputs/.md`, the atomically replaced latest view. + /// `deliveries/.md`, the atomically replaced latest view. public let latestPath: String public let verdict: String? public let deliveredAt: String @@ -493,7 +493,7 @@ nonisolated public struct WorkflowSelfInitiatedPayload: Codable, Equatable, Send public let line: String /// The materialized instruction file the line points at, for `instruction` steps. public let instructionPath: String? - /// The `prowl workflow done` commands that complete the step, one per allowed verdict. + /// The `prowl workflow deliver` commands that complete the step, one per allowed verdict. public let completion: [String] enum CodingKeys: String, CodingKey { @@ -509,9 +509,9 @@ nonisolated public struct WorkflowSelfInitiatedPayload: Codable, Equatable, Send } } -// MARK: - done +// MARK: - deliver -nonisolated public struct WorkflowDonePayload: Codable, Equatable, Sendable { +nonisolated public struct WorkflowDeliverPayload: Codable, Equatable, Sendable { public let run: WorkflowRunPayload public let delivery: WorkflowDeliveryPayload @@ -521,7 +521,7 @@ nonisolated public struct WorkflowDonePayload: Codable, Equatable, Sendable { } } -/// The receipt of one `prowl workflow done`. `delivered` means the output is the step's output +/// The receipt of one `prowl workflow deliver`. `delivered` means the output is the step's output /// and the run advanced; `provisional` means it is on disk with the listed `warnings` and the /// run waits for the user to accept it, ask again, or skip (decision H14 of docs-ai 063.007). nonisolated public struct WorkflowDeliveryPayload: Codable, Equatable, Sendable { @@ -529,7 +529,7 @@ nonisolated public struct WorkflowDeliveryPayload: Codable, Equatable, Sendable public let ordinal: Int public let step: String public let role: String - public let output: WorkflowOutputPayload + public let output: WorkflowDeliveryRecordPayload public let warnings: [WorkflowDeliveryWarningPayload] public init( @@ -537,7 +537,7 @@ nonisolated public struct WorkflowDeliveryPayload: Codable, Equatable, Sendable ordinal: Int, step: String, role: String, - output: WorkflowOutputPayload, + output: WorkflowDeliveryRecordPayload, warnings: [WorkflowDeliveryWarningPayload] ) { self.state = state diff --git a/supacode/CLIService/Shared/WorkflowControlCursor.swift b/supacode/CLIService/Shared/WorkflowControlCursor.swift index 24ed50409..aec3b20a2 100644 --- a/supacode/CLIService/Shared/WorkflowControlCursor.swift +++ b/supacode/CLIService/Shared/WorkflowControlCursor.swift @@ -47,7 +47,7 @@ nonisolated public struct WorkflowControlCursor: Equatable, Sendable { public func values(over context: [String: WorkflowJSONValue]) -> [String: WorkflowJSONValue] { var result = context result["state"] = Self.object(state.values) - for (group, expired) in [("outputs", expiredOutputs), ("actions", expiredActions)] { + for (group, expired) in [("deliveries", expiredOutputs), ("actions", expiredActions)] { if case .object(var fields) = result[group] { for name in expired { fields.removeValue(forKey: name) } result[group] = .object(fields) @@ -82,7 +82,7 @@ nonisolated public struct WorkflowControlCursor: Equatable, Sendable { public mutating func complete() { guard let step = currentStep, !frames.isEmpty else { return } if case .action = step.action { expiredActions.remove(step.id) } - if let output = step.outputName { expiredOutputs.remove(output) } + if let output = step.deliveryName { expiredOutputs.remove(output) } frames[frames.count - 1].index += 1 currentStep = nil } @@ -174,7 +174,7 @@ nonisolated public struct WorkflowControlCursor: Equatable, Sendable { private mutating func expire(_ steps: [WorkflowStepDefinition]) { for step in steps { if case .action = step.action { expiredActions.insert(step.id) } - if let output = step.outputName { expiredOutputs.insert(output) } + if let output = step.deliveryName { expiredOutputs.insert(output) } expire(step.action.children) } } diff --git a/supacode/CLIService/Shared/WorkflowDefinition.swift b/supacode/CLIService/Shared/WorkflowDefinition.swift index d3726ef68..3d28aa3eb 100644 --- a/supacode/CLIService/Shared/WorkflowDefinition.swift +++ b/supacode/CLIService/Shared/WorkflowDefinition.swift @@ -9,7 +9,7 @@ nonisolated public enum WorkflowSchema { /// `prowl.*` ids are reserved for definitions shipped inside the app bundle. public static let reservedIDPrefix = "prowl." public static let verdictRange = 2...4 - /// Carries an activation's delivery token to `prowl workflow done`: typed as the line's + /// Carries an activation's delivery token to `prowl workflow deliver`: typed as the line's /// environment prefix for a `message` step, set in the child environment for a `launch` step. public static let tokenEnvironmentKey = "PROWL_WORKFLOW_TOKEN" /// Cross-check hints in a launched surface's child environment; the dispatch store stays the authority. @@ -17,7 +17,7 @@ nonisolated public enum WorkflowSchema { public static let roleEnvironmentKey = "PROWL_WORKFLOW_ROLE" /// Workflow and skill ids may contain dots; a leading alphanumeric rules out `.` and `..`. public static var workflowIDPattern: Regex { /^[a-z0-9][a-z0-9_.-]{0,63}$/ } - /// Step ids, role names, output names, input names, and verdict values become path + /// Step ids, role names, delivery names, input names, and verdict values become path /// components and CLI arguments. public static var slugPattern: Regex { /^[a-z0-9][a-z0-9_-]{0,63}$/ } @@ -28,6 +28,10 @@ nonisolated public enum WorkflowSchema { public static func isSlug(_ value: String) -> Bool { value.wholeMatch(of: slugPattern) != nil } + + public static func isActionID(_ value: String) -> Bool { + value.utf8.count <= 64 && value.wholeMatch(of: /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/) != nil + } } /// 1-based position inside the YAML source. @@ -283,7 +287,7 @@ nonisolated public struct WorkflowRoleDefinition: Equatable, Sendable { // MARK: - Steps -nonisolated public enum WorkflowOutputFormat: String, Equatable, Sendable, Codable { +nonisolated public enum WorkflowDeliveryFormat: String, Equatable, Sendable, Codable { case markdown case text case json @@ -296,34 +300,34 @@ nonisolated public enum WorkflowTimeoutPolicy: String, Equatable, Sendable, Coda } nonisolated public struct WorkflowExpectation: Equatable, Sendable { - /// Output name; nil means the step id (see `WorkflowStepDefinition.outputName`). - public let output: String? - public let format: WorkflowOutputFormat + /// Delivery name; nil means the step id (see `WorkflowStepDefinition.deliveryName`). + public let delivery: String? + public let format: WorkflowDeliveryFormat public let sections: [String] - /// Declared verdict values; nil when the step has no verdict. - public let verdict: [String]? + /// Allowed verdict values; nil when the step does not require a verdict. + public let verdicts: [String]? /// Hard cap in seconds; nil = wait as long as the agent works. public let timeoutSeconds: Int? public let onTimeout: WorkflowTimeoutPolicy? - /// `true`: a delivery that misses `sections`, `format`, or `verdict` is rejected. `false` + /// `true`: a delivery that misses `sections`, `format`, or `verdicts` is rejected. `false` /// (default): it is kept as provisional and the run asks the user to accept, ask again, or skip. public let strict: Bool public let location: WorkflowSourceLocation? public init( - output: String? = nil, - format: WorkflowOutputFormat = .markdown, + delivery: String? = nil, + format: WorkflowDeliveryFormat = .markdown, sections: [String] = [], - verdict: [String]? = nil, + verdicts: [String]? = nil, timeoutSeconds: Int? = nil, onTimeout: WorkflowTimeoutPolicy? = nil, strict: Bool = false, location: WorkflowSourceLocation? = nil ) { - self.output = output + self.delivery = delivery self.format = format self.sections = sections - self.verdict = verdict + self.verdicts = verdicts self.timeoutSeconds = timeoutSeconds self.onTimeout = onTimeout self.strict = strict @@ -393,9 +397,9 @@ nonisolated public struct WorkflowStepDefinition: Equatable, Sendable { self.location = location } - /// The output this step delivers, when it has an `expect`. - public var outputName: String? { + /// The delivery name this step produces, when it has an `expect`. + public var deliveryName: String? { guard let expect = action.expect else { return nil } - return expect.output ?? id + return expect.delivery ?? id } } diff --git a/supacode/CLIService/Shared/WorkflowDocumentParser.swift b/supacode/CLIService/Shared/WorkflowDocumentParser.swift index fef5cda83..cbb645031 100644 --- a/supacode/CLIService/Shared/WorkflowDocumentParser.swift +++ b/supacode/CLIService/Shared/WorkflowDocumentParser.swift @@ -360,7 +360,7 @@ nonisolated public enum WorkflowDocumentParser { guard let expect = MappingReader(node: step.node(for: "expect"), collector: step.collector, path: "expect") else { return nil } - expect.checkKeys(["output", "format", "sections", "verdict", "timeout", "on_timeout", "strict"]) + expect.checkKeys(["delivery", "format", "sections", "verdicts", "timeout", "on_timeout", "strict"]) let timeout = expect.string("timeout").flatMap { text -> Int? in guard let seconds = parseDuration(text) else { expect.collector.error( @@ -376,10 +376,10 @@ nonisolated public enum WorkflowDocumentParser { at: expect.location(of: "on_timeout")) } return WorkflowExpectation( - output: expect.string("output"), - format: expect.enumValue("format", WorkflowOutputFormat.self) ?? .markdown, + delivery: expect.string("delivery"), + format: expect.enumValue("format", WorkflowDeliveryFormat.self) ?? .markdown, sections: expect.stringList("sections") ?? [], - verdict: expect.stringList("verdict"), + verdicts: expect.stringList("verdicts"), timeoutSeconds: timeout, onTimeout: onTimeout, strict: expect.bool("strict") ?? false, diff --git a/supacode/CLIService/Shared/WorkflowExpression.swift b/supacode/CLIService/Shared/WorkflowExpression.swift index 78ef93cc4..0ad695d55 100644 --- a/supacode/CLIService/Shared/WorkflowExpression.swift +++ b/supacode/CLIService/Shared/WorkflowExpression.swift @@ -202,6 +202,18 @@ nonisolated indirect enum WorkflowExpressionNode { } extension WorkflowExpressionNode { + /// Every statically named path, including optional and short-circuited accesses. + var references: [[String]] { + if let staticPath { return [staticPath] } + switch self { + case .literal, .name: return [] + case .field(let parent, _), .unary(_, let parent): return parent.references + case .index(let parent, let index): return parent.references + index.references + case .array(let items), .call(_, let items): return items.flatMap(\.references) + case .binary(_, let left, let right): return left.references + right.references + } + } + var staticPath: [String]? { switch self { case .name(let name): return [name] diff --git a/supacode/CLIService/Shared/WorkflowHistoryMetadata.swift b/supacode/CLIService/Shared/WorkflowHistoryMetadata.swift index 02323a696..0324003cd 100644 --- a/supacode/CLIService/Shared/WorkflowHistoryMetadata.swift +++ b/supacode/CLIService/Shared/WorkflowHistoryMetadata.swift @@ -24,7 +24,7 @@ nonisolated public struct WorkflowHistoryMetadata: Codable, Sendable { } var terminal: Bool { - ["completed", "cancelled", "skipped", "max_rounds_reached", "interrupted"].contains(state) + ["completed", "cancelled", "skipped", "iteration_limit_reached", "interrupted"].contains(state) } /// Caller holds run occupancy. Missing metadata after a failed write prevents automatic deletion. diff --git a/supacode/CLIService/Shared/WorkflowJSONSchema.swift b/supacode/CLIService/Shared/WorkflowJSONSchema.swift index 9641a86f6..6f4a578dd 100644 --- a/supacode/CLIService/Shared/WorkflowJSONSchema.swift +++ b/supacode/CLIService/Shared/WorkflowJSONSchema.swift @@ -412,7 +412,7 @@ nonisolated public enum WorkflowJSONSchema { }, "action": { "type": "string", - "pattern": "^(builtin:git\\.context|local:[a-z0-9][a-z0-9_-]{0,63})$" + "pattern": "^(builtin:collect-worktree-context|local:(?=[a-z0-9-]{1,64}$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$" }, "with": { "type": "object", @@ -465,7 +465,7 @@ nonisolated public enum WorkflowJSONSchema { "type": "object", "additionalProperties": false, "properties": { - "output": { + "delivery": { "$ref": "#/$defs/slug" }, "format": { @@ -482,7 +482,7 @@ nonisolated public enum WorkflowJSONSchema { "minLength": 1 } }, - "verdict": { + "verdicts": { "type": "array", "minItems": 2, "maxItems": 4, diff --git a/supacode/CLIService/Shared/WorkflowPreparedBundle.swift b/supacode/CLIService/Shared/WorkflowPreparedBundle.swift index 942fcd215..0f6a057ac 100644 --- a/supacode/CLIService/Shared/WorkflowPreparedBundle.swift +++ b/supacode/CLIService/Shared/WorkflowPreparedBundle.swift @@ -54,7 +54,7 @@ nonisolated public struct WorkflowPreparedBundle: Equatable, Sendable { } public static func environment(for action: WorkflowScriptAction, inherited: [String: String]) -> [String: String] { - let names = Set(["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL"] + action.environment) + let names = Set(["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL"] + action.inheritedEnvironmentNames) var result = inherited.filter { names.contains($0.key) && !$0.key.hasPrefix("PROWL_") } // Python helper imports must not add bytecode to the integrity-checked definition. result["PYTHONDONTWRITEBYTECODE"] = "1" diff --git a/supacode/CLIService/Shared/WorkflowScriptAction.swift b/supacode/CLIService/Shared/WorkflowScriptAction.swift index 96ab6d8ed..2fb0f6064 100644 --- a/supacode/CLIService/Shared/WorkflowScriptAction.swift +++ b/supacode/CLIService/Shared/WorkflowScriptAction.swift @@ -7,7 +7,7 @@ nonisolated public struct WorkflowScriptAction: Equatable, Sendable { public let interpreter: String public let entrypoint: String public let arguments: [String] - public let environment: [String] + public let inheritedEnvironmentNames: [String] public let timeoutSeconds: Int public let inputSchema: WorkflowJSONValue public let outputSchema: WorkflowJSONValue @@ -18,20 +18,20 @@ nonisolated public struct WorkflowScriptAction: Equatable, Sendable { let document = try WorkflowYAMLValue.parse(yaml) guard case .object(let root) = document else { throw WorkflowExpressionError.type("Action must be a mapping.") } try checkKeys(root.keys, allowed: ["schema", "name", "input_schema", "output_schema", "backend", "timeout"]) - guard root["schema"] == .string("prowl.action/v1"), WorkflowSchema.isSlug(id), + guard root["schema"] == .string("prowl.action/v1"), WorkflowSchema.isActionID(id), case .string(let name) = root["name"], !name.isEmpty, case .object(let backend) = root["backend"], backend["type"] == .string("script"), case .string(let interpreter) = backend["interpreter"], !interpreter.isEmpty, case .string(let entrypoint) = backend["entrypoint"], safeRelativePath(entrypoint), let input = root["input_schema"], let output = root["output_schema"] else { throw WorkflowExpressionError.type("Action requires schema, name, object schemas, and a script backend.") } - try checkKeys(backend.keys, allowed: ["type", "interpreter", "entrypoint", "arguments", "environment"]) + try checkKeys(backend.keys, allowed: ["type", "interpreter", "entrypoint", "arguments", "inherit_env"]) guard !interpreter.contains("{{"), !interpreter.contains("\n"), !interpreter.contains("\0") else { throw WorkflowExpressionError.type("Interpreter must be literal executable text.") } let arguments = try strings(backend["arguments"]) - let environment = try strings(backend["environment"]) - for name in environment { + let inheritedEnvironmentNames = try strings(backend["inherit_env"]) + for name in inheritedEnvironmentNames { guard name.wholeMatch(of: /[A-Za-z_][A-Za-z0-9_]*/) != nil, !name.hasPrefix("PROWL_") else { throw WorkflowExpressionError.type("Invalid inherited environment name: \(name).") } @@ -49,7 +49,8 @@ nonisolated public struct WorkflowScriptAction: Equatable, Sendable { let outputContract = try WorkflowActionJSONSchema(output, path: "actions/\(id)/output.schema.json", files: files) return Self( id: id, name: name, interpreter: interpreter, entrypoint: entrypoint, arguments: arguments, - environment: environment, timeoutSeconds: timeout, inputSchema: input, outputSchema: output, + inheritedEnvironmentNames: inheritedEnvironmentNames, timeoutSeconds: timeout, inputSchema: input, + outputSchema: output, inputContract: inputContract, outputContract: outputContract) } diff --git a/supacode/CLIService/Shared/WorkflowTaskContent.swift b/supacode/CLIService/Shared/WorkflowTaskContent.swift index ffd71629c..a3621bc75 100644 --- a/supacode/CLIService/Shared/WorkflowTaskContent.swift +++ b/supacode/CLIService/Shared/WorkflowTaskContent.swift @@ -14,7 +14,7 @@ nonisolated public struct WorkflowTaskContent: Equatable, Sendable { var rendered = text var resources: [String: String] = [:] let paths = Set(knownPaths).filter { - ($0.hasPrefix(runDirectory.path + "/outputs/") || $0.hasPrefix(runDirectory.path + "/actions/")) + ($0.hasPrefix(runDirectory.path + "/deliveries/") || $0.hasPrefix(runDirectory.path + "/actions/")) && text.contains($0) && !$0.split(separator: "/").contains("..") }.sorted { $0.count == $1.count ? $0 < $1 : $0.count > $1.count } diff --git a/supacode/CLIService/Shared/WorkflowTemplate.swift b/supacode/CLIService/Shared/WorkflowTemplate.swift index cdb4a8123..26425b122 100644 --- a/supacode/CLIService/Shared/WorkflowTemplate.swift +++ b/supacode/CLIService/Shared/WorkflowTemplate.swift @@ -1,12 +1,12 @@ // ProwlShared/WorkflowTemplate.swift -// `{{ path }}` reference scanning for workflow templates (dsl-spec.md §6). Substitution-only: -// no expressions, defaults, or filters. Rendering belongs to the runner. +// Static reference paths and template delimiter scanning. +// WorkflowExpression parses and evaluates the expressions inside each delimiter pair. import Foundation nonisolated public enum WorkflowTemplate { public struct Reference: Equatable, Sendable { - /// The dotted path as written, e.g. `outputs.findings.path`. + /// The dotted path as written, e.g. `deliveries.findings.path`. public let path: String public let components: [String] diff --git a/supacode/CLIService/Shared/WorkflowValidator.swift b/supacode/CLIService/Shared/WorkflowValidator.swift index b7ac7f37a..d01b7f916 100644 --- a/supacode/CLIService/Shared/WorkflowValidator.swift +++ b/supacode/CLIService/Shared/WorkflowValidator.swift @@ -41,7 +41,7 @@ nonisolated public struct WorkflowValidationContext: Sendable { } nonisolated public enum WorkflowValidator { - public static let completionCommand = "prowl workflow done" + public static let completionCommand = "prowl workflow deliver" public static let longTimeoutSeconds = 2 * 3600 public static func validate( @@ -102,7 +102,7 @@ nonisolated private final class Walker { private var stepIDs: Set = [] private var launchedRoles: Set = [] private var possiblyLaunchedRoles: Set = [] - private var outputs: [String: OutputInfo] = [:] + private var deliveries: [String: OutputInfo] = [:] private var consumers: [String: [OutputUse]] = [:] /// Walk position of the step being checked; 0 before the first step. private var ordinal = 0 @@ -111,7 +111,7 @@ nonisolated private final class Walker { private var checkingActionInputs = false private var currentLoopID: String? private var outerOutputNames: Set = [] - /// `on_timeout: skip` expectations, kept apart from `outputs` so folding a skippable loop + /// `on_timeout: skip` expectations, kept apart from `deliveries` so folding a skippable loop /// cannot lose them before the consumers are reported. private var skipOutputs: [SkipRecord] = [] @@ -353,7 +353,7 @@ nonisolated private final class Walker { id: id, description: contract.name, inputs: [], outputs: [ WorkflowActionOutput(name: "output", description: "Typed action output"), - WorkflowActionOutput(name: "result_path", description: "Result JSON path"), + WorkflowActionOutput(name: "output_path", description: "Result JSON path"), ]) return } @@ -401,15 +401,16 @@ nonisolated private final class Walker { do { var parser = try WorkflowExpressionParser(expression) let node = try parser.parse() + checkReferenceSpelling(node, at: step.location) for parts in node.requiredReferences { checkReference( WorkflowTemplate.Reference(path: parts.joined(separator: ".")), at: step.location, consumer: .template) } } catch { collector.error("expression_syntax", "\(error)", at: step.location) } } - let previousOutputs = outputs + let previousOutputs = deliveries let previousOuterNames = outerOutputNames - outerOutputNames.formUnion(outputs.keys) + outerOutputNames.formUnion(deliveries.keys) defer { outerOutputNames = previousOuterNames } let previousRoles = launchedRoles let previousPossibleRoles = possiblyLaunchedRoles @@ -427,7 +428,7 @@ nonisolated private final class Walker { branchRoles.append(launchedRoles) possibleRoles.formUnion(possiblyLaunchedRoles) actionScopes.removeLast() - outputs = previousOutputs + deliveries = previousOutputs launchedRoles = previousRoles possiblyLaunchedRoles = previousPossibleRoles } @@ -440,7 +441,7 @@ nonisolated private final class Walker { // MARK: Expect private func checkExpect(_ expect: WorkflowExpectation?, step: WorkflowStepDefinition) { - guard let expect, let name = step.outputName else { return } + guard let expect, let name = step.deliveryName else { return } if outerOutputNames.contains(name) { collector.error( "output_shadowing", @@ -454,14 +455,14 @@ nonisolated private final class Walker { if expect.sections.contains(where: { $0.trimmingCharacters(in: .whitespaces).isEmpty }) { collector.error("section_empty", "'sections' entries must not be empty.", at: location) } - if let verdict = expect.verdict { + if let verdict = expect.verdicts { checkVerdict(verdict, at: location) } if let timeout = expect.timeoutSeconds, timeout > WorkflowValidator.longTimeoutSeconds { collector.warning("timeout_long", "'timeout' above 2h; the watchdog already supervises waiting.", at: location) } - var info = outputs[name] ?? OutputInfo() - let verdicts = expect.verdict.map(Set.init) + var info = deliveries[name] ?? OutputInfo() + let verdicts = expect.verdicts.map(Set.init) info.producers.append(OutputProducer(verdicts: verdicts, loopID: currentLoopID)) info.latestVerdicts = verdicts if expect.onTimeout == .skip { @@ -469,15 +470,15 @@ nonisolated private final class Walker { SkipRecord( name: name, use: OutputUse(consumer: .template, ordinal: ordinal, loopID: currentLoopID), location: location)) } - outputs[name] = info + deliveries[name] = info } private func checkVerdict(_ verdict: [String], at location: WorkflowSourceLocation?) { if !WorkflowSchema.verdictRange.contains(verdict.count) { - collector.error("verdict_count", "'verdict' declares 2–4 values.", at: location) + collector.error("verdict_count", "'verdicts' declares 2–4 values.", at: location) } if Set(verdict).count != verdict.count { - collector.error("verdict_duplicate", "'verdict' repeats a value.", at: location) + collector.error("verdict_duplicate", "'verdicts' repeats a value.", at: location) } for value in verdict where !WorkflowSchema.isSlug(value) { collector.error("verdict_slug", "Verdict '\(value)' is not a valid slug.", at: location) @@ -521,12 +522,31 @@ nonisolated private final class Walker { do { var parser = try WorkflowExpressionParser(expression) let node = try parser.parse() + checkReferenceSpelling(node, at: location) for parts in node.requiredReferences { checkReference(WorkflowTemplate.Reference(path: parts.joined(separator: ".")), at: location, consumer: consumer) } } catch { collector.error("expression_syntax", "\(error)", at: location) } } + /// Optional access permits absent values, not unknown namespace or metadata names. + private func checkReferenceSpelling(_ node: WorkflowExpressionNode, at location: WorkflowSourceLocation?) { + let required = Set(node.requiredReferences) + for parts in node.references where !required.contains(parts) { + let valid: Bool + switch parts.first { + case "context": valid = checkContextReference(parts) + case "inputs", "state": valid = true + case "deliveries": valid = parts.count < 3 || (parts.count == 3 && ["path", "verdict"].contains(parts[2])) + case "actions": valid = parts.count < 3 || ["output", "output_path"].contains(parts[2]) + default: valid = false + } + if !valid { + collector.error("unknown_variable", "Unknown variable '{{ \(parts.joined(separator: ".")) }}'.", at: location) + } + } + } + private func checkReference( _ reference: WorkflowTemplate.Reference, at location: WorkflowSourceLocation?, consumer: OutputConsumer ) { @@ -537,7 +557,7 @@ nonisolated private final class Walker { valid = checkContextReference(parts) case "inputs": valid = parts.count >= 2 && definition.input(named: parts[1]) != nil case "state": valid = parts.count >= 2 && definition.state[parts[1]] != nil - case "outputs": valid = parts.count == 3 && checkOutputReference(parts, at: location, consumer: consumer) + case "deliveries": valid = parts.count == 3 && checkOutputReference(parts, at: location, consumer: consumer) case "actions": valid = parts.count >= 3 && checkActionReference(parts) default: valid = false } @@ -549,37 +569,28 @@ nonisolated private final class Walker { private func checkContextReference(_ parts: [String]) -> Bool { guard parts.count >= 2 else { return true } let fields: [String: Set] = [ - "run": ["id", "workflow_id", "directory"], + "workflow": ["id", "name"], + "run": ["id", "path"], "worktree": ["id", "path", "name", "branch", "captured_at"], - "source": ["pane_id", "tab_id"], "step": ["id", "iteration", "captured_at"], - "execution": ["id", "step_id", "attempt", "cwd", "artifact_dir"], + "initiator": ["pane_id", "tab_id"], "step": ["id", "iteration", "captured_at"], + "action": ["execution_id", "step_id", "attempt", "working_directory", "artifacts_directory"], ] - if parts[1] == "execution", !checkingActionInputs { return false } + if parts[1] == "action", !checkingActionInputs { return false } if parts[1] == "roles" { guard parts.count > 2 else { return true } guard definition.role(named: parts[2]) != nil else { return false } return parts.count == 3 - || (parts.count == 4 && ["source", "name", "agent", "pane", "observed"].contains(parts[3])) + || (parts.count == 4 && ["source", "display_name", "agent", "pane_id", "observed"].contains(parts[3])) || (parts.count == 5 && parts[3] == "observed" && ["exists", "state"].contains(parts[4])) } guard let allowed = fields[parts[1]] else { return false } return parts.count == 2 || (parts.count == 3 && allowed.contains(parts[2])) } - private func checkRoleReference(_ parts: [String], at location: WorkflowSourceLocation?) -> Bool { - guard let role = definition.role(named: parts[1]), ["name", "agent", "pane"].contains(parts[2]) else { - return false - } - if parts[2] == "pane", role.source == .launch, !launchedRoles.contains(role.name) { - return false - } - return true - } - private func checkOutputReference( _ parts: [String], at location: WorkflowSourceLocation?, consumer: OutputConsumer ) -> Bool { - guard let info = outputs[parts[1]], ["path", "verdict"].contains(parts[2]) else { return false } + guard let info = deliveries[parts[1]], ["path", "verdict"].contains(parts[2]) else { return false } if parts[2] == "verdict", info.latestVerdicts == nil { return false } diff --git a/supacode/CLIService/WorkflowCommandHandler.swift b/supacode/CLIService/WorkflowCommandHandler.swift index ed62b5dd2..d92f94902 100644 --- a/supacode/CLIService/WorkflowCommandHandler.swift +++ b/supacode/CLIService/WorkflowCommandHandler.swift @@ -1,7 +1,7 @@ // supacode/CLIService/WorkflowCommandHandler.swift // Handles `prowl workflow` over the socket (docs-ai 063 B1/B3): `list` resolves the worktree // whose repo source is searched and runs three-source discovery; `run` resolves the source pane -// or worktree and hands admission to the runtime; `status`, `done`, and `cancel` are attributed by +// or worktree and hands admission to the runtime; `status`, `deliver`, and `cancel` are attributed by // the caller pane and routed to the runtime coordinator. import Foundation @@ -64,7 +64,7 @@ final class WorkflowCommandHandler: CommandHandler { code: CLIErrorCode.workflowFailed, message: "Failed to list workflows: \(error)") } } - case .run, .status, .done, .cancel, .read: + case .run, .status, .deliver, .cancel, .read: guard let runtime else { return notConfigured() } return await handleRuntime( input, runtime: runtime, snapshot: snapshot, callerPane: callerPane) @@ -92,8 +92,8 @@ final class WorkflowCommandHandler: CommandHandler { return await runtime.read(input, callerPane: callerPane) case .status: return runtime.status(input, callerPane: callerPane) - case .done: - return await runtime.done(input, callerPane: callerPane) + case .deliver: + return await runtime.deliver(input, callerPane: callerPane) case .cancel: return runtime.cancel(input, callerPane: callerPane) } diff --git a/supacode/CLIService/WorkflowRunAdmission.swift b/supacode/CLIService/WorkflowRunAdmission.swift index 67a34bd72..4affd019d 100644 --- a/supacode/CLIService/WorkflowRunAdmission.swift +++ b/supacode/CLIService/WorkflowRunAdmission.swift @@ -132,7 +132,7 @@ enum WorkflowRunAdmission { } if let actionID = input.testAction { let localID = actionID.hasPrefix("local:") ? String(actionID.dropFirst(6)) : "" - guard actionID == "builtin:git.context" || entry.file.actions[localID] != nil else { + guard actionID == "builtin:collect-worktree-context" || entry.file.actions[localID] != nil else { return .failure(.init(code: CLIErrorCode.invalidArgument, message: "Unknown bundle action '\(actionID)'.")) } definition = WorkflowDefinition( diff --git a/supacode/CLIService/WorkflowRunPayload+App.swift b/supacode/CLIService/WorkflowRunPayload+App.swift index 2ab840a9c..26e7f7c64 100644 --- a/supacode/CLIService/WorkflowRunPayload+App.swift +++ b/supacode/CLIService/WorkflowRunPayload+App.swift @@ -53,7 +53,7 @@ extension WorkflowRunPayload { activation: activation.map { WorkflowActivationPayload($0, spellCompletion: spellsCompletion, formatter: formatter) }, - outputs: run.outputs.mapValues { WorkflowOutputPayload($0, formatter: formatter) }, + deliveries: run.deliveries.mapValues { WorkflowDeliveryRecordPayload($0, formatter: formatter) }, startedAt: formatter.string(from: run.startedAt), updatedAt: formatter.string(from: run.updatedAt), finishedAt: run.finishedAt.map(formatter.string(from:)), @@ -85,7 +85,7 @@ extension WorkflowRunPayload { WorkflowBindingPayload(source: $0.source, profile: $0.profile, pane: $0.pane) }, activation: nil, - outputs: record.outputs.mapValues { WorkflowOutputPayload($0, formatter: formatter) }, + deliveries: record.deliveries.mapValues { WorkflowDeliveryRecordPayload($0, formatter: formatter) }, startedAt: formatter.string(from: record.run.startedAt), updatedAt: formatter.string(from: record.run.updatedAt), finishedAt: record.run.finishedAt.map(formatter.string(from:)), @@ -138,11 +138,11 @@ extension WorkflowActivationPayload { role: activation.role, state: activation.state.rawValue, dispatchID: activation.dispatchID, - output: activation.outputName, + output: activation.deliveryName, expect: WorkflowExpectationPayload( format: activation.expect.format, sections: activation.expect.sections, - verdict: activation.expect.verdict, + verdicts: activation.expect.verdicts, strict: activation.expect.strict, completion: spellCompletion ? activation.completion.messageCommands : []), deadline: activation.deadline.map(formatter.string(from:)) @@ -150,8 +150,8 @@ extension WorkflowActivationPayload { } } -extension WorkflowOutputPayload { - nonisolated init(_ output: WorkflowOutputRecord, formatter: ISO8601DateFormatter) { +extension WorkflowDeliveryRecordPayload { + nonisolated init(_ output: WorkflowDeliveryRecord, formatter: ISO8601DateFormatter) { self.init( name: output.name, ordinal: output.ordinal, @@ -170,7 +170,7 @@ extension WorkflowDeliveryPayload { ordinal: receipt.ordinal, step: receipt.stepID, role: role, - output: WorkflowOutputPayload(receipt.output, formatter: WorkflowRunPayload.makeDateFormatter()), + output: WorkflowDeliveryRecordPayload(receipt.output, formatter: WorkflowRunPayload.makeDateFormatter()), warnings: receipt.issues.map { WorkflowDeliveryWarningPayload(code: $0.code, message: $0.message) } diff --git a/supacode/CLIService/WorkflowRuntimeCoordinator.swift b/supacode/CLIService/WorkflowRuntimeCoordinator.swift index 0fccaa049..8435f8d81 100644 --- a/supacode/CLIService/WorkflowRuntimeCoordinator.swift +++ b/supacode/CLIService/WorkflowRuntimeCoordinator.swift @@ -1,7 +1,7 @@ // supacode/CLIService/WorkflowRuntimeCoordinator.swift -// The socket side of `prowl workflow run / status / done / cancel` (docs-ai 063 B3). It reads the -// reducer's sessions, attributes a `done` to an activation (decision W3), enters the reducer -// through actions, and awaits the `done` rendezvous (decision W1). It owns no run state. +// The socket side of `prowl workflow run / status / deliver / cancel` (docs-ai 063 B3). It reads the +// reducer's sessions, attributes a `deliver` to an activation (decision W3), enters the reducer +// through actions, and awaits the `deliver` rendezvous (decision W1). It owns no run state. import Foundation import ProwlCLIShared @@ -48,7 +48,7 @@ final class WorkflowRuntimeCoordinator { } private let dependencies: Dependencies - /// The verified caller role of each outstanding `run` / `done`, so the answer spells completion + /// The verified caller role of each outstanding `run` / `deliver`, so the answer spells completion /// commands only to the pane that owns the activation (never to a manual or forced caller). private var callerRoles: [UUID: String] = [:] /// Request ids the reducer still owes an answer for; a cancelled waiter frees its rendezvous @@ -224,9 +224,9 @@ final class WorkflowRuntimeCoordinator { return (session, invocation) } - // MARK: - done + // MARK: - deliver - func done(_ input: WorkflowInput, callerPane: CallerPane?) async -> CommandResponse { + func deliver(_ input: WorkflowInput, callerPane: CallerPane?) async -> CommandResponse { guard let body = input.body else { return Self.failure( code: CLIErrorCode.invalidArgument, message: "The delivery has no output body.") @@ -332,7 +332,7 @@ final class WorkflowRuntimeCoordinator { return .failure( Self.refusal( code: CLIErrorCode.sourceRequired, - message: "Run `prowl workflow done` inside the pane that received the step, " + message: "Run `prowl workflow deliver` inside the pane that received the step, " + "or pass --run --step for a manual delivery.")) } return .failure( @@ -360,7 +360,7 @@ final class WorkflowRuntimeCoordinator { source: source, callerRole: nil)) } - /// The reducer's answer to a `run` or `done` request (through `WorkflowCLIResponderClient`). + /// The reducer's answer to a `run` or `deliver` request (through `WorkflowCLIResponderClient`). func resolve(_ requestID: UUID, _ resolution: WorkflowRequestResolution) { inFlight.remove(requestID) let callerRole = callerRoles.removeValue(forKey: requestID) @@ -370,21 +370,21 @@ final class WorkflowRuntimeCoordinator { response = Self.success(.run(WorkflowRunPayload(run: run, callerRole: callerRole, includeSelfInitiated: true))) case .delivered(let run, let receipt): response = Self.success( - .done(Self.donePayload(run: run, receipt: receipt, state: .delivered, callerRole: callerRole))) + .deliver(Self.deliverPayload(run: run, receipt: receipt, state: .delivered, callerRole: callerRole))) case .provisional(let run, let receipt): response = Self.success( - .done(Self.donePayload(run: run, receipt: receipt, state: .provisional, callerRole: callerRole))) + .deliver(Self.deliverPayload(run: run, receipt: receipt, state: .provisional, callerRole: callerRole))) case .failed(let code, let message): response = Self.failure(code: code, message: message) } dependencies.rendezvous.resolve(requestID, with: response) } - private static func donePayload( + private static func deliverPayload( run: WorkflowRun, receipt: WorkflowDeliveryReceipt, state: WorkflowDeliveryState, callerRole: String? - ) -> WorkflowDonePayload { + ) -> WorkflowDeliverPayload { let role = run.invocations.first { $0.ordinal == receipt.ordinal }?.role ?? "-" - return WorkflowDonePayload( + return WorkflowDeliverPayload( run: WorkflowRunPayload(run: run, callerRole: callerRole, includeSelfInitiated: false), delivery: WorkflowDeliveryPayload(state: state, receipt: receipt, role: role)) } diff --git a/supacode/Domain/Workflow/WorkflowDeliveryValidator.swift b/supacode/Domain/Workflow/WorkflowDeliveryValidator.swift index 368155ffe..2ae30e9e6 100644 --- a/supacode/Domain/Workflow/WorkflowDeliveryValidator.swift +++ b/supacode/Domain/Workflow/WorkflowDeliveryValidator.swift @@ -1,5 +1,5 @@ // supacode/Domain/Workflow/WorkflowDeliveryValidator.swift -// Validation of a `prowl workflow done` body against the step's `expect` (dsl-spec §5): size +// Validation of a `prowl workflow deliver` body against the step's `expect` (dsl-spec §5): size // caps, format, required sections, and the verdict declaration. import Foundation @@ -128,7 +128,7 @@ nonisolated enum WorkflowDeliveryValidator { } var issues: [WorkflowDeliveryIssue] = [] var acceptedVerdict: String? - switch (expect.verdict, verdict) { + switch (expect.verdicts, verdict) { case (nil, nil): break case (nil, .some(let value)): diff --git a/supacode/Domain/Workflow/WorkflowExecutionContext.swift b/supacode/Domain/Workflow/WorkflowExecutionContext.swift index 26da862c1..5ced7773e 100644 --- a/supacode/Domain/Workflow/WorkflowExecutionContext.swift +++ b/supacode/Domain/Workflow/WorkflowExecutionContext.swift @@ -8,13 +8,13 @@ extension WorkflowRun { let observed = binding.pane.flatMap { observations[$0.surfaceID.uuidString] } return .object([ "source": .string(binding.source.rawValue), - "name": .string(binding.templateRole.name), - "agent": .string(binding.templateRole.agent), - "pane": binding.pane.map { .string($0.surfaceID.uuidString) } ?? .null, + "display_name": .string(binding.displayName), + "agent": .string(binding.agent), + "pane_id": binding.pane.map { .string($0.surfaceID.uuidString) } ?? .null, "observed": observed ?? .null, ]) } - let outputValues = outputs.mapValues { output -> WorkflowJSONValue in + let outputValues = deliveries.mapValues { output -> WorkflowJSONValue in .object(["path": .string(output.latestPath), "verdict": output.verdict.map(WorkflowJSONValue.string) ?? .null]) } var typedInputs: [String: WorkflowJSONValue] = [:] @@ -26,16 +26,14 @@ extension WorkflowRun { } } let contextValue: WorkflowJSONValue = .object([ - "run": .object([ - "id": .string(id.uuidString), "workflow_id": .string(definition.id), - "directory": .string(runDirectory.path), - ]), + "workflow": .object(["id": .string(definition.id), "name": .string(definition.name)]), + "run": .object(["id": .string(id.uuidString), "path": .string(runDirectory.path)]), "worktree": .object([ "id": .string(context.worktree.id), "path": .string(context.worktree.path), "name": .string(context.worktree.name), "branch": observations["branch"] ?? .string(context.worktree.branch), "captured_at": .string(timestamp), ]), - "source": (context.sourcePaneID ?? bindings.values.first { $0.source == .current }?.pane?.surfaceID).map { + "initiator": (context.sourcePaneID ?? bindings.values.first { $0.source == .current }?.pane?.surfaceID).map { .object([ "pane_id": .string($0.uuidString), "tab_id": (context.sourceTabID ?? bindings.values.first { $0.source == .current }?.pane?.tabID) @@ -51,7 +49,7 @@ extension WorkflowRun { ]) let values = [ "context": contextValue, "inputs": WorkflowJSON.object(typedInputs), - "outputs": WorkflowJSON.object(outputValues), + "deliveries": WorkflowJSON.object(outputValues), "actions": WorkflowJSON.object(actionOutputs.mapValues(WorkflowJSON.object)), ] return controlCursor?.values(over: values) ?? values diff --git a/supacode/Domain/Workflow/WorkflowLineRenderer.swift b/supacode/Domain/Workflow/WorkflowLineRenderer.swift index 7849ecdaf..f1e227620 100644 --- a/supacode/Domain/Workflow/WorkflowLineRenderer.swift +++ b/supacode/Domain/Workflow/WorkflowLineRenderer.swift @@ -1,6 +1,6 @@ // supacode/Domain/Workflow/WorkflowLineRenderer.swift // The text a workflow run puts in front of an agent (docs-ai 063, dsl-spec §4/§10): the typed -// line formats, the single place that spells `prowl workflow done`, the launch protocol block, +// line formats, the single place that spells `prowl workflow deliver`, the launch protocol block, // and the rendered-text boundary every typed line crosses. import Foundation @@ -29,12 +29,12 @@ nonisolated enum WorkflowRenderedText { } /// The completion command of one activation. This is the only place that spells -/// `prowl workflow done`: the typed hint, the materialized instruction trailer, the launch +/// `prowl workflow deliver`: the typed hint, the materialized instruction trailer, the launch /// protocol block, the watchdog nudge, every re-delivery, and the `WORKFLOW_DELIVERY_REQUIRED` /// message all read from it, so no path can show a token-less or verdict-less command. nonisolated struct WorkflowCompletionCommand: Equatable, Sendable { static let protocolVersion = 1 - static let executable = "prowl workflow done" + static let executable = "prowl workflow deliver" static let commandSeparator = " or " let token: String @@ -46,12 +46,12 @@ nonisolated struct WorkflowCompletionCommand: Equatable, Sendable { self.verdicts = verdicts } - /// `PROWL_WORKFLOW_TOKEN= prowl workflow done [--verdict v] -`, one per allowed verdict. + /// `PROWL_WORKFLOW_TOKEN= prowl workflow deliver [--verdict v] -`, one per allowed verdict. var messageCommands: [String] { launchCommands.map { "\(WorkflowSchema.tokenEnvironmentKey)=\(token) \($0)" } } - /// `prowl workflow done [--verdict v] -`; the token travels in the launched child's environment. + /// `prowl workflow deliver [--verdict v] -`; the token travels in the launched child's environment. var launchCommands: [String] { guard let verdicts, !verdicts.isEmpty else { return ["\(Self.executable) -"] } return verdicts.map { "\(Self.executable) --verdict \($0) -" } @@ -124,7 +124,7 @@ nonisolated struct WorkflowCompletionCommand: Equatable, Sendable { + "deliver the step's output instead with: " + messageCommands.joined(separator: Self.commandSeparator) } - private static func formatDescription(_ format: WorkflowOutputFormat) -> String { + private static func formatDescription(_ format: WorkflowDeliveryFormat) -> String { switch format { case .markdown: "a markdown document" case .text: "plain text" diff --git a/supacode/Domain/Workflow/WorkflowNativeActions.swift b/supacode/Domain/Workflow/WorkflowNativeActions.swift index cfc2d3e21..d0ca8b8f8 100644 --- a/supacode/Domain/Workflow/WorkflowNativeActions.swift +++ b/supacode/Domain/Workflow/WorkflowNativeActions.swift @@ -71,10 +71,10 @@ nonisolated struct WorkflowNativeActionRunner: WorkflowActionExecuting { let artifacts = try prepareDirectory(context) var snapshot = context.values["context"] ?? .object([:]) if case .object(var fields) = snapshot { - fields["execution"] = .object([ - "id": .string(context.executionID), "step_id": .string(context.stepID), - "attempt": .integer(context.attempt), "cwd": .string(context.rootURL.path), - "artifact_dir": .string(artifacts.path), + fields["action"] = .object([ + "execution_id": .string(context.executionID), "step_id": .string(context.stepID), + "attempt": .integer(context.attempt), "working_directory": .string(context.rootURL.path), + "artifacts_directory": .string(artifacts.path), ]) snapshot = .object(fields) } @@ -95,10 +95,10 @@ nonisolated struct WorkflowNativeActionRunner: WorkflowActionExecuting { do { try context.bundle?.verifyIntegrity() let output: WorkflowJSONValue - if actionID == "builtin:git.context" { - try WorkflowActionRegistry.gitContextInput.validate(.object(inputs)) - output = try await gitContext(inputs: inputs, context: context, artifacts: artifacts) - try WorkflowActionRegistry.gitContextOutput.validate(output) + if actionID == "builtin:collect-worktree-context" { + try WorkflowActionRegistry.worktreeContextInput.validate(.object(inputs)) + output = try await collectWorktreeContext(inputs: inputs, context: context, artifacts: artifacts) + try WorkflowActionRegistry.worktreeContextOutput.validate(output) } else { output = try await script(actionID: actionID, inputs: inputs, context: context, request: requestData) } @@ -107,7 +107,7 @@ nonisolated struct WorkflowNativeActionRunner: WorkflowActionExecuting { let resultURL = directory.appending(path: "result.json") try encoder.encode(output).write(to: resultURL, options: .atomic) try record(context, state: "succeeded", detail: nil) - return ["output": output, "result_path": .string(resultURL.path)] + return ["output": output, "output_path": .string(resultURL.path)] } catch { if let processError = error as? WorkflowScriptExecutionError { try? processError.stdout.write(to: directory.appending(path: "stdout.log"), options: .atomic) @@ -166,11 +166,13 @@ nonisolated struct WorkflowNativeActionRunner: WorkflowActionExecuting { return output } - private func gitContext(inputs: [String: WorkflowJSONValue], context: WorkflowActionContext, artifacts: URL) + private func collectWorktreeContext( + inputs: [String: WorkflowJSONValue], context: WorkflowActionContext, artifacts: URL + ) async throws -> WorkflowJSONValue { guard Set(inputs.keys).isSubset(of: ["root"]) else { - throw WorkflowActionError.failed("Unknown git.context input.") + throw WorkflowActionError.failed("Unknown collect-worktree-context input.") } var root = WorkflowHistoryStorage.canonicalURL(context.rootURL) if let input = inputs["root"] { diff --git a/supacode/Domain/Workflow/WorkflowRun.swift b/supacode/Domain/Workflow/WorkflowRun.swift index e5ff67551..45798059d 100644 --- a/supacode/Domain/Workflow/WorkflowRun.swift +++ b/supacode/Domain/Workflow/WorkflowRun.swift @@ -1,6 +1,6 @@ // supacode/Domain/Workflow/WorkflowRun.swift // The state of one workflow run (docs-ai 063 B2, dsl-spec §5/§8/§10): frozen context and -// bindings, the position cursor, invocations and activations, outputs, and the attention +// bindings, the position cursor, invocations and activations, deliveries, and the attention // vocabulary the panel renders. Transitions live in WorkflowRunMachine. import Foundation @@ -62,12 +62,17 @@ nonisolated enum WorkflowRoleBinding: Equatable, Sendable { } /// `roles..name` / `roles..agent` as dsl-spec §6 defines them. - var templateRole: WorkflowTemplateContext.Role { + var displayName: String { switch self { - case .current(let pane), .pick(let pane): - WorkflowTemplateContext.Role(name: pane.displayName, agent: pane.agent ?? "", pane: pane.handle) - case .launch(let profile, let pane): - WorkflowTemplateContext.Role(name: profile.name, agent: profile.agent, pane: pane?.handle) + case .current(let pane), .pick(let pane): pane.displayName + case .launch(let profile, _): profile.name + } + } + + var agent: String { + switch self { + case .current(let pane), .pick(let pane): pane.agent ?? "" + case .launch(let profile, _): profile.agent } } @@ -164,10 +169,10 @@ nonisolated enum WorkflowRunPaths { .appending(path: "\(stepID).\(ordinal).md", directoryHint: .notDirectory) } - /// `outputs/..md`, or the latest view `outputs/.md` without an ordinal. - static func outputURL(runDirectory: URL, name: String, ordinal: Int?) -> URL { + /// `deliveries/..md`, or the latest view `deliveries/.md` without an ordinal. + static func deliveryURL(runDirectory: URL, name: String, ordinal: Int?) -> URL { let file = ordinal.map { "\(name).\($0).md" } ?? "\(name).md" - return runDirectory.appending(path: "outputs", directoryHint: .isDirectory) + return runDirectory.appending(path: "deliveries", directoryHint: .isDirectory) .appending(path: file, directoryHint: .notDirectory) } @@ -202,7 +207,7 @@ nonisolated struct WorkflowActivation: Equatable, Sendable { let role: String let token: String let expect: WorkflowExpectation - let outputName: String + let deliveryName: String var dispatchID: String? var state: WorkflowActivationState /// `expect.timeout` as an absolute deadline, fixed when the activation opened; a re-armed @@ -212,7 +217,7 @@ nonisolated struct WorkflowActivation: Equatable, Sendable { var pendingDelivery: WorkflowValidatedDelivery? var completion: WorkflowCompletionCommand { - WorkflowCompletionCommand(token: token, verdicts: expect.verdict) + WorkflowCompletionCommand(token: token, verdicts: expect.verdicts) } } @@ -235,12 +240,12 @@ nonisolated struct WorkflowInvocation: Equatable, Sendable { var endedAt: Date? } -nonisolated struct WorkflowOutputRecord: Equatable, Sendable, Codable { +nonisolated struct WorkflowDeliveryRecord: Equatable, Sendable, Codable { let name: String let ordinal: Int - /// `outputs/..md`. + /// `deliveries/..md`. let path: String - /// `outputs/.md`, the atomically replaced latest view. + /// `deliveries/.md`, the atomically replaced latest view. let latestPath: String let verdict: String? let deliveredAt: Date @@ -341,13 +346,13 @@ nonisolated enum WorkflowRunStatus: Equatable, Sendable { case completed case cancelled case skipped(step: String, dependent: String) - case maxRoundsReached + case iterationLimitReached case interrupted var isTerminal: Bool { switch self { case .running, .needsAttention: false - case .completed, .cancelled, .skipped, .maxRoundsReached, .interrupted: true + case .completed, .cancelled, .skipped, .iterationLimitReached, .interrupted: true } } @@ -382,7 +387,7 @@ nonisolated struct WorkflowRun: Equatable, Sendable { var phase: WorkflowRunPhase = .idle var invocations: [WorkflowInvocation] = [] /// Latest delivered output per name (latest wins across steps). - var outputs: [String: WorkflowOutputRecord] = [:] + var deliveries: [String: WorkflowDeliveryRecord] = [:] var actionOutputs: [String: [String: WorkflowJSONValue]] = [:] var controlCursor: WorkflowControlCursor? var stepValues: [String: WorkflowJSONValue] = [:] diff --git a/supacode/Domain/Workflow/WorkflowRunMachine.swift b/supacode/Domain/Workflow/WorkflowRunMachine.swift index ed78e8da7..045b95c2d 100644 --- a/supacode/Domain/Workflow/WorkflowRunMachine.swift +++ b/supacode/Domain/Workflow/WorkflowRunMachine.swift @@ -127,7 +127,7 @@ nonisolated enum WorkflowDeliverySelector: Equatable, Sendable { nonisolated struct WorkflowDeliveryReceipt: Equatable, Sendable { let ordinal: Int let stepID: String - let output: WorkflowOutputRecord + let output: WorkflowDeliveryRecord /// Non-empty when the delivery was accepted provisionally; the CLI reports them as warnings. let issues: [WorkflowDeliveryIssue] } @@ -145,7 +145,7 @@ nonisolated enum WorkflowRunStartError: Error, Equatable, Sendable { case invalidInput(name: String, reason: String) case unsafePath(String) case unknownSkipStep(String) - /// `--skip` names a step without an `expect`; only awaited outputs can be skipped at start. + /// `--skip` names a step without an `expect`; only awaited deliveries can be skipped at start. case skipNotExpecting(String) case skipNotAllowed(step: String, dependent: String) case missingBinding(role: String) @@ -441,9 +441,9 @@ nonisolated struct WorkflowRunMachine { let effects: [WorkflowRunEffect] = [ .disarmWatchdog(ordinal: activation.ordinal), .log( - "Step '\(activation.stepID)': output '\(activation.outputName)' accepted " + "Step '\(activation.stepID)': output '\(activation.deliveryName)' accepted " + "(invocation \(activation.ordinal))\(issueNote); persisting."), - .persistOutput(name: activation.outputName, ordinal: activation.ordinal, body: validated.body), + .persistOutput(name: activation.deliveryName, ordinal: activation.ordinal, body: validated.body), ] return ( .success( @@ -453,15 +453,15 @@ nonisolated struct WorkflowRunMachine { ) } - private func outputRecord(for activation: WorkflowActivation, verdict: String?) -> WorkflowOutputRecord { - WorkflowOutputRecord( - name: activation.outputName, + private func outputRecord(for activation: WorkflowActivation, verdict: String?) -> WorkflowDeliveryRecord { + WorkflowDeliveryRecord( + name: activation.deliveryName, ordinal: activation.ordinal, path: WorkflowRunPaths.path( - WorkflowRunPaths.outputURL( - runDirectory: run.runDirectory, name: activation.outputName, ordinal: activation.ordinal)), + WorkflowRunPaths.deliveryURL( + runDirectory: run.runDirectory, name: activation.deliveryName, ordinal: activation.ordinal)), latestPath: WorkflowRunPaths.path( - WorkflowRunPaths.outputURL(runDirectory: run.runDirectory, name: activation.outputName, ordinal: nil)), + WorkflowRunPaths.deliveryURL(runDirectory: run.runDirectory, name: activation.deliveryName, ordinal: nil)), verdict: verdict, deliveredAt: now() ) @@ -497,8 +497,8 @@ nonisolated struct WorkflowRunMachine { ) { let ordinal = activation.ordinal let record = outputRecord(for: activation, verdict: verdict) - run.outputs[activation.outputName] = record - run.skippedOutputs[activation.outputName] = nil + run.deliveries[activation.deliveryName] = record + run.skippedOutputs[activation.deliveryName] = nil updateActivation(ordinal: ordinal) { $0.state = .delivered $0.pendingDelivery = nil @@ -508,11 +508,12 @@ nonisolated struct WorkflowRunMachine { effects.append( .completeActivation( dispatchID: dispatchID, - summary: "Delivered output '\(activation.outputName)' for workflow step '\(activation.stepID)'\(verdictNote)." + summary: + "Delivered output '\(activation.deliveryName)' for workflow step '\(activation.stepID)'\(verdictNote)." )) } effects.append( - .log("Step '\(activation.stepID)': output '\(activation.outputName)' delivered (invocation \(ordinal)).")) + .log("Step '\(activation.stepID)': output '\(activation.deliveryName)' delivered (invocation \(ordinal)).")) run.status = .running completeCurrentStep(effects: &effects) advance(effects: &effects) @@ -540,7 +541,7 @@ nonisolated struct WorkflowRunMachine { private static func startConsequence( forStep stepID: String, definition: WorkflowDefinition, preSkipped: Set ) -> WorkflowSkipConsequence { - guard let name = definition.flattenedSteps.first(where: { $0.id == stepID })?.outputName else { + guard let name = definition.flattenedSteps.first(where: { $0.id == stepID })?.deliveryName else { return .noOutput } let remaining = definition.steps @@ -551,7 +552,7 @@ nonisolated struct WorkflowRunMachine { private static func skipConsequence(forStep stepID: String, in run: WorkflowRun) -> WorkflowSkipConsequence { - guard let name = run.definition.flattenedSteps.first(where: { $0.id == stepID })?.outputName else { + guard let name = run.definition.flattenedSteps.first(where: { $0.id == stepID })?.deliveryName else { return .noOutput } return consequence( @@ -614,7 +615,7 @@ nonisolated struct WorkflowRunMachine { private static func references(_ name: String, in text: String) -> Bool { guard let paths = try? WorkflowExpression.requiredReferences(in: text) else { return false } - return paths.contains { $0.count >= 2 && $0[0] == "outputs" && $0[1] == name } + return paths.contains { $0.count >= 2 && $0[0] == "deliveries" && $0[1] == name } } private static func references(_ name: String, in value: WorkflowJSONValue) -> Bool { @@ -669,7 +670,7 @@ nonisolated struct WorkflowRunMachine { private mutating func skipAtStart(_ step: WorkflowStepDefinition, effects: inout [WorkflowRunEffect]) { recordStep(step, state: .skipped, ordinal: nil) - if let name = step.outputName { + if let name = step.deliveryName { run.skippedOutputs[name] = step.id } effects.append(.log("Step '\(step.id)': skipped at start.")) @@ -766,7 +767,7 @@ nonisolated struct WorkflowRunMachine { guard let invocation = invocation(ordinal) else { return nil } guard let rendered = render(content.body, step: step, effects: &effects) else { return nil } var completion: WorkflowCompletionCommand? - if let expect, let outputName = step.outputName { + if let expect, let deliveryName = step.deliveryName { // A `roleBusy` refusal returns the same invocation to its idle wait: the activation and its // token survive, so the command the run already rendered stays valid (dsl-spec §5). let activation: WorkflowActivation @@ -775,7 +776,7 @@ nonisolated struct WorkflowRunMachine { } else { activation = WorkflowActivation( ordinal: ordinal, stepID: step.id, role: invocation.role, token: makeToken(), expect: expect, - outputName: outputName, dispatchID: nil, state: .waiting) + deliveryName: deliveryName, dispatchID: nil, state: .waiting) updateInvocation(ordinal: ordinal) { $0.activation = activation } } completion = activation.completion @@ -857,10 +858,10 @@ nonisolated struct WorkflowRunMachine { WorkflowSchema.roleEnvironmentKey: role, ] var protocolBlock: String? - if let expect, let outputName = step.outputName { + if let expect, let deliveryName = step.deliveryName { let activation = WorkflowActivation( ordinal: ordinal, stepID: step.id, role: role, token: makeToken(), expect: expect, - outputName: outputName, dispatchID: nil, state: .waiting) + deliveryName: deliveryName, dispatchID: nil, state: .waiting) updateInvocation(ordinal: ordinal) { $0.activation = activation } environment[WorkflowSchema.tokenEnvironmentKey] = activation.token let title = step.title.flatMap { try? WorkflowExpression.renderText($0, values: run.stepValues) } @@ -919,7 +920,7 @@ nonisolated struct WorkflowRunMachine { } } let known = - run.outputs.values.flatMap { [$0.path, $0.latestPath] } + run.deliveries.values.flatMap { [$0.path, $0.latestPath] } + run.actionOutputs.values.flatMap { $0.values.flatMap(paths) } + run.stepValues.values.flatMap(paths) return WorkflowTaskContent.make( @@ -936,11 +937,11 @@ nonisolated struct WorkflowRunMachine { var values = run.stepValues let directory = run.runDirectory.appending(path: "actions/\(step.id)/\(executionID)") if case .object(var context) = values["context"] { - context["execution"] = .object([ - "id": .string(executionID), "step_id": .string(step.id), + context["action"] = .object([ + "execution_id": .string(executionID), "step_id": .string(step.id), "attempt": .integer(run.actionAttempts[step.id] ?? 1), - "cwd": .string(run.context.worktree.path), - "artifact_dir": .string(directory.appending(path: "artifacts").path), + "working_directory": .string(run.context.worktree.path), + "artifacts_directory": .string(directory.appending(path: "artifacts").path), ]) values["context"] = .object(context) } @@ -973,7 +974,7 @@ nonisolated struct WorkflowRunMachine { stepID: evaluation.stepID, iteration: evaluation.iteration, state: evaluation.skipped ? .skipped : .completed, ordinal: nil)) } - for name in cursor.expiredOutputs { run.outputs.removeValue(forKey: name) } + for name in cursor.expiredOutputs { run.deliveries.removeValue(forKey: name) } for name in cursor.expiredActions { run.actionOutputs.removeValue(forKey: name) } switch outcome { case .step: return true @@ -983,7 +984,7 @@ nonisolated struct WorkflowRunMachine { } catch let limit as WorkflowLoopLimit { run.controlCursor = cursor effects.append(.log("Loop '\(limit.stepID)' reached max_iterations while its condition remained true.")) - finish(.maxRoundsReached, effects: &effects) + finish(.iterationLimitReached, effects: &effects) } catch { run.controlCursor = cursor raiseAttention( @@ -1011,7 +1012,8 @@ nonisolated struct WorkflowRunMachine { run.phase = .waitingForDelivery(ordinal: ordinal) effects.append(.persist) effects.append( - .log("Step '\(invocation.stepID)': waiting for output '\(activation.outputName)' from role '\(invocation.role)'.") + .log( + "Step '\(invocation.stepID)': waiting for output '\(activation.deliveryName)' from role '\(invocation.role)'.") ) armWatchdog(ordinal: ordinal, nudgedAlready: false, effects: &effects) } @@ -1097,8 +1099,8 @@ nonisolated struct WorkflowRunMachine { let delivery = activation.pendingDelivery { run.status = .running - effects.append(.log("Step '\(activation.stepID)': retrying to persist output '\(activation.outputName)'.")) - effects.append(.persistOutput(name: activation.outputName, ordinal: activation.ordinal, body: delivery.body)) + effects.append(.log("Step '\(activation.stepID)': retrying to persist output '\(activation.deliveryName)'.")) + effects.append(.persistOutput(name: activation.deliveryName, ordinal: activation.ordinal, body: delivery.body)) return } retryCurrentStep(effects: &effects) @@ -1138,7 +1140,7 @@ nonisolated struct WorkflowRunMachine { activation.state == .provisional, let delivery = activation.pendingDelivery else { return } let accepted: String? - if let allowed = activation.expect.verdict { + if let allowed = activation.expect.verdicts { // The delivery's own valid verdict wins; otherwise the user must pick a declared one. if let own = delivery.verdict { accepted = own @@ -1204,7 +1206,7 @@ nonisolated struct WorkflowRunMachine { if let index = run.stepRecords.indices.last, run.stepRecords[index].state == .active { run.stepRecords[index].state = .skipped } - if let name = step.outputName { + if let name = step.deliveryName { run.skippedOutputs[name] = step.id } run.status = .running @@ -1320,22 +1322,22 @@ nonisolated struct WorkflowRunMachine { private func attentionMessage(_ reason: WorkflowAttentionReason, stepID: String, role: String?) -> String { let subject: String if let role, let binding = run.bindings[role] { - subject = "\(role) (\(binding.templateRole.name))" + subject = "\(role) (\(binding.displayName))" } else { subject = role ?? "the step" } - let outputName = run.currentActivation?.outputName ?? "its output" + let deliveryName = run.currentActivation?.deliveryName ?? "its output" switch reason { case .needsInput: return "\(subject) is waiting for input in its pane." case .idleWithoutDelivery: - return "\(subject) has been idle without delivering \(outputName); Prowl nudged it once." + return "\(subject) has been idle without delivering \(deliveryName); Prowl nudged it once." case .blocked: return "\(subject) looks blocked on screen." case .agentGone(.sessionEnded): - return "\(subject)'s agent session ended before it delivered \(outputName)." + return "\(subject)'s agent session ended before it delivered \(deliveryName)." case .agentGone(.paneClosed): - return "\(subject)'s pane was closed before it delivered \(outputName)." + return "\(subject)'s pane was closed before it delivered \(deliveryName)." case .agentGone(.processGone): return "\(subject)'s agent process is gone." case .agentGone(.notLaunched): @@ -1362,7 +1364,7 @@ nonisolated struct WorkflowRunMachine { case .persistFailed(let detail): return "The delivered output of step '\(stepID)' could not be saved to the run directory: \(detail)" case .deliveryIssues(let issues): - return "\(subject) delivered \(outputName), but: \(issues.map(\.message).joined(separator: "; "))." + return "\(subject) delivered \(deliveryName), but: \(issues.map(\.message).joined(separator: "; "))." + " Accept it, ask again, or skip." case .timeout: return "Step '\(stepID)' reached its timeout without a delivery from \(subject)." @@ -1395,7 +1397,7 @@ nonisolated struct WorkflowRunMachine { case .cancelled: "cancelled" case .skipped(let step, let dependent): "skipped (step '\(step)' was skipped but '\(dependent)' depends on its output)" - case .maxRoundsReached: "max rounds reached" + case .iterationLimitReached: "iteration limit reached" case .interrupted: "interrupted" } } diff --git a/supacode/Domain/Workflow/WorkflowRunStore.swift b/supacode/Domain/Workflow/WorkflowRunStore.swift index 2726767cf..2f30ef194 100644 --- a/supacode/Domain/Workflow/WorkflowRunStore.swift +++ b/supacode/Domain/Workflow/WorkflowRunStore.swift @@ -1,6 +1,6 @@ // supacode/Domain/Workflow/WorkflowRunStore.swift // Personal run directories contain `run.json`, -// an append-only `log.md`, materialized instructions and skills, and versioned outputs with an +// an append-only `log.md`, materialized instructions and skills, and versioned deliveries with an // atomically replaced latest view. Every path is built from validated slugs and the run UUID // under the same physical containment gate as profile homes. @@ -119,7 +119,7 @@ nonisolated struct WorkflowRunRecord: Codable, Equatable, Sendable { let inputs: [String] let bindings: [String: Binding] let invocations: [WorkflowRunRecordInvocation] - let outputs: [String: WorkflowOutputRecord] + let deliveries: [String: WorkflowDeliveryRecord] let actions: [String: [String: WorkflowJSONValue]] let state: [String: WorkflowJSONValue]? let actionAttempts: [String: Int]? @@ -133,7 +133,7 @@ nonisolated struct WorkflowRunRecord: Codable, Equatable, Sendable { case inputs case bindings case invocations - case outputs + case deliveries case actions case state case actionAttempts = "action_attempts" @@ -168,13 +168,13 @@ nonisolated struct WorkflowRunRecord: Codable, Equatable, Sendable { resources: invocation.content?.resources.mapValues { String($0.dropFirst(run.runDirectory.path.count + 1)) }, skill: invocation.content?.skill, activation: invocation.activation.map { - WorkflowRunRecordActivation(dispatchID: $0.dispatchID, state: $0.state, output: $0.outputName) + WorkflowRunRecordActivation(dispatchID: $0.dispatchID, state: $0.state, output: $0.deliveryName) }, startedAt: invocation.startedAt, endedAt: invocation.endedAt ) } - outputs = run.outputs + deliveries = run.deliveries actions = run.actionOutputs state = run.controlCursor?.state.values actionAttempts = run.actionAttempts @@ -199,7 +199,7 @@ nonisolated struct WorkflowRunRecord: Codable, Equatable, Sendable { inputs = record.inputs bindings = record.bindings invocations = record.invocations - outputs = record.outputs + deliveries = record.deliveries actions = record.actions state = record.state actionAttempts = record.actionAttempts @@ -243,15 +243,15 @@ extension WorkflowRunRecord.Status { self.init(state: "cancelled", step: nil, dependent: nil, attention: nil) case .skipped(let step, let dependent): self.init(state: "skipped", step: step, dependent: dependent, attention: nil) - case .maxRoundsReached: - self.init(state: "max_rounds_reached", step: nil, dependent: nil, attention: nil) + case .iterationLimitReached: + self.init(state: "iteration_limit_reached", step: nil, dependent: nil, attention: nil) case .interrupted: self.init(state: "interrupted", step: nil, dependent: nil, attention: nil) } } nonisolated var isTerminal: Bool { - ["completed", "cancelled", "skipped", "max_rounds_reached", "interrupted"].contains(state) + ["completed", "cancelled", "skipped", "iteration_limit_reached", "interrupted"].contains(state) } } @@ -347,7 +347,7 @@ nonisolated struct WorkflowRunStore: Sendable { func ensureLayout(runID: UUID) throws { let runDirectory = directory(for: runID) try storage.prepare(runDirectory) - for name in ["instructions", "outputs", "skills"] { + for name in ["instructions", "deliveries", "skills"] { try storage.prepare(runDirectory.appending(path: name)) } } @@ -425,7 +425,7 @@ nonisolated struct WorkflowRunStore: Sendable { try handle.write(contentsOf: Data(entry.utf8)) } - // MARK: Instructions and outputs + // MARK: Instructions and deliveries @discardableResult func writeInstruction(runID: UUID, stepID: String, ordinal: Int, text: String) throws -> URL { @@ -438,14 +438,14 @@ nonisolated struct WorkflowRunStore: Sendable { return url } - /// Writes `outputs/..md` and replaces `outputs/.md` atomically + /// Writes `deliveries/..md` and replaces `deliveries/.md` atomically /// (temp file + rename), so a reader never sees a partially written latest view. @discardableResult func writeOutput(runID: UUID, name: String, ordinal: Int, body: String) throws -> (versioned: URL, latest: URL) { let runDirectory = try containedRunDirectory(runID: runID) guard WorkflowSchema.isSlug(name), ordinal > 0 else { throw WorkflowRunStoreError.unsafePath(name) } - let versioned = WorkflowRunPaths.outputURL(runDirectory: runDirectory, name: name, ordinal: ordinal) - let latest = WorkflowRunPaths.outputURL(runDirectory: runDirectory, name: name, ordinal: nil) + let versioned = WorkflowRunPaths.deliveryURL(runDirectory: runDirectory, name: name, ordinal: ordinal) + let latest = WorkflowRunPaths.deliveryURL(runDirectory: runDirectory, name: name, ordinal: nil) try requireNotSymbolicLink(versioned.deletingLastPathComponent()) try requireNotSymbolicLink(versioned) try requireNotSymbolicLink(latest) diff --git a/supacode/Domain/Workflow/WorkflowStarterTemplate.swift b/supacode/Domain/Workflow/WorkflowStarterTemplate.swift index f0d6e123a..319e7d49b 100644 --- a/supacode/Domain/Workflow/WorkflowStarterTemplate.swift +++ b/supacode/Domain/Workflow/WorkflowStarterTemplate.swift @@ -45,18 +45,18 @@ nonisolated enum WorkflowStarterTemplate { Write a short brief for a reviewer: what changed, what you are unsure about, and how to verify it. Focus: {{ inputs.focus }} Deliver the brief with the generated completion command. - expect: { output: brief } + expect: { delivery: brief } - id: review title: Reviewer checking the work launch: reviewer prompt: | - Read {{ outputs.brief.path }} and review the work it describes in this worktree. + Read {{ deliveries.brief.path }} and review the work it describes in this worktree. Report under "## Findings" and end with "## Verdict". - expect: { output: findings, sections: ["## Findings", "## Verdict"], verdict: [clean, issues] } + expect: { delivery: findings, sections: ["## Findings", "## Verdict"], verdicts: [clean, issues] } - id: done - notify: "Review finished: {{ outputs.findings.verdict }}" + notify: "Review finished: {{ deliveries.findings.verdict }}" """ } diff --git a/supacode/Domain/Workflow/WorkflowTemplateRenderer.swift b/supacode/Domain/Workflow/WorkflowTemplateRenderer.swift deleted file mode 100644 index 692fca3b4..000000000 --- a/supacode/Domain/Workflow/WorkflowTemplateRenderer.swift +++ /dev/null @@ -1,173 +0,0 @@ -// supacode/Domain/Workflow/WorkflowTemplateRenderer.swift -// Substitution of the dsl-spec §6 whitelist over a typed context. Substituted values are never -// re-scanned, and a reference to a skipped output is the runtime side of the §5 Skip rule. - -import Foundation -import ProwlCLIShared - -nonisolated struct WorkflowTemplateContext: Equatable, Sendable { - struct Run: Equatable, Sendable { - let id: String - let directory: String - } - - struct Worktree: Equatable, Sendable { - let path: String - let name: String - let branch: String - } - - struct Role: Equatable, Sendable { - let name: String - let agent: String - /// The pane short handle (`p12`); nil until a launch role is launched. - let pane: String? - } - - struct Output: Equatable, Sendable { - let path: String - let verdict: String? - } - - struct Loop: Equatable, Sendable { - /// 1-based iteration; nil outside a `repeat`. - let index: Int? - /// Iterations completed by the latest loop. - let count: Int - } - - var run: Run - var worktree: Worktree - var roles: [String: Role] - /// Latest delivered output per name. - var outputs: [String: Output] - var skippedOutputs: Set - var actions: [String: [String: String]] - var inputs: [String: String] - var loop: Loop - - init( - run: Run, - worktree: Worktree, - roles: [String: Role], - outputs: [String: Output], - skippedOutputs: Set = [], - actions: [String: [String: String]] = [:], - inputs: [String: String] = [:], - loop: Loop = Loop(index: nil, count: 0) - ) { - self.run = run - self.worktree = worktree - self.roles = roles - self.outputs = outputs - self.skippedOutputs = skippedOutputs - self.actions = actions - self.inputs = inputs - self.loop = loop - } -} - -nonisolated enum WorkflowTemplateError: Error, Equatable, Sendable { - case malformed(WorkflowTemplate.ScanError) - case unknownVariable(String) - /// The output was skipped or never delivered: the consumer cannot render (§5 Skip rule). - case missingOutput(name: String) - case verdictUnavailable(output: String) - case paneUnavailable(role: String) -} - -extension WorkflowTemplate { - nonisolated static func render(_ text: String, context: WorkflowTemplateContext) throws(WorkflowTemplateError) - -> String - { - guard containsReference(text) else { return text } - let references: [Reference] - do { - references = try Self.references(in: text) - } catch { - throw .malformed(error) - } - var rendered = "" - var remainder = Substring(text) - for reference in references { - guard let range = remainder.firstRange(of: "{{"), - let close = remainder[range.upperBound...].firstRange(of: "}}") - else { break } - rendered += remainder[.. String - { - let parts = reference.components - let value: String? = - switch (parts.first, parts.count) { - case ("run", 2): runValue(parts[1], context: context) - case ("worktree", 2): worktreeValue(parts[1], context: context) - case ("inputs", 2): context.inputs[parts[1]] - case ("loop", 2): loopValue(parts[1], context: context) - case ("roles", 3): try roleValue(parts[1], field: parts[2], context: context) - case ("outputs", 3): try outputValue(parts[1], field: parts[2], context: context) - case ("actions", 3): context.actions[parts[1]]?[parts[2]] - default: nil - } - guard let value else { throw .unknownVariable(reference.path) } - return value - } - - private nonisolated static func runValue(_ field: String, context: WorkflowTemplateContext) -> String? { - switch field { - case "id": context.run.id - case "dir": context.run.directory - default: nil - } - } - - private nonisolated static func worktreeValue(_ field: String, context: WorkflowTemplateContext) -> String? { - switch field { - case "path": context.worktree.path - case "name": context.worktree.name - case "branch": context.worktree.branch - default: nil - } - } - - private nonisolated static func loopValue(_ field: String, context: WorkflowTemplateContext) -> String? { - switch field { - case "index": context.loop.index.map(String.init) - case "count": String(context.loop.count) - default: nil - } - } - - private nonisolated static func roleValue( - _ role: String, field: String, context: WorkflowTemplateContext - ) throws(WorkflowTemplateError) -> String? { - guard let binding = context.roles[role] else { return nil } - switch field { - case "name": return binding.name - case "agent": return binding.agent - case "pane": - guard let pane = binding.pane else { throw .paneUnavailable(role: role) } - return pane - default: return nil - } - } - - private nonisolated static func outputValue( - _ name: String, field: String, context: WorkflowTemplateContext - ) throws(WorkflowTemplateError) -> String? { - guard ["path", "verdict"].contains(field) else { return nil } - guard !context.skippedOutputs.contains(name), let output = context.outputs[name] else { - throw .missingOutput(name: name) - } - if field == "path" { return output.path } - guard let verdict = output.verdict else { throw .verdictUnavailable(output: name) } - return verdict - } -} diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 85d77956f..d5d777237 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -1182,7 +1182,7 @@ struct AppFeature { effects.append( .send(.repositories(.showToast(.success("\(notice.workflowName) completed")))) ) - case .skipped, .maxRoundsReached: + case .skipped, .iterationLimitReached: effects.append(.send(.repositories(.showToast(.warning(notice.title))))) case .needsAttention: break diff --git a/supacode/Features/Help/WorkflowAuthoringPrompt.swift b/supacode/Features/Help/WorkflowAuthoringPrompt.swift index d2a8c8c40..b1c8583bf 100644 --- a/supacode/Features/Help/WorkflowAuthoringPrompt.swift +++ b/supacode/Features/Help/WorkflowAuthoringPrompt.swift @@ -3,7 +3,7 @@ import ProwlCLIShared /// The copyable prompt behind Settings › Agents › Workflows › "Ask an Agent…" (docs-ai 063 /// D1): it points a coding agent at the bundled `prowl-workflow` skill and the workflows -/// manual, and asks it to author, validate, and place a workflow file. Localized like +/// manual, and asks it to author, validate, and place a workflow bundle. Localized like /// `AskAgentHelpPrompt`; pure so it is unit-testable. nonisolated enum WorkflowAuthoringPrompt { static func strings( @@ -30,12 +30,12 @@ nonisolated enum WorkflowAuthoringPrompt { explanation: "Copy this prompt and paste it into your coding agent (Claude Code, Codex, …) in a " + "terminal. It points the agent at the bundled workflow skill and manual and asks it to " - + "write, validate, and place a workflow file for you.", + + "write, validate, and place a workflow bundle for you.", prompt: """ Write a Prowl Agent Workflow for me. - Prowl runs multi-agent workflows from YAML files (schema prowl.workflow/v1). The authoring \ - guide and the feature manual ship inside the app: + Prowl runs multi-agent workflows from .pwlworkflow bundles containing workflow.yaml \ + (schema prowl.workflow/v1). The authoring guide and feature manual ship inside the app: - Skill: \(skill) - Manual: \(manual) @@ -44,8 +44,8 @@ nonisolated enum WorkflowAuthoringPrompt { 1. Ask me what the workflow should do: which agents take part, what each step asks for, \ and when it ends. - 2. Write the file into \(directory) with a short, unique id. - 3. Validate it with `prowl workflow validate ` and fix every error. + 2. Write a .pwlworkflow directory containing workflow.yaml into \(directory) with a short, unique id. + 3. Validate it with `prowl workflow validate .pwlworkflow` and fix every error. 4. Tell me how to start it (Command Palette → "Run Workflow: ", or `prowl workflow run `). Reply in my preferred language. @@ -61,11 +61,11 @@ nonisolated enum WorkflowAuthoringPrompt { title: "让 agent 写一个 workflow", explanation: "复制这段提示词,粘贴给你在终端里的编码 agent(Claude Code、Codex…)。" - + "它会让 agent 读取 Prowl 内置的 workflow skill 和说明书,然后替你编写、校验并放置 workflow 文件。", + + "它会让 agent 读取 Prowl 内置的 workflow skill 和说明书,然后替你编写、校验并放置 workflow bundle。", prompt: """ 请为我编写一个 Prowl Agent Workflow。 - Prowl 用 YAML 文件(schema prowl.workflow/v1)运行多 agent 工作流。编写指南和功能说明书都打包在 app 内: + Prowl 用包含 workflow.yaml 的 .pwlworkflow 目录(schema prowl.workflow/v1)运行多 agent 工作流。编写指南和功能说明书都打包在 app 内: - Skill: \(skill) - 说明书: \(manual) @@ -73,8 +73,8 @@ nonisolated enum WorkflowAuthoringPrompt { 请先读 skill(它链接了 references/ 下的完整 DSL 和校验规则)。然后: 1. 问我这个 workflow 要做什么:哪些 agent 参与、每一步要求什么、何时结束。 - 2. 用一个简短且唯一的 id,把文件写到 \(directory)。 - 3. 用 `prowl workflow validate <文件>` 校验,并修复所有错误。 + 2. 用一个简短且唯一的 id,把包含 workflow.yaml 的 .pwlworkflow 目录写到 \(directory)。 + 3. 用 `prowl workflow validate .pwlworkflow` 校验,并修复所有错误。 4. 告诉我怎么启动它(Command Palette → “Run Workflow: <名称>”,或 `prowl workflow run `)。 请用我的首选语言回答。 @@ -90,11 +90,11 @@ nonisolated enum WorkflowAuthoringPrompt { title: "讓 agent 寫一個 workflow", explanation: "複製這段提示詞,貼給你在終端機裡的編碼 agent(Claude Code、Codex…)。" - + "它會讓 agent 讀取 Prowl 內建的 workflow skill 和說明書,然後替你撰寫、檢驗並放置 workflow 檔案。", + + "它會讓 agent 讀取 Prowl 內建的 workflow skill 和說明書,然後替你撰寫、檢驗並放置 workflow bundle。", prompt: """ 請為我撰寫一個 Prowl Agent Workflow。 - Prowl 用 YAML 檔案(schema prowl.workflow/v1)執行多 agent 工作流。撰寫指南和功能說明書都打包在 app 內: + Prowl 用包含 workflow.yaml 的 .pwlworkflow 目錄(schema prowl.workflow/v1)執行多 agent 工作流。撰寫指南和功能說明書都打包在 app 內: - Skill: \(skill) - 說明書: \(manual) @@ -102,8 +102,8 @@ nonisolated enum WorkflowAuthoringPrompt { 請先讀 skill(它連結了 references/ 下的完整 DSL 和檢驗規則)。然後: 1. 問我這個 workflow 要做什麼:哪些 agent 參與、每一步要求什麼、何時結束。 - 2. 用一個簡短且唯一的 id,把檔案寫到 \(directory)。 - 3. 用 `prowl workflow validate <檔案>` 檢驗,並修正所有錯誤。 + 2. 用一個簡短且唯一的 id,把包含 workflow.yaml 的 .pwlworkflow 目錄寫到 \(directory)。 + 3. 用 `prowl workflow validate .pwlworkflow` 檢驗,並修正所有錯誤。 4. 告訴我怎麼啟動它(Command Palette → 「Run Workflow: <名稱>」,或 `prowl workflow run `)。 請用我的首選語言回答。 @@ -120,11 +120,11 @@ nonisolated enum WorkflowAuthoringPrompt { explanation: "このプロンプトをコピーして、ターミナルのコーディングエージェント(Claude Code、Codex など)" + "に貼り付けてください。Prowl に同梱されたワークフロースキルとマニュアルを読ませ、" - + "ワークフローファイルの作成・検証・配置を任せられます。", + + "ワークフローバンドルの作成・検証・配置を任せられます。", prompt: """ Prowl の Agent Workflow を書いてください。 - Prowl は YAML ファイル(schema prowl.workflow/v1)で複数エージェントのワークフローを実行します。\ + Prowl は workflow.yaml を含む .pwlworkflow ディレクトリ(schema prowl.workflow/v1)で複数エージェントのワークフローを実行します。\ 作成ガイドと機能マニュアルはアプリ内に同梱されています: - スキル: \(skill) @@ -133,8 +133,8 @@ nonisolated enum WorkflowAuthoringPrompt { まずスキルを読んでください(references/ 以下に完全な DSL と検証ルールがリンクされています)。そのうえで: 1. このワークフローで何をしたいか私に確認してください:参加するエージェント、各ステップで求めること、終了条件。 - 2. 短くて一意な id を付けて、ファイルを \(directory) に書いてください。 - 3. `prowl workflow validate <ファイル>` で検証し、すべてのエラーを修正してください。 + 2. 短くて一意な id を付けて、workflow.yaml を含む .pwlworkflow ディレクトリを \(directory) に書いてください。 + 3. `prowl workflow validate .pwlworkflow` で検証し、すべてのエラーを修正してください。 4. 起動方法を教えてください(Command Palette → 「Run Workflow: <名前>」、または `prowl workflow run `)。 私の優先言語で回答してください。 diff --git a/supacode/Features/Repositories/Views/WorkflowStatusPopoverButton.swift b/supacode/Features/Repositories/Views/WorkflowStatusPopoverButton.swift index 0252ecce4..dfff34a34 100644 --- a/supacode/Features/Repositories/Views/WorkflowStatusPopoverButton.swift +++ b/supacode/Features/Repositories/Views/WorkflowStatusPopoverButton.swift @@ -457,11 +457,11 @@ private struct WorkflowRunPanelView: View { requestConfirmation( intent: .userAction(runID: run.id, action: .cancel), label: "Cancel Run", - message: "Cancel this workflow run? Its panes and delivered outputs will be kept.", + message: "Cancel this workflow run? Its panes and deliveries will be kept.", isDestructive: true ) } - .help("Cancel the workflow and keep its panes and outputs") + .help("Cancel the workflow and keep its panes and deliveries") } .controlSize(.small) } diff --git a/supacode/Features/Workflow/Models/WorkflowRunNotice.swift b/supacode/Features/Workflow/Models/WorkflowRunNotice.swift index bf5a03fb9..cec93aba5 100644 --- a/supacode/Features/Workflow/Models/WorkflowRunNotice.swift +++ b/supacode/Features/Workflow/Models/WorkflowRunNotice.swift @@ -6,7 +6,7 @@ nonisolated struct WorkflowRunNotice: Equatable, Sendable { case needsAttention case completed case skipped - case maxRoundsReached + case iterationLimitReached } let kind: Kind @@ -39,10 +39,10 @@ nonisolated struct WorkflowRunNotice: Equatable, Sendable { kind = .skipped title = "\(run.definition.name) ended after a skipped step" body = "Step '\(step)' was skipped; step '\(dependent)' depended on its output." - case .maxRoundsReached: - kind = .maxRoundsReached - title = "\(run.definition.name) reached its round limit" - body = "The workflow ended after reaching its maximum number of rounds." + case .iterationLimitReached: + kind = .iterationLimitReached + title = "\(run.definition.name) reached its iteration limit" + body = "The workflow ended after reaching its maximum number of iterations." case .running, .cancelled, .interrupted: return nil } diff --git a/supacode/Features/Workflow/Models/WorkflowStatusCenterPresentation.swift b/supacode/Features/Workflow/Models/WorkflowStatusCenterPresentation.swift index 095631cef..f40cd05e0 100644 --- a/supacode/Features/Workflow/Models/WorkflowStatusCenterPresentation.swift +++ b/supacode/Features/Workflow/Models/WorkflowStatusCenterPresentation.swift @@ -257,8 +257,8 @@ nonisolated struct WorkflowRolePresentation: Equatable, Sendable, Identifiable { init(role: WorkflowRoleDefinition, binding: WorkflowRoleBinding?) { id = role.name - displayName = binding?.templateRole.name ?? role.name - agent = binding?.templateRole.agent.nilIfEmpty + displayName = binding?.displayName ?? role.name + agent = binding?.agent.nilIfEmpty paneHandle = binding?.pane?.handle surfaceID = binding?.pane?.surfaceID } @@ -333,7 +333,7 @@ nonisolated struct WorkflowAttentionControl: Equatable, Sendable, Identifiable { self.action = action let rolePane = attention.role.flatMap { run.bindings[$0]?.pane } focusSurfaceID = action == .focusPane ? rolePane?.surfaceID : nil - verdicts = action == .acceptWithVerdict ? (run.activeActivation?.expect.verdict ?? []) : [] + verdicts = action == .acceptWithVerdict ? (run.activeActivation?.expect.verdicts ?? []) : [] isDestructive = action == .cancel switch action { case .focusPane: @@ -378,7 +378,7 @@ nonisolated struct WorkflowAttentionControl: Equatable, Sendable, Identifiable { case .cancel: label = "Cancel Run" systemImage = "xmark" - confirmationMessage = "Cancel this workflow run? Its panes and delivered outputs will be kept." + confirmationMessage = "Cancel this workflow run? Its panes and deliveries will be kept." } } diff --git a/supacode/Features/Workflow/Reducer/WorkflowRunsFeature.swift b/supacode/Features/Workflow/Reducer/WorkflowRunsFeature.swift index 04372c7ac..add2068b7 100644 --- a/supacode/Features/Workflow/Reducer/WorkflowRunsFeature.swift +++ b/supacode/Features/Workflow/Reducer/WorkflowRunsFeature.swift @@ -2,7 +2,7 @@ // The reducer that owns every live workflow run (docs-ai 063 B3, decision H2/W1). The pure // `WorkflowRunMachine` is reconstructed per transition; this reducer performs its effects against // the terminal, dispatch, launch, store, native-action, and watchdog boundaries, answers the CLI -// `done` rendezvous when an activation leaves `persisting`, and cleans up what arrives late. +// `deliver` rendezvous when an activation leaves `persisting`, and cleans up what arrives late. import ComposableArchitecture import Foundation @@ -50,14 +50,14 @@ nonisolated struct WorkflowRunSession: Equatable, Sendable { } } -/// A CLI `done` accepted by the machine and waiting for its output to reach the run directory. +/// A CLI `deliver` accepted by the machine and waiting for its output to reach the run directory. nonisolated struct WorkflowPendingDelivery: Equatable, Sendable { let runID: UUID let ordinal: Int let receipt: WorkflowDeliveryReceipt } -/// `prowl workflow done` after the handler attributed it (decision W3). +/// `prowl workflow deliver` after the handler attributed it (decision W3). nonisolated struct WorkflowDeliveryRequest: Equatable, Sendable { let requestID: UUID let runID: UUID @@ -293,7 +293,7 @@ struct WorkflowRunsFeature { /// Answers a self-initiated `run` once its first activation is open — or once opening it failed /// and the run sits in attention or ended — so the caller never holds a completion command - /// before the dispatch record `done` is attributed by exists. + /// before the dispatch record `deliver` is attributed by exists. private func resolvePendingStarts( _ state: inout State, runID: UUID, session: WorkflowRunSession ) -> Effect { @@ -333,7 +333,7 @@ struct WorkflowRunsFeature { } } - /// Answers every `done` whose activation left `persisting` (decision W1): delivered and + /// Answers every `deliver` whose activation left `persisting` (decision W1): delivered and /// provisional succeed; a revoked, skipped, or unpersistable activation and a run that ended fail. private func resolvePendingDeliveries( _ state: inout State, runID: UUID, session: WorkflowRunSession @@ -541,7 +541,7 @@ struct WorkflowRunsFeature { let timestamp = now let context = WorkflowActionContext( runID: run.id, rootURL: run.context.worktree.rootURL, - roleAgents: run.bindings.mapValues { $0.templateRole.agent }, outgoingAgent: nil, now: timestamp, + roleAgents: run.bindings.mapValues { $0.agent }, outgoingAgent: nil, now: timestamp, stepID: stepID, executionID: executionID, attempt: run.actionAttempts[stepID] ?? 1, bundle: run.context.bundle, values: run.stepValues, runDirectory: run.runDirectory) run.context.occupancy?.beginActivity() diff --git a/supacode/Features/Workflow/Views/WorkflowHistoryView.swift b/supacode/Features/Workflow/Views/WorkflowHistoryView.swift index 2ce12fd54..7aefb544e 100644 --- a/supacode/Features/Workflow/Views/WorkflowHistoryView.swift +++ b/supacode/Features/Workflow/Views/WorkflowHistoryView.swift @@ -109,7 +109,7 @@ struct WorkflowHistoryView: View { VStack(alignment: .leading, spacing: 12) { Text("Clean Up Workflow History?").font(.title2.bold()) Text( - "Remove \(preview.candidates.count) complete run(s), including outputs and action artifacts. " + "Remove \(preview.candidates.count) complete run(s), including deliveries and action artifacts. " + "Estimated space: \(size(preview.reclaimedBytes)). This cannot be undone.") List(preview.candidates) { entry in VStack(alignment: .leading) { @@ -141,6 +141,6 @@ struct WorkflowHistoryView: View { private func canExport(_ entry: WorkflowHistoryEntry) -> Bool { entry.finishedAt != nil - && ["completed", "cancelled", "skipped", "max_rounds_reached", "interrupted"].contains(entry.state) + && ["completed", "cancelled", "skipped", "iteration_limit_reached", "interrupted"].contains(entry.state) } } diff --git a/supacodeTests/AgentDispatchCommandHandlerTests.swift b/supacodeTests/AgentDispatchCommandHandlerTests.swift index 3ca787fd3..98abd6c29 100644 --- a/supacodeTests/AgentDispatchCommandHandlerTests.swift +++ b/supacodeTests/AgentDispatchCommandHandlerTests.swift @@ -76,7 +76,7 @@ struct AgentDispatchCommandHandlerTests { #expect(completedSurfaces == [caller.surfaceID, caller.surfaceID]) } - /// A workflow activation is completed by `prowl workflow done`, never here (063 B3, W3). + /// A workflow activation is completed by `prowl workflow deliver`, never here (063 B3, W3). @Test func completionIsInterceptedBeforeTheStoreForWorkflowActivations() async throws { let caller = CallerPane(worktreeID: "w1", surfaceID: UUID()) var completed = 0 @@ -88,7 +88,8 @@ struct AgentDispatchCommandHandlerTests { }, intercept: { surfaceID in #expect(surfaceID == caller.surfaceID) - return CommandError(code: CLIErrorCode.workflowDeliveryRequired, message: "deliver with prowl workflow done -") + return CommandError( + code: CLIErrorCode.workflowDeliveryRequired, message: "deliver with prowl workflow deliver -") } ) let response = await handler.handle( @@ -98,7 +99,7 @@ struct AgentDispatchCommandHandlerTests { #expect(response.ok == false) #expect(response.command == "agents.dispatch-complete") #expect(response.error?.code == CLIErrorCode.workflowDeliveryRequired) - #expect(response.error?.message == "deliver with prowl workflow done -") + #expect(response.error?.message == "deliver with prowl workflow deliver -") #expect(completed == 0) } diff --git a/supacodeTests/AgentProfileHookCarrierTests.swift b/supacodeTests/AgentProfileHookCarrierTests.swift index a02c7dcd0..7a25055a2 100644 --- a/supacodeTests/AgentProfileHookCarrierTests.swift +++ b/supacodeTests/AgentProfileHookCarrierTests.swift @@ -202,7 +202,7 @@ struct AgentProfileWorkflowCarrierTests { token: "hook-token", coveredEvents: [.turnEnded] ) - let prompt = "Review the brief.\n\n---\nProwl workflow completion protocol v1:\nprowl workflow done -\n" + let prompt = "Review the brief.\n\n---\nProwl workflow completion protocol v1:\nprowl workflow deliver -\n" let attached = try hooked.attachingWorkflow( prompt: prompt, environment: [ diff --git a/supacodeTests/AppFeatureWorkflowNoticeTests.swift b/supacodeTests/AppFeatureWorkflowNoticeTests.swift index 087d19e30..b1e1bb216 100644 --- a/supacodeTests/AppFeatureWorkflowNoticeTests.swift +++ b/supacodeTests/AppFeatureWorkflowNoticeTests.swift @@ -103,7 +103,7 @@ struct AppFeatureWorkflowNoticeTests { #expect(store.state.repositories.statusToast == .success("Review completed")) } - @Test(arguments: [WorkflowRunNotice.Kind.skipped, .maxRoundsReached]) + @Test(arguments: [WorkflowRunNotice.Kind.skipped, .iterationLimitReached]) func selectedNonSuccessTerminalOutcomeShowsAWarning(kind: WorkflowRunNotice.Kind) async { let worktree = makeWorktree(id: "selected") var repositories = RepositoriesFeature.State( @@ -197,7 +197,7 @@ struct AppFeatureWorkflowNoticeTests { case .needsAttention: "Review needs attention" case .completed: "Review completed" case .skipped: "Review ended after a skipped step" - case .maxRoundsReached: "Review reached its round limit" + case .iterationLimitReached: "Review reached its iteration limit" } return WorkflowRunNotice( kind: kind, diff --git a/supacodeTests/CLISocketServerTests.swift b/supacodeTests/CLISocketServerTests.swift index 186286d28..bcd5f8765 100644 --- a/supacodeTests/CLISocketServerTests.swift +++ b/supacodeTests/CLISocketServerTests.swift @@ -20,7 +20,8 @@ struct CLISocketServerTests { let request = try JSONEncoder().encode( CommandEnvelope( output: .json, - command: .workflow(.init(action: .done, body: String(repeating: "\u{1}", count: WorkflowSizeLimits.payload))))) + command: .workflow(.init(action: .deliver, body: String(repeating: "\u{1}", count: WorkflowSizeLimits.payload))) + )) #expect(request.count > 32 * 1024 * 1024) #expect(request.count <= WorkflowSizeLimits.transportFrame) let response: Data = try await withCheckedThrowingContinuation { continuation in diff --git a/supacodeTests/WorkflowAuthoringPromptTests.swift b/supacodeTests/WorkflowAuthoringPromptTests.swift index 7070e1827..cce670d85 100644 --- a/supacodeTests/WorkflowAuthoringPromptTests.swift +++ b/supacodeTests/WorkflowAuthoringPromptTests.swift @@ -16,7 +16,8 @@ struct WorkflowAuthoringPromptTests { #expect(strings.prompt.contains(skill)) #expect(strings.prompt.contains(manual)) #expect(strings.prompt.contains(directory)) - #expect(strings.prompt.contains("prowl workflow validate")) + #expect(strings.prompt.contains("prowl workflow validate .pwlworkflow")) + #expect(strings.prompt.contains("workflow.yaml")) #expect(!strings.title.isEmpty) #expect(!strings.explanation.isEmpty) } diff --git a/supacodeTests/WorkflowBundleRunMachineTests.swift b/supacodeTests/WorkflowBundleRunMachineTests.swift index 5da737443..38179940e 100644 --- a/supacodeTests/WorkflowBundleRunMachineTests.swift +++ b/supacodeTests/WorkflowBundleRunMachineTests.swift @@ -23,7 +23,7 @@ struct WorkflowBundleRunMachineTests { .init( id: "test", name: "Test", steps: [ - .init(id: "snapshot", action: .action(id: "builtin:git.context", inputs: [:])), + .init(id: "snapshot", action: .action(id: "builtin:collect-worktree-context", inputs: [:])), .init(id: "end", action: .notify("{{ actions.snapshot.output.branch }}")), ])) let first = try #require(machine.run.actionExecutionID) @@ -75,7 +75,7 @@ struct WorkflowBundleRunMachineTests { id: "local:echo", inputs: [ "count": .integer(3), - "execution": .string("{{ context.execution.id }}"), + "execution": .string("{{ context.action.execution_id }}"), ])) ])) let invocation = try #require(effects.first { if case .runAction = $0 { true } else { false } }) @@ -123,8 +123,8 @@ struct WorkflowBundleRunMachineTests { condition: "true", maximum: 2, steps: [.init(id: "tick", action: .control(.set([:])))]))) ])) - #expect(machine.run.status == .maxRoundsReached) - #expect(effects.contains(.finished(.maxRoundsReached))) + #expect(machine.run.status == .iterationLimitReached) + #expect(effects.contains(.finished(.iterationLimitReached))) } @Test func repeatedLaunchKeepsTheExistingRoleBinding() throws { diff --git a/supacodeTests/WorkflowDeliveryValidatorTests.swift b/supacodeTests/WorkflowDeliveryValidatorTests.swift index c60347880..77a8e31c0 100644 --- a/supacodeTests/WorkflowDeliveryValidatorTests.swift +++ b/supacodeTests/WorkflowDeliveryValidatorTests.swift @@ -91,7 +91,7 @@ struct WorkflowDeliveryValidatorTests { } #expect(strictJSON.code == "OUTPUT_INVALID") - let declared = WorkflowExpectation(verdict: ["clean", "issues"]) + let declared = WorkflowExpectation(verdicts: ["clean", "issues"]) let missing = try validate("# ok\n", expect: declared).get() #expect(missing.issues == [.verdictMissing(allowed: ["clean", "issues"])]) #expect(missing.verdict == nil) @@ -107,7 +107,7 @@ struct WorkflowDeliveryValidatorTests { } @Test func emptyBodyIsOutputInvalidForEveryFormat() { - for format in [WorkflowOutputFormat.markdown, .text, .json] { + for format in [WorkflowDeliveryFormat.markdown, .text, .json] { guard case .failure(let error) = validate(" \n\n", expect: WorkflowExpectation(format: format)) else { Issue.record("expected failure for \(format)") continue @@ -131,7 +131,7 @@ struct WorkflowDeliveryValidatorTests { } @Test func verdictRulesFollowTheDeclarationUnderStrict() throws { - let declared = WorkflowExpectation(verdict: ["clean", "issues"], strict: true) + let declared = WorkflowExpectation(verdicts: ["clean", "issues"], strict: true) guard case .failure(let required) = validate("# ok\n", expect: declared) else { Issue.record("expected VERDICT_REQUIRED") return diff --git a/supacodeTests/WorkflowExecutionContextTests.swift b/supacodeTests/WorkflowExecutionContextTests.swift new file mode 100644 index 000000000..184f6a3c4 --- /dev/null +++ b/supacodeTests/WorkflowExecutionContextTests.swift @@ -0,0 +1,56 @@ +import Foundation +import ProwlCLIShared +import Testing + +@testable import supacode + +struct WorkflowExecutionContextTests { + private func makeRun() throws -> WorkflowRun { + let pane = WorkflowPaneIdentity( + surfaceID: UUID(), tabID: UUID(), handle: "p3", displayName: "Source Agent", agent: "pi") + let definition = WorkflowDefinition( + id: "local.naming", name: "Naming", + roles: [.init(name: "author", source: .current)], + steps: [.init(id: "snapshot", action: .action(id: "builtin:collect-worktree-context", inputs: [:]))]) + let (machine, _) = try WorkflowRunMachine.start( + .init( + definition: definition, runID: UUID(), + context: .init( + scope: .user, definitionPath: nil, + worktree: .init(id: "target", name: "Project", branch: "main", path: "/tmp/project")), + bindings: ["author": .current(pane)]), + now: { Date(timeIntervalSince1970: 1) }) + return machine.run + } + + @Test func definitionRunTargetAndRoleHaveDistinctIdentities() throws { + let run = try makeRun() + let values = run.expressionValues(capturedAt: Date(timeIntervalSince1970: 2)) + #expect( + try WorkflowExpression.renderText("{{ context.workflow.id }}:{{ context.workflow.name }}", values: values) + == "local.naming:Naming") + #expect(try WorkflowExpression.renderText("{{ context.run.id }}", values: values) == run.id.uuidString) + #expect(try WorkflowExpression.renderText("{{ context.run.path }}", values: values) == run.runDirectory.path) + #expect(try WorkflowExpression.renderText("{{ context.worktree.path }}", values: values) == "/tmp/project") + #expect( + try WorkflowExpression.renderText("{{ context.roles.author.display_name }}", values: values) == "Source Agent") + let paneID = try #require(run.bindings["author"]?.pane?.surfaceID.uuidString) + #expect(try WorkflowExpression.renderText("{{ context.roles.author.pane_id }}", values: values) == paneID) + #expect(try WorkflowExpression.renderText("{{ context.initiator.pane_id }}", values: values) == paneID) + } + + @Test func actionInputsReceiveAttemptMetadata() throws { + let run = try makeRun() + let values = run.stepValues + #expect( + try WorkflowExpression.renderText("{{ context.action.execution_id }}", values: values) + == run.actionExecutionID) + #expect( + try WorkflowExpression.renderText("{{ context.action.working_directory }}", values: values) + == "/tmp/project") + #expect( + try WorkflowExpression.renderText("{{ context.action.artifacts_directory }}", values: values) + .hasSuffix("/artifacts")) + #expect(try WorkflowExpression.evaluate("context.action.attempt", values: values) == .integer(1)) + } +} diff --git a/supacodeTests/WorkflowLineRendererTests.swift b/supacodeTests/WorkflowLineRendererTests.swift index df5001bd3..1457521bb 100644 --- a/supacodeTests/WorkflowLineRendererTests.swift +++ b/supacodeTests/WorkflowLineRendererTests.swift @@ -15,21 +15,23 @@ struct WorkflowLineRendererTests { @Test func messageCommandCarriesTokenPrefixAndOneCommandPerVerdict() { let plain = WorkflowCompletionCommand(token: token, verdicts: nil) - #expect(plain.messageCommands == ["PROWL_WORKFLOW_TOKEN=\(token) prowl workflow done -"]) - #expect(plain.typedSuffix == " — finish with: PROWL_WORKFLOW_TOKEN=\(token) prowl workflow done -") + #expect(plain.messageCommands == ["PROWL_WORKFLOW_TOKEN=\(token) prowl workflow deliver -"]) + #expect(plain.typedSuffix == " — finish with: PROWL_WORKFLOW_TOKEN=\(token) prowl workflow deliver -") let verdicts = WorkflowCompletionCommand(token: token, verdicts: ["clean", "issues"]) #expect( verdicts.messageCommands == [ - "PROWL_WORKFLOW_TOKEN=\(token) prowl workflow done --verdict clean -", - "PROWL_WORKFLOW_TOKEN=\(token) prowl workflow done --verdict issues -", + "PROWL_WORKFLOW_TOKEN=\(token) prowl workflow deliver --verdict clean -", + "PROWL_WORKFLOW_TOKEN=\(token) prowl workflow deliver --verdict issues -", ]) #expect( verdicts.typedSuffix - == " — finish with: PROWL_WORKFLOW_TOKEN=\(token) prowl workflow done --verdict clean -" - + " or PROWL_WORKFLOW_TOKEN=\(token) prowl workflow done --verdict issues -") + == " — finish with: PROWL_WORKFLOW_TOKEN=\(token) prowl workflow deliver --verdict clean -" + + " or PROWL_WORKFLOW_TOKEN=\(token) prowl workflow deliver --verdict issues -") #expect( - verdicts.launchCommands == ["prowl workflow done --verdict clean -", "prowl workflow done --verdict issues -"]) + verdicts.launchCommands == [ + "prowl workflow deliver --verdict clean -", "prowl workflow deliver --verdict issues -", + ]) } @Test func nudgeLineUsesThePrefixAndTheSameCommands() throws { @@ -39,8 +41,8 @@ struct WorkflowLineRendererTests { #expect( line == "[Prowl] When your work for this step is fully complete, finish with: " - + "PROWL_WORKFLOW_TOKEN=\(token) prowl workflow done --verdict clean -" - + " or PROWL_WORKFLOW_TOKEN=\(token) prowl workflow done --verdict issues -") + + "PROWL_WORKFLOW_TOKEN=\(token) prowl workflow deliver --verdict clean -" + + " or PROWL_WORKFLOW_TOKEN=\(token) prowl workflow deliver --verdict issues -") } @Test func protocolBlockNamesRunRoleSectionsAndEveryCommandWithoutPrefix() { @@ -53,8 +55,8 @@ struct WorkflowLineRendererTests { #expect(block.contains("Adversarial Review")) #expect(block.contains("\"reviewer\"")) #expect(block.contains("## Findings, ## Verdict")) - #expect(block.contains("\nprowl workflow done --verdict clean -\n")) - #expect(block.contains("\nprowl workflow done --verdict issues -\n")) + #expect(block.contains("\nprowl workflow deliver --verdict clean -\n")) + #expect(block.contains("\nprowl workflow deliver --verdict issues -\n")) #expect(!block.contains("PROWL_WORKFLOW_TOKEN")) #expect(block.contains("dispatch-complete")) } @@ -62,11 +64,11 @@ struct WorkflowLineRendererTests { @Test func instructionTrailerAndDeliveryRequiredMessageListTheMessageCommands() { let command = WorkflowCompletionCommand(token: token, verdicts: nil) let trailer = command.instructionTrailer() - #expect(trailer.contains("PROWL_WORKFLOW_TOKEN=\(token) prowl workflow done -")) + #expect(trailer.contains("PROWL_WORKFLOW_TOKEN=\(token) prowl workflow deliver -")) let message = command.deliveryRequiredMessage(runID: "RUN-1", stepID: "brief") #expect(message.contains("RUN-1")) #expect(message.contains("brief")) - #expect(message.contains("PROWL_WORKFLOW_TOKEN=\(token) prowl workflow done -")) + #expect(message.contains("PROWL_WORKFLOW_TOKEN=\(token) prowl workflow deliver -")) } // MARK: - Typed lines @@ -74,11 +76,11 @@ struct WorkflowLineRendererTests { @Test func textLineIsPrefixedAndSuffixed() throws { let command = WorkflowCompletionCommand(token: token, verdicts: nil) #expect( - try WorkflowTypedLine.text("Findings: /r/outputs/findings.md", completion: nil) - == "[Prowl] Findings: /r/outputs/findings.md") + try WorkflowTypedLine.text("Findings: /r/deliveries/findings.md", completion: nil) + == "[Prowl] Findings: /r/deliveries/findings.md") #expect( try WorkflowTypedLine.text("Fix each item.", completion: command) - == "[Prowl] Fix each item. — finish with: PROWL_WORKFLOW_TOKEN=\(token) prowl workflow done -") + == "[Prowl] Fix each item. — finish with: PROWL_WORKFLOW_TOKEN=\(token) prowl workflow deliver -") } @Test func pointerLineNamesTheAbsolutePath() throws { diff --git a/supacodeTests/WorkflowNativeActionsTests.swift b/supacodeTests/WorkflowNativeActionsTests.swift index 08f1e7604..6f706ed6e 100644 --- a/supacodeTests/WorkflowNativeActionsTests.swift +++ b/supacodeTests/WorkflowNativeActionsTests.swift @@ -45,12 +45,12 @@ struct WorkflowNativeActionsTests { now: Self.now) } - @Test func gitContextWritesInvocationArtifactsAndRecords() async throws { + @Test func worktreeContextWritesInvocationArtifactsAndRecords() async throws { let root = try makeRepo() defer { try? FileManager.default.removeItem(at: root) } let invocation = context(root: root) let outputs = try await WorkflowNativeActionRunner().execute( - actionID: "builtin:git.context", inputs: [:], context: invocation) + actionID: "builtin:collect-worktree-context", inputs: [:], context: invocation) guard case .object(let output) = outputs["output"], case .string(let path) = output["path"] else { Issue.record("Missing typed repository result") return @@ -69,7 +69,7 @@ struct WorkflowNativeActionsTests { defer { try? FileManager.default.removeItem(at: root) } let path = WorkflowHistoryStorage.canonicalURL(root).path let output = try await WorkflowNativeActionRunner().execute( - actionID: "builtin:git.context", inputs: ["root": .string(path)], context: context(root: root)) + actionID: "builtin:collect-worktree-context", inputs: ["root": .string(path)], context: context(root: root)) #expect(output["output"] != nil) } @@ -104,6 +104,19 @@ struct WorkflowNativeActionsTests { let result = try await WorkflowNativeActionRunner().execute( actionID: "local:echo", inputs: ["count": .integer(3)], context: valid) #expect(result["output"] == .object(["count": .integer(3)])) + let request = try JSONDecoder().decode( + WorkflowJSONValue.self, from: Data(contentsOf: valid.directory.appending(path: "request.json"))) + guard case .object(let fields) = request, case .object(let context) = fields["context"], + case .object(let metadata) = context["action"] + else { + Issue.record("Missing action request context") + return + } + #expect(metadata["execution_id"] == .string(valid.executionID)) + #expect(metadata["id"] == nil) + #expect(metadata["working_directory"] == .string(root.path)) + #expect(metadata["artifacts_directory"] == .string(valid.directory.appending(path: "artifacts").path)) + #expect(result["output_path"] == .string(valid.directory.appending(path: "result.json").path)) #expect(try String(contentsOf: valid.directory.appending(path: "stderr.log"), encoding: .utf8) == "diagnostic") let invalid = invocation() await #expect(throws: (any Error).self) { @@ -203,7 +216,7 @@ struct WorkflowNativeActionsTests { defer { try? FileManager.default.removeItem(at: root) } await #expect(throws: WorkflowActionError.unsafePath("/tmp")) { try await WorkflowNativeActionRunner().execute( - actionID: "builtin:git.context", inputs: ["root": "/tmp"], context: context(root: root)) + actionID: "builtin:collect-worktree-context", inputs: ["root": "/tmp"], context: context(root: root)) } await #expect(throws: WorkflowActionError.unknownAction("handoff.transition")) { try await WorkflowNativeActionRunner().execute( diff --git a/supacodeTests/WorkflowRunAdmissionTests.swift b/supacodeTests/WorkflowRunAdmissionTests.swift index 924d6f3c4..7a7d0dbad 100644 --- a/supacodeTests/WorkflowRunAdmissionTests.swift +++ b/supacodeTests/WorkflowRunAdmissionTests.swift @@ -31,14 +31,14 @@ struct WorkflowRunAdmissionTests { - id: brief message: author text: "Brief {{ inputs.rounds }}" - expect: { output: brief } + expect: { delivery: brief } - id: launch launch: reviewer - prompt: "Review {{ outputs.brief.path }}" - expect: { output: findings } + prompt: "Review {{ deliveries.brief.path }}" + expect: { delivery: findings } - id: ping message: partner - text: "Findings: {{ outputs.findings.path }}" + text: "Findings: {{ deliveries.findings.path }}" """ private static let contextOnly = """ @@ -50,7 +50,7 @@ struct WorkflowRunAdmissionTests { source: current steps: - id: ctx - action: builtin:git.context + action: builtin:collect-worktree-context with: { root: "{{ context.worktree.path }}" } """ @@ -225,14 +225,14 @@ struct WorkflowRunAdmissionTests { try fixture.write(Self.contextOnly, to: "context") let admitted = try WorkflowRunAdmission.admit( WorkflowInput( - action: .run, workflow: "context", testAction: "builtin:git.context", + action: .run, workflow: "context", testAction: "builtin:collect-worktree-context", actionInputs: ["root": .string("{{ literal.directory }}")]), source: fixture.source(pane: nil), snapshot: fixture.snapshot(), environment: fixture.environment ).get() #expect( admitted.effects.contains( .runAction( - stepID: "action-test", actionID: "builtin:git.context", + stepID: "action-test", actionID: "builtin:collect-worktree-context", inputs: ["root": .string("{{ literal.directory }}")]))) } @@ -242,7 +242,7 @@ struct WorkflowRunAdmissionTests { try fixture.write(Self.contextOnly, to: "context") let admitted = try admit(fixture, workflow: "context", pane: fixture.authorPane).get() #expect( - try WorkflowExpression.evaluate("context.source.tab_id", values: admitted.session.run.stepValues) + try WorkflowExpression.evaluate("context.initiator.tab_id", values: admitted.session.run.stepValues) == .string(fixture.tabID.uuidString)) } @@ -276,7 +276,7 @@ struct WorkflowRunAdmissionTests { #expect(admitted.session.bindingMemoryKeys["reviewer"]?.role == "reviewer") #expect(admitted.callerRole == "author") #expect( - run.selfInitiatedLine?.contains("PROWL_WORKFLOW_TOKEN=TOKEN prowl workflow done -") == true) + run.selfInitiatedLine?.contains("PROWL_WORKFLOW_TOKEN=TOKEN prowl workflow deliver -") == true) #expect( run.phase == .injecting(ordinal: 1), "self-initiated: the activation opens without typing") #expect(fixture.plannedProfiles == ["Codex"]) @@ -305,7 +305,7 @@ struct WorkflowRunAdmissionTests { Self.contextOnly.replacing("id: context", with: "id: other"), to: "other", scope: .user) try fixture.write( Self.contextOnly.replacing("id: context", with: "id: broken").replacing( - "git.context", with: "nope"), to: "broken") + "collect-worktree-context", with: "nope"), to: "broken") #expect( code(admit(fixture, workflow: "missing", pane: fixture.authorPane)) diff --git a/supacodeTests/WorkflowRunHarnessTests.swift b/supacodeTests/WorkflowRunHarnessTests.swift index 6a6ecb0bc..cb698c77c 100644 --- a/supacodeTests/WorkflowRunHarnessTests.swift +++ b/supacodeTests/WorkflowRunHarnessTests.swift @@ -142,7 +142,7 @@ final class WorkflowRunHarness { case .runAction(let stepID, let actionID, let inputs): let context = WorkflowActionContext( runID: runID, rootURL: machine.run.context.worktree.rootURL, - roleAgents: machine.run.bindings.mapValues { $0.templateRole.agent.isEmpty ? nil : $0.templateRole.agent }, + roleAgents: machine.run.bindings.mapValues { $0.agent.isEmpty ? nil : $0.agent }, outgoingAgent: machine.run.bindings.values.first { $0.source == .current }?.pane?.agent, now: now) do { let outputs = try await actions.execute(actionID: actionID, inputs: inputs, context: context) @@ -197,7 +197,7 @@ struct WorkflowRunHarnessTests { "output": .object([ "path": .string(context.directory.appending(path: "artifacts/context.md").path), "branch": .string("feat/x"), "kickoff_prompt": .string("Take over."), - ]), "result_path": .string(context.directory.appending(path: "result.json").path), + ]), "output_path": .string(context.directory.appending(path: "result.json").path), ] } } @@ -254,14 +254,14 @@ struct WorkflowRunHarnessTests { #expect( harness.typedLines[0].line.hasPrefix( "[Prowl] Read the assigned task with `prowl workflow read --run \(harness.run.id.uuidString) --invocation 1`")) - #expect(harness.typedLines[0].line.contains("PROWL_WORKFLOW_TOKEN=TOKEN-1 prowl workflow done -")) + #expect(harness.typedLines[0].line.contains("PROWL_WORKFLOW_TOKEN=TOKEN-1 prowl workflow deliver -")) #expect(harness.bridge.opened.map(\.dispatchID) == ["dispatch-1"]) #expect(harness.run.phase == .waitingForDelivery(ordinal: 1)) let instruction = try String(contentsOf: runDirectory.appending(path: "instructions/brief.1.md"), encoding: .utf8) #expect( instruction.hasPrefix( "Write a short brief for an adversarial reviewer: ## Scope, ## Claims.\nFocus: the parser\n\n---\n")) - #expect(instruction.contains("PROWL_WORKFLOW_TOKEN=TOKEN-1 prowl workflow done -")) + #expect(instruction.contains("PROWL_WORKFLOW_TOKEN=TOKEN-1 prowl workflow deliver -")) _ = try await harness.deliver(token: "TOKEN-1", body: "# Brief\n## Scope\nx\n## Claims\ny") #expect(harness.bridge.completed.map(\.dispatchID) == ["dispatch-1"]) @@ -283,7 +283,7 @@ struct WorkflowRunHarnessTests { harness.typedLines[1].line == "[Prowl] Findings: workflow-resource:resource-1. Fix or rebut each item. " + (harness.run.currentInvocation?.content?.guidance ?? "") - + " — finish with: PROWL_WORKFLOW_TOKEN=TOKEN-3 prowl workflow done -") + + " — finish with: PROWL_WORKFLOW_TOKEN=TOKEN-3 prowl workflow deliver -") _ = try await harness.deliver(token: "TOKEN-3", body: "# Done") #expect(harness.typedLines[2].surfaceID == harness.run.bindings["reviewer"]?.pane?.surfaceID) _ = try await harness.deliver(token: "TOKEN-4", body: "# Findings\nnone", verdict: "clean") @@ -296,16 +296,16 @@ struct WorkflowRunHarnessTests { #expect(harness.bridge.abandoned.isEmpty) #expect(harness.watchdogs.map(\.ordinal) == [1, 2, 3, 4]) - let outputs = try FileManager.default.contentsOfDirectory( - atPath: runDirectory.appending(path: "outputs").path(percentEncoded: false) + let deliveries = try FileManager.default.contentsOfDirectory( + atPath: runDirectory.appending(path: "deliveries").path(percentEncoded: false) ).sorted() #expect( - outputs == [ + deliveries == [ "brief.1.md", "brief.md", "disposition.3.md", "disposition.md", "findings.2.md", "findings.md", "round_findings.4.md", "round_findings.md", ]) #expect( - try String(contentsOf: runDirectory.appending(path: "outputs/round_findings.md"), encoding: .utf8) + try String(contentsOf: runDirectory.appending(path: "deliveries/round_findings.md"), encoding: .utf8) == "# Findings\nnone\n") let record = try harness.store.readRecord(runID: harness.run.id) #expect(record.run.status.state == "completed") @@ -328,23 +328,24 @@ struct WorkflowRunHarnessTests { let root = try makeRoot() defer { try? FileManager.default.removeItem(at: root) } let harness = try await makeHarness(root: root) - let outputs = harness.store.directory(for: harness.run.id).appending(path: "outputs", directoryHint: .isDirectory) - try FileManager.default.removeItem(at: outputs) + let deliveries = harness.store.directory(for: harness.run.id).appending( + path: "deliveries", directoryHint: .isDirectory) + try FileManager.default.removeItem(at: deliveries) let elsewhere = root.appending(path: "elsewhere", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: elsewhere, withIntermediateDirectories: true) - try FileManager.default.createSymbolicLink(at: outputs, withDestinationURL: elsewhere) + try FileManager.default.createSymbolicLink(at: deliveries, withDestinationURL: elsewhere) let result = try await harness.deliver(token: "TOKEN-1", body: "## Scope\nx\n## Claims\ny") #expect((try? result.get()) != nil) #expect(harness.run.status.attention?.reason.code == "persist_failed") #expect(harness.bridge.completed.isEmpty) #expect(harness.launches.isEmpty) #expect(try FileManager.default.contentsOfDirectory(atPath: elsewhere.path(percentEncoded: false)).isEmpty) - try FileManager.default.removeItem(at: outputs) - try FileManager.default.createDirectory(at: outputs, withIntermediateDirectories: true) + try FileManager.default.removeItem(at: deliveries) + try FileManager.default.createDirectory(at: deliveries, withIntermediateDirectories: true) try await harness.user(.retry) #expect(harness.bridge.completed.map(\.dispatchID) == ["dispatch-1"]) #expect(harness.launches.count == 1) - #expect(try harness.store.readRecord(runID: harness.run.id).outputs["brief"]?.ordinal == 1) + #expect(try harness.store.readRecord(runID: harness.run.id).deliveries["brief"]?.ordinal == 1) } @Test func aProvisionalDeliveryIsKeptOnDiskUntilTheUserAcceptsIt() async throws { @@ -356,13 +357,13 @@ struct WorkflowRunHarnessTests { #expect(harness.run.status.attention?.reason.code == "delivery_issues") #expect(harness.bridge.completed.isEmpty) #expect(harness.launches.isEmpty) - let brief = harness.store.directory(for: harness.run.id).appending(path: "outputs/brief.md") + let brief = harness.store.directory(for: harness.run.id).appending(path: "deliveries/brief.md") #expect(try String(contentsOf: brief, encoding: .utf8) == "## Scope\nonly\n") #expect(try harness.store.readRecord(runID: harness.run.id).run.status.attention?.issues == ["missing_sections"]) try await harness.user(.acceptDelivery(verdict: nil)) #expect(harness.bridge.completed.map(\.dispatchID) == ["dispatch-1"]) #expect(harness.launches.count == 1) - #expect(try harness.store.readRecord(runID: harness.run.id).outputs["brief"]?.ordinal == 1) + #expect(try harness.store.readRecord(runID: harness.run.id).deliveries["brief"]?.ordinal == 1) } @Test func cancelAbandonsThePendingActivationAndKeepsDeliveredOutputs() async throws { @@ -378,7 +379,7 @@ struct WorkflowRunHarnessTests { #expect(try harness.store.readRecord(runID: harness.run.id).run.status.state == "cancelled") #expect( FileManager.default.fileExists( - atPath: harness.store.directory(for: harness.run.id).appending(path: "outputs/brief.md").path( + atPath: harness.store.directory(for: harness.run.id).appending(path: "deliveries/brief.md").path( percentEncoded: false))) #expect(harness.closed.isEmpty) } diff --git a/supacodeTests/WorkflowRunMachineTests.swift b/supacodeTests/WorkflowRunMachineTests.swift index 9dcc9b64c..c718596c2 100644 --- a/supacodeTests/WorkflowRunMachineTests.swift +++ b/supacodeTests/WorkflowRunMachineTests.swift @@ -35,17 +35,17 @@ struct WorkflowRunMachineTests { instruction: | Write a short brief for an adversarial reviewer: ## Scope, ## Claims. Focus: {{ inputs.focus }} - expect: { output: brief, sections: ["## Scope", "## Claims"], timeout: 10m } + expect: { delivery: brief, sections: ["## Scope", "## Claims"], timeout: 10m } - id: launch title: "Reviewer starting round 1" launch: reviewer - prompt: "Read {{ outputs.brief.path }} and review ({{ inputs.mode }})." + prompt: "Read {{ deliveries.brief.path }} and review ({{ inputs.mode }})." skill: prowl.adversarial-reviewer - expect: { output: findings, sections: ["## Findings", "## Verdict"], verdict: [clean, issues] } + expect: { delivery: findings, sections: ["## Findings", "## Verdict"], verdicts: [clean, issues] } - id: remember set: - verdict: outputs.findings.verdict - path: outputs.findings.path + verdict: deliveries.findings.verdict + path: deliveries.findings.path - id: rounds while: "state.verdict != 'clean'" max_iterations: 3 @@ -54,19 +54,19 @@ struct WorkflowRunMachineTests { title: "Round {{ context.step.iteration }}: author addressing findings" message: author text: "Findings: {{ state.path }}. Fix or rebut each item." - expect: { output: disposition } + expect: { delivery: disposition } - id: rereview title: "Round {{ context.step.iteration }}: reviewer re-checking" message: reviewer - text: "Disposition: {{ outputs.disposition.path }}. Re-review." - expect: { output: round_findings, verdict: [clean, issues] } + text: "Disposition: {{ deliveries.disposition.path }}. Re-review." + expect: { delivery: round_findings, verdicts: [clean, issues] } - id: retain set: - verdict: outputs.round_findings.verdict - path: outputs.round_findings.path + verdict: deliveries.round_findings.verdict + path: deliveries.round_findings.path rounds: state.rounds + 1 - id: context - action: builtin:git.context + action: builtin:collect-worktree-context with: { root: "{{ context.worktree.path }}" } - id: done notify: "Adversarial review: {{ state.verdict }} after {{ state.rounds }} round(s)" @@ -90,15 +90,15 @@ struct WorkflowRunMachineTests { message: source instruction: | Write the handoff briefing. - expect: { output: brief, sections: ["## Objective"] } + expect: { delivery: brief, sections: ["## Objective"] } - id: transition action: local:prepare - with: { briefing: "{{ outputs.brief.path ?? '' }}", from: source, to: receiver } + with: { briefing: "{{ deliveries.brief.path ?? '' }}", from: source, to: receiver } - id: launch launch: receiver prompt: "{{ actions.transition.output.kickoff_prompt }}" - id: done - notify: "Handed off to {{ context.roles.receiver.name }}" + notify: "Handed off to {{ context.roles.receiver.display_name }}" """ nonisolated static let authorPane = WorkflowPaneIdentity( @@ -237,7 +237,7 @@ struct WorkflowRunMachineTests { .inject( role: "author", surfaceID: Self.authorPane.surfaceID, ordinal: 1, line: "[Prowl] " + (machine.run.invocations[0].content?.guidance ?? "") - + " — finish with: PROWL_WORKFLOW_TOKEN=TOKEN-1 prowl workflow done -", + + " — finish with: PROWL_WORKFLOW_TOKEN=TOKEN-1 prowl workflow deliver -", opensActivation: true))) #expect(machine.run.phase == .injecting(ordinal: 1)) #expect(machine.run.invocations[0].activation?.token == "TOKEN-1") @@ -278,8 +278,8 @@ struct WorkflowRunMachineTests { ordinal: 1, selector: .token("TOKEN-1"), body: "```md\n# Brief\n## Scope\nx\n## Claims\ny\n```", verdict: nil) let receipt = try result.get() #expect(receipt.output.name == "brief") - #expect(receipt.output.path == "\(runDir)/outputs/brief.1.md") - #expect(receipt.output.latestPath == "\(runDir)/outputs/brief.md") + #expect(receipt.output.path == "\(runDir)/deliveries/brief.1.md") + #expect(receipt.output.latestPath == "\(runDir)/deliveries/brief.md") #expect( effects == [ .disarmWatchdog(ordinal: 1), @@ -289,7 +289,7 @@ struct WorkflowRunMachineTests { // Nothing advances until the output is on disk (dsl-spec §5: validate, persist, complete). #expect(machine.run.phase == .waitingForDelivery(ordinal: 1)) #expect(machine.run.invocations[0].activation?.state == .persisting) - #expect(machine.run.outputs.isEmpty) + #expect(machine.run.deliveries.isEmpty) #expect( machine.deliver(ordinal: 1, selector: .token("TOKEN-1"), body: "again", verdict: nil).result == .failure(.stepNotExpecting)) @@ -321,7 +321,8 @@ struct WorkflowRunMachineTests { request.prompt.hasPrefix( "Read workflow-resource:resource-1 and review (strict).")) #expect( - request.prompt.contains("\nprowl workflow done --verdict clean -\nor:\nprowl workflow done --verdict issues -\n")) + request.prompt.contains( + "\nprowl workflow deliver --verdict clean -\nor:\nprowl workflow deliver --verdict issues -\n")) #expect(request.prompt.contains("Reviewer starting round 1")) #expect( request.environment == [ @@ -336,13 +337,13 @@ struct WorkflowRunMachineTests { #expect(machine.run.phase == .launching(ordinal: 2)) #expect(machine.run.invocations[0].activation?.state == .delivered) #expect(machine.run.invocations[0].activation?.pendingDelivery == nil) - #expect(machine.run.outputs["brief"]?.ordinal == 1) + #expect(machine.run.deliveries["brief"]?.ordinal == 1) } @Test func watchdogVerdictsAreIgnoredOnceADeliveryWasAccepted() throws { let skipYAML = Self.handoff.replacing( - "expect: { output: brief, sections: [\"## Objective\"] }", - with: "expect: { output: brief, timeout: 1m, on_timeout: skip }") + "expect: { delivery: brief, sections: [\"## Objective\"] }", + with: "expect: { delivery: brief, timeout: 1m, on_timeout: skip }") var (machine, _) = try makeMachine( skipYAML, roles: ["source": .current(Self.authorPane), "receiver": .launch(Self.reviewerProfile, pane: nil)]) _ = machine.apply(.roleIdle(ordinal: 1)) @@ -408,8 +409,8 @@ struct WorkflowRunMachineTests { let yaml = Self.adversarialReview .replacing("timeout: 10m }", with: "timeout: 10m, strict: true }") .replacing( - "verdict: [clean, issues] }\n - id: remember", - with: "verdict: [clean, issues], strict: true }\n - id: remember") + "verdicts: [clean, issues] }\n - id: remember", + with: "verdicts: [clean, issues], strict: true }\n - id: remember") var (machine, _) = try makeMachine(yaml) _ = machine.apply(.roleIdle(ordinal: 1)) _ = machine.apply(.injectionSucceeded(ordinal: 1, dispatchID: "d1")) @@ -447,7 +448,7 @@ struct WorkflowRunMachineTests { #expect( attention.message == "author (Claude Code) delivered brief, but: missing section(s) ## Claims. Accept it, ask again, or skip.") - #expect(machine.run.outputs.isEmpty) + #expect(machine.run.deliveries.isEmpty) #expect(machine.apply(.watchdog(ordinal: 1, .nudge)).isEmpty) #expect( machine.deliver(ordinal: 1, selector: .token("TOKEN-1"), body: "x", verdict: nil).result @@ -458,7 +459,7 @@ struct WorkflowRunMachineTests { accepted.contains( .completeActivation(dispatchID: "d1", summary: "Delivered output 'brief' for workflow step 'brief'."))) #expect(machine.run.status == .running) - #expect(machine.run.outputs["brief"]?.ordinal == 1) + #expect(machine.run.deliveries["brief"]?.ordinal == 1) #expect(machine.run.invocations[0].activation?.state == .delivered) #expect(machine.run.phase == .launching(ordinal: 2)) } @@ -477,7 +478,7 @@ struct WorkflowRunMachineTests { #expect(machine.apply(.user(.acceptDelivery(verdict: "maybe"))).isEmpty) #expect(machine.run.status.attention != nil) _ = machine.apply(.user(.acceptDelivery(verdict: "clean"))) - #expect(machine.run.outputs["findings"]?.verdict == "clean") + #expect(machine.run.deliveries["findings"]?.verdict == "clean") #expect(machine.run.controlCursor?.state.values["rounds"] == .integer(0)) #expect(machine.run.phase == .runningAction(stepID: "context")) } @@ -497,7 +498,7 @@ struct WorkflowRunMachineTests { role: "author", surfaceID: Self.authorPane.surfaceID, line: "[Prowl] Your delivery for this step had missing section(s) ## Claims. Deliver it again, complete, with: " - + "PROWL_WORKFLOW_TOKEN=TOKEN-1 prowl workflow done -"))) + + "PROWL_WORKFLOW_TOKEN=TOKEN-1 prowl workflow deliver -"))) #expect( effects.contains { if case .armWatchdog(let request) = $0 { @@ -558,7 +559,7 @@ struct WorkflowRunMachineTests { role: "author", surfaceID: Self.authorPane.surfaceID, ordinal: 3, line: "[Prowl] Findings: workflow-resource:resource-1. Fix or rebut each item. " + (machine.run.currentInvocation?.content?.guidance ?? "") - + " — finish with: PROWL_WORKFLOW_TOKEN=TOKEN-3 prowl workflow done -", + + " — finish with: PROWL_WORKFLOW_TOKEN=TOKEN-3 prowl workflow deliver -", opensActivation: true))) _ = machine.apply(.injectionSucceeded(ordinal: 3, dispatchID: "d3")) try deliverPersisted(&machine, ordinal: 3, token: "TOKEN-3", body: "# Done\nfixed") @@ -569,8 +570,8 @@ struct WorkflowRunMachineTests { let round1 = try deliverPersisted( &machine, ordinal: 4, token: "TOKEN-4", body: "# Findings\nstill", verdict: "issues") #expect(round1.contains(.persistOutput(name: "round_findings", ordinal: 4, body: "# Findings\nstill\n"))) - #expect(machine.run.outputs["round_findings"] == nil) - #expect(machine.run.controlCursor?.state.values["path"] == .string("\(runDir)/outputs/round_findings.md")) + #expect(machine.run.deliveries["round_findings"] == nil) + #expect(machine.run.controlCursor?.state.values["path"] == .string("\(runDir)/deliveries/round_findings.md")) #expect(machine.run.controlCursor?.state.values["rounds"] == .integer(1)) #expect(machine.run.currentIteration == 2) #expect(machine.run.currentIteration == 2) @@ -599,8 +600,8 @@ struct WorkflowRunMachineTests { _ = machine.apply(.roleIdle(ordinal: 4)) _ = machine.apply(.injectionSucceeded(ordinal: 4, dispatchID: "d4")) let effects = try deliverPersisted(&machine, ordinal: 4, token: "TOKEN-4", body: "# Findings", verdict: "issues") - #expect(machine.run.status == .maxRoundsReached) - #expect(effects.last == .finished(.maxRoundsReached)) + #expect(machine.run.status == .iterationLimitReached) + #expect(effects.last == .finished(.iterationLimitReached)) #expect(machine.run.controlCursor?.state.values["rounds"] == .integer(1)) } @@ -732,11 +733,11 @@ struct WorkflowRunMachineTests { - id: produce message: author text: "Write it." - expect: { output: draft } + expect: { delivery: draft } - id: consume message: author - text: "Polish {{ outputs.draft.path }}." - expect: { output: final } + text: "Polish {{ deliveries.draft.path }}." + expect: { delivery: final } - id: done notify: "done" """) @@ -953,8 +954,8 @@ struct WorkflowRunMachineTests { @Test func anExpiredDeadlineOnKeepWaitingAppliesTheTimeoutPolicy() throws { let skipYAML = Self.handoff.replacing( - "expect: { output: brief, sections: [\"## Objective\"] }", - with: "expect: { output: brief, timeout: 1m, on_timeout: skip }") + "expect: { delivery: brief, sections: [\"## Objective\"] }", + with: "expect: { delivery: brief, timeout: 1m, on_timeout: skip }") let now = NowBox(Self.start) var (machine, _) = try makeMachine( skipYAML, roles: ["source": .current(Self.authorPane), "receiver": .launch(Self.reviewerProfile, pane: nil)], @@ -996,8 +997,8 @@ struct WorkflowRunMachineTests { #expect(attention.run.status.attention?.actions == [.nudge, .keepWaiting, .skip, .cancel]) let skipYAML = Self.handoff.replacing( - "expect: { output: brief, sections: [\"## Objective\"] }", - with: "expect: { output: brief, timeout: 1m, on_timeout: skip }") + "expect: { delivery: brief, sections: [\"## Objective\"] }", + with: "expect: { delivery: brief, timeout: 1m, on_timeout: skip }") var skipping = try makeMachine( skipYAML, roles: ["source": .current(Self.authorPane), "receiver": .launch(Self.reviewerProfile, pane: nil)] ).0 @@ -1010,8 +1011,8 @@ struct WorkflowRunMachineTests { #expect(skipping.run.phase == .runningAction(stepID: "transition")) let cancelYAML = Self.handoff.replacing( - "expect: { output: brief, sections: [\"## Objective\"] }", - with: "expect: { output: brief, timeout: 1m, on_timeout: cancel }") + "expect: { delivery: brief, sections: [\"## Objective\"] }", + with: "expect: { delivery: brief, timeout: 1m, on_timeout: cancel }") var cancelling = try makeMachine( cancelYAML, roles: ["source": .current(Self.authorPane), "receiver": .launch(Self.reviewerProfile, pane: nil)] ).0 @@ -1032,7 +1033,7 @@ struct WorkflowRunMachineTests { .abandonActivation(dispatchID: "d3", reason: "Workflow run \(Self.runID.uuidString) cancelled at step 'fix'."))) #expect(effects.contains(.disarmWatchdog(ordinal: 3))) #expect(effects.last == .finished(.cancelled)) - #expect(machine.run.outputs.keys.sorted() == ["brief", "findings"]) + #expect(machine.run.deliveries.keys.sorted() == ["brief", "findings"]) #expect(!effects.contains { if case .close = $0 { return true } else { return false } }) #expect(machine.apply(.user(.retry)).isEmpty) } @@ -1123,7 +1124,9 @@ struct WorkflowRunMachineTests { #expect(attention.actions == [.retry, .cancel]) #expect(machine.apply(.user(.skip)).isEmpty) let retry = machine.apply(.user(.retry)) - #expect(retry.contains(.runAction(stepID: "context", actionID: "builtin:git.context", inputs: ["root": "/repo"]))) + #expect( + retry.contains( + .runAction(stepID: "context", actionID: "builtin:collect-worktree-context", inputs: ["root": "/repo"]))) #expect(machine.run.status == .running) } @@ -1168,11 +1171,11 @@ struct WorkflowRunMachineTests { - id: launch launch: reviewer prompt: "Review." - expect: { output: ready } + expect: { delivery: ready } - id: ping message: reviewer text: "hello" - expect: { output: pong } + expect: { delivery: pong } """ var (machine, _) = try makeMachine(yaml, skipped: ["launch"]) #expect(machine.run.phase == .injecting(ordinal: 1)) diff --git a/supacodeTests/WorkflowRunStoreTests.swift b/supacodeTests/WorkflowRunStoreTests.swift index 9f5f43a2b..b9d7ab145 100644 --- a/supacodeTests/WorkflowRunStoreTests.swift +++ b/supacodeTests/WorkflowRunStoreTests.swift @@ -73,7 +73,7 @@ struct WorkflowRunStoreTests { try store.ensureLayout(runID: runID) let runs = store.directory(for: runID).deletingLastPathComponent() #expect(!FileManager.default.fileExists(atPath: root.appending(path: ".prowl").path)) - for name in ["instructions", "outputs", "skills"] { + for name in ["instructions", "deliveries", "skills"] { var isDirectory: ObjCBool = false let path = runs.appending(path: runID.uuidString).appending(path: name).path(percentEncoded: false) #expect(FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue) @@ -329,11 +329,11 @@ struct WorkflowRunStoreTests { let store = WorkflowRunStore(rootURL: root) let runID = UUID() try store.ensureLayout(runID: runID) - let outputs = store.directory(for: runID).appending(path: "outputs", directoryHint: .isDirectory) - try FileManager.default.removeItem(at: outputs) + let deliveries = store.directory(for: runID).appending(path: "deliveries", directoryHint: .isDirectory) + try FileManager.default.removeItem(at: deliveries) let elsewhere = root.appending(path: "elsewhere", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: elsewhere, withIntermediateDirectories: true) - try FileManager.default.createSymbolicLink(at: outputs, withDestinationURL: elsewhere) + try FileManager.default.createSymbolicLink(at: deliveries, withDestinationURL: elsewhere) #expect(throws: WorkflowRunStoreError.self) { try store.writeOutput(runID: runID, name: "findings", ordinal: 1, body: "x") } diff --git a/supacodeTests/WorkflowRunsFeatureTests.swift b/supacodeTests/WorkflowRunsFeatureTests.swift index 94a153c85..abbc98bd1 100644 --- a/supacodeTests/WorkflowRunsFeatureTests.swift +++ b/supacodeTests/WorkflowRunsFeatureTests.swift @@ -39,17 +39,17 @@ struct WorkflowRunsFeatureTests { - id: brief message: author text: "Write the brief." - expect: { output: brief } + expect: { delivery: brief } - id: launch launch: reviewer - prompt: "Review {{ outputs.brief.path }}." - expect: { output: findings } + prompt: "Review {{ deliveries.brief.path }}." + expect: { delivery: findings } - id: cleanup close: reviewer - id: summary message: author - text: "Findings: {{ outputs.findings.path }}. Summarize." - expect: { output: summary } + text: "Findings: {{ deliveries.findings.path }}. Summarize." + expect: { delivery: summary } """ /// A native action as the first step: the run starts in `runningAction`. @@ -65,7 +65,7 @@ struct WorkflowRunsFeatureTests { placement: tab steps: - id: context - action: builtin:git.context + action: builtin:collect-worktree-context with: { root: "{{ context.worktree.path }}" } - id: launch launch: reviewer @@ -356,7 +356,7 @@ struct WorkflowRunsFeatureTests { #expect( fixture.typed[0].instructionExisted, "the instruction file must exist before the pointer is typed") - #expect(fixture.typed[0].line.contains("PROWL_WORKFLOW_TOKEN=\(Self.firstToken) prowl workflow done -")) + #expect(fixture.typed[0].line.contains("PROWL_WORKFLOW_TOKEN=\(Self.firstToken) prowl workflow deliver -")) #expect(fixture.armed.map(\.ordinal) == [1]) let record = try session.store.readRecord(runID: runID) #expect(record.run.status.state == "running") @@ -376,7 +376,7 @@ struct WorkflowRunsFeatureTests { return } #expect(receipt.ordinal == 1) - #expect(run.outputs["brief"]?.ordinal == 1) + #expect(run.deliveries["brief"]?.ordinal == 1) #expect(store.state.pendingDeliveries.isEmpty) #expect(fixture.disarmed.first == 1, "the accepted delivery disarms its watchdog") #expect(fixture.completed == ["dispatch-1"]) @@ -729,7 +729,7 @@ struct WorkflowRunsFeatureTests { #expect( effects.contains( .runAction( - stepID: "context", actionID: "builtin:git.context", + stepID: "context", actionID: "builtin:collect-worktree-context", inputs: ["root": .string(fixture.root.path(percentEncoded: false))]))) await store.send(.started(session, effects: effects)) await queue.reached() @@ -894,7 +894,9 @@ struct WorkflowRunsFeatureTests { await store.finish(timeout: Self.timeout) let log = try String( contentsOf: session.store.directory(for: runID).appending(path: "log.md"), encoding: .utf8) - #expect(log.contains("Step 'context': native action 'builtin:git.context' not started; the run had moved on.")) + #expect( + log.contains( + "Step 'context': native action 'builtin:collect-worktree-context' not started; the run had moved on.")) #expect(!log.contains("finished after the run moved on")) } @@ -938,7 +940,8 @@ struct WorkflowRunsFeatureTests { WorkflowRunEffect.inject(role: "r", surfaceID: pane, ordinal: 1, line: "l", opensActivation: true).isRevocable) #expect(WorkflowRunEffect.typeLine(role: "r", surfaceID: pane, line: "l").isRevocable) #expect(WorkflowRunEffect.launch(request).isRevocable) - #expect(WorkflowRunEffect.runAction(stepID: "s", actionID: "builtin:git.context", inputs: [:]).isRevocable) + #expect( + WorkflowRunEffect.runAction(stepID: "s", actionID: "builtin:collect-worktree-context", inputs: [:]).isRevocable) #expect(!WorkflowRunEffect.completeActivation(dispatchID: "d", summary: "s").isRevocable) #expect(!WorkflowRunEffect.abandonActivation(dispatchID: "d", reason: "r").isRevocable) #expect(!WorkflowRunEffect.persist.isRevocable) @@ -1076,8 +1079,8 @@ struct WorkflowRunsFeatureTests { #expect(WorkflowRunNotice.statusEdge(from: running, to: session.run)?.kind == .completed) session.run.status = .skipped(step: "brief", dependent: "launch") #expect(WorkflowRunNotice.statusEdge(from: running, to: session.run)?.kind == .skipped) - session.run.status = .maxRoundsReached - #expect(WorkflowRunNotice.statusEdge(from: running, to: session.run)?.kind == .maxRoundsReached) + session.run.status = .iterationLimitReached + #expect(WorkflowRunNotice.statusEdge(from: running, to: session.run)?.kind == .iterationLimitReached) session.run.status = .cancelled #expect(WorkflowRunNotice.statusEdge(from: running, to: session.run) == nil) #expect(WorkflowRunNotice.statusEdge(from: .completed, to: session.run) == nil) diff --git a/supacodeTests/WorkflowRuntimeCoordinatorTests.swift b/supacodeTests/WorkflowRuntimeCoordinatorTests.swift index cae8ad525..0c27dbcda 100644 --- a/supacodeTests/WorkflowRuntimeCoordinatorTests.swift +++ b/supacodeTests/WorkflowRuntimeCoordinatorTests.swift @@ -1,5 +1,5 @@ // supacodeTests/WorkflowRuntimeCoordinatorTests.swift -// `prowl workflow status / done / cancel` attribution and responses (docs-ai 063 B3, W1/W3/W5). +// `prowl workflow status / deliver / cancel` attribution and responses (docs-ai 063 B3, W1/W3/W5). import Foundation import ProwlCLIShared @@ -216,7 +216,7 @@ struct WorkflowRuntimeCoordinatorTests { try JSONDecoder().decode(WorkflowCommandPayload.self, from: try #require(response.data).bytes) } - // MARK: - done attribution (decision W3) + // MARK: - deliver attribution (decision W3) @Test func doneFromTheRolePaneIsAttributedByItsPendingDispatch() async throws { let fixture = try Fixture() @@ -225,8 +225,9 @@ struct WorkflowRuntimeCoordinatorTests { fixture.sessions = [session] fixture.answer = try delivered(session) - let response = await fixture.coordinator.done( - WorkflowInput(action: .done, body: "## Scope\nx\n## Claims\ny", token: "TOKEN-1"), callerPane: Self.authorCaller) + let response = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, body: "## Scope\nx\n## Claims\ny", token: "TOKEN-1"), + callerPane: Self.authorCaller) #expect(response.ok, "\(response.error?.message ?? "")") guard case .deliver(let request) = fixture.sent.first else { Issue.record("expected a deliver action") @@ -238,27 +239,27 @@ struct WorkflowRuntimeCoordinatorTests { #expect(request.selector == .token("TOKEN-1")) #expect(request.source == "pane") #expect(request.body == "## Scope\nx\n## Claims\ny") - guard case .done(let done) = try payload(response) else { - Issue.record("expected a done payload") + guard case .deliver(let deliver) = try payload(response) else { + Issue.record("expected a deliver payload") return } - #expect(done.delivery.state == .delivered) - #expect(done.delivery.role == "author") - #expect(done.delivery.output.name == "brief") - #expect(done.run.role == "author") + #expect(deliver.delivery.state == .delivered) + #expect(deliver.delivery.role == "author") + #expect(deliver.delivery.output.name == "brief") + #expect(deliver.run.role == "author") #expect(fixture.rendezvous.pendingRequestIDs.isEmpty) } @Test func doneWithoutABodyOrHalfAManualTargetIsInvalid() async throws { let fixture = try Fixture() defer { fixture.cleanUp() } - let noBody = await fixture.coordinator.done(WorkflowInput(action: .done), callerPane: Self.authorCaller) + let noBody = await fixture.coordinator.deliver(WorkflowInput(action: .deliver), callerPane: Self.authorCaller) #expect(noBody.error?.code == CLIErrorCode.invalidArgument) - let half = await fixture.coordinator.done( - WorkflowInput(action: .done, runID: UUID().uuidString, body: "x"), callerPane: Self.authorCaller) + let half = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, runID: UUID().uuidString, body: "x"), callerPane: Self.authorCaller) #expect(half.error?.code == CLIErrorCode.invalidArgument) - let badID = await fixture.coordinator.done( - WorkflowInput(action: .done, runID: "nope", stepID: "brief", body: "x"), callerPane: nil) + let badID = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, runID: "nope", stepID: "brief", body: "x"), callerPane: nil) #expect(badID.error?.code == CLIErrorCode.invalidArgument) #expect(fixture.sent.isEmpty) } @@ -270,12 +271,12 @@ struct WorkflowRuntimeCoordinatorTests { fixture.sessions = [session] fixture.answer = .failed(code: CLIErrorCode.stepNotExpecting, message: "no") - let missing = await fixture.coordinator.done(WorkflowInput(action: .done, body: "x"), callerPane: nil) + let missing = await fixture.coordinator.deliver(WorkflowInput(action: .deliver, body: "x"), callerPane: nil) #expect(missing.error?.code == CLIErrorCode.sourceRequired) #expect(fixture.sent.isEmpty) - let manual = await fixture.coordinator.done( - WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "brief", body: "x"), callerPane: nil) + let manual = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, runID: session.run.id.uuidString, stepID: "brief", body: "x"), callerPane: nil) #expect(manual.error?.code == CLIErrorCode.stepNotExpecting, "the reducer's answer is passed through") guard case .deliver(let request) = fixture.sent.first else { Issue.record("expected a deliver action") @@ -285,8 +286,8 @@ struct WorkflowRuntimeCoordinatorTests { #expect(request.selector == .manual(stepID: "brief")) #expect(request.source == "manual") - let unknown = await fixture.coordinator.done( - WorkflowInput(action: .done, runID: UUID().uuidString, stepID: "brief", body: "x"), callerPane: nil) + let unknown = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, runID: UUID().uuidString, stepID: "brief", body: "x"), callerPane: nil) #expect(unknown.error?.code == CLIErrorCode.runNotFound) } @@ -297,13 +298,13 @@ struct WorkflowRuntimeCoordinatorTests { fixture.sessions = [session] fixture.answer = .failed(code: CLIErrorCode.stepNotExpecting, message: "no") - let implicit = await fixture.coordinator.done( - WorkflowInput(action: .done, body: "x"), callerPane: Self.strangerCaller) + let implicit = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, body: "x"), callerPane: Self.strangerCaller) #expect(implicit.error?.code == CLIErrorCode.stepNotExpecting) #expect(fixture.sent.isEmpty) - _ = await fixture.coordinator.done( - WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "brief", body: "x"), + _ = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, runID: session.run.id.uuidString, stepID: "brief", body: "x"), callerPane: Self.strangerCaller) guard case .deliver(let request) = fixture.sent.first else { Issue.record("expected a deliver action") @@ -319,14 +320,14 @@ struct WorkflowRuntimeCoordinatorTests { fixture.sessions = [session] fixture.answer = .failed(code: CLIErrorCode.stepNotExpecting, message: "no") - let mismatch = await fixture.coordinator.done( - WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "launch", body: "x"), + let mismatch = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, runID: session.run.id.uuidString, stepID: "launch", body: "x"), callerPane: Self.authorCaller) #expect(mismatch.error?.code == CLIErrorCode.roleMismatch) #expect(fixture.sent.isEmpty) - let agreeing = await fixture.coordinator.done( - WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "brief", body: "x", token: "TOKEN-1"), + let agreeing = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, runID: session.run.id.uuidString, stepID: "brief", body: "x", token: "TOKEN-1"), callerPane: Self.authorCaller) #expect(agreeing.error?.code == CLIErrorCode.stepNotExpecting) guard case .deliver(let agreed) = fixture.sent.last else { @@ -336,8 +337,8 @@ struct WorkflowRuntimeCoordinatorTests { #expect(agreed.source == "pane") #expect(agreed.ordinal == 1) - _ = await fixture.coordinator.done( - WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "launch", body: "x", force: true), + _ = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, runID: session.run.id.uuidString, stepID: "launch", body: "x", force: true), callerPane: Self.authorCaller) guard case .deliver(let forced) = fixture.sent.last else { Issue.record("expected a deliver action") @@ -354,8 +355,8 @@ struct WorkflowRuntimeCoordinatorTests { fixture.sessions = [session] // No synchronous answer: the reducer resolves later, after persistence. let task = Task { @MainActor in - await fixture.coordinator.done( - WorkflowInput(action: .done, body: "## Scope\nonly", token: "TOKEN-1"), callerPane: Self.authorCaller) + await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, body: "## Scope\nonly", token: "TOKEN-1"), callerPane: Self.authorCaller) } await Task.yield() #expect(fixture.rendezvous.pendingRequestIDs == [fixture.requestID]) @@ -365,14 +366,14 @@ struct WorkflowRuntimeCoordinatorTests { fixture.coordinator.resolve(fixture.requestID, .provisional(run: machine.run, receipt: try result.get())) let response = await task.value #expect(response.ok) - guard case .done(let done) = try payload(response) else { - Issue.record("expected a done payload") + guard case .deliver(let deliver) = try payload(response) else { + Issue.record("expected a deliver payload") return } - #expect(done.delivery.state == .provisional) - #expect(done.delivery.warnings.map(\.code) == ["missing_sections"]) - #expect(done.run.status.state == "needs_attention") - #expect(done.run.status.attention?.issues == ["missing_sections"]) + #expect(deliver.delivery.state == .provisional) + #expect(deliver.delivery.warnings.map(\.code) == ["missing_sections"]) + #expect(deliver.run.status.state == "needs_attention") + #expect(deliver.run.status.attention?.issues == ["missing_sections"]) } @Test func aDuplicateRequestIDIsRefusedWithoutEnteringTheReducer() async throws { @@ -382,13 +383,13 @@ struct WorkflowRuntimeCoordinatorTests { fixture.sessions = [session] // No synchronous answer: the first request stays pending under the fixed request id. let first = Task { @MainActor in - await fixture.coordinator.done( - WorkflowInput(action: .done, body: "x", token: "TOKEN-1"), callerPane: Self.authorCaller) + await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, body: "x", token: "TOKEN-1"), callerPane: Self.authorCaller) } await Task.yield() #expect(fixture.rendezvous.pendingRequestIDs == [fixture.requestID]) - let duplicate = await fixture.coordinator.done( - WorkflowInput(action: .done, body: "y", token: "TOKEN-1"), callerPane: Self.authorCaller) + let duplicate = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, body: "y", token: "TOKEN-1"), callerPane: Self.authorCaller) #expect(duplicate.error?.code == CLIErrorCode.requestConflict) #expect(fixture.sent.count == 1, "the duplicate never reaches the reducer") fixture.coordinator.resolve(fixture.requestID, .failed(code: CLIErrorCode.stepNotExpecting, message: "no")) @@ -404,24 +405,24 @@ struct WorkflowRuntimeCoordinatorTests { let session = try fixture.waitingSession() fixture.sessions = [session] let first = Task { @MainActor in - await fixture.coordinator.done( - WorkflowInput(action: .done, body: "x", token: "TOKEN-1"), callerPane: Self.authorCaller) + await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, body: "x", token: "TOKEN-1"), callerPane: Self.authorCaller) } await Task.yield() first.cancel() #expect((await first.value).error?.code == CLIErrorCode.requestCancelled) #expect(fixture.rendezvous.pendingRequestIDs.isEmpty) - let reuse = await fixture.coordinator.done( - WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "brief", body: "y"), callerPane: nil) + let reuse = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, runID: session.run.id.uuidString, stepID: "brief", body: "y"), callerPane: nil) #expect(reuse.error?.code == CLIErrorCode.requestConflict) #expect(fixture.sent.count == 1) // The old transaction's answer goes nowhere, and only then is the id free again. fixture.coordinator.resolve(fixture.requestID, .failed(code: CLIErrorCode.stepNotExpecting, message: "late")) fixture.answer = .failed(code: CLIErrorCode.stepNotExpecting, message: "fresh") - let fresh = await fixture.coordinator.done( - WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "brief", body: "y"), callerPane: nil) + let fresh = await fixture.coordinator.deliver( + WorkflowInput(action: .deliver, runID: session.run.id.uuidString, stepID: "brief", body: "y"), callerPane: nil) #expect(fresh.error?.message == "fresh") #expect(fixture.sent.count == 2) } @@ -443,19 +444,19 @@ struct WorkflowRuntimeCoordinatorTests { _ = machine.apply(.launched(ordinal: 2, pane: reviewerPane, dispatchID: "dispatch-2")) fixture.answer = .delivered(run: machine.run, receipt: try result.get()) - let response = await fixture.coordinator.done( + let response = await fixture.coordinator.deliver( WorkflowInput( - action: .done, runID: session.run.id.uuidString, stepID: "brief", body: "## Scope\nx\n## Claims\ny"), + action: .deliver, runID: session.run.id.uuidString, stepID: "brief", body: "## Scope\nx\n## Claims\ny"), callerPane: nil) #expect(response.ok, "\(response.error?.message ?? "")") - guard case .done(let done) = try payload(response) else { - Issue.record("expected a done payload") + guard case .deliver(let deliver) = try payload(response) else { + Issue.record("expected a deliver payload") return } - #expect(done.delivery.role == "author") - #expect(done.run.role == nil) - #expect(done.run.activation?.role == "reviewer") - #expect(done.run.activation?.expect.completion == [], "a manual caller is not the reviewer's pane") + #expect(deliver.delivery.role == "author") + #expect(deliver.run.role == nil) + #expect(deliver.run.activation?.role == "reviewer") + #expect(deliver.run.activation?.expect.completion == [], "a manual caller is not the reviewer's pane") } /// `run` spells the completion command only for the caller's own activation: a workflow whose @@ -474,7 +475,7 @@ struct WorkflowRuntimeCoordinatorTests { - id: launch launch: reviewer prompt: "Review." - expect: { output: findings } + expect: { delivery: findings } """ let definition = try #require(WorkflowDocumentParser.parse(yaml).definition) let started = try WorkflowRunMachine.start( @@ -500,7 +501,7 @@ struct WorkflowRuntimeCoordinatorTests { let asNobody = WorkflowRunPayload(run: machine.run, callerRole: nil, includeSelfInitiated: true) #expect(asNobody.activation?.expect.completion == []) let asReviewer = WorkflowRunPayload(run: machine.run, callerRole: "reviewer", includeSelfInitiated: false) - #expect(asReviewer.activation?.expect.completion == ["PROWL_WORKFLOW_TOKEN=SECRET prowl workflow done -"]) + #expect(asReviewer.activation?.expect.completion == ["PROWL_WORKFLOW_TOKEN=SECRET prowl workflow deliver -"]) } /// `agents dispatch-complete` is refused for a workflow activation even after its run ended: @@ -511,7 +512,7 @@ struct WorkflowRuntimeCoordinatorTests { let session = try fixture.waitingSession() let live = WorkflowRuntimeCoordinator.deliveryRefusal(dispatchID: "dispatch-1", sessions: [session]) #expect(live?.code == CLIErrorCode.workflowDeliveryRequired) - #expect(live?.message.contains("prowl workflow done") == true) + #expect(live?.message.contains("prowl workflow deliver") == true) var machine = session.machine(now: { Self.now }, makeToken: { "T" }) _ = machine.apply(.user(.cancel)) var ended = session @@ -538,7 +539,7 @@ struct WorkflowRuntimeCoordinatorTests { #expect(whoAmI.source == .live) #expect(whoAmI.role == "author") #expect(whoAmI.step == "brief") - #expect(whoAmI.activation?.expect.completion == ["PROWL_WORKFLOW_TOKEN=TOKEN-1 prowl workflow done -"]) + #expect(whoAmI.activation?.expect.completion == ["PROWL_WORKFLOW_TOKEN=TOKEN-1 prowl workflow deliver -"]) #expect(whoAmI.selfInitiated == nil) let stranger = fixture.coordinator.status(WorkflowInput(action: .status), callerPane: Self.strangerCaller) @@ -576,8 +577,8 @@ struct WorkflowRuntimeCoordinatorTests { #expect(malformed.error?.code == CLIErrorCode.invalidArgument) } - /// An invocation stuck in an injection attention has no activation `done` could address, so - /// `status` must not advertise one (the caller would otherwise retry `done` forever). + /// An invocation stuck in an injection attention has no activation `deliver` could address, so + /// `status` must not advertise one (the caller would otherwise retry `deliver` forever). @Test func statusReportsNoActivationWhileTheStepIsInAnInjectionAttention() throws { let fixture = try Fixture() defer { fixture.cleanUp() } diff --git a/supacodeTests/WorkflowSettingsCatalogTests.swift b/supacodeTests/WorkflowSettingsCatalogTests.swift index 64e0b7464..e4abd6d5a 100644 --- a/supacodeTests/WorkflowSettingsCatalogTests.swift +++ b/supacodeTests/WorkflowSettingsCatalogTests.swift @@ -188,7 +188,7 @@ struct WorkflowSettingsCatalogTests { @Test func listStatusPrioritizesRecoveryAndEffectiveAvailability() throws { let warning = Self.review.replacing( " prompt: \"Review.\"", - with: " prompt: \"Review.\"\n expect: { output: report, timeout: 3h }") + with: " prompt: \"Review.\"\n expect: { delivery: report, timeout: 3h }") let ready = try #require( WorkflowSettingsCatalog.build( scan: scan(entries: [entry(Self.review)]), diff --git a/supacodeTests/WorkflowStartContextTests.swift b/supacodeTests/WorkflowStartContextTests.swift index f417206d6..64afbeb47 100644 --- a/supacodeTests/WorkflowStartContextTests.swift +++ b/supacodeTests/WorkflowStartContextTests.swift @@ -21,10 +21,10 @@ struct WorkflowStartContextTests { - id: brief message: author text: "Write about {{ inputs.focus }}." - expect: { output: brief } + expect: { delivery: brief } - id: launch launch: reviewer - prompt: "Read {{ outputs.brief.path }}." + prompt: "Read {{ deliveries.brief.path }}." """ static let worktreeOnly = """ diff --git a/supacodeTests/WorkflowStartFeatureTests.swift b/supacodeTests/WorkflowStartFeatureTests.swift index ff04d8898..e213109c2 100644 --- a/supacodeTests/WorkflowStartFeatureTests.swift +++ b/supacodeTests/WorkflowStartFeatureTests.swift @@ -25,10 +25,10 @@ struct WorkflowStartFeatureTests { - id: brief message: author text: "Brief on {{ inputs.goal }}." - expect: { output: brief } + expect: { delivery: brief } - id: launch launch: reviewer - prompt: "Read {{ outputs.brief.path }}." + prompt: "Read {{ deliveries.brief.path }}." """ static let skippableNote = """ @@ -45,7 +45,7 @@ struct WorkflowStartFeatureTests { - id: note message: author text: "Write a note." - expect: { output: note } + expect: { delivery: note } - id: launch launch: runner prompt: "Just do it." diff --git a/supacodeTests/WorkflowStatusCenterPresentationTests.swift b/supacodeTests/WorkflowStatusCenterPresentationTests.swift index 288866197..5ec110422 100644 --- a/supacodeTests/WorkflowStatusCenterPresentationTests.swift +++ b/supacodeTests/WorkflowStatusCenterPresentationTests.swift @@ -133,14 +133,14 @@ struct WorkflowStatusCenterPresentationTests { @Test func attentionControlsExhaustivelyMapEveryMachineAction() throws { var session = try makeSession(id: UUID(5), worktreeID: "selected", updatedAt: Self.now) let ordinal = try #require(session.run.currentInvocation?.ordinal) - let expectation = WorkflowExpectation(output: "brief", verdict: ["clean", "issues"]) + let expectation = WorkflowExpectation(delivery: "brief", verdicts: ["clean", "issues"]) session.run.invocations[0].activation = WorkflowActivation( ordinal: ordinal, stepID: "brief", role: "author", token: "token", expect: expectation, - outputName: "brief", + deliveryName: "brief", dispatchID: "dispatch", state: .provisional, pendingDelivery: WorkflowValidatedDelivery( @@ -202,8 +202,8 @@ struct WorkflowStatusCenterPresentationTests { WorkflowStepRecord(stepID: "rereview", iteration: 1, state: .completed, ordinal: 3), WorkflowStepRecord(stepID: "fix", iteration: 2, state: .active, ordinal: 4), ] - let briefPath = "/tmp/selected/.prowl/workflow-runs/\(session.run.id.uuidString)/outputs/brief.md" - session.run.outputs["brief"] = WorkflowOutputRecord( + let briefPath = "/tmp/selected/.prowl/workflow-runs/\(session.run.id.uuidString)/deliveries/brief.md" + session.run.deliveries["brief"] = WorkflowDeliveryRecord( name: "brief", ordinal: 1, path: briefPath, @@ -229,7 +229,7 @@ struct WorkflowStatusCenterPresentationTests { session.run.stepValues = session.run.expressionValues(capturedAt: Self.now) let run = WorkflowRunPresentation(run: session.run, now: Self.now) let expectedInstruction = - "Read /tmp/selected/.prowl/workflow-runs/\(run.id.uuidString)/outputs/brief.md " + "Read /tmp/selected/.prowl/workflow-runs/\(run.id.uuidString)/deliveries/brief.md " + "and address the findings in round 2." #expect(run.currentStepTitle == "Round 2: address findings") @@ -389,7 +389,7 @@ struct WorkflowStatusCenterPresentationTests { title: "Write the brief" message: author instruction: "Write a brief." - expect: { output: brief } + expect: { delivery: brief } - id: rounds while: 'true' max_iterations: 3 @@ -397,13 +397,14 @@ struct WorkflowStatusCenterPresentationTests { - id: fix title: "Round {{ context.step.iteration }}: address findings" message: author - instruction: "Read {{ outputs.brief.path }} and address the findings in round {{ context.step.iteration }}." - expect: { output: disposition } + instruction: >- + Read {{ deliveries.brief.path }} and address the findings in round {{ context.step.iteration }}. + expect: { delivery: disposition } - id: rereview title: "Round {{ context.step.iteration }}: re-review" message: author text: "Review again." - expect: { output: findings, verdict: [clean, issues] } + expect: { delivery: findings, verdicts: [clean, issues] } - id: finish notify: "Done" """ @@ -419,7 +420,7 @@ struct WorkflowStatusCenterPresentationTests { - id: note message: author text: "Write an optional note." - expect: { output: note } + expect: { delivery: note } - id: finish notify: "Done" """ diff --git a/supacodeTests/WorkflowTemplateRendererTests.swift b/supacodeTests/WorkflowTemplateRendererTests.swift deleted file mode 100644 index f17aa69d5..000000000 --- a/supacodeTests/WorkflowTemplateRendererTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import ProwlCLIShared -import Testing - -@testable import supacode - -struct WorkflowTemplateRendererTests { - private func makeContext() -> WorkflowTemplateContext { - WorkflowTemplateContext( - run: WorkflowTemplateContext.Run(id: "RUN-1", directory: "/repo/.prowl/workflow-runs/RUN-1"), - worktree: WorkflowTemplateContext.Worktree(path: "/repo", name: "feature", branch: "feat/x"), - roles: [ - "author": WorkflowTemplateContext.Role(name: "Claude Code", agent: "claude", pane: "p3"), - "reviewer": WorkflowTemplateContext.Role(name: "Pi Reviewer", agent: "pi", pane: nil), - ], - outputs: [ - "findings": WorkflowTemplateContext.Output( - path: "/repo/.prowl/workflow-runs/RUN-1/outputs/findings.md", verdict: "issues"), - "brief": WorkflowTemplateContext.Output( - path: "/repo/.prowl/workflow-runs/RUN-1/outputs/brief.md", verdict: nil), - ], - skippedOutputs: ["disposition"], - actions: ["transition": ["kickoff_prompt": "Take over.", "has_briefing": "true"]], - inputs: ["max_rounds": "5", "focus": "the parser"], - loop: WorkflowTemplateContext.Loop(index: 2, count: 1) - ) - } - - @Test func rendersEveryAllowedVariable() throws { - let context = makeContext() - let text = """ - {{ run.id }}|{{ run.dir }}|{{ worktree.path }}|{{ worktree.name }}|{{ worktree.branch }}|\ - {{ roles.author.name }}|{{ roles.author.agent }}|{{ roles.author.pane }}|{{ roles.reviewer.name }}|\ - {{ outputs.findings.path }}|{{ outputs.findings.verdict }}|{{ actions.transition.kickoff_prompt }}|\ - {{ inputs.max_rounds }}|{{ inputs.focus }}|{{ loop.index }}|{{ loop.count }} - """ - let rendered = try WorkflowTemplate.render(text, context: context) - #expect( - rendered - == "RUN-1|/repo/.prowl/workflow-runs/RUN-1|/repo|feature|feat/x|Claude Code|claude|p3|Pi Reviewer|" - + "/repo/.prowl/workflow-runs/RUN-1/outputs/findings.md|issues|Take over.|5|the parser|2|1") - } - - @Test func textWithoutReferencesIsReturnedVerbatim() throws { - #expect(try WorkflowTemplate.render("no placeholders here", context: makeContext()) == "no placeholders here") - } - - @Test func skippedOutputIsTheSkipRuleSignal() { - #expect(throws: WorkflowTemplateError.missingOutput(name: "disposition")) { - try WorkflowTemplate.render("Read {{ outputs.disposition.path }}", context: makeContext()) - } - } - - @Test func neverProducedOutputIsAlsoMissing() { - #expect(throws: WorkflowTemplateError.missingOutput(name: "nothing")) { - try WorkflowTemplate.render("{{ outputs.nothing.path }}", context: makeContext()) - } - } - - @Test func verdictOfAnOutputWithoutVerdictIsUnavailable() { - #expect(throws: WorkflowTemplateError.verdictUnavailable(output: "brief")) { - try WorkflowTemplate.render("{{ outputs.brief.verdict }}", context: makeContext()) - } - } - - @Test func paneOfAnUnlaunchedRoleIsUnavailable() { - #expect(throws: WorkflowTemplateError.paneUnavailable(role: "reviewer")) { - try WorkflowTemplate.render("{{ roles.reviewer.pane }}", context: makeContext()) - } - } - - @Test func unknownVariablesAndMalformedPlaceholdersThrow() { - #expect(throws: WorkflowTemplateError.unknownVariable("run.nope")) { - try WorkflowTemplate.render("{{ run.nope }}", context: makeContext()) - } - #expect(throws: WorkflowTemplateError.unknownVariable("inputs.other")) { - try WorkflowTemplate.render("{{ inputs.other }}", context: makeContext()) - } - #expect(throws: WorkflowTemplateError.unknownVariable("loop.index")) { - var context = makeContext() - context.loop = WorkflowTemplateContext.Loop(index: nil, count: 0) - _ = try WorkflowTemplate.render("{{ loop.index }}", context: context) - } - #expect(throws: WorkflowTemplateError.malformed(.unbalanced)) { - try WorkflowTemplate.render("{{ run.id", context: makeContext()) - } - } - - @Test func substitutedValuesAreNotReScanned() throws { - var context = makeContext() - context.inputs["focus"] = "{{ run.id }}" - #expect(try WorkflowTemplate.render("[{{ inputs.focus }}]", context: context) == "[{{ run.id }}]") - } -} From a7f981f98769d3c53e2fb3a3cc4f01ecb9efdf49 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 8 Sep 2026 12:03:14 +0900 Subject: [PATCH 2/2] Preserve custom data scopes in workflow naming checks --- .../019-workflow-naming.md | 15 ++++++++++ scripts/check_workflow_naming.py | 13 ++++++--- scripts/test_workflow_naming.py | 28 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/docs-ai/063-agent-workflows/019-workflow-naming.md b/docs-ai/063-agent-workflows/019-workflow-naming.md index 89e40dc55..c8beb4b45 100644 --- a/docs-ai/063-agent-workflows/019-workflow-naming.md +++ b/docs-ai/063-agent-workflows/019-workflow-naming.md @@ -62,3 +62,18 @@ remain authoritative; custom input and output schemas do not reserve retired wor D3 remains separate: the renamed collector still requires one Git directory, and no handoff migration or additional collector was implemented. + +### Fourth review follow-up (2026-09-08) + +The fourth review confirmed the previous fixes and found one remaining required +scanner correction: nested custom keys could replace the outer exemption scope, +and a sequence mapping beginning with `with` was not recognized. The checker now +retains the outer key indentation and ends the exemption at its actual siblings. +Regression cases cover reordered step keys, nested schema properties, and a real +sibling expectation that must still fail. Additional probes cover spaced custom +paths and a custom field named `max_rounds_reached`. + +Flow-style YAML declaration scanning remains an optional coverage gap. The runtime +workflow/action parsers reject those retired declaration keys; the lightweight +repository check does not parse all YAML syntax. No new runtime/schema/reference +naming defect was confirmed in the fourth review. diff --git a/scripts/check_workflow_naming.py b/scripts/check_workflow_naming.py index 3be319d70..0471ec80e 100644 --- a/scripts/check_workflow_naming.py +++ b/scripts/check_workflow_naming.py @@ -16,7 +16,7 @@ (r"(?-]+\.(?:name|pane)\b", "Use display_name and pane_id."), (r"(?-]+\.result_path\b|\bmax_rounds_reached\b", "Use output_path and iteration_limit_reached."), + (r"(?-]+\.result_path\b|(? list[tuple[int, str]]: custom_indent = None # Custom action inputs and schemas are not workflow declarations. yaml_line = re.sub(r"""['"]([a-z_]+)['"]\s*:""", r"\1:", line) - custom_opener = re.match(r"\s*(?:with|input_schema|output_schema):", yaml_line) + custom_opener = re.match(r"\s*(?:-\s*)?(?Pwith|input_schema|output_schema):", yaml_line) custom_data = custom_indent is not None or custom_opener is not None - if custom_opener: - custom_indent = indent + if custom_opener and custom_indent is None: + custom_indent = custom_opener.start("key") # Swift property names are internal implementation, not expression namespaces. public_text = line if swift: @@ -66,6 +66,11 @@ def violations(text: str, *, swift: bool = False) -> list[tuple[int, str]]: candidate = line if pattern.startswith(r"\bWorkflowDone") else public_text candidate = candidate.replace("\\", "") candidate = re.sub(r"""\[\s*['"]([a-zA-Z_][\w-]*)['"]\s*\]""", r".\1", candidate) + candidate = re.sub( + r"{{(.*?)}}", + lambda match: re.sub(r"\s*\.\s*", ".", match[0]), + candidate, + ) if re.search(pattern, candidate): findings.append((number, message)) if block and line.strip() and indent <= block_indent: diff --git a/scripts/test_workflow_naming.py b/scripts/test_workflow_naming.py index d1eeb3f87..807dfd9f4 100644 --- a/scripts/test_workflow_naming.py +++ b/scripts/test_workflow_naming.py @@ -56,6 +56,34 @@ def test_custom_data_is_not_reserved(self): with self.subTest(text=text): self.assertFalse(violations(text)) + def test_nested_custom_scope_and_spaced_paths(self): + for text in ( + "with:\n with:\n x: 1\n expect: {output: report}", + "{{ actions.snapshot.output . context.source }}", + "{{ actions.snapshot.output.max_rounds_reached }}", + ): + with self.subTest(text=text): + self.assertFalse(violations(text)) + self.assertTrue(violations("with:\n with: {x: 1}\nexpect: {output: report}")) + + def test_custom_scope_is_independent_of_key_order_and_schema_properties(self): + for text in ( + "steps:\n - with:\n expect: {output: report}\n" + " id: snapshot\n action: local:write-report", + "with:\n with: {}\n expect: {output: report}", + "with:\n input_schema: {}\n expect: {output: report}", + *( + f"{key}:\n type: object\n properties:\n with: {{type: object}}\n" + " expect:\n type: object\n properties:\n output: {type: string}" + for key in ("input_schema", "output_schema") + ), + ): + with self.subTest(text=text): + self.assertFalse(violations(text)) + self.assertTrue(violations( + "steps:\n - with:\n expect: {output: report}\n expect: {output: report}" + )) + def test_accepts_current_contract_and_internal_swift_properties(self): self.assertFalse(violations(""" prowl workflow deliver -