Task changes
donehive · create-and-ship-a-first-260719-7fa2
Available
Diff available. Open the public pull request for the complete change.
Committed changes
diff --git a/.github/workflows/live-agent-skills.yml b/.github/workflows/live-agent-skills.yml
index c0ab06b1b9..b67698f703 100644
--- a/.github/workflows/live-agent-skills.yml
+++ b/.github/workflows/live-agent-skills.yml
@@ -194,10 +194,72 @@ jobs:
if-no-files-found: error
retention-days: 7
+ live-workflow-creator:
+ name: OpenClaw live workflow-creator skill
+ runs-on: ubuntu-24.04
+ needs: [validate, build]
+ timeout-minutes: 25
+ environment: live-agent-skills-openclaw
+ permissions:
+ actions: read
+ contents: read
+ steps:
+ - uses: actions/download-artifact@v8
+ with:
+ name: live-agent-candidate-artifacts-${{ github.run_attempt }}
+ path: candidate-artifacts
+ - name: Extract the exact candidate source
+ env:
+ CANDIDATE_SHA: ${{ needs.validate.outputs.candidate_sha }}
+ run: |
+ set -euo pipefail
+ mkdir -p candidate-source
+ tar -xzf "candidate-artifacts/hive-source-${CANDIDATE_SHA}.tar.gz" -C candidate-source
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "3.4"
+ - uses: actions/setup-node@v6
+ with:
+ node-version: "22"
+ - name: Install OpenClaw in the ephemeral runner
+ run: npm install --global openclaw
+ - name: Install the exact candidate gem and test dependencies
+ run: |
+ set -euo pipefail
+ gem_file="$(find candidate-artifacts -maxdepth 1 -type f -name 'hive-cli-*.gem' -print -quit)"
+ [[ -n "$gem_file" && -s "$gem_file" ]]
+ bash candidate-source/packaging/live_agent_skills/install_candidate_gem.sh \
+ "$gem_file" "$RUNNER_TEMP/proven-gems"
+ "$RUNNER_TEMP/proven-gems/bin/hive" --version
+ cd candidate-source
+ bundle install --jobs 4 --retry 2
+ - name: Run authenticated workflow-creator proof
+ env:
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ HIVE_LIVE_MODEL: ${{ vars.HIVE_LIVE_MODEL }}
+ HIVE_LIVE_AGENT_SKILLS: "1"
+ HIVE_RELEASE_GATE: "1"
+ HIVE_CANDIDATE_SHA: ${{ needs.validate.outputs.candidate_sha }}
+ HIVE_PROOF_ARTIFACTS: ${{ github.workspace }}/candidate-artifacts
+ HIVE_CREATOR_EVIDENCE_PATH: ${{ runner.temp }}/workflow-creator-evidence/openclaw-workflow-creator.json
+ run: |
+ set -euo pipefail
+ mkdir -p "$RUNNER_TEMP/workflow-creator-evidence"
+ cd candidate-source
+ bundle exec ruby -Itest test/smoke/live_hive_workflow_creator_smoke_test.rb
+ - name: Upload redacted workflow-creator evidence
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: workflow-creator-evidence-openclaw-${{ github.run_attempt }}
+ path: ${{ runner.temp }}/workflow-creator-evidence/openclaw-workflow-creator.json
+ if-no-files-found: error
+ retention-days: 7
+
attest:
name: Attest exact-SHA live agent proof
runs-on: ubuntu-24.04
- needs: [validate, build, live-agent]
+ needs: [validate, build, live-agent, live-workflow-creator]
permissions:
actions: read
checks: write
@@ -214,6 +276,11 @@ jobs:
pattern: live-agent-evidence-*-${{ github.run_attempt }}
path: evidence
merge-multiple: true
+ - uses: actions/download-artifact@v8
+ with:
+ pattern: workflow-creator-evidence-*-${{ github.run_attempt }}
+ path: creator-evidence
+ merge-multiple: true
- name: Extract candidate verifier
env:
CANDIDATE_SHA: ${{ needs.validate.outputs.candidate_sha }}
@@ -224,7 +291,7 @@ jobs:
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
- - name: Validate four proofs and assemble private evidence
+ - name: Validate operating and workflow-creator proofs
id: attest
env:
CANDIDATE_SHA: ${{ needs.validate.outputs.candidate_sha }}
@@ -234,7 +301,7 @@ jobs:
result="$(ruby candidate-source/packaging/live_agent_skills/attest.rb \
"$CANDIDATE_SHA" "$WORKFLOW_REVISION" "$GITHUB_REPOSITORY" \
"$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" \
- candidate-artifacts evidence proof)"
+ candidate-artifacts evidence creator-evidence proof)"
attestation_sha256="$(jq -er '.attestation_sha256' <<<"$result")"
echo "attestation_sha256=$attestation_sha256" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -262,7 +329,8 @@ jobs:
run_id: context.runId,
run_attempt: runAttempt,
attestation_sha256: process.env.ATTESTATION_SHA256,
- platforms: ["openclaw", "claude", "codex", "pi"]
+ platforms: ["openclaw", "claude", "codex", "pi"],
+ workflow_creator: "openclaw"
};
await github.rest.checks.create({
owner: context.repo.owner,
@@ -275,7 +343,7 @@ jobs:
external_id: externalId,
details_url: detailsUrl,
output: {
- title: "All four Hive operating-skill surfaces passed",
+ title: "Hive operating and workflow-creator proofs passed",
summary: `\`\`\`json\n${JSON.stringify(summary, null, 2)}\n\`\`\``
}
});
diff --git a/README.md b/README.md
index e98fec430a..ef5671283d 100644
--- a/README.md
+++ b/README.md
@@ -135,7 +135,12 @@ readiness state alongside the daemon outcome. Project enrollment is a separate
babysitter defaults can be reviewed before confirmation. After setup, pass Hive
CLI commands through `/hive ...`, for example
`/hive status --operational --json`, `/hive watch <project>:<task>`,
-`/hive new . "build this feature"`, and `/hive review <task-slug>`.
+`/hive new . "build this feature"`, and `/hive review <task-slug>`. The same
+canonical skill can create a new project-local workflow from natural language:
+`/hive create a three-stage editorial workflow that researches, drafts, and
+requires approval before publishing`. It scaffolds through Hive, validates the
+result, reports every inferred default, and creates no task unless the request
+explicitly asks for one.
For local checkout testing, run `openclaw skills install ./openclaw/skills/hive
--as hive`. See [openclaw/README.md](openclaw/README.md) for the publish
@@ -321,19 +326,28 @@ full 24-hour periods or the exact lowercase value `never`; omission remains
compatible and means `3`. This changes visibility only—`hive archive` remains
the complete record, and no task folders are deleted or moved by retention.
-Three commands cover authoring: `hive workflow new ID` (scaffold a blank starter in an existing project), `hive workflow new ID --template research` (seed from the multi-stage research sample), and `hive init --new-workflow ID` (bootstrap a project and bind the workflow as its default in one go). Custom workflows are discovered from `.hive-state/workflows/*.yml` and run through the same surfaces as the built-ins — `hive new --workflow`, `status`, `run`, `approve`, and the daemon.
+Ask the canonical `/hive` skill to create a workflow in natural language when
+you do not want to author YAML. It uses the same three CLI primitives:
+`hive workflow new ID` (scaffold a blank starter in an existing project),
+`hive workflow new ID --template research` (seed from the multi-stage research
+sample), and `hive init --new-workflow ID` (bootstrap a project and bind the
+workflow as its default in one go). Validate edits with
+`hive workflow validate ID --json`. Custom workflows are discovered from
+`.hive-state/workflows/*.yml` and run through the same surfaces as the
+built-ins — `hive new --workflow`, `status`, `run`, `approve`, `decide`, and
+the daemon.
**Full walkthrough** — mental model, descriptor anatomy, writing stage instructions, advanced options (terminal approval gates, `skill:` stages, per-stage permissions), and gotchas: **[hivecli.sh/docs/custom-workflows](https://hivecli.sh/docs/custom-workflows/)** (also in-repo at [docs/workflows.md](docs/workflows.md)).
## Power-User / Scripting CLI
-The TUI is the recommended human interface and an agent-driven CLI is the recommended automation surface, but the workflow commands are also available directly on `bin/hive` (or the `hv` shim when Apache Hive shadows the name) for scripting, debugging, and recovery. Stage-driving verbs support `--json` and return typed envelopes; `hive new` remains plain text and prints the captured task path plus the next-step hint.
+The TUI is the recommended human interface and an agent-driven CLI is the recommended automation surface, but the workflow commands are also available directly on `bin/hive` (or the `hv` shim when Apache Hive shadows the name) for scripting, debugging, and recovery. Stage-driving verbs support `--json` and return typed envelopes. `hive new` keeps its existing text output by default and adds an opt-in JSON contract for idempotent automation.
| Group | Verbs | What it's for |
|---|---|---|
| Native setup & web | `hive setup`, `hive web`, `hive web status/install/start/stop` | Provision the default loopback Hive web service, run the foreground server, or observe/repair the managed unit. `--no-service` opts out of web-service mutation. See [docs/cli.md#day-to-day-workflow](docs/cli.md#day-to-day-workflow). |
-| Workflow | `hive new`, `hive brainstorm`, `hive plan`, `hive develop`, `hive open-pr`, `hive review`, `hive artifacts`, `hive finalize`, `hive archive`, `hive run`, `hive approve` | Drive a single stage of a single task by hand. `--from <stage>` lets you re-run a stage in place. See [docs/cli.md#day-to-day-workflow](docs/cli.md#day-to-day-workflow). |
-| Workflow authoring | `hive workflow new` | Scaffold a project-local workflow descriptor under `.hive-state/workflows/` (`--template research` seeds from a sample). See [Custom Workflows](#custom-workflows) and [docs/workflows.md](docs/workflows.md). |
+| Workflow | `hive new`, `hive brainstorm`, `hive plan`, `hive develop`, `hive open-pr`, `hive review`, `hive artifacts`, `hive finalize`, `hive archive`, `hive run`, `hive approve`, `hive decide` | Drive a single stage of a single task by hand. `--from <stage>` makes advances retry-safe; human decisions also require the visit-specific `--decision-id`. See [docs/cli.md#day-to-day-workflow](docs/cli.md#day-to-day-workflow). |
+| Workflow authoring | `hive workflow new`, `hive workflow validate` | Scaffold and read-only validate a project-local workflow descriptor under `.hive-state/workflows/` (`--template research` seeds from a sample). See [Custom Workflows](#custom-workflows) and [docs/workflows.md](docs/workflows.md). |
| Review findings | `hive findings`, `hive accept-finding`, `hive reject-finding` | Inspect GFM-checkbox findings from the latest review pass and tick which ones should feed the next fix pass. See [docs/cli.md#findings-triage](docs/cli.md#findings-triage). |
| Patrol | `hive patrol` | Run one opt-in repository patrol cycle: map feature slices, review them, validate fixes, and open PRs for passed fixes only. See [docs/cli.md#patrol](docs/cli.md#patrol). |
| Daemon | `hive daemon install/enable/start/status/tail/stop/disable` | Manage the global daemon service plus per-project enrollment. The service polls `hive status --json` and dispatches workflow verbs for enrolled projects. Read [wiki/operating.md](wiki/operating.md) before going live. See [docs/cli.md#daemon](docs/cli.md#daemon). |
diff --git a/bin/hive b/bin/hive
index 863596e536..2c4bf7a217 100755
--- a/bin/hive
+++ b/bin/hive
@@ -5,13 +5,35 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "hive"
require "hive/llm_wiki_bootstrap"
+# The workflow creator owns two strict observation routes. They must stay
+# byte-for-byte no-write even when an upgraded binary would otherwise repair
+# scheduler units during process startup.
+init_index = ARGV.index("init")
+workflow_index = ARGV.index("workflow")
+strict_no_write_route =
+ if init_index && (!workflow_index || init_index < workflow_index)
+ ARGV.drop(init_index + 1).any? do |arg|
+ arg == "--preview" || arg.match?(/\A--preview=true\z/i)
+ end
+ elsif workflow_index
+ workflow_subcommands = %w[new validate commit install list update remove publish].freeze
+ workflow_subcommand = ARGV.drop(workflow_index + 1).find do |arg|
+ workflow_subcommands.include?(arg)
+ end
+ workflow_subcommand == "validate"
+ else
+ false
+ end
+
# Repair legacy path-keyed LLM-wiki timers as soon as an upgraded Hive binary
# is invoked, including `hive --version`. The reconciliation is idempotent and
# touches only Hive-managed units.
-begin
- Hive::LlmWikiBootstrap::Scheduler.reconcile_existing!
-rescue StandardError => e
- warn "hive: warning: could not reconcile LLM-wiki scheduler units: #{e.message}"
+unless strict_no_write_route
+ begin
+ Hive::LlmWikiBootstrap::Scheduler.reconcile_existing!
+ rescue StandardError => e
+ warn "hive: warning: could not reconcile LLM-wiki scheduler units: #{e.message}"
+ end
end
if ENV["HIVE_ATTEMPT_INTERNAL"] == "1"
@@ -49,7 +71,7 @@ end
JSON_BOOLEAN_OPTIONS = Hive::CliJsonOptions::BOOLEAN_OPTIONS
STAGE_ACTION_USAGE_COMMANDS = %w[brainstorm plan develop open-pr review artifacts finalize archive].freeze
-NEW_VALUE_OPTIONS = %w[--base --depends-on --workflow].freeze
+NEW_VALUE_OPTIONS = %w[--base --depends-on --workflow --idempotency-key].freeze
JSON_USAGE_ERROR_CONTRACTS = {
"run" => { schema: "hive-run", error_kind: "invalid_task_path" },
"rebase-status" => { schema: "hive-rebase-status", schema_version: 1, error_kind: "invalid_task_path" },
@@ -350,6 +372,8 @@ def json_usage_error_contract(argv)
subcommand = json_usage_subcommand(argv, command_index)
schema = subcommand == "approve" ? "hive-pairing-approve" : "hive-pairing-list"
return { schema: schema, error_kind: "invalid_arguments" }
+ when "decide"
+ return { schema: "hive-decide", error_kind: "invalid_task_path" }
when "refactor-patrol"
if (jobs = refactor_patrol_jobs_argv(option_argv))
return {
@@ -365,9 +389,18 @@ def json_usage_error_contract(argv)
when "remove" then "hive-workflow-remove"
when "update" then "hive-workflow-update"
when "publish" then "hive-workflow-publish"
+ when "validate" then "hive-workflow-validate"
else "hive-workflow-new"
end
- return { schema: schema, error_kind: "usage" } if Hive::Schemas::SCHEMA_VERSIONS.key?(schema)
+ if Hive::Schemas::SCHEMA_VERSIONS.key?(schema)
+ extras = if subcommand == "validate"
+ id = json_usage_positionals(argv, command_index).fetch(1, "")
+ { "valid" => false, "id" => id }
+ else
+ {}
+ end
+ return { schema: schema, error_kind: "usage", extras: extras }
+ end
end
JSON_USAGE_ERROR_CONTRACTS[command]
@@ -480,6 +513,9 @@ def json_usage_error_payload(contract, error, argv: [])
error_kind: error_kind,
extras: extras
)
+ if schema == "hive-workflow-validate"
+ payload["diagnostics"] = [ { "message" => error.message } ]
+ end
payload.delete("error_class") if contract[:omit_error_class]
return payload
end
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index f002409d0e..9655b6763d 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -272,6 +272,20 @@ bullets (prefix fixes with "Fixed"); no `[Unreleased]` accumulator and no dates
notable minor/major releases; keep routine patch releases terse. A release with
nothing user-facing gets a one-line "no user-facing changes" note.
+### Workflow-creator public wording
+
+The canonical skill may describe current-main behavior because its generated
+projections and candidate artifacts are pinned to the same source commit. Do
+not describe current-main workflow-creator commands as stable on hivecli.sh,
+ClawHub, or release announcements until a separately authorized release
+containing those commands is published. Stable-user wording should name the
+containing release or tell users to run the supported `hive update` path after
+that release exists.
+
+The downstream wording handoff is tracked as `hive-site #23116`. It does not block this repository.
+Implementation, tests, and release readiness proceed independently, and this
+repository must not mutate or deploy hive-site as part of that coordination.
+
---
## One-time setup
diff --git a/docs/cli.md b/docs/cli.md
index f5d7846a99..ea007789a0 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -11,7 +11,7 @@ This page documents the command surface exposed by `bin/hive` in this checkout.
| `hive web status [--json]` | Observe installed, enabled, running, and readiness state without mutation. | `hive web status --json` |
| `hive web install [--force] [--json]` | Install or explicitly repair the managed per-user web service. | `hive web install --force` |
| `hive status` | See active tasks grouped by next action. | `hive status` |
-| `hive new PROJECT TEXT` | Capture an idea in `1-inbox/`. | `hive new xbookmark "save bookmarks"` |
+| `hive new PROJECT [--workflow ID] [--idempotency-key KEY] [--json] TEXT` | Capture an idea in the workflow entry stage; optional idempotency makes agent retries return the existing task. | `hive new xbookmark --workflow editorial --idempotency-key creator:42 --json "draft a launch post"` |
| `hive brainstorm TARGET` | Promote inbox to brainstorm or re-run brainstorm. | `hive brainstorm <slug>` |
| `hive plan TARGET` | Promote completed brainstorm to plan or re-run plan. | `hive plan <slug> --from 2-brainstorm` |
| `hive develop TARGET` | Promote completed plan to execute or re-run execute. | `hive develop <slug> --from 3-plan` |
@@ -21,6 +21,7 @@ This page documents the command surface exposed by `bin/hive` in this checkout.
| `hive artifacts TARGET` | Collect the reviewed task's release artifacts. | `hive artifacts <slug> --from 6-review` |
| `hive finalize TARGET` | Refresh PR body and mark the draft PR ready. | `hive finalize <slug> --from 7-artifacts` |
| `hive archive TARGET` | Move finalized work to `9-done/`. | `hive archive <slug> --from 8-finalize` |
+| `hive decide TARGET OUTCOME --from STAGE --decision-id ID [--note TEXT] [--json]` | Apply one descriptor-declared outcome to the observed visit of a durable human stage. | `hive decide <slug> approve --from approval --decision-id abcdef0123456789 --json` |
The workflow verbs promote then run when the task is at the previous stage. If the task is already at the target stage, they only run that stage.
@@ -103,6 +104,9 @@ returns every flat patch and supersession receipt.
| `hive refactor-patrol PROJECT --list\|--show JOB_ID [--limit N]` | Inspect durable architecture-patrol jobs without mutation; list pages use `--cursor`, while show accepts explicit `--full` history. |
| `hive migrate [PROJECT_PATH]` | Rewrite legacy project config, rename in-flight task folders from older stage layouts, and backfill legacy task metadata. |
| `hive workflow new ID` | Scaffold a blank project workflow descriptor under `<hive_state_path>/workflows/`. |
+| `hive workflow validate ID [--json]` | Read-only load and validate a built-in or project workflow, including instructions and normalized transitions/outcomes. |
+| `hive workflow commit ID` | Validate and commit a populated owner-authored descriptor and instruction directory under Hive's state commit lock. |
+| `hive init --new-workflow ID --minimal --preview --json` | Resolve and disclose creator-safe fresh-project initialization without writing files, registration, hooks, services, or timers. |
| `hive workflow install honeycomb/NAME[@REF]` | Verify and atomically select a reviewed package. |
| `hive workflow list` | Show built-in, authored, selected, and retained workflows with integrity/provenance; JSON v2 includes the active managed configuration and redacted input availability. |
| `hive workflow update NAME [--dry-run]` | Report a semantic/security diff and atomically advance after consent. |
@@ -168,7 +172,7 @@ never prompts; without `--yes`, planned mutation returns 64.
## JSON Output
-Workflow verbs (`brainstorm`, `plan`, `develop`, `open-pr`, `review`, `artifacts`, `finalize`, `archive`, `run`, `approve`), findings triage (`findings`, `accept-finding`, `reject-finding`), patrol (`patrol`), architecture-patrol job inspection (`refactor-patrol --list` / `--show`), diagnostics/setup (`status`, `setup`, `doctor`, `setup-agents`, `web status`, `web install`, `rebase-status`, `markers clear`, `metrics rollback-rate`), registry cleanup (`forget`, `prune`), workflow lifecycle (`workflow new/install/list/update/remove/publish`), `init`, and daemon control support `--json` where documented and emit typed envelopes. Native setup uses `hive-setup.v1`; web observation and installation use `hive-web-status.v1` and `hive-web-install.v1`. These contracts keep `mode`, effective `url`, compatibility `warnings`, and service installed/enabled/running/readiness distinct. `hive doctor --json` emits `hive-doctor.v2`; `hive setup-agents --json --yes` emits `hive-setup-agents.v1`, never prompts, and requires `--yes` whenever mutation is planned. `hive init --json` emits a single `hive-init.v2` success payload with resolved answers and project metadata; architecture-patrol list/show emits `hive-refactor-patrol-jobs.v1`. Workflow verbs emit a `hive-stage-action` envelope; Honeycomb lifecycle schemas are `hive-workflow-{install,list,update,remove,publish}.v1`. Non-TTY/JSON mutations require `--yes`, and an escalating workflow update separately requires `--allow-escalation`. Schema files live under [schemas/](../schemas/), and [wiki/cli.md](../wiki/cli.md) lists the contract details. `hive tui` rejects `--json`; legacy or one-shot utilities (`version`, `tree`, `new`, `migrate`) are still text-only.
+Workflow verbs (`brainstorm`, `plan`, `develop`, `open-pr`, `review`, `artifacts`, `finalize`, `archive`, `run`, `approve`, `decide`), findings triage (`findings`, `accept-finding`, `reject-finding`), patrol (`patrol`), architecture-patrol job inspection (`refactor-patrol --list` / `--show`), diagnostics/setup (`status`, `setup`, `doctor`, `setup-agents`, `web status`, `web install`, `rebase-status`, `markers clear`, `metrics rollback-rate`), registry cleanup (`forget`, `prune`), workflow lifecycle (`workflow new/validate/install/list/update/remove/publish`), `new`, `init`, and daemon control support `--json` where documented and emit typed envelopes. Native setup uses `hive-setup.v1`; web observation and installation use `hive-web-status.v1` and `hive-web-install.v1`. These contracts keep `mode`, effective `url`, compatibility `warnings`, and service installed/enabled/running/readiness distinct. `hive doctor --json` emits `hive-doctor.v2`; `hive setup-agents --json --yes` emits `hive-setup-agents.v1`, never prompts, and requires `--yes` whenever mutation is planned. Ordinary `hive init --json` emits `hive-init.v2`; minimal preview emits `hive-init-preview.v1` without mutation. Idempotent task capture emits `hive-new.v1`, human decisions emit `hive-decide.v1`, and read-only workflow validation emits `hive-workflow-validate.v1`. Architecture-patrol list/show emits `hive-refactor-patrol-jobs.v1`. Other workflow verbs emit a `hive-stage-action` envelope; Honeycomb lifecycle schemas are `hive-workflow-{install,list,update,remove,publish}.v1`. Non-TTY/JSON mutations require `--yes` where documented, and an escalating workflow update separately requires `--allow-escalation`. Schema files live under [schemas/](../schemas/), and [wiki/cli.md](../wiki/cli.md) lists the contract details. `hive tui` rejects `--json`; `version`, `tree`, and `migrate` remain text-only.
## Exit Codes
diff --git a/docs/workflows.md b/docs/workflows.md
index 6a3feb3f0d..32f60d42b8 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -37,6 +37,99 @@ the stage writes a cooldown-aware `limits_reached` marker so the daemon retries
after reset; malformed/missing/non-limit failures remain manual `WAITING`
states.
+## Create A Workflow From Natural Language
+
+The canonical Hive agent skill accepts ordinary-language workflow creation
+requests. In OpenClaw and Claude invoke it as `/hive`; Codex uses `$hive` and
+Pi uses `/skill:hive`. For example:
+
+```text
+/hive create a three-stage editorial workflow that researches, drafts, and requires approval before publishing
+```
+
+The creator checks the installed Hive version, project settings, templates,
+reserved IDs, and existing workflow IDs before it writes anything. In an
+initialized project it scaffolds only new paths with `hive workflow new`, edits
+those generated files, validates them, and commits the populated descriptor and
+instruction directory to `hive/state` before reporting success:
+
+```bash
+hive workflow validate editorial --json
+hive workflow commit editorial
+```
+
+In a fresh project it first renders the no-write
+`hive init --new-workflow editorial --minimal --preview --json` plan and waits
+for one explicit confirmation before running the matching command without
+`--preview`. The minimal profile creates the core Hive project, state worktree,
+registration, and required wiki/context integration while leaving optional
+patrol, auto-fix, daemon dispatch/autostart, babysitter, and timer setup off. It
+never selects a starter template or adds `--force`.
+
+The accepted editorial result has exactly three stages:
+
+```yaml
+id: editorial
+stages:
+ - name: research
+ kind: agent
+ state_file: research.md
+ instruction: ./editorial/research.md
+ permissions: yolo
+ - name: draft
+ kind: agent
+ state_file: draft.md
+ instruction: ./editorial/draft.md
+ permissions: yolo
+ - name: approval
+ kind: human
+ state_file: approval.md
+ input: draft.md
+ outcomes:
+ approve:
+ complete: true
+ artifact: draft.md
+ reject:
+ to: draft
+```
+
+The stage and model choices inherit project defaults. The creator reports that
+inheritance, sequential transitions, local `yolo` permissions, artifact names,
+the neutral scaffold/template choice, every created file, and the validation
+result. No task is created by workflow creation alone. Its completion summary
+includes the exact opt-in command:
+
+```bash
+hive new '<project>' --workflow 'editorial' '<your request>'
+```
+
+If the original request explicitly asks to create or run a task, the creator
+uses `hive new --idempotency-key KEY --json`, so a retry finds the same task
+even after it moves stages. It queries `hive status --operational --json` after
+creation or an explicitly requested first run. It derives that key
+deterministically from the real project path, normalized workflow ID, and
+canonicalized request text. Every dynamic argument uses POSIX shell escaping,
+so quotes, substitutions, variables, glob characters, and newlines remain
+literal request bytes.
+
+When the task reaches approval, the only transitions are:
+
+```bash
+hive decide <task> approve --from approval --decision-id <decision-id>
+hive decide <task> reject --from approval --decision-id <decision-id> --note "revise the conclusion"
+```
+
+The waiting `hive run --json` response supplies the visit-specific decision
+ID. Approve requires a non-empty, regular task-local `draft.md` (symlinks are
+refused), records it as publish-ready, and completes the task. Reject records the decision, returns the same task to
+`draft`, and resets that stage to `WAITING`. Neither outcome publishes, deploys,
+sends, or otherwise acts outside Hive; an external destination and separate
+authorization are always required.
+
+The creator is create-only. Reserved or colliding IDs stop before mutation and
+return an available alternative. Editing or repairing an existing workflow is
+outside this contract.
+
## Create A Blank Workflow
```bash
@@ -55,7 +148,12 @@ This writes a minimal `inbox -> work -> done` workflow:
The generated descriptor is valid immediately, and the placeholder
`work.md` is the prompt for the `work` stage. Edit that file to define what the
agent should do. New scaffolds explicitly set
-`archive_visibility_retention_days: 3`.
+`archive_visibility_retention_days: 3`. Then validate the production-loaded
+descriptor:
+
+```bash
+hive workflow validate my-flow --json
+```
`README.md` and `honeycomb.yml` are publish-preflight inputs. The scaffolded
summary and author are placeholders; edit them before running `workflow
@@ -208,6 +306,9 @@ Rules:
- `kind: terminal` creates an inert stage; it does not spawn an agent.
- `kind: agent` spawns the generic stage runner.
- `kind: council` runs a document review council over an input artifact.
+- `kind: human` persists `WAITING` and exposes only descriptor-declared
+ outcomes through
+ `hive decide TARGET OUTCOME --from STAGE --decision-id DECISION_ID`.
- Every agent stage must declare exactly one of `skill:` or `instruction:`.
- `agent:`, `model:`, and `effort:` are optional on `kind: agent` and
`kind: council`. Descriptor `agent` overrides the project stage block;
@@ -226,6 +327,12 @@ Rules:
enforce it; `timeout_sec` remains enforced for every agent spawn.
- Council command reviewers and command revisers also use `timeout_sec`; Hive
terminates their process group when the limit expires.
+- A human stage declares `input:` plus one or more named `outcomes:`. Every
+ outcome has exactly one action: `complete: true` with a required non-empty
+ `artifact:` basename, or `to: <stage>`. Human stages cannot declare agent,
+ model, permission, runner, reviewer, or executable command settings. A
+ completing outcome writes a durable decision and `completed_at`, then enters
+ the same archive visibility/retention path as other completed workflows.
- `skill:`, `instruction:`, `agent:`, `model:`, `effort:`, `budget_usd:`,
`timeout_sec:`, `permissions:`, `input:`, `reviewers:`, `council:`, and
`deliverable:` are rejected on `kind: terminal` stages.
@@ -237,10 +344,11 @@ Rules:
report names, inert/council use, and nonterminal use are rejected. The pair
creates a controller-owned exact-origin worktree and controller-owned draft
PR delivery without embedding an agent, model, or effort choice.
-- The last stage may be `kind: terminal`, `kind: agent`, or `kind: council`.
+- The last stage may be `kind: terminal`, `kind: agent`, `kind: council`, or `kind: human`.
A terminal agent/council stage is archived only when it has a terminal
`COMPLETE` marker and a non-empty deliverable. `deliverable:` defaults to the
- stage's `state_file`.
+ stage's `state_file`. A terminal human stage is archived only after a named
+ completing outcome verifies its declared non-empty artifact.
Council stages declare reviewers and a council policy:
diff --git a/lib/hive.rb b/lib/hive.rb
index f56e55c269..0c01151ba7 100644
--- a/lib/hive.rb
+++ b/lib/hive.rb
@@ -20,6 +20,8 @@ module Schemas
"hive-watch-event" => 1,
"hive-act" => 2,
"hive-init" => 2,
+ "hive-init-preview" => 1,
+ "hive-new" => 1,
"hive-setup-agents" => 1,
"hive-setup" => 1,
"hive-web-status" => 1,
@@ -31,6 +33,7 @@ module Schemas
"hive-status-diagnose" => 2,
"hive-run" => 2,
"hive-approve" => 2,
+ "hive-decide" => 1,
"hive-findings" => 1,
"hive-stage-action" => 2,
"hive-metrics-rollback-rate" => 1,
@@ -58,6 +61,9 @@ module Schemas
# every other agent-callable command's error envelope; the success arm
# builds its hash directly.
"hive-workflow-new" => 1,
+ # Read-only production-resolution validation for owner-authored,
+ # managed, and built-in workflows (`hive workflow validate ID --json`).
+ "hive-workflow-validate" => 1,
"hive-workflow-install" => 2,
# v2 gives selected managed rows their active immutable configuration
# digest plus redacted per-slot mapping and optional-input discovery.
diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb
index c78964e808..c2d2302bb5 100644
--- a/lib/hive/cli.rb
+++ b/lib/hive/cli.rb
@@ -128,6 +128,19 @@ def version
already-initialized project scaffolds the workflow and rebinds the
default in one hive-state commit.
+ Workflow creators can inspect the neutral fresh-project plan without
+ writes, then execute that exact automation-disabled profile after an
+ explicit confirmation:
+
+ hive init --new-workflow editorial --minimal --preview --json
+ hive init --new-workflow editorial --minimal --json
+
+ Minimal init is fresh-target only, rejects --force, and disables patrol,
+ architecture patrol, ad-hoc auto-fix, daemon dispatch/autostart,
+ babysitting, optional reviewers, and service/timer installation. It
+ retains the core hive/state worktree, global project registration, and
+ required llm-wiki/context hooks.
+
Headless callers can explicitly select architecture discovery before
any state is written with --refactor-patrol or --no-refactor-patrol;
omitting both keeps the fresh-project default enabled. Set other
@@ -151,6 +164,10 @@ def version
option :workflow, type: :string, desc: workflow_option_desc
option :new_workflow, type: :string,
desc: "scaffold custom workflow ID, bind it as this project's default, and print paths to edit"
+ option :minimal, type: :boolean, default: false,
+ desc: "with --new-workflow on a fresh project: use the neutral automation-disabled profile"
+ option :preview, type: :boolean, default: false,
+ desc: "with --minimal: report the resolved initialization plan without writing"
option :refactor_patrol, type: :boolean, default: nil,
desc: "enable or disable post-merge architecture discovery before init writes state"
def init(project_path = Dir.pwd)
@@ -161,7 +178,9 @@ def init(project_path = Dir.pwd)
json: options[:json],
workflow: options[:workflow],
new_workflow: options[:new_workflow],
- refactor_patrol: options[:refactor_patrol]
+ refactor_patrol: options[:refactor_patrol],
+ minimal: options[:minimal],
+ preview: options[:preview]
).call
end
@@ -454,6 +473,8 @@ def wiki(subcommand, project_path = Dir.pwd)
new ID Scaffold a per-project workflow descriptor under
<hive_state_path>/workflows/ID.yml plus its stage
instruction(s) under <hive_state_path>/workflows/ID/.
+ validate ID Read and validate the normalized workflow graph without writes.
+ commit ID Commit a validated owner-authored graph under Hive's state lock.
install honeycomb/NAME[@VERSION] Verify and install a reviewed package.
list Inspect built-in, authored, and managed workflows.
update NAME Diff and advance a managed package.
@@ -524,6 +545,11 @@ def bench(subcommand, slug = nil)
uses the project's default workflow; pass --workflow to pin a registered
workflow for this task. (Options may also follow the text.)
+ --idempotency-key makes retries state-wide and machine-safe. Hive stores
+ the opaque key with a fingerprint of the input and resolved workflow,
+ returns the original task after it moves stages, and rejects reuse for
+ different input. Pair it with --json for the hive-new.v1 result.
+
--depends-on stacks this task on a prerequisite: the daemon holds
auto-advance until the prerequisite reaches the project's dependency
gate stage (8-finalize by default, configurable via
@@ -544,6 +570,8 @@ def bench(subcommand, slug = nil)
hive new myproj --depends-on add-export-endpoint-260618-ab12 "wire up export API"
hive new myproj --depends-on api:add-export-endpoint-260618-ab12 "wire up export UI"
+
+ hive new myproj --workflow content --idempotency-key creator:launch:v1 --json "write the launch post"
DESC
option :depends_on, type: :string,
desc: "depend on a same-project id/slug or explicit project:slug; hold daemon " \
@@ -552,6 +580,8 @@ def bench(subcommand, slug = nil)
option :base, type: :string,
desc: "base branch for a worktree/draft-PR workflow"
option :workflow, type: :string, desc: workflow_option_desc
+ option :idempotency_key, type: :string,
+ desc: "return the original task on a retry with identical input; reject conflicting reuse"
def new_task(project, *text_parts)
require "hive/commands/new"
text = text_parts.join(" ")
@@ -562,7 +592,9 @@ def new_task(project, *text_parts)
text,
base: options[:base],
depends_on: options[:depends_on],
- workflow: options[:workflow]
+ workflow: options[:workflow],
+ idempotency_key: options[:idempotency_key],
+ json: options[:json]
).call
end
map "new" => :new_task
@@ -1106,6 +1138,34 @@ def approve(target)
).call
end
+ desc "decide TARGET OUTCOME", "Apply a named outcome to a durable human workflow stage"
+ long_desc <<~DESC
+ TARGET is a task slug or folder currently waiting at a descriptor-declared
+ human stage. OUTCOME must be one of that stage's closed named outcomes.
+
+ --from and --decision-id are required and identify the expected human-stage
+ visit. Repeating the same decision is a no-op; a conflicting or stale
+ decision fails with WRONG_STAGE instead of changing the moved task.
+ DESC
+ option :from, type: :string, required: true,
+ desc: "expected human stage, full or short form"
+ option :decision_id, type: :string,
+ desc: "required decision identity reported by run/status for this visit"
+ option :note, type: :string, desc: "optional audit note recorded with the decision"
+ option :project, type: :string, desc: "scope slug lookup to one registered project"
+ def decide(target, outcome)
+ require "hive/commands/decide"
+ Hive::Commands::Decide.new(
+ target,
+ outcome,
+ from: options[:from],
+ decision_id: options[:decision_id],
+ note: options[:note],
+ project: options[:project],
+ json: options[:json]
+ ).call
+ end
+
desc "findings TARGET", "List findings in the latest reviews/ce-review-NN.md (or --pass N)"
long_desc <<~DESC
TARGET is either a 4-execute task folder path or a bare slug. Findings
diff --git a/lib/hive/commands/approve.rb b/lib/hive/commands/approve.rb
index 6ef7216128..e9d32afd9e 100644
--- a/lib/hive/commands/approve.rb
+++ b/lib/hive/commands/approve.rb
@@ -2,6 +2,7 @@
require "json"
require "open3"
require "pathname"
+require "securerandom"
require "hive/config"
require "hive/task"
require "hive/task_resolver"
@@ -14,6 +15,7 @@
require "hive/commit_or_rollback"
require "hive/dependency_snapshot"
require "hive/attempts/context"
+require "hive/atomic_file"
require "hive/conditions/transition_guard"
require "hive/workflow_package/mutation_lock"
require "hive/task_meta"
@@ -62,7 +64,7 @@ def self.error_kind_for(error)
end
def initialize(target, to: nil, from: nil, project: nil, force: false, json: false, quiet: false,
- observation_guard: nil, clock: DEFAULT_CLOCK)
+ observation_guard: nil, commit_lock: true, clock: DEFAULT_CLOCK)
@target = target
@to = to
@from = from
@@ -74,6 +76,7 @@ def initialize(target, to: nil, from: nil, project: nil, force: false, json: fal
# State changes and typed exceptions still propagate normally.
@quiet = quiet
@observation_guard = observation_guard
+ @commit_lock = commit_lock
@clock = clock
end
@@ -266,35 +269,108 @@ def stage_for_dest!(task, dest_stage)
def perform_move_and_commit(task, dest_stage, enforce_admission: false)
new_folder = nil
commit_action = nil
+ human_state_snapshot = nil
completion_snapshot = nil
completion_time = nil
- Hive::Lock.with_commit_lock(task.hive_state_path) do
- Hive::Lock.with_task_lock(task.folder, slug: task.slug, op: "approve") do
- # Preserve the command's typed collision contract before building
- # a multi-project snapshot: a pre-existing destination necessarily
- # duplicates the slug, but it is first and foremost an unsafe move
- # destination and requires no dependency inference to reject.
- raise_destination_collision(destination_folder(task, dest_stage)) if
- destination_exists?(destination_folder(task, dest_stage))
- Hive::DependencySnapshot.enforce_admission!(task) if enforce_admission
- Hive::Attempts::Context.current&.validate_generation!(task)
- @observation_guard&.call(task)
- if completion_on_terminal_entry?(task, dest_stage)
- completion_snapshot = Hive::TaskMeta.snapshot(task.folder)
- completion_time = legacy_completion_time(task)
+ begin
+ with_optional_commit_lock(task.hive_state_path) do
+ Hive::Lock.with_task_lock(task.folder, slug: task.slug, op: "approve") do
+ # Preserve the command's typed collision contract before building
+ # a multi-project snapshot: a pre-existing destination necessarily
+ # duplicates the slug, but it is first and foremost an unsafe move
+ # destination and requires no dependency inference to reject.
+ raise_destination_collision(destination_folder(task, dest_stage)) if
+ destination_exists?(destination_folder(task, dest_stage))
+ Hive::DependencySnapshot.enforce_admission!(task) if enforce_admission
+ Hive::Attempts::Context.current&.validate_generation!(task)
+ @observation_guard&.call(task)
+ human_state_snapshot = initialize_human_destination!(task, dest_stage)
+ if completion_on_terminal_entry?(task, dest_stage)
+ completion_snapshot = Hive::TaskMeta.snapshot(task.folder)
+ completion_time = legacy_completion_time(task)
+ end
+ new_folder = move_task!(task, dest_stage)
end
- new_folder = move_task!(task, dest_stage)
+ cleanup_orphan_task_lock(new_folder)
+ commit_action = "approve #{task.stage_index}-#{task.stage_name} -> #{dest_stage}"
+ record_commit_or_rollback!(
+ task, dest_stage, new_folder, commit_action,
+ completion_snapshot: completion_snapshot, completion_time: completion_time
+ )
end
- cleanup_orphan_task_lock(new_folder)
- commit_action = "approve #{task.stage_index}-#{task.stage_name} -> #{dest_stage}"
- record_commit_or_rollback!(
- task, dest_stage, new_folder, commit_action,
- completion_snapshot: completion_snapshot, completion_time: completion_time
- )
+ rescue StandardError, Interrupt
+ restore_human_destination!(task, new_folder, human_state_snapshot)
+ raise
end
[ new_folder, commit_action ]
end
+ def with_optional_commit_lock(hive_state_path, &block)
+ return block.call unless @commit_lock
+
+ Hive::Lock.with_commit_lock(hive_state_path, &block)
+ end
+
+ def initialize_human_destination!(task, dest_stage)
+ stage = stage_for_dest!(task, dest_stage)
+ return nil unless stage.kind == :human
+
+ path = File.join(task.folder, stage.state_file)
+ snapshot = nil
+ Hive::Markers.with_markers_lock(path) do
+ snapshot = read_human_state_snapshot!(path, stage.state_file)
+ marker = Hive::Markers.build_marker(
+ "WAITING", "decision_id" => SecureRandom.hex(8)
+ )
+ body, count = Hive::Markers.replace_last_marker(snapshot.fetch(:body).to_s, marker)
+ if count.zero?
+ separator = body.empty? || body.end_with?("\n") ? "" : "\n"
+ body = "#{body}#{separator}#{marker}\n"
+ end
+ Hive::AtomicFile.write(path, body)
+ end
+ snapshot
+ end
+
+ def read_human_state_snapshot!(path, state_file)
+ stat = File.lstat(path)
+ unless stat.file? && !stat.symlink?
+ raise Hive::InvalidTaskPath,
+ "human stage state file #{state_file.inspect} must be a regular file, not a symlink"
+ end
+
+ flags = File::RDONLY
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
+ body = File.open(path, flags) do |file|
+ opened = file.stat
+ unless opened.file? && opened.dev == stat.dev && opened.ino == stat.ino
+ raise Hive::InvalidTaskPath,
+ "human stage state file #{state_file.inspect} changed while entering the stage"
+ end
+ file.read
+ end
+ { state_file: state_file, existed: true, body: body }
+ rescue Errno::ENOENT
+ { state_file: state_file, existed: false, body: nil }
+ rescue Errno::ELOOP, Errno::EMLINK
+ raise Hive::InvalidTaskPath,
+ "human stage state file #{state_file.inspect} must be a regular file, not a symlink"
+ end
+
+ def restore_human_destination!(task, new_folder, snapshot)
+ return unless snapshot
+
+ folder = File.directory?(task.folder) ? task.folder : new_folder
+ return unless folder && File.directory?(folder)
+
+ path = File.join(folder, snapshot.fetch(:state_file))
+ if snapshot.fetch(:existed)
+ Hive::AtomicFile.write(path, snapshot.fetch(:body))
+ else
+ File.delete(path) if File.exist?(path) || File.symlink?(path)
+ end
+ end
+
def move_task!(task, dest_stage)
workflows_dir = File.join(task.hive_state_path, "workflows")
Hive::WorkflowPackage::MutationLock.with_lock(workflows_dir, shared: true) do
diff --git a/lib/hive/commands/decide.rb b/lib/hive/commands/decide.rb
new file mode 100644
index 0000000000..316ce737b3
--- /dev/null
+++ b/lib/hive/commands/decide.rb
@@ -0,0 +1,474 @@
+require "json"
+require "open3"
+require "securerandom"
+require "time"
+require "hive/commands/approve"
+require "hive/git_ops"
+require "hive/lock"
+require "hive/markers"
+require "hive/task"
+require "hive/task_action"
+require "hive/task_meta"
+require "hive/task_resolver"
+
+module Hive
+ module Commands
+ # Apply one descriptor-declared outcome to a durable human workflow stage.
+ # The decision identity is minted when the task enters the stage and carried
+ # by its WAITING marker, so retries can distinguish the current visit from a
+ # prior reject/return cycle without a second workflow language.
+ class Decide
+ include Hive::Schemas::EnvelopeEmitter
+
+ RECORD_RE = /<!-- HIVE_DECISION_V1 (?<payload>\{.*\}) -->/.freeze
+ DEFAULT_CLOCK = -> { Time.now.utc }
+ SUCCESS_KEYS = %w[
+ schema schema_version ok applied noop slug workflow from_stage outcome
+ decision_id note decided_at completed artifact artifact_path to
+ current_stage task_folder next_action
+ ].freeze
+
+ def self.latest_record(path)
+ latest_record_from_content(read_state_file(path))
+ end
+
+ def self.latest_record_from_content(content)
+ content.to_s.each_line.filter_map do |line|
+ match = RECORD_RE.match(line)
+ JSON.parse(match[:payload]) if match
+ rescue JSON::ParserError
+ nil
+ end.last
+ end
+
+ def self.read_state_file(path, binary: false)
+ stat = File.lstat(path)
+ unless stat.file? && !stat.symlink?
+ raise Hive::InvalidTaskPath,
+ "human stage state file #{File.basename(path).inspect} must be a regular file, not a symlink"
+ end
+
+ flags = File::RDONLY
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
+ body = File.open(path, flags) do |file|
+ opened = file.stat
+ unless opened.file? && opened.dev == stat.dev && opened.ino == stat.ino
+ raise Hive::InvalidTaskPath,
+ "human stage state file #{File.basename(path).inspect} changed while opening it"
+ end
+ file.read
+ end
+ return body if binary
+
+ body.force_encoding(Encoding::UTF_8)
+ unless body.valid_encoding?
+ raise Hive::InvalidTaskPath,
+ "human stage state file #{File.basename(path).inspect} must contain valid UTF-8"
+ end
+ body
+ rescue Errno::ENOENT
+ nil
+ rescue Errno::ELOOP, Errno::EMLINK
+ raise Hive::InvalidTaskPath,
+ "human stage state file #{File.basename(path).inspect} must be a regular file, not a symlink"
+ end
+
+ def initialize(target, outcome, from:, decision_id: nil, note: nil, project: nil, json: false,
+ clock: DEFAULT_CLOCK)
+ @target = target
+ @outcome_name = outcome.to_s.strip
+ @from = from.to_s.strip
+ @decision_id = decision_id.to_s.strip
+ @note = note
+ @project_filter = project
+ @json = json
+ @clock = clock
+ end
+
+ def call
+ call_with_envelope { do_call }
+ end
+
+ def envelope_schema = "hive-decide"
+
+ def envelope_error_kind(error)
+ case error
+ when Hive::WrongStage then "wrong_stage"
+ when Hive::AmbiguousSlug then "ambiguous_slug"
+ when Hive::InvalidTaskPath then "invalid_task_path"
+ when Hive::ConcurrentRunError then "concurrent_run"
+ when Hive::ConfigError then "config"
+ when Hive::GitError then "git"
+ when Hive::InternalError then "internal"
+ else "error"
+ end
+ end
+
+ def envelope_serialization_failure_policy = :raise
+
+ private
+
+ def do_call
+ validate_arguments!
+ task = resolve_task
+ stage = resolve_human_stage!(task)
+ outcome = resolve_outcome!(stage)
+ state_body = self.class.read_state_file(File.join(task.folder, stage.state_file))
+ record = self.class.latest_record_from_content(state_body)
+ expected_dir = stage.dir
+ actual_dir = "#{task.stage_index}-#{task.stage_name}"
+
+ if actual_dir != expected_dir
+ return emit_replay_or_stale!(task, stage, outcome, record)
+ end
+
+ marker = Hive::Markers.current_from_content(state_body)
+ if marker.name == :waiting
+ current_id = marker.attrs["decision_id"].to_s
+ unless current_id.match?(/\A[0-9a-f]{16}\z/) && current_id == @decision_id
+ return emit_same_or_conflicting!(task, stage, outcome, record) if
+ current_record?(record, @decision_id)
+
+ raise Hive::WrongStage.new(
+ "decision observation is stale; task or decision identity changed",
+ current_stage: actual_dir, target_stage: expected_dir
+ )
+ end
+ elsif current_record?(record, @decision_id)
+ return emit_same_or_conflicting!(task, stage, outcome, record)
+ else
+ raise Hive::WrongStage.new(
+ "task #{task.slug} is not awaiting a decision at #{stage.dir}",
+ current_stage: actual_dir, target_stage: expected_dir
+ )
+ end
+
+ outcome.complete ? complete!(task, stage, outcome, @decision_id) : return_to_stage!(task, stage, outcome, @decision_id)
+ end
+
+ def validate_arguments!
+ raise Hive::InvalidTaskPath, "--from must name the expected human stage" if @from.empty?
+ unless @decision_id.match?(/\A[0-9a-f]{16}\z/)
+ raise Hive::InvalidTaskPath,
+ "--decision-id must be the 16-character identity reported for this human-stage visit"
+ end
+ raise Hive::InvalidTaskPath, "outcome must be a non-empty name" if @outcome_name.empty?
+ return if @note.nil? || @note.is_a?(String)
+
+ raise Hive::InvalidTaskPath, "--note must be text"
+ end
+
+ def resolve_task
+ Hive::TaskResolver.new(@target, project_filter: @project_filter).resolve
+ end
+
+ def resolve_human_stage!(task)
+ dir = task.workflow.resolve_stage_ref(@from) ||
+ raise(Hive::InvalidTaskPath,
+ "unknown --from stage #{@from.inspect}; valid: #{task.workflow.stage_dirs.join(', ')}")
+ stage = task.workflow.stage_for_dir(dir)
+ return stage if stage.kind == :human
+
+ raise Hive::InvalidTaskPath, "stage #{dir} is not a human stage"
+ end
+
+ def resolve_outcome!(stage)
+ stage.outcomes[@outcome_name] ||
+ raise(Hive::InvalidTaskPath,
+ "unknown outcome #{@outcome_name.inspect} for #{stage.dir}; " \
+ "allowed: #{stage.outcomes.keys.join(', ')}")
+ end
+
+ def current_record?(record, decision_id)
+ record && !decision_id.empty? && record["decision_id"] == decision_id
+ end
+
+ def emit_replay_or_stale!(task, stage, outcome, record)
+ if record && record["from"] == stage.name && record["decision_id"] == @decision_id
+ return emit_same_or_conflicting!(task, stage, outcome, record)
+ end
+
+ raise Hive::WrongStage.new(
+ "task is at #{task.stage_index}-#{task.stage_name} but --from expected #{stage.dir}",
+ current_stage: "#{task.stage_index}-#{task.stage_name}", target_stage: stage.dir
+ )
+ end
+
+ def emit_same_or_conflicting!(task, stage, outcome, record)
+ unless record["outcome"] == outcome.name
+ raise Hive::WrongStage.new(
+ "decision #{record['decision_id']} already recorded #{record['outcome'].inspect}; " \
+ "cannot apply conflicting outcome #{outcome.name.inspect}",
+ current_stage: "#{task.stage_index}-#{task.stage_name}", target_stage: stage.dir
+ )
+ end
+
+ emit_success(task, stage, outcome, record, applied: false)
+ end
+
+ def complete!(task, stage, outcome, decision_id)
+ record = nil
+ snapshot = nil
+ meta_snapshot = nil
+ ops = Hive::GitOps.new(task.project_root)
+
+ Hive::Lock.with_commit_lock(task.hive_state_path) do
+ begin
+ Hive::Lock.with_task_lock(task.folder, slug: task.slug, op: "decide") do
+ locked = Hive::Task.new(task.folder)
+ locked_record = self.class.latest_record(File.join(locked.folder, stage.state_file))
+ return emit_same_or_conflicting!(locked, stage, outcome, locked_record) if
+ current_record?(locked_record, decision_id)
+
+ validate_current_decision!(locked, stage, decision_id)
+ artifact_path = File.join(locked.folder, outcome.artifact)
+ validate_publishable_artifact!(artifact_path, locked.folder, stage, outcome)
+ record = decision_record(
+ locked, stage, outcome, decision_id,
+ artifact_path: artifact_path, artifact_status: "publish_ready"
+ )
+ snapshot = snapshot_files(locked.folder, [ stage.state_file ])
+ meta_snapshot = Hive::TaskMeta.snapshot(locked.folder)
+ record["decided_at"] = Hive::TaskMeta.write_completed_at_once(
+ locked.folder, Time.iso8601(record.fetch("decided_at"))
+ )
+ write_decision_record(File.join(locked.folder, stage.state_file), record)
+ end
+ ops.hive_commit(stage_name: stage.dir, slug: task.slug,
+ action: "decide #{stage.name} #{outcome.name}")
+ rescue StandardError, Interrupt
+ if snapshot
+ restore_files(task.folder, snapshot)
+ Hive::TaskMeta.restore(task.folder, meta_snapshot) if meta_snapshot
+ restage_restored_files(
+ task, snapshot, extra_names: meta_snapshot ? [ Hive::TaskMeta::FILENAME ] : []
+ )
+ end
+ raise
+ end
+ end
+
+ emit_success(task, stage, outcome, record, applied: true)
+ end
+
+ def validate_publishable_artifact!(artifact_path, task_folder, stage, outcome)
+ expanded = File.expand_path(artifact_path)
+ task_root = "#{File.expand_path(task_folder)}/"
+ raise Hive::WrongStage, "artifact leaves the task folder" unless expanded.start_with?(task_root)
+
+ stat = File.lstat(expanded)
+ raise Hive::WrongStage, "artifact must be a regular task-local file" unless stat.file? && !stat.symlink?
+
+ flags = File::RDONLY
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
+ body = File.open(expanded, flags) do |file|
+ opened = file.stat
+ unless opened.file? && opened.dev == stat.dev && opened.ino == stat.ino
+ raise Hive::WrongStage, "artifact identity changed while opening it"
+ end
+
+ file.read
+ end
+ publishable = body && !Hive::Markers.without_markers(body).strip.empty?
+ return if publishable
+
+ raise Hive::WrongStage.new(
+ "outcome #{outcome.name.inspect} requires non-empty artifact #{outcome.artifact.inspect}",
+ current_stage: stage.dir, target_stage: stage.dir
+ )
+ rescue Hive::WrongStage, SystemCallError, IOError
+ raise Hive::WrongStage.new(
+ "outcome #{outcome.name.inspect} requires non-empty artifact #{outcome.artifact.inspect}",
+ current_stage: stage.dir, target_stage: stage.dir
+ )
+ end
+
+ def return_to_stage!(task, stage, outcome, decision_id)
+ target = task.workflow.stage_named(outcome.to)
+ return repeat_stage!(task, stage, outcome, decision_id) if target.dir == stage.dir
+
+ record = decision_record(task, stage, outcome, decision_id)
+ snapshot = nil
+ mutation = lambda do |locked|
+ validate_current_decision!(locked, stage, decision_id)
+ snapshot = snapshot_files(locked.folder, [ stage.state_file, target.state_file ])
+ write_decision_record(File.join(locked.folder, stage.state_file), record)
+ Hive::Markers.set(File.join(locked.folder, target.state_file), :waiting)
+ end
+
+ begin
+ Hive::Lock.with_commit_lock(task.hive_state_path) do
+ Hive::Commands::Approve.new(
+ task.slug, to: target.dir, from: stage.dir, project: task.project_name,
+ force: true, quiet: true, observation_guard: mutation, commit_lock: false
+ ).call
+ end
+ rescue Hive::WrongStage => error
+ folder = File.directory?(task.folder) ? task.folder : File.join(
+ task.hive_state_path, "stages", target.dir, task.slug
+ )
+ restore_files(folder, snapshot) if snapshot
+ replay = resolve_task
+ replay_record = self.class.latest_record(File.join(replay.folder, stage.state_file))
+ return emit_same_or_conflicting!(replay, stage, outcome, replay_record) if
+ current_record?(replay_record, decision_id)
+
+ raise error
+ rescue StandardError, Interrupt
+ folder = File.directory?(task.folder) ? task.folder : File.join(
+ task.hive_state_path, "stages", target.dir, task.slug
+ )
+ restore_files(folder, snapshot) if snapshot
+ raise
+ end
+
+ moved = Hive::Task.new(File.join(task.hive_state_path, "stages", target.dir, task.slug))
+ emit_success(moved, stage, outcome, record, applied: true)
+ end
+
+ def repeat_stage!(task, stage, outcome, decision_id)
+ record = decision_record(task, stage, outcome, decision_id)
+ snapshot = nil
+ ops = Hive::GitOps.new(task.project_root)
+
+ Hive::Lock.with_commit_lock(task.hive_state_path) do
+ begin
+ Hive::Lock.with_task_lock(task.folder, slug: task.slug, op: "decide") do
+ locked = Hive::Task.new(task.folder)
+ locked_record = self.class.latest_record(File.join(locked.folder, stage.state_file))
+ return emit_same_or_conflicting!(locked, stage, outcome, locked_record) if
+ current_record?(locked_record, decision_id)
+
+ validate_current_decision!(locked, stage, decision_id)
+ snapshot = snapshot_files(locked.folder, [ stage.state_file ])
+ state_path = File.join(locked.folder, stage.state_file)
+ write_decision_record(state_path, record)
+ Hive::Markers.set(
+ state_path, :waiting, "decision_id" => SecureRandom.hex(8)
+ )
+ end
+ ops.hive_commit(
+ stage_name: stage.dir, slug: task.slug,
+ action: "decide #{stage.name} #{outcome.name}"
+ )
+ rescue StandardError, Interrupt
+ if snapshot
+ restore_files(task.folder, snapshot)
+ restage_restored_files(task, snapshot)
+ end
+ raise
+ end
+ end
+
+ emit_success(Hive::Task.new(task.folder), stage, outcome, record, applied: true)
+ end
+
+ def validate_current_decision!(task, stage, decision_id)
+ actual = "#{task.stage_index}-#{task.stage_name}"
+ body = self.class.read_state_file(File.join(task.folder, stage.state_file))
+ marker = Hive::Markers.current_from_content(body)
+ return if actual == stage.dir && marker.name == :waiting && marker.attrs["decision_id"] == decision_id
+
+ raise Hive::WrongStage.new(
+ "decision observation is stale; task or decision identity changed",
+ current_stage: actual, target_stage: stage.dir
+ )
+ end
+
+ def decision_record(task, stage, outcome, decision_id, artifact_path: nil, artifact_status: nil)
+ {
+ "schema_version" => 1,
+ "decision_id" => decision_id,
+ "from" => stage.name,
+ "outcome" => outcome.name,
+ "note" => @note,
+ "artifact" => outcome.artifact,
+ "artifact_path" => artifact_path,
+ "artifact_status" => artifact_status,
+ "to" => outcome.to,
+ "decided_at" => @clock.call.utc.iso8601
+ }
+ end
+
+ def write_decision_record(path, record)
+ Hive::Markers.with_markers_lock(path) do
+ body = self.class.read_state_file(path) || ""
+ complete = Hive::Markers.build_marker(
+ "COMPLETE", "decision_id" => record.fetch("decision_id"), "outcome" => record.fetch("outcome")
+ )
+ updated, count = Hive::Markers.replace_last_marker(body, complete)
+ updated = "#{updated}#{updated.end_with?("\n") || updated.empty? ? "" : "\n"}#{complete}\n" if count.zero?
+ updated = "#{updated}#{updated.end_with?("\n") ? "" : "\n"}" \
+ "<!-- HIVE_DECISION_V1 #{JSON.generate(record)} -->\n"
+ Hive::Markers.write_atomic(path, updated)
+ end
+ end
+
+ def snapshot_files(folder, names)
+ names.uniq.to_h do |name|
+ path = File.join(folder, name)
+ body = self.class.read_state_file(path, binary: true)
+ [ name, { existed: !body.nil?, body: body } ]
+ end
+ end
+
+ def restore_files(folder, snapshots)
+ snapshots.each do |name, snapshot|
+ path = File.join(folder, name)
+ if snapshot.fetch(:existed)
+ Hive::Markers.write_atomic(path, snapshot.fetch(:body))
+ else
+ File.delete(path) if File.exist?(path) || File.symlink?(path)
+ end
+ end
+ end
+
+ def restage_restored_files(task, snapshots, extra_names: [])
+ ops = Hive::GitOps.new(task.project_root)
+ (snapshots.keys + extra_names).uniq.each do |name|
+ rel = File.join("stages", "#{task.stage_index}-#{task.stage_name}", task.slug, name)
+ ops.run_git!("-C", task.hive_state_path, "add", "-A", "--", rel)
+ end
+ rescue Hive::GitError
+ nil
+ end
+
+ def emit_success(task, stage, outcome, record, applied:)
+ current_stage = "#{task.stage_index}-#{task.stage_name}"
+ next_action = if outcome.complete
+ { "kind" => Hive::Schemas::NextActionKind::NO_OP, "reason" => "workflow_complete" }
+ else
+ action = Hive::TaskAction.for(task, Hive::Markers.current(task.state_file))
+ { "kind" => action.key, "command" => action.command }
+ end
+ payload = {
+ "schema" => "hive-decide",
+ "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-decide"),
+ "ok" => true,
+ "applied" => applied,
+ "noop" => !applied,
+ "slug" => task.slug,
+ "workflow" => task.workflow.id.to_s,
+ "from_stage" => stage.dir,
+ "outcome" => outcome.name,
+ "decision_id" => record.fetch("decision_id"),
+ "note" => record["note"],
+ "decided_at" => record.fetch("decided_at"),
+ "completed" => outcome.complete,
+ "artifact" => record["artifact"],
+ "artifact_path" => record["artifact_path"],
+ "to" => record["to"],
+ "current_stage" => current_stage,
+ "task_folder" => task.folder,
+ "next_action" => next_action
+ }
+ puts JSON.generate(SUCCESS_KEYS.to_h { |key| [ key, payload.fetch(key) ] }) if @json
+ unless @json
+ puts "hive: decided #{task.slug} #{stage.name}=#{outcome.name}"
+ puts " current_stage: #{current_stage}"
+ end
+ payload
+ end
+ end
+ end
+end
diff --git a/lib/hive/commands/init.rb b/lib/hive/commands/init.rb
index e1242f3281..82748ee38d 100644
--- a/lib/hive/commands/init.rb
+++ b/lib/hive/commands/init.rb
@@ -23,6 +23,19 @@
module Hive
module Commands
class Init
+ include Hive::Schemas::EnvelopeEmitter
+
+ class PreconditionError < Hive::Error
+ attr_reader :error_kind
+
+ def initialize(message, error_kind:)
+ super(message)
+ @error_kind = error_kind
+ end
+ end
+
+ ProjectRegistrationCollision = Hive::Config::ProjectRegistrationCollision
+
INIT_MAIN_CHECKOUT_PATHS = %w[
.gitignore
.llm-wiki/config.json
@@ -82,6 +95,7 @@ def initialize(descriptor:, source:)
def initialize(project_path, force: false, json: false, prompts: nil,
workflow: nil, new_workflow: nil, refactor_patrol: nil,
+ minimal: false, preview: false,
workflow_input: $stdin, workflow_output: $stderr,
provisioning_input: $stdin, provisioning_output: $stdout,
provisioning_error: $stderr, preflight_inspector: nil,
@@ -91,6 +105,11 @@ def initialize(project_path, force: false, json: false, prompts: nil,
@json = json
@workflow_name = workflow
@new_workflow = new_workflow
+ @minimal = minimal
+ @preview = preview
+ unless [ true, false ].include?(@minimal) && [ true, false ].include?(@preview)
+ raise ArgumentError, "minimal and preview must be boolean"
+ end
unless refactor_patrol.nil? || [ true, false ].include?(refactor_patrol)
raise ArgumentError, "refactor_patrol must be true, false, or nil"
end
@@ -123,6 +142,50 @@ def initialize(project_path, force: false, json: false, prompts: nil,
end
def call
+ return do_call unless minimal_json?
+
+ call_with_envelope { do_call }
+ end
+
+ def envelope_schema
+ @preview ? "hive-init-preview" : "hive-init"
+ end
+
+ def envelope_error_kind(error)
+ case error
+ when PreconditionError then error.error_kind
+ when ProjectRegistrationCollision then "project_name_collision"
+ when Hive::AlreadyInitialized then "already_initialized"
+ when Hive::Commands::Workflow::UsageError then "usage"
+ when Hive::ConcurrentRunError then "concurrent_run"
+ when Hive::ConfigError then "config"
+ when Hive::GitError then "git"
+ when Hive::InternalError then "internal"
+ else "error"
+ end
+ end
+
+ def envelope_extras_for(error)
+ extras = {
+ "minimal" => true,
+ "preview" => @preview,
+ "project" => File.basename(@project_path),
+ "path" => @project_path,
+ "workflow" => @new_workflow.to_s
+ }
+ extras["value"] = error.value if error.respond_to?(:value) && !error.value.nil?
+ if error.respond_to?(:suggested_id) && !error.suggested_id.nil?
+ extras["suggested_id"] = error.suggested_id
+ end
+ if error.is_a?(ProjectRegistrationCollision)
+ extras["existing_path"] = error.existing_path
+ end
+ extras
+ end
+
+ def envelope_serialization_failure_policy = :raise
+
+ def do_call
validate_git_repo!
# Argument-level mutual exclusivity is checked before validate_clean_tree!
# so a dirty repo passing both flags gets the clearer usage error instead
@@ -130,9 +193,14 @@ def call
if @workflow_name && @new_workflow
raise Hive::Commands::Workflow::UsageError, "--workflow and --new-workflow are mutually exclusive"
end
+ validate_minimal_arguments!
validate_clean_tree! unless @force
ops = Hive::GitOps.new(@project_path)
+ if @minimal
+ init_minimal(ops)
+ return
+ end
reject_existing_refactor_patrol_selector!(ops)
if @new_workflow
init_with_new_workflow(ops)
@@ -194,6 +262,157 @@ def call
raise Hive::InternalError, "init failed: #{e.class}: #{e.message}"
end
+ def validate_minimal_arguments!
+ if @preview && !@minimal
+ raise Hive::Commands::Workflow::UsageError, "--preview requires --minimal"
+ end
+ return unless @minimal
+
+ raise Hive::Commands::Workflow::UsageError, "--minimal requires --new-workflow ID" unless @new_workflow
+ raise Hive::Commands::Workflow::UsageError, "--minimal never accepts --force" if @force
+ if @refactor_patrol
+ raise Hive::Commands::Workflow::UsageError,
+ "--minimal fixes refactor patrol off; do not pass --refactor-patrol"
+ end
+ end
+
+ def init_minimal(ops)
+ ensure_minimal_target_fresh!(ops)
+ ensure_minimal_registration_available!
+ id = Hive::Commands::Workflow.normalize_and_validate_id!(
+ @new_workflow, project_root: @project_path
+ )
+ if Hive::Commands::Workflow.scaffold_collision?(id, project_root: @project_path)
+ suggested = Hive::Commands::Workflow.available_id(id, project_root: @project_path)
+ raise Hive::Commands::Workflow::UsageError.new(
+ "workflow scaffold already exists for #{id.inspect}; try #{suggested.inspect}",
+ value: id, suggested_id: suggested
+ )
+ end
+
+ if @preview
+ emit_minimal_preview(ops, id)
+ else
+ scaffold_and_bind_fresh_minimal(ops, id)
+ end
+ end
+
+ def ensure_minimal_target_fresh!(ops)
+ if ops.hive_state_branch_exists? ||
+ File.exist?(ops.hive_state_path) ||
+ File.symlink?(ops.hive_state_path)
+ raise Hive::AlreadyInitialized,
+ "minimal initialization requires a fresh target with no hive/state branch or .hive-state path"
+ end
+ end
+
+ def ensure_minimal_registration_available!
+ name = File.basename(@project_path)
+ existing = Hive::Config.registered_projects.find { |project| project["name"] == name }
+ return unless existing
+ return if File.expand_path(existing.fetch("path")) == @project_path
+
+ raise ProjectRegistrationCollision.new(
+ "minimal initialization refuses to replace registered project #{name.inspect} " \
+ "at #{existing.fetch('path')}; rename the target directory or forget the existing project first",
+ name: name, existing_path: existing.fetch("path")
+ )
+ end
+
+ def scaffold_and_bind_fresh_minimal(ops, id)
+ answers = minimal_answers
+ project_config_content = render_project_config(ops, answers: answers, default_workflow: id)
+ scaffold = nil
+ entry = initialize_project_state(ops, content: project_config_content) do
+ scaffold = scaffold_descriptor_commit!(ops, id)
+ end
+ extra = scaffold_payload(scaffold.fetch(:paths)).merge("minimal" => true)
+ if @json
+ emit_json_summary(entry: entry, ops: ops, answers: answers, workflow: id, extra: extra)
+ else
+ print_summary(
+ entry: entry, ops: ops, answers: answers, workflow: id,
+ scaffold_paths: scaffold.fetch(:paths)
+ )
+ end
+ # Minimal initialization intentionally omits service installation,
+ # daemon-autostart persistence, optional agent provisioning, patrol,
+ # babysitting, and timers. Core project registration and wiki/context
+ # hooks were installed transactionally by initialize_project_state.
+ end
+
+ def emit_minimal_preview(ops, id)
+ payload = minimal_preview_payload(ops, id)
+ if @json
+ puts JSON.generate(payload)
+ else
+ puts "hive: minimal init preview for #{@project_path}"
+ puts " workflow: #{id}"
+ puts " creates: #{payload.fetch('project_files').length} project/context paths plus hive/state"
+ puts " automation: disabled"
+ end
+ payload
+ rescue Errno::EPIPE
+ nil
+ end
+
+ def minimal_preview_payload(ops, id)
+ answers = minimal_answers
+ hive_state_path = ops.hive_state_path
+ workflows = File.join(hive_state_path, "workflows")
+ {
+ "schema" => "hive-init-preview",
+ "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-init-preview"),
+ "ok" => true,
+ "preview" => true,
+ "minimal" => true,
+ "project" => File.basename(@project_path),
+ "path" => @project_path,
+ "default_branch" => ops.default_branch,
+ "workflow" => id,
+ "profile" => "minimal",
+ "template" => "blank",
+ "project_files" => INIT_MAIN_CHECKOUT_PATHS.map { |path| File.join(@project_path, path) },
+ "state_worktree" => {
+ "branch" => Hive::GitOps::HIVE_BRANCH,
+ "path" => hive_state_path,
+ "config_path" => File.join(hive_state_path, "config.yml"),
+ "workflow_descriptor" => File.join(workflows, "#{id}.yml"),
+ "workflow_instruction" => File.join(workflows, id, "work.md")
+ },
+ "context_integration" => {
+ "llm_wiki" => true,
+ "agent_instructions" => true,
+ "post_commit_hook" => true,
+ "scheduler" => false
+ },
+ "global_registration" => {
+ "enabled" => true,
+ "config_path" => Hive::Config.global_config_path,
+ "name" => File.basename(@project_path),
+ "status" => "available"
+ },
+ "services" => {
+ "daemon_install" => false,
+ "daemon_autostart" => false,
+ "babysitter_timer" => false,
+ "llm_wiki_timer" => false
+ },
+ "background_automation" => {
+ "daemon_dispatch" => answers.fetch("daemon_enabled"),
+ "patrol" => answers.fetch("patrol_mode") != "off",
+ "refactor_patrol" => answers.fetch("refactor_patrol_enabled"),
+ "adhoc_auto_fix" => answers.fetch("adhoc_auto_fix"),
+ "babysitter" => answers.fetch("babysitter_enabled")
+ },
+ "task_creation" => false
+ }
+ end
+
+ def minimal_answers
+ Hive::Commands::Init::Prompts.minimal_defaults
+ end
+
def init_with_new_workflow(ops)
id = Hive::Commands::Workflow.normalize_and_validate_id!(@new_workflow)
if ops.hive_state_branch_exists?
@@ -247,7 +466,7 @@ def scaffold_descriptor_commit!(ops, id)
)
end
scaffold
- rescue StandardError
+ rescue StandardError, Interrupt
# Re-derive paths from `scaffold` rather than the local `paths` (bound
# just above): if scaffold_files! raised before its assignment, the
# local is unset — the `if scaffold` guard makes this the only safe read.
@@ -270,7 +489,7 @@ def scaffold_and_bind_existing(ops, id)
write_default_workflow!(cfg_path, id)
begin
Hive::Commands::Workflow.commit_workflow_scaffold(ops, slug: id, pathspecs: pathspecs)
- rescue StandardError
+ rescue StandardError, Interrupt
# hive_commit stages config.yml + descriptor + instruction dir via
# a per-pathspec `git add -A -- <path>` before it runs; a failure
# AFTER staging leaves those entries in the long-lived .hive-state
@@ -287,7 +506,7 @@ def scaffold_and_bind_existing(ops, id)
raise
end
end
- rescue StandardError
+ rescue StandardError, Interrupt
# Filesystem-only scaffold teardown stays outside the lock: it removes
# working-tree files, not index entries, so it cannot be swept into a
# concurrent commit.
@@ -395,7 +614,7 @@ def update_existing_default_workflow!(ops, workflow_id)
action: "set to #{next_value}",
pathspecs: [ "config.yml" ]
)
- rescue StandardError
+ rescue StandardError, Interrupt
restore_config_snapshot(cfg_path, before)
raise
end
@@ -564,6 +783,7 @@ def fieldless_in_flight_tasks(ops)
def initialize_project_state(ops, content:, workflow: nil, &after_bootstrap)
rollback_on_failure = false
+ registration_written = false
side_effect_snapshot = capture_init_side_effect_snapshot
begin
rollback_on_failure = true
@@ -574,12 +794,20 @@ def initialize_project_state(ops, content:, workflow: nil, &after_bootstrap)
ops.add_hive_state_to_master_gitignore!
Hive::LlmWikiBootstrap.install!(@project_path, post_commit_hook: false, scheduler: false)
ops.commit_llm_wiki_bootstrap!
- Hive::LlmWikiBootstrap.install_runtime_hooks!(@project_path)
+ Hive::LlmWikiBootstrap.install_runtime_hooks!(@project_path, scheduler: !@minimal)
- entry = Hive::Config.register_project(name: File.basename(@project_path), path: @project_path)
+ entry = Hive::Config.register_project(
+ name: File.basename(@project_path),
+ path: @project_path,
+ replace_existing: !@minimal
+ )
+ registration_written = true
after_bootstrap&.call
entry
- rescue StandardError
+ rescue StandardError, Interrupt
+ unless registration_written
+ side_effect_snapshot.fetch(:paths).delete(Hive::Config.global_config_path)
+ end
rollback_partial_init(ops, side_effect_snapshot: side_effect_snapshot) if rollback_on_failure
raise
end
@@ -1067,6 +1295,11 @@ def collect_prompt_answers
def validate_git_repo!
out, _err, status = Open3.capture3("git", "-C", @project_path, "rev-parse", "--git-common-dir")
unless status.success?
+ if minimal_json?
+ raise PreconditionError.new(
+ "not a git repository: #{@project_path}", error_kind: "not_git_repository"
+ )
+ end
warn "hive: not a git repository: #{@project_path}"
exit 1
end
@@ -1075,6 +1308,12 @@ def validate_git_repo!
expected = File.join(@project_path, ".git")
return if File.expand_path(common) == File.expand_path(expected)
+ if minimal_json?
+ raise PreconditionError.new(
+ "target appears to be inside a worktree (common dir #{common}); init must run on the main checkout",
+ error_kind: "main_checkout_required"
+ )
+ end
warn "hive: target appears to be inside a worktree (common dir #{common}); init must run on the main checkout"
exit 1
end
@@ -1088,10 +1327,20 @@ def validate_clean_tree!
modified = out.lines.reject { |l| l.start_with?("??") }
return if modified.empty?
+ if minimal_json?
+ raise PreconditionError.new(
+ "uncommitted modifications to tracked files; commit before minimal init",
+ error_kind: "dirty_worktree"
+ )
+ end
warn "hive: uncommitted modifications to tracked files; commit or pass --force"
exit 1
end
+ def minimal_json?
+ @minimal && @json
+ end
+
def write_per_project_config(ops, content:)
cfg_path = File.join(ops.hive_state_path, "config.yml")
return if File.exist?(cfg_path)
diff --git a/lib/hive/commands/init/prompts.rb b/lib/hive/commands/init/prompts.rb
index 4b17ca284b..22f134c292 100644
--- a/lib/hive/commands/init/prompts.rb
+++ b/lib/hive/commands/init/prompts.rb
@@ -147,6 +147,37 @@ def initialize(input: $stdin, output: $stderr, summary_io: $stdout,
end
end
+ # Neutral non-interactive profile used exclusively by
+ # `hive init --new-workflow ID --minimal`. It keeps the core project,
+ # context hooks, and inherited agent choices while disabling every
+ # optional background or automatic workflow.
+ def self.minimal_defaults
+ {
+ "planning_agent" => DEFAULT_PLANNING_AGENT,
+ "claude_mode" => DEFAULT_CLAUDE_MODE,
+ "claude_permission_mode" => DEFAULT_CLAUDE_PERMISSION_MODE,
+ "claude_model" => DEFAULT_CLAUDE_MODEL,
+ "claude_effort" => DEFAULT_CLAUDE_EFFORT,
+ "development_agent" => DEFAULT_DEVELOPMENT_AGENT,
+ "enabled_reviewers" => [],
+ "patrol_reviewers" => [],
+ "patrol_mode" => "off",
+ "triage_bias" => DEFAULT_TRIAGE_BIAS,
+ "adhoc_auto_fix" => false,
+ "refactor_patrol_enabled" => false,
+ "budgets" => limit_defaults("budget_usd"),
+ "timeouts" => limit_defaults("timeout_sec"),
+ "daemon_enabled" => false,
+ "babysitter_enabled" => false,
+ "daemon_autostart" => false
+ }
+ end
+
+ def self.limit_defaults(section)
+ LIMIT_KEYS.to_h { |key| [ key, Hive::Config::DEFAULTS.fetch(section).fetch(key) ] }
+ end
+ private_class_method :limit_defaults
+
# Run the prompt flow (or short-circuit to defaults).
# Returns the answers hash with shape:
# {
diff --git a/lib/hive/commands/new.rb b/lib/hive/commands/new.rb
index 08988594b0..6c44e9c2b2 100644
--- a/lib/hive/commands/new.rb
+++ b/lib/hive/commands/new.rb
@@ -2,12 +2,18 @@
require "fileutils"
require "time"
require "erb"
+require "digest"
+require "json"
+require "tmpdir"
require "hive/config"
require "hive/git_ops"
require "hive/lock"
require "hive/paths"
require "hive/task_counter"
require "hive/task_meta"
+require "hive/task"
+require "hive/task_action"
+require "hive/markers"
require "hive/workflows"
require "hive/workflow_selection"
require "hive/workflow_package/managed_store"
@@ -19,6 +25,9 @@
module Hive
module Commands
class New
+ include Hive::Schemas::EnvelopeEmitter
+
+ SCHEMA = "hive-new".freeze
RESERVED_SLUGS = %w[
head fetch_head orig_head merge_head
master main origin hive hive-state hive_state state
@@ -70,9 +79,13 @@ class UnregisteredProjectWorkflow < TypedValueError; end
# config.yml (mirroring UnregisteredProjectWorkflow) instead of crashing
# with a raw Psych backtrace that escapes call's rescue list.
class ProjectConfigUnreadable < TypedValueError; end
+ class IdempotencyConflict < TypedValueError
+ def exit_code = Hive::ExitCodes::USAGE
+ end
+ PreparedAttachment = Data.define(:snapshot_path, :destination, :name, :sha256)
def initialize(project_name, text, slug_override: nil, body_override: nil, attachments: [], base: nil,
- depends_on: nil, workflow: nil)
+ depends_on: nil, workflow: nil, idempotency_key: nil, json: false)
@project_name = project_name
@text = text.to_s
@slug_override = slug_override
@@ -81,6 +94,11 @@ def initialize(project_name, text, slug_override: nil, body_override: nil, attac
@base = base
@depends_on = depends_on
@workflow_name = workflow
+ @idempotency_key_raw = idempotency_key
+ # Machine-readable creation was added for idempotent automation.
+ # Preserve the legacy plain-text contract for a bare `hive new --json`
+ # whose caller did not opt into that side-effect boundary.
+ @json = json && !idempotency_key.nil?
end
class << self
@@ -103,13 +121,9 @@ def name_generator_spawn_configured?
# raising so they can rescue typed errors without losing the alt
# screen.
def call
- call!
- rescue ProjectNotFound, InvalidSlugError, InvalidDependencyError, InvalidBaseError,
- InvalidDraftPrCombination, InvalidAttachmentError,
- SlugCollisionError, UnregisteredProjectWorkflow, ProjectConfigUnreadable,
- Hive::Workflows::UnknownWorkflow, Hive::UnsupportedProjectConfigError,
- SystemCallError, IOError => e
- warn "hive: #{e.message}"
+ call_with_envelope { call! }
+ rescue Hive::Error, SystemCallError, IOError => e
+ warn "hive: #{e.message}" unless @json
# Honor each typed error's contract exit code (e.g. UnknownWorkflow →
# USAGE 64 so an agent can tell "typo'd --workflow, fix the flag" from a
# transient GENERIC failure); fall back to 1 for the untyped
@@ -117,6 +131,28 @@ def call
exit(e.respond_to?(:exit_code) ? e.exit_code : 1)
end
+ def envelope_schema = SCHEMA
+
+ def envelope_error_kind(error)
+ case error
+ when IdempotencyConflict, InvalidBaseError, InvalidDraftPrCombination,
+ Hive::Workflows::UnknownWorkflow then "usage"
+ when Hive::ConfigError, ProjectConfigUnreadable, UnregisteredProjectWorkflow then "config"
+ when Hive::ConcurrentRunError then "concurrent_run"
+ when Hive::InternalError then "internal"
+ else "error"
+ end
+ end
+
+ def envelope_extras_for(error)
+ extras = {}
+ extras["value"] = error.value if error.respond_to?(:value) && !error.value.nil?
+ extras["idempotency_key"] = @idempotency_key if @idempotency_key
+ extras
+ end
+
+ def envelope_serialization_failure_policy = :raise
+
def call!
project = Hive::Config.find_project(@project_name)
unless project
@@ -126,8 +162,8 @@ def call!
)
end
- slug = @slug_override || derive_slug(@text)
- validate_slug!(slug)
+ @idempotency_key = validate_idempotency_key!
+ prepare_idempotent_attachments! if @idempotency_key
depends_on = normalize_optional(@depends_on)
depends_on = validate_dependency!(depends_on) if depends_on
workflow_info = resolve_workflow(project)
@@ -147,51 +183,211 @@ def call!
end
base_branch = effective_base_branch(project, workflow)
entry_stage = workflow.stages.first
-
hive_state = project["hive_state_path"]
+ fingerprint = input_fingerprint(
+ workflow_info, depends_on: depends_on, base_branch: base_branch
+ ) if @idempotency_key
+ slug = @slug_override || derive_slug(@text)
+ validate_slug!(slug)
task_dir = File.join(hive_state, "stages", entry_stage.dir, slug)
- if File.exist?(task_dir)
- raise SlugCollisionError.new(
- "slug collision at #{task_dir} (rare; retry the command)",
- value: slug
- )
+ ops = Hive::GitOps.new(project["path"])
+
+ if @idempotency_key
+ result = with_stable_workflow_selection(workflow_info, hive_state) do |stable_selection|
+ Hive::Lock.with_commit_lock(hive_state) do
+ validate_stable_authored_workflow!(workflow_info, hive_state)
+ existing = find_idempotent_task!(hive_state, @idempotency_key, fingerprint)
+ validate_stable_authored_workflow!(workflow_info, hive_state)
+ next { folder: existing.fetch(:folder), created: false } if existing
+
+ create_task_candidate!(
+ task_dir, slug: slug, entry_stage: entry_stage, workflow: workflow,
+ depends_on: depends_on, base_branch: base_branch,
+ workflow_info: workflow_info, hive_state: hive_state,
+ idempotency_key: @idempotency_key, input_fingerprint: fingerprint,
+ stable_selection: stable_selection
+ )
+ begin
+ ops.hive_commit(stage_name: entry_stage.dir, slug: slug, action: "captured")
+ rescue StandardError, Interrupt
+ cleanup_failed_task_commit!(ops, hive_state, task_dir)
+ raise
+ end
+ { folder: task_dir, created: true }
+ end
+ end
+ unless result.fetch(:created)
+ return emit_task_result(result.fetch(:folder), workflow, created: false)
+ end
+
+ spawn_name_generator(task_dir)
+ return emit_task_result(task_dir, workflow, created: true)
+ end
+
+ create_task_candidate!(
+ task_dir, slug: slug, entry_stage: entry_stage, workflow: workflow,
+ depends_on: depends_on, base_branch: base_branch,
+ workflow_info: workflow_info, hive_state: hive_state,
+ idempotency_key: nil, input_fingerprint: nil
+ )
+ Hive::Lock.with_commit_lock(hive_state) do
+ begin
+ ops.hive_commit(stage_name: entry_stage.dir, slug: slug, action: "captured")
+ rescue StandardError, Interrupt
+ cleanup_failed_task_commit!(ops, hive_state, task_dir)
+ raise
+ end
end
- FileUtils.mkdir_p(task_dir)
+ spawn_name_generator(task_dir)
+ emit_task_result(task_dir, workflow, created: true)
+ ensure
+ cleanup_prepared_attachments!
+ end
+ def create_task_candidate!(task_dir, slug:, entry_stage:, workflow:, depends_on:, base_branch:,
+ workflow_info:, hive_state:, idempotency_key:, input_fingerprint:,
+ stable_selection: :unlocked)
+ created = false
+ FileUtils.mkdir_p(File.dirname(task_dir))
+ Dir.mkdir(task_dir)
+ created = true
idea_path = File.join(task_dir, entry_stage.state_file)
- id = nil
- begin
- File.write(idea_path, render_initial_state(slug, @text, body_override: @body_override, workflow: workflow))
- copy_attachments!(task_dir)
- id = Hive::TaskCounter.next_or_nil
- write_task_meta(task_dir, id: id, slug: slug, depends_on: depends_on,
- base_branch: base_branch, workflow: workflow,
- workflow_info: workflow_info, hive_state: hive_state)
- rescue StandardError
- # An idea.md or attachment write failure leaves an orphan
- # uncommitted task on disk that the snapshot would surface as a
- # broken entry under the workflow's entry stage dir (`entry_stage.dir`
- # — `1-inbox` for coding, but different for other workflows). Roll the
- # directory back so the capture is atomic — either the task is
- # committed or it never existed.
+ File.write(
+ idea_path,
+ render_initial_state(slug, @text, body_override: @body_override, workflow: workflow)
+ )
+ if entry_stage.kind == :human
+ Hive::Markers.set(idea_path, :waiting, "decision_id" => SecureRandom.hex(8))
+ end
+ copy_attachments!(task_dir)
+ validate_stable_authored_workflow!(workflow_info, hive_state)
+ id = Hive::TaskCounter.next_or_nil
+ write_task_meta(
+ task_dir, id: id, slug: slug, depends_on: depends_on,
+ base_branch: base_branch, workflow: workflow,
+ workflow_info: workflow_info, hive_state: hive_state,
+ idempotency_key: idempotency_key, input_fingerprint: input_fingerprint,
+ stable_selection: stable_selection
+ )
+ rescue Errno::EEXIST
+ if created
FileUtils.rm_rf(task_dir)
raise
end
- ops = Hive::GitOps.new(project["path"])
- Hive::Lock.with_commit_lock(hive_state) do
- ops.hive_commit(stage_name: entry_stage.dir, slug: slug, action: "captured")
+ raise SlugCollisionError.new(
+ "slug collision at #{task_dir} (rare; retry the command)",
+ value: slug
+ )
+ rescue StandardError, Interrupt
+ # Once this invocation owns the directory, any idea, attachment, or
+ # metadata failure must remove its uncommitted candidate. An EEXIST
+ # contender never owns the directory and therefore never removes it.
+ FileUtils.rm_rf(task_dir) if created
+ raise
+ end
+
+ def validate_idempotency_key!
+ return nil if @idempotency_key_raw.nil?
+
+ key = @idempotency_key_raw.to_s.strip
+ unless !key.empty? && key.valid_encoding? && key.bytesize <= 512
+ raise IdempotencyConflict.new(
+ "idempotency key must be non-empty UTF-8 text no longer than 512 bytes",
+ value: @idempotency_key_raw.to_s
+ )
end
- spawn_name_generator(task_dir)
+ key
+ end
+
+ def input_fingerprint(workflow_info, depends_on:, base_branch:)
+ managed = workflow_info.fetch(:managed)
+ attachments = @prepared_attachments.map do |attachment|
+ {
+ "destination" => attachment.destination,
+ "sha256" => attachment.sha256
+ }
+ end
+ input = {
+ "text" => @text,
+ "body" => @body_override,
+ "attachments" => attachments,
+ "depends_on" => depends_on,
+ "base_branch" => base_branch,
+ "workflow" => workflow_info.fetch(:descriptor).id.to_s,
+ "workflow_commit" => managed&.fetch("source_commit"),
+ "workflow_configuration_digest" => managed&.fetch("configuration_digest"),
+ "workflow_content_digest" => workflow_info[:authored_digest]
+ }
+ ::Digest::SHA256.hexdigest(JSON.generate(input))
+ end
- puts "hive: captured #{idea_path}"
- target_hint = id ? id.to_s : slug
- next_stage = workflow.next_stage_after(entry_stage.name)
- if next_stage
- puts "next: mv #{task_dir} #{File.join(hive_state, 'stages', "#{next_stage.dir}/")} && hive run #{target_hint}"
+ def find_idempotent_task!(hive_state, key, fingerprint)
+ matches = Dir.glob(File.join(hive_state, "stages", "*", "*", Hive::TaskMeta::FILENAME)).filter_map do |path|
+ folder = File.dirname(path)
+ read = Hive::TaskMeta.read_for_admission(folder)
+ unless read.status == :ok
+ raise IdempotencyConflict.new(
+ "cannot prove idempotency while task metadata is unreadable at #{path}: " \
+ "#{read.error || read.status}",
+ value: key
+ )
+ end
+ meta = read.data
+ { folder: folder, meta: meta } if meta[:idempotency_key] == key
+ end
+ return nil if matches.empty?
+ if matches.length > 1
+ raise IdempotencyConflict.new(
+ "idempotency key #{key.inspect} is already attached to multiple tasks; repair metadata before retrying",
+ value: key
+ )
+ end
+
+ match = matches.first
+ return match if match.dig(:meta, :input_fingerprint) == fingerprint
+
+ raise IdempotencyConflict.new(
+ "idempotency key #{key.inspect} was already used for different input or workflow",
+ value: key
+ )
+ end
+
+ def emit_task_result(task_folder, workflow, created:)
+ task = Hive::Task.new(task_folder)
+ action = Hive::TaskAction.for(task, Hive::Markers.current(task.state_file))
+ payload = {
+ "schema" => SCHEMA,
+ "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch(SCHEMA),
+ "ok" => true,
+ "created" => created,
+ "slug" => task.slug,
+ "workflow" => workflow.id.to_s,
+ "current_stage" => "#{task.stage_index}-#{task.stage_name}",
+ "task_folder" => task.folder,
+ "next_action" => {
+ "kind" => action.key,
+ "command" => action.command,
+ "outcomes" => action.allowed_outcomes
+ }
+ }
+ if @json
+ puts JSON.generate(payload)
+ elsif created
+ puts "hive: captured #{task.state_file}"
+ target_hint = task.id ? task.id.to_s : task.slug
+ next_stage = workflow.next_stage_after(task.stage_name)
+ if next_stage
+ destination = File.join(task.hive_state_path, "stages", "#{next_stage.dir}/")
+ puts "next: mv #{task.folder} #{destination} && hive run #{target_hint}"
+ else
+ puts "next: hive run #{target_hint}"
+ end
else
- puts "next: hive run #{target_hint}"
+ puts "hive: idempotent task already exists at #{task.folder}"
+ puts "next: #{action.command}" if action.command
end
+ payload
end
def resolve_workflow(project)
@@ -218,31 +414,88 @@ def workflow_resolution(descriptor, project, pin:)
managed = store.selected(descriptor.id.to_s) { cfg = managed_project_config(project) }
{
descriptor: descriptor, pin: pin,
- managed: managed, managed_cfg: cfg
+ managed: managed, managed_cfg: cfg,
+ authored_digest: authored_workflow_digest(project.fetch("hive_state_path"), descriptor)
}
end
- def write_task_meta(task_dir, id:, slug:, depends_on:, base_branch:, workflow:, workflow_info:, hive_state:)
+ def write_task_meta(task_dir, id:, slug:, depends_on:, base_branch:, workflow:, workflow_info:, hive_state:,
+ idempotency_key: nil, input_fingerprint: nil, stable_selection: :unlocked)
managed = workflow_info.fetch(:managed)
managed_cfg = workflow_info.fetch(:managed_cfg, {})
store = Hive::WorkflowPackage::ManagedStore.new(hive_state)
- store.with_stable_selection(workflow.id.to_s, cfg: managed_cfg) do |current|
- if managed
- unless current && current.fetch("source_commit") == managed.fetch("source_commit") &&
- current.fetch("manifest_digest") == managed.fetch("manifest_digest") &&
- current.fetch("configuration_digest") == managed.fetch("configuration_digest")
- raise Hive::ConcurrentRunError.new("managed workflow selection changed while creating the task")
- end
- end
+ writer = lambda do |current|
+ validate_stable_selection!(managed, current)
Hive::TaskMeta.write(
task_dir, id: id, slug: slug, display_name: nil, depends_on: depends_on,
base_branch: base_branch,
workflow: workflow_info.fetch(:pin) ? workflow.id.to_s : nil,
workflow_commit: managed&.fetch("source_commit"),
workflow_manifest_digest: managed&.fetch("manifest_digest"),
- workflow_configuration_digest: managed&.fetch("configuration_digest")
+ workflow_configuration_digest: managed&.fetch("configuration_digest"),
+ idempotency_key: idempotency_key,
+ input_fingerprint: input_fingerprint
)
end
+ return writer.call(stable_selection) unless stable_selection == :unlocked
+
+ store.with_stable_selection(workflow.id.to_s, cfg: managed_cfg, &writer)
+ end
+
+ def with_stable_workflow_selection(workflow_info, hive_state)
+ workflow = workflow_info.fetch(:descriptor)
+ store = Hive::WorkflowPackage::ManagedStore.new(hive_state)
+ store.with_stable_selection(
+ workflow.id.to_s, cfg: workflow_info.fetch(:managed_cfg, {})
+ ) do |current|
+ validate_stable_selection!(workflow_info.fetch(:managed), current)
+ yield current
+ end
+ end
+
+ def validate_stable_selection!(managed, current)
+ return unless managed
+ return if current && current.fetch("source_commit") == managed.fetch("source_commit") &&
+ current.fetch("manifest_digest") == managed.fetch("manifest_digest") &&
+ current.fetch("configuration_digest") == managed.fetch("configuration_digest")
+
+ raise Hive::ConcurrentRunError.new("managed workflow selection changed while creating the task")
+ end
+
+ def validate_stable_authored_workflow!(workflow_info, hive_state)
+ expected = workflow_info[:authored_digest]
+ return unless expected
+
+ actual = authored_workflow_digest(hive_state, workflow_info.fetch(:descriptor))
+ return if actual == expected
+
+ raise Hive::ConcurrentRunError,
+ "owner-authored workflow changed while creating the task; retry against the current descriptor"
+ end
+
+ def authored_workflow_digest(hive_state, descriptor)
+ workflows = File.join(hive_state, "workflows")
+ descriptor_path = File.join(workflows, "#{descriptor.id}.yml")
+ return nil unless File.file?(descriptor_path)
+
+ instruction_root = File.join(workflows, descriptor.id.to_s)
+ paths = [ descriptor_path ]
+ if File.directory?(instruction_root)
+ paths.concat(
+ Dir.glob(File.join(instruction_root, "**", "*"), File::FNM_DOTMATCH)
+ .select { |path| File.file?(path) }
+ )
+ end
+ entries = paths.sort.map do |path|
+ [ path.delete_prefix("#{workflows}/"), ::Digest::SHA256.file(path).hexdigest ]
+ end
+ ::Digest::SHA256.hexdigest(JSON.generate(entries))
+ end
+
+ def cleanup_failed_task_commit!(ops, hive_state, task_dir)
+ FileUtils.rm_rf(task_dir)
+ relative = task_dir.delete_prefix("#{File.expand_path(hive_state)}/")
+ ops.run_git!("-C", hive_state, "reset", "-q", "HEAD", "--", relative)
end
def managed_project_config(project)
@@ -408,27 +661,74 @@ def copy_attachments!(task_dir)
assets_dir = File.join(task_dir, "assets")
FileUtils.mkdir_p(assets_dir)
- @attachments.each do |src, dest_name|
- name = Hive::Tui::Text.sanitize(dest_name.to_s)
- # `name != File.basename(name)` rejects directory separators
- # and the like, but `File.basename(".") == "."` and
- # `File.basename("..") == ".."` — both would otherwise slip
- # through and either overwrite `assets/` itself or escape it
- # via FileUtils.cp's path-join. Reject them explicitly.
- if name.empty? || name == "." || name == ".." || name != File.basename(name)
- raise InvalidAttachmentError.new("invalid attachment filename '#{name}'", value: name)
+ if @prepared_attachments
+ @prepared_attachments.each do |attachment|
+ destination = File.join(assets_dir, attachment.name)
+ FileUtils.cp(attachment.snapshot_path, destination)
+ next if ::Digest::SHA256.file(destination).hexdigest == attachment.sha256
+
+ raise InvalidAttachmentError.new(
+ "attachment snapshot changed while creating the task",
+ value: attachment.destination
+ )
end
+ else
+ @attachments.each do |src, dest_name|
+ name = attachment_name!(dest_name)
+ # `attachments:` is a programmatic contract used by the TUI and
+ # tests; callers may pass any absolute source path they captured.
+ # Keep the destination guard strict, but let FileUtils surface
+ # source readability/existence failures directly.
+ src_path = File.expand_path(src.to_s)
+ FileUtils.cp(src_path, File.join(assets_dir, name))
+ end
+ end
+ end
- # `attachments:` is a programmatic contract used by the TUI and
- # tests; callers may pass any absolute source path they captured.
- # Keep the destination guard strict, but let FileUtils surface
- # source readability/existence failures directly.
- src_path = File.expand_path(src.to_s)
+ def prepare_idempotent_attachments!
+ if @attachments.empty?
+ @prepared_attachments = []
+ return
+ end
- FileUtils.cp(src_path, File.join(assets_dir, name))
+ @attachment_snapshot_dir = Dir.mktmpdir("hive-new-attachments")
+ @prepared_attachments = @attachments.each_with_index.map do |(source, destination), index|
+ name = attachment_name!(destination)
+ snapshot_path = File.join(@attachment_snapshot_dir, index.to_s)
+ FileUtils.cp(File.expand_path(source.to_s), snapshot_path)
+ PreparedAttachment.new(
+ snapshot_path: snapshot_path,
+ destination: destination.to_s,
+ name: name,
+ sha256: ::Digest::SHA256.file(snapshot_path).hexdigest
+ )
end
end
+ def cleanup_prepared_attachments!
+ return unless @attachment_snapshot_dir
+
+ FileUtils.remove_entry_secure(@attachment_snapshot_dir)
+ rescue Errno::ENOENT
+ nil
+ ensure
+ @attachment_snapshot_dir = nil
+ @prepared_attachments = nil
+ end
+
+ def attachment_name!(destination)
+ name = Hive::Tui::Text.sanitize(destination.to_s)
+ # `name != File.basename(name)` rejects directory separators and the
+ # like, but `File.basename(".") == "."` and `File.basename("..") ==
+ # ".."` — both would otherwise slip through and overwrite or escape
+ # `assets/`.
+ if name.empty? || name == "." || name == ".." || name != File.basename(name)
+ raise InvalidAttachmentError.new("invalid attachment filename '#{name}'", value: name)
+ end
+
+ name
+ end
+
def spawn_name_generator(task_dir)
return nil if defined?(Minitest) && !self.class.name_generator_spawn_configured?
diff --git a/lib/hive/commands/run.rb b/lib/hive/commands/run.rb
index 53d7c294c6..fc905cd634 100644
--- a/lib/hive/commands/run.rb
+++ b/lib/hive/commands/run.rb
@@ -39,7 +39,7 @@ class Run
# only when the runner result carries the corresponding data — currently
# `cleanup_instructions:` from the terminal `done` stage. See
# schemas/hive-run.v1.json $defs.SuccessPayload.properties.
- OPTIONAL_PAYLOAD_KEYS = %w[cleanup_instructions].freeze
+ OPTIONAL_PAYLOAD_KEYS = %w[cleanup_instructions allowed_outcomes].freeze
# Exact pre-run task-folder snapshot used only for terminal runners. Lock
# artifacts are process-owned and intentionally remain live across a
@@ -134,6 +134,12 @@ def do_call
@observation_guard&.call(task)
cfg = Hive::Config.load(task.project_root)
marker = Hive::Markers.current(task.state_file)
+ if human_stage?(task)
+ @rebase_result = human_stage_rebase_result
+ status = marker.name == :complete ? :complete : :needs_input
+ report(task, { commit: nil, status: status })
+ return
+ end
if marker.name == :manual_steering
@rebase_result = manual_steering_rebase_result
report(task, { commit: nil, status: :manual_steering })
@@ -249,6 +255,12 @@ def manual_steering_rebase_result
Hive::Rebase::Result.skipped(:manual_steering)
end
+ def human_stage_rebase_result
+ require "hive/rebase"
+
+ Hive::Rebase::Result.skipped(:human_stage)
+ end
+
def pick_runner(task)
Hive::Stages::Resolver.resolve(task, descriptor: task.workflow)
end
@@ -373,6 +385,7 @@ def report_json(task, result, marker)
if result.is_a?(Hash) && result[:cleanup_instructions]
payload["cleanup_instructions"] = result[:cleanup_instructions]
end
+ payload["allowed_outcomes"] = allowed_outcomes(task) if human_decision_required?(task, marker)
# The JSON payload is written to stdout *before* the raise. bin/hive
# rescues Hive::Error and calls `exit(e.exit_code)`; Ruby's normal
# interpreter shutdown flushes stdout via IO finalizers, so the
@@ -410,6 +423,21 @@ def rebase_envelope_block
def json_next_action(task, marker)
kind = Hive::Schemas::NextActionKind
+ if human_stage?(task)
+ if marker.name == :complete
+ return { "kind" => kind::NO_OP, "reason" => "human_stage_complete" }
+ end
+
+ decision_id = marker.attrs["decision_id"].to_s
+ return {
+ "kind" => kind::NO_OP,
+ "reason" => "human_decision_required",
+ "allowed_outcomes" => allowed_outcomes(task),
+ "decide_with" => "hive decide #{task.slug} <outcome> --from #{task.stage_name} " \
+ "--decision-id #{decision_id}"
+ }
+ end
+
case marker.name
when :waiting
{ "kind" => kind::EDIT, "target" => task.state_file, "rerun_with" => friendly_command(task, marker) }
@@ -596,6 +624,18 @@ def report_text(task, result, marker)
end
puts "hive: marker=#{marker.name}"
puts " state_file: #{task.state_file}"
+ if human_stage?(task)
+ if marker.name == :complete
+ puts " complete: human workflow outcome recorded"
+ return
+ end
+
+ decision_id = marker.attrs["decision_id"].to_s
+ puts " outcomes: #{allowed_outcomes(task).map { |outcome| outcome.fetch('name') }.join(', ')}"
+ puts " next: hive decide #{task.slug} <outcome> --from #{task.stage_name} " \
+ "--decision-id #{decision_id}"
+ return
+ end
case marker.name
when :waiting
puts " next: edit the file, then `#{friendly_command(task, marker)}` again"
@@ -661,6 +701,18 @@ def project_name_for(task)
project ? project["name"] : task.project_name
end
+ def human_stage?(task)
+ task.workflow.stage_named(task.stage_name)&.kind == :human
+ end
+
+ def human_decision_required?(task, marker)
+ human_stage?(task) && marker.name != :complete
+ end
+
+ def allowed_outcomes(task)
+ Hive::TaskAction.for(task, Hive::Markers.current(task.state_file)).allowed_outcomes
+ end
+
# Map a Hive::Error subclass to a RunErrorKind value. Ordering matters:
# `case/when` uses `===` (is_a?), so subclasses MUST precede their
# ancestors. Notably:
diff --git a/lib/hive/commands/status.rb b/lib/hive/commands/status.rb
index 684628d3e8..6cd0b003d7 100644
--- a/lib/hive/commands/status.rb
+++ b/lib/hive/commands/status.rb
@@ -611,6 +611,7 @@ def task_payload(row, now: Time.now.utc)
"action" => row[:action_key],
"action_label" => row[:action_label],
"suggested_command" => row[:suggested_command],
+ "outcomes" => row[:outcomes] || [],
"next_action" => row[:next_action],
"diagnostic" => row[:diagnostic]
}
@@ -1477,6 +1478,7 @@ def decorate(task, marker, lock_holder: nil, live_task_lock: false)
"Answer questions",
"Review plan draft",
"Needs your input",
+ "Awaiting human decision",
"Needs review decision",
"Confirm finalize",
"Ready to plan",
@@ -1592,6 +1594,7 @@ def annotate_actions(rows, project, project_count, config: nil, with_diagnostic:
action_key: action.key,
action_label: action.label,
suggested_command: action.command,
+ outcomes: action.allowed_outcomes,
next_action: action.next_action,
diagnostic: with_diagnostic ? action.diagnostic : nil,
condition_gate: action.condition_gate&.to_h,
diff --git a/lib/hive/commands/workflow.rb b/lib/hive/commands/workflow.rb
index a74e5cce8a..1193f96fef 100644
--- a/lib/hive/commands/workflow.rb
+++ b/lib/hive/commands/workflow.rb
@@ -3,6 +3,7 @@
require "json"
require "pathname"
require "shellwords"
+require "stringio"
require "hive"
require "hive/config"
require "hive/git_ops"
@@ -20,20 +21,21 @@ class Workflow
HONEYCOMB_TEMPLATES = %w[architecture writing].freeze
WORKFLOW_ID_RE = Hive::Workflows::DescriptorParser::SAFE_SLUG
SCHEMA = "hive-workflow-new".freeze
- SUBCOMMANDS = %w[new install list update remove publish].freeze
+ SUBCOMMANDS = %w[new validate commit install list update remove publish].freeze
class UsageError < Hive::Error
- attr_reader :value, :expected
+ attr_reader :value, :expected, :suggested_id
# Closed set of usage-error shapes (which structured field each carries):
# missing subcommand -> expected only
# unknown subcommand -> value (the rejected verb) + expected
# bad/missing/reserved id / -> value (the rejected/colliding token) only
# scaffold collision
- def initialize(message, value: nil, expected: nil)
+ def initialize(message, value: nil, expected: nil, suggested_id: nil)
super(message)
@value = value
@expected = expected
+ @suggested_id = suggested_id
end
def exit_code
@@ -45,22 +47,22 @@ def self.new!(id, project_root: Dir.pwd, json: false, stdout: $stdout, template:
new("new", id, project_root: project_root, json: json, stdout: stdout, template: template).call!
end
- def self.normalize_and_validate_id!(raw)
+ def self.normalize_and_validate_id!(raw, project_root: nil)
id = normalize_id(raw)
- validate_id!(id)
+ validate_id!(id, project_root: project_root)
id
end
def self.scaffold_files!(id_raw, project_root:, template: DEFAULT_TEMPLATE)
- id = normalize_and_validate_id!(id_raw)
+ id = normalize_and_validate_id!(id_raw, project_root: project_root)
template_dir = template_dir!(template)
paths = scaffold_paths(id, project_root: project_root, template_dir: template_dir)
- refuse_overwrite!(paths)
+ refuse_overwrite!(paths, id: id, project_root: project_root)
begin
write_scaffold!(id, paths, template_dir)
validate_descriptor!(paths.fetch(:descriptor))
Hive::Workflows::Project.reset!
- rescue StandardError
+ rescue StandardError, Interrupt
rollback_scaffold(paths)
raise
end
@@ -86,6 +88,21 @@ def self.scaffold_collision?(id, project_root:)
scaffold_collisions(paths).any?
end
+ # Return the first create-safe id in a deterministic numeric sequence.
+ # This is intentionally read-only and shared by the CLI and canonical
+ # skill so collision recovery never depends on an agent guessing a name.
+ def self.available_id(raw, project_root:)
+ base = raw.to_s.strip
+ base = "workflow" unless WORKFLOW_ID_RE.match?(base)
+ suffix = 1
+ loop do
+ candidate = suffix == 1 ? base : "#{base}-#{suffix}"
+ return candidate unless unavailable_id?(candidate, project_root: project_root)
+
+ suffix += 1
+ end
+ end
+
# Sample templates ship as directories under `templates/workflows/` (the
# bare `blank` stub plus richer multi-stage samples). A template dir holds
# `descriptor.yml.erb` (rendered with the new id) and one `.md` instruction
@@ -131,19 +148,9 @@ def self.commit_workflow_scaffold(ops, slug:, pathspecs:)
end
# Filesystem-only rollback: unwinds the working-tree files write_scaffold!
- # created, NOT any git side-effects of a partially-run commit. hive_commit
- # stages by explicit pathspec, so a same-command retry re-stages exactly
- # these paths and refuse_overwrite! re-checks them — removing the files is
- # all a retry needs.
- #
- # NOTE: the shared-worktree callers commit into the long-lived
- # `.hive-state` worktree, where a failed commit ALSO leaves these pathspecs
- # STAGED. `hive init --new-workflow` on an existing project resets the index
- # for them (Init#reset_hive_state_index); `hive workflow new`'s own
- # commit_scaffold! (below) shares the SAME pre-existing exposure but
- # currently relies on this filesystem-only rollback alone — out of scope to
- # fix here, but `workflow new` is NOT index-safe just because init now is.
- # Either way this method intentionally leaves git untouched.
+ # created. Callers that attempted a commit own exact-path index rollback
+ # while still holding the commit lock; this helper intentionally leaves
+ # git untouched.
# remove_scaffold_path tolerates
# an already-absent target (like rm_f/rm_rf's force) and continues to the
# next path on a fault, but CAPTURES the errno so warn_failed_scaffold_cleanup
@@ -152,11 +159,19 @@ def self.commit_workflow_scaffold(ops, slug:, pathspecs:)
# refuse_overwrite! "already exists" on the next attempt.
def self.rollback_scaffold(paths)
reasons = {}
- remove_scaffold_path(paths.fetch(:descriptor), reasons)
- remove_scaffold_path(paths.fetch(:instruction_dir), reasons)
- warn_failed_scaffold_cleanup(paths, reasons)
+ rollback_targets(paths).reverse_each do |path|
+ remove_scaffold_path(path, reasons)
+ end
+ warn_failed_scaffold_cleanup(paths, reasons, targets: rollback_targets(paths))
end
+ def self.rollback_targets(paths)
+ paths.fetch(:owned_paths) do
+ [ paths.fetch(:instruction_dir), paths.fetch(:descriptor) ]
+ end
+ end
+ private_class_method :rollback_targets
+
# Remove one scaffold path, recording the underlying errno when a genuine
# permission/busy fault keeps it on disk. The existence guard makes an
# already-absent target a no-op (matching rm_f/rm_rf's force: true) without
@@ -178,16 +193,34 @@ def self.normalize_id(value)
end
private_class_method :normalize_id
- def self.validate_id!(id)
+ def self.validate_id!(id, project_root: nil)
unless WORKFLOW_ID_RE.match?(id)
raise UsageError.new("invalid workflow id #{id.inspect} (must match #{WORKFLOW_ID_RE.source})", value: id)
end
return unless Hive::Workflows::Registry::WORKFLOWS.key?(id.to_sym)
- raise UsageError.new("workflow id #{id.inspect} is reserved by a built-in workflow", value: id)
+ suggested = available_id(id, project_root: project_root) if project_root
+ suffix = suggested ? "; try #{suggested.inspect}" : ""
+ raise UsageError.new(
+ "workflow id #{id.inspect} is reserved by a built-in workflow#{suffix}",
+ value: id, suggested_id: suggested
+ )
end
private_class_method :validate_id!
+ def self.unavailable_id?(id, project_root:)
+ return true if Hive::Workflows::Registry::WORKFLOWS.key?(id.to_sym)
+
+ dir = workflow_dir(project_root)
+ path_present?(File.join(dir, "#{id}.yml")) || path_present?(File.join(dir, id))
+ end
+ private_class_method :unavailable_id?
+
+ def self.path_present?(path)
+ File.exist?(path) || File.symlink?(path)
+ end
+ private_class_method :path_present?
+
def self.scaffold_paths(id, project_root:, template_dir:)
workflows_dir = workflow_dir(project_root)
instruction_dir = File.join(workflows_dir, id)
@@ -219,11 +252,15 @@ def self.workflow_dir(project_root)
end
private_class_method :workflow_dir
- def self.refuse_overwrite!(paths)
+ def self.refuse_overwrite!(paths, id:, project_root:)
collisions = scaffold_collisions(paths)
return if collisions.empty?
- raise UsageError.new("workflow scaffold already exists at #{collisions.join(', ')}", value: collisions.first)
+ suggested = available_id(id, project_root: project_root)
+ raise UsageError.new(
+ "workflow scaffold already exists at #{collisions.join(', ')}; try #{suggested.inspect}",
+ value: collisions.first, suggested_id: suggested
+ )
end
private_class_method :refuse_overwrite!
@@ -233,28 +270,55 @@ def self.refuse_overwrite!(paths)
# dir, and every per-stage instruction file the template would write.
def self.scaffold_collisions(paths)
([ paths.fetch(:descriptor), paths.fetch(:instruction_dir) ] + paths.fetch(:instructions)).select do |path|
- File.exist?(path)
+ path_present?(path)
end
end
private_class_method :scaffold_collisions
def self.write_scaffold!(id, paths, template_dir)
- FileUtils.mkdir_p(paths.fetch(:instruction_dir))
- File.write(paths.fetch(:descriptor), render_descriptor(id, template_dir))
+ paths[:owned_paths] = []
+ ensure_scaffold_root!(File.dirname(paths.fetch(:instruction_dir)))
+ Dir.mkdir(paths.fetch(:instruction_dir))
+ paths.fetch(:owned_paths) << paths.fetch(:instruction_dir)
+ write_exclusive(paths.fetch(:descriptor), render_descriptor(id, template_dir))
+ paths.fetch(:owned_paths) << paths.fetch(:descriptor)
template_instruction_files(template_dir).each do |file|
- File.write(File.join(paths.fetch(:instruction_dir), file), File.read(File.join(template_dir, file)))
+ write_exclusive(
+ File.join(paths.fetch(:instruction_dir), file),
+ File.read(File.join(template_dir, file))
+ )
end
write_optional_template_asset(id, template_dir, "README.md.erb", File.join(paths.fetch(:instruction_dir), "README.md"))
write_optional_template_asset(id, template_dir, "honeycomb.yml.erb", File.join(paths.fetch(:instruction_dir), "honeycomb.yml"))
end
private_class_method :write_scaffold!
+ def self.ensure_scaffold_root!(path)
+ begin
+ Dir.mkdir(path)
+ rescue Errno::EEXIST
+ nil
+ end
+ stat = File.lstat(path)
+ return if stat.directory? && !stat.symlink?
+
+ raise Hive::ConfigError, "workflow scaffold root #{path} must be a regular directory, not a symlink"
+ end
+ private_class_method :ensure_scaffold_root!
+
+ def self.write_exclusive(path, content)
+ flags = File::WRONLY | File::CREAT | File::EXCL
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
+ File.open(path, flags, 0o644) { |file| file.write(content) }
+ end
+ private_class_method :write_exclusive
+
def self.write_optional_template_asset(id, template_dir, template_name, destination)
source = File.join(template_dir, template_name)
return unless File.file?(source)
rendered = ERB.new(File.read(source), trim_mode: "-").result_with_hash(id: id)
- File.write(destination, rendered)
+ write_exclusive(destination, rendered)
end
private_class_method :write_optional_template_asset
@@ -275,8 +339,8 @@ def self.validate_descriptor!(path)
# the errno remove_scaffold_path captured per leftover so the operator
# learns WHY cleanup failed (e.g. EACCES on a read-only parent) instead of
# a bare path list.
- def self.warn_failed_scaffold_cleanup(paths, reasons = {})
- leftovers = [ paths.fetch(:descriptor), paths.fetch(:instruction_dir) ].select { |path| File.exist?(path) }
+ def self.warn_failed_scaffold_cleanup(paths, reasons = {}, targets: rollback_targets(paths))
+ leftovers = targets.select { |path| path_present?(path) }
return if leftovers.empty?
described = leftovers.map do |path|
@@ -309,13 +373,12 @@ def initialize(subcommand, id = nil, project_root: Dir.pwd, json: false, stdout:
def call
call!
- rescue UsageError, Hive::ConfigError, Hive::GitError, Hive::ConcurrentRunError,
- SystemCallError, IOError => e
+ rescue Hive::Error, SystemCallError, IOError => e
# ConcurrentRunError (commit-lock contention, TEMPFAIL/75) is now caught
# too: previously it escaped to bin/hive as plain stderr, hiding the
# retryable-vs-terminal distinction from --json agent callers.
# SystemCallError/IOError are caught for the same reason: a disk-write
- # fault from write_scaffold!'s mkdir_p/File.write would otherwise escape
+ # fault from write_scaffold!'s exclusive directory/file creation would otherwise escape
# past `call` to bin/hive (which only handles Errno::EPIPE/Thor::Error/
# Hive::Error), yielding a raw backtrace instead of the schema'd --json
# envelope. error_kind_for's else→ERROR arm classifies them.
@@ -346,6 +409,7 @@ def call!
)
end
+ return commit_authored_workflow! if @subcommand == "commit"
return lifecycle_command.call unless @subcommand == "new"
scaffold = self.class.scaffold_files!(@id, project_root: @project_root, template: @template)
@@ -357,7 +421,7 @@ def call!
# descriptor + instruction files on disk that make the next retry fail
# in refuse_overwrite!, turning a retryable command into a stuck one.
commit_scaffold!(id, paths)
- rescue StandardError
+ rescue StandardError, Interrupt
self.class.rollback_scaffold(paths)
raise
end
@@ -379,8 +443,55 @@ def call!
private
+ def commit_authored_workflow!
+ raise UsageError.new("workflow commit does not support --json", value: @id) if @json
+
+ id = self.class.normalize_and_validate_id!(@id, project_root: @project_root)
+ require "hive/commands/workflow/validate"
+ validation = Hive::Commands::Workflow::Validate.new(
+ id, project_root: @project_root, stdout: StringIO.new
+ ).call!
+ unless validation.fetch("origin") == "authored"
+ raise UsageError.new(
+ "workflow commit requires an owner-authored workflow, got #{validation.fetch('origin')}",
+ value: id
+ )
+ end
+
+ descriptor = validation.fetch("descriptor_path")
+ instruction_dir = File.join(File.dirname(descriptor), id)
+ pathspecs = [ relative_to_workflows_root(descriptor) ]
+ pathspecs << relative_to_workflows_root(instruction_dir) if File.directory?(instruction_dir)
+ ops = Hive::GitOps.new(@project_root)
+ result = Hive::Lock.with_commit_lock(hive_state_path) do
+ begin
+ ops.hive_commit(
+ stage_name: "workflows", slug: id, action: "defined",
+ pathspecs: pathspecs
+ )
+ rescue StandardError, Interrupt
+ ops.run_git!("-C", hive_state_path, "reset", "-q", "HEAD", "--", *pathspecs)
+ raise
+ end
+ end
+ @stdout.puts(
+ result == :committed ?
+ "hive: committed populated workflow #{id}" :
+ "hive: populated workflow #{id} is already committed"
+ )
+ {
+ "ok" => true, "id" => id, "committed" => result == :committed,
+ "descriptor_path" => descriptor
+ }
+ end
+
def lifecycle_command
case @subcommand
+ when "validate"
+ require "hive/commands/workflow/validate"
+ Hive::Commands::Workflow::Validate.new(
+ @id, project_root: @project_root, json: @json, stdout: @stdout
+ )
when "install"
require "hive/commands/workflow/install"
Hive::Commands::Workflow::Install.new(
@@ -416,11 +527,19 @@ def lifecycle_command
def commit_scaffold!(id, paths)
ops = Hive::GitOps.new(@project_root)
+ pathspecs = [
+ relative_to_workflows_root(paths.fetch(:descriptor)),
+ relative_to_workflows_root(paths.fetch(:instruction_dir))
+ ]
Hive::Lock.with_commit_lock(hive_state_path) do
- self.class.commit_workflow_scaffold(ops, slug: id, pathspecs: [
- relative_to_workflows_root(paths.fetch(:descriptor)),
- relative_to_workflows_root(paths.fetch(:instruction_dir))
- ])
+ begin
+ self.class.commit_workflow_scaffold(
+ ops, slug: id, pathspecs: pathspecs
+ )
+ rescue StandardError, Interrupt
+ ops.run_git!("-C", hive_state_path, "reset", "-q", "HEAD", "--", *pathspecs)
+ raise
+ end
end
end
@@ -449,7 +568,7 @@ def success_payload(id, paths)
# (agents branch on those uniformly).
def error_payload(error)
Hive::Schemas::ErrorEnvelope.build(
- schema: SCHEMA,
+ schema: response_schema,
error: error,
error_kind: error_kind_for(error),
extras: error_extras(error)
@@ -463,14 +582,27 @@ def error_extras(error)
extras = {}
extras["value"] = error.value if error.respond_to?(:value) && !error.value.nil?
extras["expected"] = error.expected if error.respond_to?(:expected) && !error.expected.nil?
+ if error.respond_to?(:suggested_id) && !error.suggested_id.nil?
+ extras["suggested_id"] = error.suggested_id
+ end
+ if @subcommand == "validate"
+ extras["valid"] = false
+ extras["id"] = @id.to_s
+ extras["diagnostics"] = [ { "message" => error.message } ]
+ end
extras
end
+ def response_schema
+ @subcommand == "validate" ? "hive-workflow-validate" : SCHEMA
+ end
+
def error_kind_for(error)
kinds = Hive::Schemas::WorkflowNewErrorKind
case error
when UsageError then kinds::USAGE
+ when Hive::Workflows::UnknownWorkflow then kinds::USAGE
when Hive::ConcurrentRunError then kinds::CONCURRENT_RUN
when Hive::GitError then kinds::GIT
when Hive::ConfigError then kinds::CONFIG
diff --git a/lib/hive/commands/workflow/validate.rb b/lib/hive/commands/workflow/validate.rb
new file mode 100644
index 0000000000..de9c4e30b6
--- /dev/null
+++ b/lib/hive/commands/workflow/validate.rb
@@ -0,0 +1,194 @@
+require "json"
+require "hive/config"
+require "hive/workflows/descriptor_parser"
+require "hive/workflows/registry"
+require "hive/workflow_package/managed_store"
+
+module Hive
+ module Commands
+ class Workflow
+ # Strictly read-only validation through direct authored, built-in, and
+ # managed-store lookups. It deliberately bypasses production selection
+ # reconciliation because even lock creation or journal repair would
+ # violate this command's no-write contract.
+ class Validate
+ SCHEMA = "hive-workflow-validate".freeze
+
+ def initialize(id, project_root:, json: false, stdout: $stdout)
+ @id = id.to_s.strip
+ @project_root = File.expand_path(project_root)
+ @json = json
+ @stdout = stdout
+ end
+
+ def call!
+ validate_id!
+ workflow = resolve_read_only!
+ validate_instruction_paths!(workflow)
+ payload = payload_for(workflow)
+ emit(payload)
+ payload
+ end
+
+ def call = call!
+
+ private
+
+ def validate_id!
+ raise UsageError.new("missing workflow id", value: @id) if @id.empty?
+ return if WORKFLOW_ID_RE.match?(@id)
+
+ raise UsageError.new(
+ "invalid workflow id #{@id.inspect} (must match #{WORKFLOW_ID_RE.source})", value: @id
+ )
+ end
+
+ def validate_instruction_paths!(workflow)
+ workflow.each do |stage|
+ next unless stage.instruction
+ next if File.file?(stage.instruction) && File.readable?(stage.instruction)
+
+ raise Hive::ConfigError,
+ "workflow descriptor #{descriptor_path || workflow.id.inspect} " \
+ "stage #{stage.name.inspect} references missing instruction #{stage.instruction.inspect}"
+ end
+ end
+
+ def payload_for(workflow)
+ {
+ "schema" => SCHEMA,
+ "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch(SCHEMA),
+ "ok" => true,
+ "valid" => true,
+ "id" => workflow.id.to_s,
+ "origin" => origin,
+ "descriptor_path" => descriptor_path,
+ "instruction_paths" => workflow.filter_map(&:instruction).uniq,
+ "stages" => workflow.map { |stage| stage_payload(stage) },
+ "automatic_edges" => automatic_edges(workflow),
+ "human_outcomes" => human_outcomes(workflow),
+ "diagnostics" => []
+ }
+ end
+
+ def stage_payload(stage)
+ {
+ "name" => stage.name,
+ "dir" => stage.dir,
+ "kind" => stage.kind&.to_s,
+ "state_file" => stage.state_file,
+ "instruction_path" => stage.instruction,
+ "input" => stage.input
+ }
+ end
+
+ def automatic_edges(workflow)
+ workflow.filter_map do |stage|
+ target = workflow.next_stage_after(stage.name)
+ next if stage.kind == :human || target.nil?
+
+ { "from" => stage.name, "to" => target.name }
+ end
+ end
+
+ def human_outcomes(workflow)
+ workflow.flat_map do |stage|
+ next [] unless stage.kind == :human
+
+ stage.outcomes.values.map do |outcome|
+ {
+ "stage" => stage.name,
+ "name" => outcome.name,
+ "complete" => outcome.complete,
+ "artifact" => outcome.artifact,
+ "to" => outcome.to
+ }
+ end
+ end
+ end
+
+ def workflows_dir
+ @workflows_dir ||= begin
+ source_path, data = Hive::Config.read_project_config(@project_root)
+ data = Hive::Config.normalize_legacy_project_config(
+ data, source_path, emit_warning: false
+ )
+ hive_state_path = data["hive_state_path"]
+ unless hive_state_path.is_a?(String)
+ hive_state_path = Hive::Config::DEFAULTS.fetch("hive_state_path")
+ end
+ File.join(File.expand_path(hive_state_path, @project_root), "workflows")
+ end
+ end
+
+ def resolve_read_only!
+ if descriptor_path
+ workflow = Hive::Workflows::DescriptorParser.parse_file(descriptor_path)
+ if Hive::Workflows::Registry::WORKFLOWS.key?(workflow.id)
+ raise Hive::ConfigError,
+ "workflow descriptor #{descriptor_path} collides with registered workflow #{workflow.id.inspect}"
+ end
+ @origin = "authored"
+ return workflow
+ end
+
+ id = @id.to_sym
+ if Hive::Workflows::Registry::WORKFLOWS.key?(id)
+ @origin = "built_in"
+ return Hive::Workflows::Registry.fetch(id)
+ end
+
+ store = Hive::WorkflowPackage::ManagedStore.new(File.dirname(workflows_dir))
+ selection = store.selected_read_only(@id)
+ if selection
+ @origin = "managed"
+ return store.workflow(
+ @id, selection.fetch("source_commit"), selection.fetch("manifest_digest"),
+ configuration_digest: selection.fetch("configuration_digest"),
+ verify_profiles: false
+ )
+ end
+
+ raise Hive::Workflows::UnknownWorkflow.new(
+ "unknown workflow #{@id.inspect}; valid workflows: #{read_only_workflow_ids.join(', ')}",
+ value: @id, valid: read_only_workflow_ids
+ )
+ end
+
+ def read_only_workflow_ids
+ authored = Dir.glob(File.join(workflows_dir, "*.yml")).map do |path|
+ File.basename(path, ".yml")
+ end
+ managed = Dir.glob(
+ File.join(workflows_dir, "*", Hive::WorkflowPackage::ManagedStore::LOCK_FILE)
+ ).map { |path| File.basename(File.dirname(path)) }
+ (Hive::Workflows::Registry::WORKFLOWS.keys.map(&:to_s) + authored + managed).uniq.sort
+ end
+
+ def descriptor_path
+ @descriptor_path ||= begin
+ path = File.join(workflows_dir, "#{@id}.yml")
+ File.file?(path) ? path : nil
+ end
+ end
+
+ def origin
+ return @origin if @origin
+ return "authored" if descriptor_path
+ return "built_in" if Hive::Workflows::Registry::WORKFLOWS.key?(@id.to_sym)
+
+ "managed"
+ end
+
+ def emit(payload)
+ if @json
+ @stdout.puts JSON.generate(payload)
+ else
+ @stdout.puts "hive: workflow #{@id} is valid (#{origin})"
+ @stdout.puts " stages: #{payload.fetch('stages').map { |stage| stage.fetch('name') }.join(' -> ')}"
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/hive/config.rb b/lib/hive/config.rb
index 1ec95dc843..ec8de88e53 100644
--- a/lib/hive/config.rb
+++ b/lib/hive/config.rb
@@ -1663,7 +1663,19 @@ def global_bot_defaults
defaults
end
- def register_project(name:, path:, repository_identity: :detect)
+ class ProjectRegistrationCollision < ConfigError
+ attr_reader :name, :existing_path
+
+ def initialize(message, name:, existing_path:)
+ super(message)
+ @name = name
+ @existing_path = existing_path
+ end
+
+ def exit_code = Hive::ExitCodes::USAGE
+ end
+
+ def register_project(name:, path:, repository_identity: :detect, replace_existing: true)
entry = nil
update_global_config! do |data|
data["registered_projects"] = Array(data["registered_projects"])
@@ -1676,6 +1688,13 @@ def register_project(name:, path:, repository_identity: :detect)
entry["real_path"] = real_path if real_path
existing = data["registered_projects"].find { |p| p.is_a?(Hash) && p["name"] == name }
if existing
+ existing_path = File.expand_path(existing.fetch("path"))
+ if !replace_existing && existing_path != abs_path
+ raise ProjectRegistrationCollision.new(
+ "project #{name.inspect} is already registered at #{existing.fetch('path')}",
+ name: name, existing_path: existing.fetch("path")
+ )
+ end
existing.replace(entry)
else
data["registered_projects"] << entry
diff --git a/lib/hive/llm_wiki_bootstrap.rb b/lib/hive/llm_wiki_bootstrap.rb
index 849003948f..5ef8bd995e 100644
--- a/lib/hive/llm_wiki_bootstrap.rb
+++ b/lib/hive/llm_wiki_bootstrap.rb
@@ -17,11 +17,35 @@ module LlmWikiBootstrap
POST_COMMIT_BEGIN = "# BEGIN LLM WIKI POST-COMMIT".freeze
POST_COMMIT_END = "# END LLM WIKI POST-COMMIT".freeze
SESSION_HOOK_MARKER = "LLM WIKI SESSION START".freeze
+ PROJECT_MANAGED_PATHS = %w[
+ .llm-wiki
+ .llm-wiki/config.json
+ .llm-wiki/refresh-wiki.sh
+ .llm-wiki/post-commit-refresh.sh
+ .llm-wiki/compile-log.sh
+ .claude
+ .claude/settings.json
+ AGENTS.md
+ CLAUDE.md
+ wiki
+ wiki/log.d
+ wiki/log.d/.gitkeep
+ wiki/index.md
+ wiki/log.md
+ wiki/gaps.md
+ wiki/architecture.md
+ wiki/decisions.md
+ wiki/dependencies.md
+ raw
+ raw/notes
+ raw/notes/.gitkeep
+ ].freeze
module_function
def install!(project_root, post_commit_hook: true, scheduler: true)
project_root = File.expand_path(project_root)
+ validate_project_managed_paths!(project_root)
FileUtils.mkdir_p(File.join(project_root, ".llm-wiki"))
ensure_config(project_root)
@@ -33,13 +57,36 @@ def install!(project_root, post_commit_hook: true, scheduler: true)
Scheduler.install(project_root) if scheduler
end
- def install_runtime_hooks!(project_root)
+ def install_runtime_hooks!(project_root, scheduler: true)
project_root = File.expand_path(project_root)
+ validate_project_managed_paths!(project_root)
ensure_config(project_root)
ensure_refresh_scripts(project_root)
ensure_shared_runtime(project_root)
ensure_post_commit_hook(project_root)
- Scheduler.install(project_root)
+ Scheduler.install(project_root) if scheduler
+ end
+
+ def validate_project_managed_paths!(project_root)
+ PROJECT_MANAGED_PATHS.each do |relative|
+ current = project_root
+ relative.split(File::SEPARATOR).each_with_index do |component, index|
+ current = File.join(current, component)
+ break unless File.exist?(current) || File.symlink?(current)
+
+ stat = File.lstat(current)
+ if stat.symlink?
+ raise Hive::ConfigError,
+ "llm-wiki managed path #{current} must not be a symlink"
+ end
+ next if index == relative.split(File::SEPARATOR).length - 1
+ next if stat.directory?
+
+ raise Hive::ConfigError,
+ "llm-wiki managed parent #{current} must be a directory"
+ end
+ end
+ true
end
def ensure_config(project_root)
@@ -231,7 +278,14 @@ def managed_content(existing, begin_marker, end_marker, block)
def write_file(path, content)
FileUtils.mkdir_p(File.dirname(path))
- File.binwrite(path, content)
+ flags = File::WRONLY | File::CREAT | File::TRUNC
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
+ File.open(path, flags, 0o644) do |file|
+ file.binmode
+ file.write(content)
+ end
+ rescue Errno::ELOOP, Errno::EMLINK
+ raise Hive::ConfigError, "llm-wiki managed file #{path} must not be a symlink"
end
end
end
diff --git a/lib/hive/markers.rb b/lib/hive/markers.rb
index 26651012c0..569b930960 100644
--- a/lib/hive/markers.rb
+++ b/lib/hive/markers.rb
@@ -49,9 +49,12 @@ def none?
def current(state_file_path)
return State.new(name: :none, attrs: {}, raw: nil) unless File.exist?(state_file_path)
- content = File.read(state_file_path, encoding: "UTF-8")
+ current_from_content(File.read(state_file_path, encoding: "UTF-8"))
+ end
+
+ def current_from_content(content)
last = nil
- content.scan(MARKER_RE) do
+ content.to_s.scan(MARKER_RE) do
match = Regexp.last_match
last = match
end
diff --git a/lib/hive/operational_status.rb b/lib/hive/operational_status.rb
index 10db88d41c..9a50981e5f 100644
--- a/lib/hive/operational_status.rb
+++ b/lib/hive/operational_status.rb
@@ -345,7 +345,11 @@ def project_row(project, row, scheduler)
"folder" => row.fetch("folder")
},
"workflow" => row["workflow"],
- "position" => { "stage" => row.fetch("stage"), "marker" => row.fetch("marker") },
+ "position" => {
+ "stage" => row.fetch("stage"),
+ "marker" => row.fetch("marker"),
+ "allowed_outcomes" => Array(row["outcomes"])
+ },
"liveness" => {
"status" => liveness_status(row),
"pid" => row["task_lock_pid"] || row["claude_pid"],
diff --git a/lib/hive/task_action.rb b/lib/hive/task_action.rb
index 3123de109a..ee2040f253 100644
--- a/lib/hive/task_action.rb
+++ b/lib/hive/task_action.rb
@@ -160,6 +160,11 @@ class TaskAction
label: "Needs your input",
command: "run"
},
+ human_needs_input: {
+ key: Hive::Schemas::TaskActionKind::NEEDS_INPUT,
+ label: "Awaiting human decision",
+ command: nil
+ },
recover_draft_pr: {
key: Hive::Schemas::TaskActionKind::RECOVER_DRAFT_PR,
label: "Retry draft PR handoff manually",
@@ -280,10 +285,25 @@ def payload
"key" => key,
"label" => label,
"command" => command,
- "next_action" => next_action
+ "next_action" => next_action,
+ "outcomes" => allowed_outcomes
}
end
+ def allowed_outcomes
+ stage = workflow_stage
+ return [] unless stage&.kind == :human
+
+ stage.outcomes.values.map do |outcome|
+ {
+ "name" => outcome.name,
+ "complete" => outcome.complete,
+ "artifact" => outcome.artifact,
+ "to" => outcome.to
+ }
+ end
+ end
+
def migration_selection
@migration_selection ||= Hive::Conditions::Migration.selection(
config: @config, stage: "#{task.stage_index}-#{task.stage_name}",
@@ -363,6 +383,8 @@ def kind_action
review_action
when :finalize
finalize_action
+ when :human
+ human_action
when :agent, :council, :inert
# `|| generic_action(stage)` is the LIVE path for NON-coding
# `:agent`/`:inert` workflows: `coding_table_action` returns nil for any
@@ -377,6 +399,10 @@ def kind_action
end
end
+ def human_action
+ marker.name == :complete ? ACTIONS.fetch(:done) : ACTIONS.fetch(:human_needs_input)
+ end
+
def coding_table_action(stage)
return nil unless Hive::Workflows.coding_id?(task_workflow.id)
diff --git a/lib/hive/task_meta.rb b/lib/hive/task_meta.rb
index e17f251ebe..a0c4a01605 100644
--- a/lib/hive/task_meta.rb
+++ b/lib/hive/task_meta.rb
@@ -11,7 +11,8 @@ module TaskMeta
FILENAME = "meta.yml".freeze
WRITABLE_FIELDS = %i[
id slug display_name depends_on workflow workflow_commit
- workflow_manifest_digest workflow_configuration_digest base_branch completed_at
+ workflow_manifest_digest workflow_configuration_digest base_branch
+ idempotency_key input_fingerprint completed_at
].freeze
class InvalidMetadata < StandardError; end
@@ -53,6 +54,12 @@ def read(task_folder)
raw["workflow_configuration_digest"] || raw[:workflow_configuration_digest]
)
end
+ if raw.key?("idempotency_key") || raw.key?(:idempotency_key)
+ data[:idempotency_key] = normalize_string(raw["idempotency_key"] || raw[:idempotency_key])
+ end
+ if raw.key?("input_fingerprint") || raw.key?(:input_fingerprint)
+ data[:input_fingerprint] = normalize_string(raw["input_fingerprint"] || raw[:input_fingerprint])
+ end
if key?(raw, "completed_at")
data[:completed_at] = normalize_completed_at(
fetch(raw, "completed_at"), label: path(task_folder), warn_on_invalid: true
@@ -72,13 +79,14 @@ def read(task_folder)
# are observable instead of failing the gate open in silence.
warn "hive: task_meta: failed to read #{path(task_folder)} " \
"(#{e.class}: #{e.message}); treating meta as empty " \
- "(depends_on, workflow, base_branch dropped; managed provenance dropped; completed_at dropped)"
+ "(depends_on, workflow, base_branch dropped; idempotency dropped; " \
+ "managed provenance dropped; completed_at dropped)"
empty
end
def read_for_admission(task_folder)
source = File.read(path(task_folder))
- %w[depends_on base_branch].each do |field|
+ %w[depends_on base_branch idempotency_key input_fingerprint].each do |field|
if Hive::Dependencies.duplicate_top_level_key?(source, field)
return AdmissionRead.new(
status: :invalid,
@@ -134,7 +142,7 @@ def read_for_admission(task_folder)
def write(task_folder, id:, slug:, display_name:, depends_on: nil, workflow: nil, base_branch: nil,
workflow_commit: nil, workflow_manifest_digest: nil, workflow_configuration_digest: nil,
- completed_at: nil)
+ idempotency_key: nil, input_fingerprint: nil, completed_at: nil)
FileUtils.mkdir_p(task_folder)
normalized_depends_on = normalize_string(depends_on)
normalized_workflow = normalize_string(workflow)
@@ -142,6 +150,8 @@ def write(task_folder, id:, slug:, display_name:, depends_on: nil, workflow: nil
normalized_commit = normalize_string(workflow_commit)
normalized_digest = normalize_string(workflow_manifest_digest)
normalized_configuration_digest = normalize_string(workflow_configuration_digest)
+ normalized_idempotency_key = normalize_string(idempotency_key)
+ normalized_input_fingerprint = normalize_string(input_fingerprint)
normalized_completed_at = normalize_completed_at(completed_at, label: "completed_at", strict: true)
if normalized_commit.nil? != normalized_digest.nil?
raise ArgumentError, "workflow_commit and workflow_manifest_digest must be written together"
@@ -149,6 +159,9 @@ def write(task_folder, id:, slug:, display_name:, depends_on: nil, workflow: nil
if normalized_configuration_digest && normalized_commit.nil?
raise ArgumentError, "workflow_configuration_digest requires immutable workflow provenance"
end
+ if normalized_idempotency_key.nil? != normalized_input_fingerprint.nil?
+ raise ArgumentError, "idempotency_key and input_fingerprint must be written together"
+ end
data = {
"id" => normalize_id(id),
"slug" => normalize_string(slug),
@@ -160,6 +173,8 @@ def write(task_folder, id:, slug:, display_name:, depends_on: nil, workflow: nil
data["workflow_commit"] = normalized_commit if normalized_commit
data["workflow_manifest_digest"] = normalized_digest if normalized_digest
data["workflow_configuration_digest"] = normalized_configuration_digest if normalized_configuration_digest
+ data["idempotency_key"] = normalized_idempotency_key if normalized_idempotency_key
+ data["input_fingerprint"] = normalized_input_fingerprint if normalized_input_fingerprint
data["completed_at"] = normalized_completed_at if normalized_completed_at
tmp = File.join(task_folder, ".#{FILENAME}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}")
File.write(tmp, data.to_yaml)
@@ -174,6 +189,10 @@ def write(task_folder, id:, slug:, display_name:, depends_on: nil, workflow: nil
result[:workflow_manifest_digest] = normalized_digest
result[:workflow_configuration_digest] = normalized_configuration_digest if normalized_configuration_digest
end
+ if normalized_idempotency_key
+ result[:idempotency_key] = normalized_idempotency_key
+ result[:input_fingerprint] = normalized_input_fingerprint
+ end
result[:completed_at] = normalized_completed_at if normalized_completed_at
result
ensure
@@ -260,7 +279,9 @@ def normalized_data(raw)
base_branch: normalize_string(fetch(raw, "base_branch")),
workflow_commit: normalize_string(fetch(raw, "workflow_commit")),
workflow_manifest_digest: normalize_string(fetch(raw, "workflow_manifest_digest")),
- workflow_configuration_digest: normalize_string(fetch(raw, "workflow_configuration_digest"))
+ workflow_configuration_digest: normalize_string(fetch(raw, "workflow_configuration_digest")),
+ idempotency_key: normalize_string(fetch(raw, "idempotency_key")),
+ input_fingerprint: normalize_string(fetch(raw, "input_fingerprint"))
}
if key?(raw, "completed_at")
data[:completed_at] = normalize_completed_at(fetch(raw, "completed_at"), label: "completed_at")
diff --git a/lib/hive/tui/snapshot.rb b/lib/hive/tui/snapshot.rb
index 9cfab2fdee..459b27b683 100644
--- a/lib/hive/tui/snapshot.rb
+++ b/lib/hive/tui/snapshot.rb
@@ -91,6 +91,7 @@ def initialize(legacy_stage_dirs: [].freeze, legacy_migrate_command: nil,
:action_label,
:suggested_command,
:next_action,
+ :outcomes,
:diagnostic,
:live_task_lock,
:task_lock_pid,
@@ -112,6 +113,8 @@ def initialize(legacy_stage_dirs: [].freeze, legacy_migrate_command: nil,
# unanswered_questions defaults to 0 so payloads / test factories
# that predate issue #270 keep working; production payloads always
# emit the integer explicitly.
+ # outcomes defaults to [] for pre-human-stage payloads and factories;
+ # current status payloads always emit the descriptor-derived array.
def initialize(id: nil, display_name: nil, workflow: nil, worktree_path: nil,
pr_url: nil, attempt_id: nil, task_generation: nil,
condition_task_generation: nil, commit_generation: nil,
@@ -122,7 +125,7 @@ def initialize(id: nil, display_name: nil, workflow: nil, worktree_path: nil,
observation_mtime: nil, folder_mtime: nil, live_task_lock: false,
task_lock_pid: nil, task_lock_process_start_time: nil, task_lock_id: nil,
implementation_identity: nil,
- unanswered_questions: 0, depends_on: nil,
+ unanswered_questions: 0, outcomes: [], depends_on: nil,
blocked_by: nil, dependency_stage: nil,
blocked: false, admission_error: nil, held: nil, **rest)
super(id: id, display_name: display_name,
@@ -157,7 +160,8 @@ def initialize(id: nil, display_name: nil, workflow: nil, worktree_path: nil,
task_lock_process_start_time: task_lock_process_start_time,
task_lock_id: task_lock_id,
implementation_identity: implementation_identity,
- unanswered_questions: unanswered_questions, **rest)
+ unanswered_questions: unanswered_questions,
+ outcomes: outcomes, **rest)
end
end
@@ -268,6 +272,7 @@ def self.build_row(payload, project_name)
action_label: payload["action_label"],
suggested_command: payload["suggested_command"],
next_action: payload["next_action"],
+ outcomes: Array(payload["outcomes"]).freeze,
diagnostic: payload["diagnostic"],
live_task_lock: payload["live_task_lock"] == true,
task_lock_pid: payload["task_lock_pid"],
diff --git a/lib/hive/workflow.rb b/lib/hive/workflow.rb
index 60ef8e4900..208672b320 100644
--- a/lib/hive/workflow.rb
+++ b/lib/hive/workflow.rb
@@ -16,7 +16,7 @@ class Workflow
# :execute/:review_council/:finalize drive coding status/action
# classification (the coding runners are selected by name, not kind — see
# Stages::Resolver), and nil is the unspecified default.
- KNOWN_KINDS = [ nil, :agent, :council, :inert, :execute, :review_council, :finalize ].freeze
+ KNOWN_KINDS = [ nil, :agent, :council, :human, :inert, :execute, :review_council, :finalize ].freeze
# Single source of truth for the council triage artifact default. Referenced
# by the Council default below, the descriptor parser, and both council
@@ -107,7 +107,8 @@ def executable_slots
:name, :index, :state_file, :advance_verb, :kind, :skill, :instruction,
:permissions, :status_mode, :budget_usd, :timeout_sec, :capability,
:agent, :model, :effort, :input, :reviewers, :council, :deliverable,
- :workspace, :handoff, :condition_policy, :mapping_role, :mapping_contract
+ :workspace, :handoff, :condition_policy, :mapping_role, :mapping_contract,
+ :outcomes
) do
def initialize(name:, index:, state_file:, advance_verb: nil, kind: nil,
skill: nil, instruction: nil, permissions: nil,
@@ -115,13 +116,21 @@ def initialize(name:, index:, state_file:, advance_verb: nil, kind: nil,
capability: nil, agent: nil, model: nil, effort: nil,
input: nil, reviewers: nil, council: nil, deliverable: nil,
workspace: nil, handoff: nil,
- condition_policy: nil, mapping_role: nil, mapping_contract: nil)
+ condition_policy: nil, mapping_role: nil, mapping_contract: nil,
+ outcomes: nil)
+ outcomes = outcomes&.dup&.freeze unless outcomes&.frozen?
super
end
def dir = "#{index}-#{name}"
end
+ Outcome = Data.define(:name, :complete, :artifact, :to) do
+ def initialize(name:, complete: false, artifact: nil, to: nil) = super
+
+ def terminal? = complete
+ end
+
Council = Data.define(:quorum, :max_rounds, :exit_rule, :on_max_rounds, :triage_output, :revise) do
def initialize(quorum:, max_rounds: 1, exit_rule: :human,
on_max_rounds: :wait, triage_output: DEFAULT_TRIAGE_OUTPUT, revise: nil)
@@ -187,8 +196,58 @@ def validate_structure!
stages: structural_stages,
allowed_kinds: KNOWN_KINDS
)
+ each { |stage| validate_human_stage!(stage) }
rescue Hive::WorkLedger::InvalidDescriptor => e
raise ArgumentError, "workflow #{id.inspect} #{e.message}"
end
+
+ def validate_human_stage!(stage)
+ if stage.kind != :human
+ return unless stage.respond_to?(:outcomes) && stage.outcomes
+
+ raise ArgumentError,
+ "workflow #{id.inspect} non-human stage #{stage.name.inspect} must not declare outcomes"
+ end
+
+ outcomes = stage.outcomes
+ unless outcomes.is_a?(Hash) && !outcomes.empty?
+ raise ArgumentError,
+ "workflow #{id.inspect} human stage #{stage.name.inspect} must declare at least one outcome"
+ end
+
+ outcomes.each do |name, outcome|
+ unless outcome.is_a?(Outcome) && name == outcome.name
+ raise ArgumentError,
+ "workflow #{id.inspect} stage #{stage.name.inspect} outcome #{name.inspect} is invalid"
+ end
+
+ actions = [ outcome.complete, !outcome.to.nil? ].count(true)
+ unless actions == 1
+ raise ArgumentError,
+ "workflow #{id.inspect} stage #{stage.name.inspect} outcome #{name.inspect} " \
+ "must declare exactly one of complete or to"
+ end
+
+ if outcome.complete
+ unless outcome.artifact
+ raise ArgumentError,
+ "workflow #{id.inspect} stage #{stage.name.inspect} outcome #{name.inspect} " \
+ "must declare an artifact when complete"
+ end
+ next
+ end
+
+ if outcome.artifact
+ raise ArgumentError,
+ "workflow #{id.inspect} stage #{stage.name.inspect} outcome #{name.inspect} " \
+ "artifact is only valid for a completing outcome"
+ end
+ next if stage_named(outcome.to)
+
+ raise ArgumentError,
+ "workflow #{id.inspect} stage #{stage.name.inspect} outcome #{name.inspect} " \
+ "targets unknown stage #{outcome.to.inspect}"
+ end
+ end
end
end
diff --git a/lib/hive/workflow_package/managed_store.rb b/lib/hive/workflow_package/managed_store.rb
index 816202cfcd..e09ea8c637 100644
--- a/lib/hive/workflow_package/managed_store.rb
+++ b/lib/hive/workflow_package/managed_store.rb
@@ -126,6 +126,15 @@ def selected(name, cfg: {}, &legacy_cfg_loader)
result
end
+ # Validation and other strict no-write probes cannot use #selected:
+ # with_stable_read creates the mutation lock and reconciles an interrupted
+ # transaction before taking its shared read. This variant validates the
+ # selected pointer and configuration without creating, restoring, or
+ # clearing any managed-state path.
+ def selected_read_only(name, cfg: {}, &legacy_cfg_loader)
+ selected_unlocked(name, cfg: cfg, &legacy_cfg_loader)
+ end
+
def selections(cfg: {})
result = []
with_stable_read do
diff --git a/lib/hive/workflows/descriptor_parser.rb b/lib/hive/workflows/descriptor_parser.rb
index 74db4bf3ba..f1c7835b9e 100644
--- a/lib/hive/workflows/descriptor_parser.rb
+++ b/lib/hive/workflows/descriptor_parser.rb
@@ -36,7 +36,9 @@ class DescriptorParser
permissions
mapping_role
mapping_contract
+ outcomes
].freeze
+ OUTCOME_KEYS = %w[complete artifact to].freeze
REVIEWER_KEYS = %w[
name
agent
@@ -112,10 +114,10 @@ def validate_terminal_last_stage!(stages)
return if stages.empty?
last = stages.last
- return if [ :inert, :agent, :council ].include?(last.kind)
+ return if [ :inert, :agent, :council, :human ].include?(last.kind)
raise descriptor_error(
- "last stage #{last.name.inspect} must be terminal, agent, or council"
+ "last stage #{last.name.inspect} must be terminal, agent, council, or human"
)
end
@@ -271,6 +273,7 @@ def parse_stage(data, id:, index:)
condition_policy = parse_condition_policy(stage["conditions"], label: label)
reviewers = parse_reviewers(stage["reviewers"], id: id, label: label) if kind == :council
council = parse_council(stage["council"], id: id, label: label, reviewer_count: reviewers&.length) if kind == :council
+ outcomes = parse_outcomes(stage["outcomes"], stage_name: name, label: label) if kind == :human
validate_agent_instruction!(skill: skill, instruction: instruction, label: label) if kind == :agent
Hive::Workflow::Stage.new(
@@ -295,7 +298,8 @@ def parse_stage(data, id:, index:)
condition_policy: condition_policy,
permissions: permissions,
mapping_role: parse_mapping_role(stage["mapping_role"], label: label),
- mapping_contract: optional_string(stage["mapping_contract"], label: "#{label} mapping_contract")
+ mapping_contract: optional_string(stage["mapping_contract"], label: "#{label} mapping_contract"),
+ outcomes: outcomes
)
end
@@ -320,11 +324,15 @@ def parse_stage_name(value, label:)
# the rest of the runner assumes and removes the nested-dir fragility in the
# agent runner's output path.
def parse_state_file(value, label:)
- file = required_string(value, label: "#{label} state_file")
+ parse_task_file(value, label: "#{label} state_file")
+ end
+
+ def parse_task_file(value, label:)
+ file = required_string(value, label: label)
return file if file == File.basename(file) && ![ ".", ".." ].include?(file)
raise descriptor_error(
- "#{label} state_file #{file.inspect} must be a bare filename inside the task folder " \
+ "#{label} #{file.inspect} must be a bare filename inside the task folder " \
"(no '/' path separator, not '.' or '..'); built-in stages use basenames like 'idea.md'"
)
end
@@ -334,10 +342,50 @@ def parse_kind(value, label:)
case raw
when "agent" then :agent
when "council" then :council
+ when "human" then :human
when "terminal" then :inert
else
- raise descriptor_error("#{label} kind #{raw.inspect} must be agent, council, or terminal")
+ raise descriptor_error("#{label} kind #{raw.inspect} must be agent, council, human, or terminal")
+ end
+ end
+
+ def parse_outcomes(data, stage_name:, label:)
+ unless data.is_a?(Hash) && !data.empty?
+ raise descriptor_error("#{label} human stage must declare at least one outcome")
end
+
+ outcomes = stringify_hash(data, label: "#{label} #{stage_name.inspect} outcomes")
+ outcomes.each_with_object({}) do |(name, raw), parsed|
+ unless SAFE_SLUG.match?(name)
+ raise descriptor_error(
+ "#{label} #{stage_name.inspect} outcome name #{name.inspect} must match #{SAFE_SLUG.source}"
+ )
+ end
+
+ outcome_label = "#{label} #{stage_name.inspect} outcome #{name.inspect}"
+ value = stringify_hash(raw, label: outcome_label)
+ reject_unknown_keys!(value, OUTCOME_KEYS, label: outcome_label)
+ action_keys = %w[complete to].select { |key| value.key?(key) }
+ unless action_keys.one?
+ raise descriptor_error("#{outcome_label} must declare exactly one of complete or to")
+ end
+
+ if value.key?("complete")
+ unless value["complete"] == true
+ raise descriptor_error("#{outcome_label} complete must be true")
+ end
+ artifact = parse_task_file(value["artifact"], label: "#{outcome_label} artifact")
+ parsed[name] = Hive::Workflow::Outcome.new(
+ name: name, complete: true, artifact: artifact
+ )
+ else
+ if value.key?("artifact")
+ raise descriptor_error("#{outcome_label} artifact is only valid on a completing outcome")
+ end
+ target = parse_stage_name(value["to"], label: "#{outcome_label} target")
+ parsed[name] = Hive::Workflow::Outcome.new(name: name, to: target)
+ end
+ end.freeze
end
def parse_instruction(value, label:)
@@ -579,6 +627,16 @@ def parse_advance_verb(stage, name:, index:, label:)
def reject_agent_only_fields!(stage, kind:, label:)
return if [ :agent, :council ].include?(kind)
+ if kind == :human
+ present = %w[skill instruction agent model effort budget_usd timeout_sec reviewers council deliverable workspace handoff conditions permissions mapping_role mapping_contract]
+ .select { |key| stage.key?(key) }
+ return if present.empty?
+
+ raise descriptor_error(
+ "#{label} #{present.inspect} #{present.one? ? 'is' : 'are'} not valid on a human stage"
+ )
+ end
+
present = %w[skill instruction agent model effort budget_usd timeout_sec input reviewers council deliverable workspace handoff permissions mapping_role mapping_contract]
.select { |key| stage.key?(key) }
return if present.empty?
@@ -605,6 +663,10 @@ def reject_agent_only_fields!(stage, kind:, label:)
AGENT_ONLY_FIELDS = %w[skill instruction workspace handoff].freeze
def reject_wrong_kind_fields!(stage, kind:, label:)
+ if kind != :human && stage.key?("outcomes")
+ raise descriptor_error("#{label} outcomes is only valid on a human stage")
+ end
+
case kind
when :agent
reject_fields_for_kind!(stage, COUNCIL_ONLY_FIELDS, kind: :agent, other: "council", label: label)
diff --git a/lib/hive/workflows/loader.rb b/lib/hive/workflows/loader.rb
index c5152b9919..52288a3227 100644
--- a/lib/hive/workflows/loader.rb
+++ b/lib/hive/workflows/loader.rb
@@ -51,6 +51,13 @@ def load_dir(workflows_dir)
def load_managed(workflows_dir)
hive_state = File.dirname(workflows_dir)
store = Hive::WorkflowPackage::ManagedStore.new(hive_state)
+ # Avoid manufacturing the managed-workflow lock file on the overwhelmingly
+ # common authored/built-in read path. No selection exists when there is
+ # no lock manifest to load, so taking the mutation lock cannot improve
+ # consistency and would make read-only commands observable as writes.
+ locks = Dir.glob(File.join(workflows_dir, "*", Hive::WorkflowPackage::ManagedStore::LOCK_FILE))
+ return {} if store.is_a?(Hive::WorkflowPackage::ManagedStore) && locks.empty?
+
store.selections.each_with_object({}) do |lock, workflows|
name = lock.fetch("name")
workflow = store.workflow(
diff --git a/openclaw/README.md b/openclaw/README.md
index 8444a090d6..7434ffc189 100644
--- a/openclaw/README.md
+++ b/openclaw/README.md
@@ -32,6 +32,7 @@ through the progressive setup reference:
/hive setup
/hive status
/hive watch <project>:<slug>
+/hive create a three-stage editorial workflow that researches, drafts, and requires approval before publishing
```
Guided setup explains the selected platform channel and asks before changing
@@ -46,6 +47,13 @@ launching OpenClaw and points missing or stale installations to
`openclaw skills install/update @ivankuznetsov/hive-cli`;
`hive setup-agents` never writes OpenClaw state.
+Natural-language workflow creation is a focused capability inside this same
+`/hive` skill, not another ClawHub listing. It inspects the installed Hive
+version and project workflow IDs before mutation, uses Hive's scaffold and
+validation commands, and creates no task by default. A fresh directory first
+receives the machine-readable minimal-init preview and changes only after one
+explicit confirmation.
+
On supported Linux/macOS, the approved guided setup reports the loopback URL
and distinct installed, enabled, running, and ready state for the default
managed Hive web service alongside daemon setup. Use
diff --git a/openclaw/skills/hive/.hive-skill.json b/openclaw/skills/hive/.hive-skill.json
index 731f525158..6a24ea51a3 100644
--- a/openclaw/skills/hive/.hive-skill.json
+++ b/openclaw/skills/hive/.hive-skill.json
@@ -5,15 +5,23 @@
"platform": "openclaw",
"invocation": "/hive",
"skill_version": "0.1.3",
- "canonical_digest": "c8aacce6c5e2af2a84c51c65d0b756613a14c686c839321f8a79b9a38a172a7a",
+ "canonical_digest": "f49fae3aaaa1f9c1d0c7f19ebc86f88e3d08ad6069a66133fb43b44ce82e8799",
"hive_version": "0.6.9",
"files": {
- "SKILL.md": "291d24e4ee417d4ad1bdf5d24ca6e14db2181c0571c7e09413da72b801414a01",
+ "SKILL.md": "1fafce4871d38a4372bc2532c3f9d3df5f93681bfceccb1eafd2c67cae11f840",
"references/recovery.md": "feb3a27a582673d87a46742f2935ee6e1067e3123ef865478263375c1bfd125c",
"references/release-candidate-qa.md": "595dbc9a9780d56ddb41ffad8554d3121c7bf24567b8b02c539b034b76297dbf",
- "references/safety.md": "4b420b964a5d729ccafe92bda01738517449f9779e1fe90d184463f461efacd3",
- "references/setup-and-platforms.md": "f7e3079e8a0b3de79ebce6257c2ab576d22649a97c988043f3053471ee350d8b",
+ "references/safety.md": "1b519b4f186e9b015604c96bf9d1e5d1269ca0d97476a153f595da483453002a",
+ "references/setup-and-platforms.md": "06713fc76c29e7b1507b9b8788e1369e3be7cef48c5ac4d6fefc2dd88bac69ec",
"references/status-and-watch.md": "9cff9c6eef5344646b682c7f005d56334410f20c6a9a5632b29c2bd7f62b9d49",
- "references/workflow-actions.md": "e94624d41b1093b2edf1c6e2105b9fd5a17549ef1be46e6260e2cf5178c5ce07"
+ "references/workflow-actions.md": "e94624d41b1093b2edf1c6e2105b9fd5a17549ef1be46e6260e2cf5178c5ce07",
+ "references/workflow-checkpoints.md": "42d2339d9cb068bb2f72c671710cf08770dc1a9a2b65ad96172dfa1376b985a3",
+ "references/workflow-common-mistakes.md": "4e08b7afc90e83b3af340b9e4e59acff49ec6d0434e126e42ee7249f86a00839",
+ "references/workflow-creator-example.md": "6c12986e656d733f154d055d59928e248c423934ffe789bf2d315d5bdb7741e5",
+ "references/workflow-creator.md": "a501ef08124e1f251c80c84443ac569e2d248055d883b4f3e8680fa8cbd0ae74",
+ "references/workflow-permissions.md": "301d72cae02115fec26f450cdc478e27d7c322e5c3dcb0e047bed34804dc6714",
+ "references/workflow-schema.md": "5ecdb7dac296d055aa904d248318e57e941ad7ab7da58f9785c66b7c2a7fbcd2",
+ "references/workflow-stage-design.md": "578b9daf8b2ad2d6fcf984a3f7caad2898a80fa04a5cf97f2bb7f648c960e7fa",
+ "references/workflow-testing.md": "8b44cf16044022bb56b3df91a069ead4c95b12913348c53f06147f4aefd81833"
}
}
diff --git a/openclaw/skills/hive/SKILL.md b/openclaw/skills/hive/SKILL.md
index fb4966411a..a9d4c7dd98 100644
--- a/openclaw/skills/hive/SKILL.md
+++ b/openclaw/skills/hive/SKILL.md
@@ -1,6 +1,6 @@
---
name: hive
-description: "Operate Hive task pipelines and pre-release QA through its native CLI. Use when an agent needs to answer current Hive or Hive-bench status, identify the exact active task and blocker owner, follow work through bounded transitions, inspect candidate QA evidence, advance a fresh routine recommendation, diagnose recovery state, initialize or configure Hive, or explain what requires operator action across registered projects."
+description: "Operate Hive task pipelines and pre-release QA through its native CLI. Use when an agent needs to answer current Hive or Hive-bench status, identify the exact active task and blocker owner, follow work through bounded transitions, create a new project-local workflow from an ordinary-language request, inspect candidate QA evidence, advance a fresh routine recommendation, diagnose recovery state, initialize or configure Hive, or explain what requires operator action across registered projects."
version: "0.1.3"
user-invocable: true
metadata:
@@ -17,7 +17,7 @@ metadata:
platform: openclaw
invocation: /hive
skill-version: 0.1.3
-canonical-digest: c8aacce6c5e2af2a84c51c65d0b756613a14c686c839321f8a79b9a38a172a7a
+canonical-digest: f49fae3aaaa1f9c1d0c7f19ebc86f88e3d08ad6069a66133fb43b44ce82e8799
hive-version: 0.6.9
-->
@@ -56,6 +56,9 @@ Ask before destructive or administrative changes, marker clearing, force/bypass
## Load the relevant reference
+- Read [workflow-creator.md](references/workflow-creator.md) when ordinary language asks to create a new project-local workflow. This focused route is the `hive-workflow-creator` capability inside the single canonical `/hive` skill; it is not a second skill or package.
+- Read [workflow-creator-example.md](references/workflow-creator-example.md) for the accepted research → draft → approval editorial example.
+- Read [workflow-schema.md](references/workflow-schema.md), [workflow-stage-design.md](references/workflow-stage-design.md), [workflow-checkpoints.md](references/workflow-checkpoints.md), [workflow-permissions.md](references/workflow-permissions.md), [workflow-testing.md](references/workflow-testing.md), and [workflow-common-mistakes.md](references/workflow-common-mistakes.md) only as needed while authoring or diagnosing a newly scaffolded descriptor.
- Read [status-and-watch.md](references/status-and-watch.md) for status interpretation, reporting, compatibility, and native watch semantics.
- Read [workflow-actions.md](references/workflow-actions.md) for task creation, direct workflow verbs, daemon ownership, closed actions, and completion checks.
- Read [recovery.md](references/recovery.md) for diagnosis, coordinator ownership, provider holds, stale workers, migration, and guarded recovery.
diff --git a/openclaw/skills/hive/references/safety.md b/openclaw/skills/hive/references/safety.md
index 8b89180bf7..46e0e552a8 100644
--- a/openclaw/skills/hive/references/safety.md
+++ b/openclaw/skills/hive/references/safety.md
@@ -10,10 +10,16 @@ Explain scope, affected state, and recovery before running:
- Foreground or unbounded streams such as daemon or bot tails when a bounded native status/watch answer is sufficient.
- Any external message, PR mutation beyond the user’s request, publication, deployment, tag, package release, or version change.
- Workflow install/update/remove/publish, `setup-agents`, manual patrol starts,
- and outbound `digest` or `bench submit` operations.
+ and outbound `bench submit` operations.
Routine read-only inspection, `hive watch`, `hive doctor`, and a fresh `hive act` descriptor with `confirmation_required: false` do not require another prompt.
+Creating a new workflow authorizes only the newly scaffolded descriptor and
+instruction paths. It never authorizes edits to an existing workflow or task
+creation. Fresh minimal initialization requires its own disclosed preview and
+one explicit confirmation. External publication remains separately gated even
+when a human outcome marks an artifact publish-ready.
+
No-write Honeycomb workflow previews (`workflow install|update|remove` with
`--dry-run --json`) are read-only. Do not generalize that exemption: patrol
and refactor-patrol dry-runs still launch agents and remain consent-gated.
diff --git a/openclaw/skills/hive/references/setup-and-platforms.md b/openclaw/skills/hive/references/setup-and-platforms.md
index 2b1cbf4fdb..1ab6381266 100644
--- a/openclaw/skills/hive/references/setup-and-platforms.md
+++ b/openclaw/skills/hive/references/setup-and-platforms.md
@@ -106,6 +106,14 @@ the user reviews and approves enrollment, ask them to run `hive init .` in
their own real terminal so they can change those defaults. Do not run it
headlessly on their behalf or invent bypass flags.
+The workflow-creator route has one narrower fresh-project exception. It must
+first show the no-write `hive init --new-workflow ID --minimal --preview
+--json` payload and receive one explicit confirmation. The matching minimal
+execution disables patrol, architecture patrol, ad-hoc auto-fix, daemon
+dispatch/autostart, babysitting, optional reviewers, and service/timer setup;
+it retains core hive/state, registration, and required context hooks. Do not
+substitute ordinary non-TTY init for this preview/confirmation contract.
+
OpenClaw installs the public ClawHub projection separately:
```bash
diff --git a/openclaw/skills/hive/references/workflow-checkpoints.md b/openclaw/skills/hive/references/workflow-checkpoints.md
new file mode 100644
index 0000000000..426ffab186
--- /dev/null
+++ b/openclaw/skills/hive/references/workflow-checkpoints.md
@@ -0,0 +1,20 @@
+# Workflow checkpoints
+
+Checkpoints are durable human stages, not comments in an agent instruction.
+Add one only when the request names a decision or the next action would be
+irreversible, externally visible, privileged, or otherwise high consequence.
+
+Declare a small closed set of named outcomes. Each outcome either completes
+with a verified artifact or returns to an existing stage. Hive records the
+decision with its stage visit identity, note, artifact, and timestamp.
+
+Operate a waiting checkpoint with:
+
+```bash
+hive decide TARGET OUTCOME --from STAGE --decision-id DECISION_ID --note "REASON" --json
+```
+
+Read `DECISION_ID` from the waiting stage's `hive run --json` response. The
+stage and visit observations make retries safe. Repeating the same decision is
+a no-op; a stale or conflicting decision is rejected. Do not bypass a
+checkpoint with marker edits, a forced move, or an inferred external action.
diff --git a/openclaw/skills/hive/references/workflow-common-mistakes.md b/openclaw/skills/hive/references/workflow-common-mistakes.md
new file mode 100644
index 0000000000..17187d0959
--- /dev/null
+++ b/openclaw/skills/hive/references/workflow-common-mistakes.md
@@ -0,0 +1,22 @@
+# Workflow creator common mistakes
+
+- Editing an existing descriptor after discovering a collision. Stop and
+ propose Hive’s deterministic available ID instead.
+- Scaffolding before checking the installed version, project, and ID inventory.
+- Treating neutral blank scaffolding as permission to select a research,
+ writing, or coding template by keyword alone.
+- Adding stage indices to YAML. Array order is authoritative.
+- Adding `input` to an ordinary agent stage when its instruction can name the
+ prior artifact; `input` belongs to supported consumers such as human/council
+ stages.
+- Emitting agent/model on every stage instead of inheriting project choices.
+- Adding checkpoints for harmless local work, or omitting them before a named
+ material decision.
+- Turning “before publishing” into an inferred publish stage. Approval may mark
+ an artifact publish-ready but never publishes it.
+- Claiming success after YAML parsing alone. Production workflow resolution and
+ `hive workflow validate` are required.
+- Creating or running a task when the original request asked only for the
+ workflow, or retrying a moved task without its stable idempotency key.
+- Using force, overwriting generated paths not returned by the scaffold, or
+ repairing invalid output in unrelated existing files.
diff --git a/openclaw/skills/hive/references/workflow-creator-example.md b/openclaw/skills/hive/references/workflow-creator-example.md
new file mode 100644
index 0000000000..d55188cb06
--- /dev/null
+++ b/openclaw/skills/hive/references/workflow-creator-example.md
@@ -0,0 +1,48 @@
+# Workflow creator example: editorial approval
+
+Request: “Create a three-stage editorial workflow that researches, drafts,
+and requires approval before publishing.”
+
+The accepted interpretation is exactly `research -> draft -> approval`. There
+is no inferred publish stage and no external publication action. Research and
+draft inherit the project agent/model and use ordinary local `yolo`
+permissions. Approval is a durable human stage.
+
+```yaml
+id: editorial
+stages:
+ - name: research
+ kind: agent
+ state_file: research.md
+ instruction: editorial/research.md
+ permissions: yolo
+ - name: draft
+ kind: agent
+ state_file: draft.md
+ instruction: editorial/draft.md
+ permissions: yolo
+ - name: approval
+ kind: human
+ state_file: approval.md
+ input: draft.md
+ outcomes:
+ approve:
+ complete: true
+ artifact: draft.md
+ reject:
+ to: draft
+```
+
+`approve` requires a non-empty `draft.md`, records it as publish-ready, and
+completes from approval. “Publish-ready” is an artifact status, not authority
+to publish. `reject` records the decision, returns the same task to `draft`,
+and resets draft to `WAITING` so stale completion cannot re-advance it.
+
+Required validation facts:
+
+- stages: research, draft, approval;
+- automatic edges: research to draft, draft to approval;
+- human outcomes: approval.approve completes with draft.md;
+ approval.reject returns to draft;
+- no task for a creation-only request; and
+- no publish stage, destination, command, or external side effect.
diff --git a/openclaw/skills/hive/references/workflow-creator.md b/openclaw/skills/hive/references/workflow-creator.md
new file mode 100644
index 0000000000..68e81824b2
--- /dev/null
+++ b/openclaw/skills/hive/references/workflow-creator.md
@@ -0,0 +1,130 @@
+# Natural-language workflow creator
+
+This is the `hive-workflow-creator` contract inside the canonical `/hive`
+skill. It creates one new owner-authored project workflow. It never edits,
+repairs, overwrites, or partially merges an existing workflow.
+
+## Gate before mutation
+
+The minimum Hive version: 0.6.9. First run `hive version` and parse
+the bare semantic version. If it is older, stop before inspecting or writing
+project workflow paths and say exactly: “Hive 0.6.9 or newer is
+required; run `hive update`, then retry.” Do not invent a compatibility adapter.
+
+After the version passes, run `hive workflow list --json` in the target project
+and inspect `.hive-state/config.yml` when present. Record the project’s current
+agent/model choices, built-ins, managed workflows, and owner-authored IDs.
+Infer a safe lowercase-hyphen ID. A reserved or existing ID is a hard stop:
+leave every file byte-identical and report Hive’s `suggested_id` (or the first
+available numeric suffix obtained from the same ID inventory). Never silently
+switch IDs and continue.
+
+Determine whether the project is initialized before scaffolding. For a fresh
+target, obtain the authoritative preview with:
+
+```bash
+hive init --new-workflow ID --minimal --preview --json
+```
+
+Summarize its project files, hive/state worktree, global registration,
+wiki/context hooks, and explicitly disabled services and automation. Wait for
+one explicit confirmation. If confirmation is declined, absent, or ambiguous,
+stop and leave the target unchanged. After approval, run the same command
+without `--preview`. Never add a force option, select a starter template, or
+initialize an existing target through the minimal route.
+
+For an initialized project, scaffold only after all gates pass:
+
+```bash
+hive workflow new ID --json
+```
+
+Use a built-in sample only when its semantics genuinely match the request;
+otherwise keep the neutral blank scaffold. Modify only `descriptor_path` and
+the newly returned instruction directory. Treat any collision returned by the
+CLI as authoritative and stop with its deterministic alternative.
+
+## Infer, populate, and validate
+
+Translate the original request into the smallest complete graph:
+
+- Preserve the requested stage order and use sequential automatic edges by default.
+- Infer one durable artifact/state file per stage and reusable instruction text for agent stages.
+- Omit stage `agent` and `model` when project inheritance is sufficient.
+- Use `permissions: yolo` for ordinary local agent work unless the request or a high-consequence boundary requires less authority.
+- Add a human stage only for a materially necessary decision or irreversible/high-consequence boundary. Never infer branching, specialist models, or external destinations.
+- Never publish externally, deploy, message, tag, or release from an inferred stage. A separate destination and explicit authorization are mandatory.
+
+Write only the newly scaffolded paths, then run:
+
+```bash
+hive workflow validate ID --json
+```
+
+Success requires `valid: true`, the intended ordered stages and kinds, exact
+automatic edges, exact human outcomes, and every instruction path present.
+On any diagnostic, stop and report it; do not claim success or create a task.
+
+After successful validation, commit the populated graph through Hive so the
+write shares the project commit lock with daemon and task writers:
+
+```bash
+hive workflow commit "$ID"
+```
+
+The commit is part of workflow creation. If it fails, stop and report it; do
+not claim success or create a task from an uncommitted graph.
+
+## Task side-effect boundary
+
+No task by default. A creation-only request ends after validation and the
+populated-graph commit, then prints an exact shell-quoted next command. Render
+every dynamic argv element with POSIX `Shellwords.escape` semantics. In
+particular, never put the request in double quotes: surround it with single
+quotes and replace each embedded `'` with the exact shell sequence `'"'"'`, so
+`$()`, backticks, variables, glob characters, and newlines
+remain literal bytes:
+
+```bash
+hive new 'PROJECT' --workflow 'ID' 'ORIGINAL REQUEST'
+```
+
+Only when the original request explicitly says to create or run a task, derive
+one stable opaque key and keep it unchanged across retries. The
+`workflow-creator:v1` derivation is canonical:
+
+1. Project is `File.realpath(PROJECT_PATH)`.
+2. Workflow is Hive's normalized ID from the scaffold result.
+3. Request is valid UTF-8 normalized to Unicode NFC, with CRLF/CR converted to
+ LF, leading/trailing whitespace stripped, and every remaining whitespace run
+ collapsed to one ASCII space.
+4. Hash the UTF-8 bytes of `JSON.generate([project, workflow, request])` with
+ SHA-256 and use `workflow-creator:v1:<64 lowercase hex>` as `KEY`.
+
+Do not add timestamps, random bytes, agent/session IDs, slugs, or retry counts.
+Create at most once with the same POSIX shell-quoting rule:
+
+```bash
+hive new 'PROJECT' --workflow 'ID' --idempotency-key 'KEY' --json 'ORIGINAL REQUEST'
+```
+
+If the original request also explicitly said run, invoke the reported first
+stage only when `created: true`; `created: false` means a retry and must not
+dispatch that stage again. Then query `hive status --operational --json` and
+report the slug, current stage, daemon disposition, and expected next
+transition from live state.
+
+## Required completion summary
+
+Always report:
+
+- created files (descriptor plus every instruction/reference path);
+- neutral scaffold or the genuinely reused template;
+- applied defaults, including ID, stage order, artifacts, inheritance,
+ permissions, automatic transitions, and every checkpoint/outcome;
+- validation result and normalized graph;
+- task side effect (`none`, `created: true`, or `created: false`);
+- the exact next command when no task was created; and
+- any question or explicit choice that remains material.
+
+Do not describe a workflow as created until Hive has loaded and validated it.
diff --git a/openclaw/skills/hive/references/workflow-permissions.md b/openclaw/skills/hive/references/workflow-permissions.md
new file mode 100644
index 0000000000..d7c8fa2130
--- /dev/null
+++ b/openclaw/skills/hive/references/workflow-permissions.md
@@ -0,0 +1,16 @@
+# Workflow permissions
+
+For ordinary local agent stages created from natural language, use
+`permissions: yolo`; this preserves Hive’s normal local execution semantics.
+Omit stage agent/model fields to inherit project choices.
+
+Do not silently grant external authority. Publishing, deployment, messaging,
+credential use, production mutation, release operations, and destination
+selection require an explicit destination and authorization outside inferred
+workflow creation. Represent an approval request as a human stage; do not hide
+it in an agent prompt.
+
+Use a narrower supported permission scope only when the request clearly needs
+it and the selected agent can enforce it. If enforcement capability or the
+required directories/tools are unclear, ask one material question rather than
+inventing a scope that may be ineffective.
diff --git a/openclaw/skills/hive/references/workflow-schema.md b/openclaw/skills/hive/references/workflow-schema.md
new file mode 100644
index 0000000000..9bb87cb66b
--- /dev/null
+++ b/openclaw/skills/hive/references/workflow-schema.md
@@ -0,0 +1,34 @@
+# Owner-authored workflow schema
+
+Descriptors live at `<hive_state_path>/workflows/ID.yml`; referenced agent
+instructions live below `<hive_state_path>/workflows/ID/`. Use the current
+format only. Stage indices are array order and are not authored as keys.
+
+Every stage declares a safe `name`, `kind`, and relative `state_file`.
+Agent stages declare exactly one of `instruction` or `skill`. Omit agent/model
+to inherit project choices. Ordinary transitions are sequential array edges.
+
+A human stage is closed and non-executable:
+
+```yaml
+- name: approval
+ kind: human
+ state_file: approval.md
+ input: draft.md
+ outcomes:
+ approve:
+ complete: true
+ artifact: draft.md
+ reject:
+ to: draft
+```
+
+Each outcome declares exactly one of `complete: true` or `to: STAGE`.
+Completing outcomes require an artifact. Returning outcomes cannot declare an
+artifact. Outcome and target names must be safe and targets must exist in the
+same descriptor. Human stages cannot declare agent, model, permissions,
+instruction, skill, budgets, timeouts, reviewers, councils, workspaces,
+handoffs, conditions, or executable commands.
+
+Treat `hive workflow validate ID --json` as authoritative for normalized
+stages, automatic edges, human outcomes, and instruction paths.
diff --git a/openclaw/skills/hive/references/workflow-stage-design.md b/openclaw/skills/hive/references/workflow-stage-design.md
new file mode 100644
index 0000000000..8334bd09c6
--- /dev/null
+++ b/openclaw/skills/hive/references/workflow-stage-design.md
@@ -0,0 +1,19 @@
+# Workflow stage design
+
+Use the fewest stages that preserve distinct ownership, durable artifacts, or
+material decisions. A verb phrase in the request is not automatically a stage.
+
+- Split work when it produces a reusable artifact consumed later, needs a
+ different actor/permission boundary, or must wait durably for a person.
+- Keep ordinary transformations sequential. Do not infer speculative branches.
+- Give each agent stage one focused instruction: inputs, required output,
+ completion criteria, and explicit non-goals.
+- Prefer project agent/model inheritance. Specialize only when the request
+ materially requires a capability the inherited choice cannot supply.
+- Use safe lowercase-hyphen stage names and distinct state files.
+- A terminal human approval uses `complete: true` with a required non-empty
+ artifact basename; it does not need a synthetic “done” or “publish” stage.
+
+Before writing, restate the inferred ordered graph, artifacts, inheritance,
+permissions, and decision points. Ask only when two plausible interpretations
+would materially change behavior or consequences.
diff --git a/openclaw/skills/hive/references/workflow-testing.md b/openclaw/skills/hive/references/workflow-testing.md
new file mode 100644
index 0000000000..fbfa6bef2f
--- /dev/null
+++ b/openclaw/skills/hive/references/workflow-testing.md
@@ -0,0 +1,22 @@
+# Workflow creator testing
+
+Validate the descriptor with Hive before reporting success:
+
+```bash
+hive workflow validate ID --json
+```
+
+Check `valid`, origin, descriptor path, ordered stages/kinds, instruction
+paths, automatic edges, and human outcomes. Validation must not change the
+project tree, hive/state commit, tasks, registry, services, or timers.
+
+For a human checkpoint, exercise both outcomes in a disposable task: completing
+requires a non-empty artifact; returning records the decision, moves to the
+declared stage, and leaves it waiting. Verify no agent dispatch occurs while a
+human stage waits.
+
+For an explicitly requested task, repeat the same idempotency key after the
+task moves stages. The response must return one slug with `created: false`.
+Reuse with different input/workflow must fail and leave one task. Finish with a
+fresh operational status snapshot; hermetic validation is primary and live
+agent proof is an additional, credential-gated attestation.
diff --git a/packaging/live_agent_skills/attest.rb b/packaging/live_agent_skills/attest.rb
index fcfe386857..c9cd28b82b 100644
--- a/packaging/live_agent_skills/attest.rb
+++ b/packaging/live_agent_skills/attest.rb
@@ -3,12 +3,13 @@
require_relative "proof"
-abort "usage: attest.rb CANDIDATE_SHA WORKFLOW_REVISION REPOSITORY RUN_ID RUN_ATTEMPT ARTIFACT_DIR EVIDENCE_DIR OUTPUT_DIR" unless ARGV.length == 8
+abort "usage: attest.rb CANDIDATE_SHA WORKFLOW_REVISION REPOSITORY RUN_ID RUN_ATTEMPT ARTIFACT_DIR EVIDENCE_DIR CREATOR_EVIDENCE_DIR OUTPUT_DIR" unless ARGV.length == 9
begin
result = HiveLiveAgentProof::Attestor.new(
candidate_sha: ARGV[0], workflow_revision: ARGV[1], repository: ARGV[2], run_id: ARGV[3],
- run_attempt: ARGV[4], artifact_dir: ARGV[5], evidence_dir: ARGV[6], output_dir: ARGV[7]
+ run_attempt: ARGV[4], artifact_dir: ARGV[5], evidence_dir: ARGV[6],
+ creator_evidence_dir: ARGV[7], output_dir: ARGV[8]
).call
puts JSON.generate("attestation_sha256" => result.fetch("sha256"), "path" => result.fetch("path"))
rescue HiveLiveAgentProof::Error => e
diff --git a/packaging/live_agent_skills/proof.rb b/packaging/live_agent_skills/proof.rb
index 07cc495364..d63450eda7 100644
--- a/packaging/live_agent_skills/proof.rb
+++ b/packaging/live_agent_skills/proof.rb
@@ -29,6 +29,45 @@ module HiveLiveAgentProof
"--timeout", "20", "--max-events", "5", "--interval", "1", "--json-lines"
].freeze
OBSERVATION_COMMANDS = [ STATUS_ARGV, WATCH_ARGV ].freeze
+ WORKFLOW_CREATOR_REQUEST =
+ "Create a three-stage editorial workflow that researches, drafts, and requires approval before publishing.".freeze
+ WORKFLOW_CREATOR_PROMPT = <<~PROMPT.freeze
+ /hive
+ #{WORKFLOW_CREATOR_REQUEST}
+ Use the installed Hive workflow-creator capability in this initialized project.
+ This is creation-only: validate the result, report the defaults, and do not create or run a task.
+ PROMPT
+ WORKFLOW_CREATOR_TASK_REQUEST = "Research and draft the launch announcement for approval.".freeze
+ WORKFLOW_CREATOR_TASK_KEY = "workflow-creator-proof:editorial:live-proof".freeze
+ WORKFLOW_CREATOR_TASK_SLUG = "editorial-live-proof".freeze
+ WORKFLOW_CREATOR_TASK_PROMPT = <<~PROMPT.freeze
+ /hive
+ Use the validated editorial workflow to create and run one task for:
+ "#{WORKFLOW_CREATOR_TASK_REQUEST}"
+ Use idempotency key #{WORKFLOW_CREATOR_TASK_KEY}. In this exact order: create the task,
+ run its first stage once, repeat the same creation command once to prove the retry is a no-op,
+ then query operational status. Do not publish or perform any other external action.
+ PROMPT
+ WORKFLOW_CREATOR_TASK_NEW_ARGV = [
+ "new", "workflow-creator-proof", "--workflow", "editorial",
+ "--idempotency-key", WORKFLOW_CREATOR_TASK_KEY, "--json", WORKFLOW_CREATOR_TASK_REQUEST
+ ].freeze
+ WORKFLOW_CREATOR_COMMANDS = [
+ [ "version" ],
+ [ "workflow", "list", "--json" ],
+ [ "workflow", "new", "editorial", "--json" ],
+ [ "workflow", "validate", "editorial", "--json" ],
+ [ "workflow", "commit", "editorial" ],
+ WORKFLOW_CREATOR_TASK_NEW_ARGV,
+ [ "run", WORKFLOW_CREATOR_TASK_SLUG ],
+ WORKFLOW_CREATOR_TASK_NEW_ARGV,
+ [ "status", "--operational", "--json" ]
+ ].freeze
+ WORKFLOW_CREATOR_FILES = [
+ ".hive-state/workflows/editorial.yml",
+ ".hive-state/workflows/editorial/draft.md",
+ ".hive-state/workflows/editorial/research.md"
+ ].freeze
SAFE_SHA = /\A[0-9a-f]{40}\z/.freeze
SAFE_REPOSITORY = /\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/.freeze
SECRET_PATTERNS = [
@@ -312,7 +351,7 @@ def read_skill_archive(path)
class Attestor
def initialize(candidate_sha:, workflow_revision:, repository:, run_id:, run_attempt:,
- artifact_dir:, evidence_dir:, output_dir:)
+ artifact_dir:, evidence_dir:, creator_evidence_dir:, output_dir:)
@candidate_sha = HiveLiveAgentProof.validate_sha!(candidate_sha, "candidate_sha")
@workflow_revision = HiveLiveAgentProof.validate_sha!(workflow_revision, "workflow_revision")
@repository = HiveLiveAgentProof.validate_repository!(repository)
@@ -320,6 +359,7 @@ def initialize(candidate_sha:, workflow_revision:, repository:, run_id:, run_att
@run_attempt = Integer(run_attempt, 10)
@artifact_dir = File.expand_path(artifact_dir)
@evidence_dir = File.expand_path(evidence_dir)
+ @creator_evidence_dir = File.expand_path(creator_evidence_dir)
@output_dir = File.expand_path(output_dir)
raise Error, "run_id and run_attempt must be positive" unless @run_id.positive? && @run_attempt.positive?
rescue ArgumentError
@@ -329,6 +369,7 @@ def initialize(candidate_sha:, workflow_revision:, repository:, run_id:, run_att
def call
manifest = validate_artifacts!
evidence = validate_evidence!(manifest)
+ creator_evidence = validate_creator_evidence!(manifest)
raise Error, "proof output already exists: #{@output_dir}" if File.exist?(@output_dir)
FileUtils.mkdir_p(File.join(@output_dir, "artifacts"), mode: 0o700)
FileUtils.mkdir_p(File.join(@output_dir, "evidence"), mode: 0o700)
@@ -341,6 +382,10 @@ def call
evidence.each do |platform, row|
HiveLiveAgentProof.write_json(File.join(@output_dir, "evidence", "#{platform}.json"), row)
end
+ HiveLiveAgentProof.write_json(
+ File.join(@output_dir, "evidence", "openclaw-workflow-creator.json"),
+ creator_evidence
+ )
attestation = {
"schema" => "hive-live-agent-skills-attestation",
@@ -356,10 +401,11 @@ def call
},
"artifacts" => manifest,
"platforms" => PLATFORMS.to_h { |platform| [ platform, evidence.fetch(platform) ] },
+ "workflow_creator" => creator_evidence,
"secret_scan" => {
"status" => "passed",
"scanner" => "hive-live-agent-proof/v1",
- "files_scanned" => PLATFORMS.length + 1
+ "files_scanned" => PLATFORMS.length + 2
}
}
attestation_path = File.join(@output_dir, "attestation.json")
@@ -429,6 +475,82 @@ def validate_evidence_row!(platform, row, manifest)
"#{platform} must use exactly one operational status and one bounded native watch"
end
end
+
+ def validate_creator_evidence!(manifest)
+ actual_names = Dir.glob(File.join(@creator_evidence_dir, "*.json")).map { |path| File.basename(path) }
+ unless actual_names == [ "openclaw-workflow-creator.json" ]
+ raise Error, "workflow-creator evidence must contain exactly openclaw-workflow-creator.json"
+ end
+
+ path = File.join(@creator_evidence_dir, actual_names.fetch(0))
+ row = HiveLiveAgentProof.read_json(path)
+ unless row.is_a?(Hash) && row["schema"] == "hive-live-workflow-creator-evidence" &&
+ row["schema_version"] == SCHEMA_VERSION && row["platform"] == "openclaw" &&
+ row["candidate_sha"] == @candidate_sha && row["result"] == "passed"
+ raise Error, "workflow-creator evidence identity or result is invalid"
+ end
+ unless row.dig("skill", "canonical_digest") == manifest["canonical_digest"] &&
+ row.dig("skill", "skill_version") == manifest["skill_version"]
+ raise Error, "workflow-creator evidence canonical provenance mismatch"
+ end
+ unless HiveLiveAgentProof.valid_native_activation?("openclaw", row["native_activation"])
+ raise Error, "workflow-creator native activation evidence is invalid"
+ end
+ expected_prompt = Digest::SHA256.hexdigest(WORKFLOW_CREATOR_PROMPT)
+ expected_task_prompt = Digest::SHA256.hexdigest(WORKFLOW_CREATOR_TASK_PROMPT)
+ unless row["prompt_sha256"] == expected_prompt &&
+ row["task_prompt_sha256"] == expected_task_prompt &&
+ row["hive_commands"] == WORKFLOW_CREATOR_COMMANDS
+ raise Error, "workflow-creator prompt or command sequence is invalid"
+ end
+ validate_creator_result!(row)
+ unless row.dig("secret_scan", "status") == "passed" && row.dig("cleanup", "status") == "passed"
+ raise Error, "workflow-creator evidence lacks secret-scan or cleanup proof"
+ end
+ findings = HiveLiveAgentProof.secret_findings(File.read(path))
+ raise Error, "workflow-creator evidence secret scan failed: #{findings.join(', ')}" unless findings.empty?
+
+ row
+ end
+
+ def validate_creator_result!(row)
+ created = row["created_files"]
+ unless created.is_a?(Array) && created.map { |record| record["path"] }.sort == WORKFLOW_CREATOR_FILES
+ raise Error, "workflow-creator created files are incomplete"
+ end
+ unless created.all? { |record| record.keys.sort == %w[path sha256 size] &&
+ record["sha256"].to_s.match?(/\A[0-9a-f]{64}\z/) && record["size"].to_i.positive? }
+ raise Error, "workflow-creator created-file records are invalid"
+ end
+
+ validation = row["validation"]
+ stages = validation.is_a?(Hash) ? validation["stages"] : nil
+ outcomes = validation.is_a?(Hash) ? validation["human_outcomes"] : nil
+ unless validation&.fetch("valid", false) == true &&
+ stages == %w[research draft approval] &&
+ validation["automatic_edges"] == [ %w[research draft], %w[draft approval] ] &&
+ outcomes == [
+ { "stage" => "approval", "name" => "approve", "complete" => true,
+ "artifact" => "draft.md", "to" => nil },
+ { "stage" => "approval", "name" => "reject", "complete" => false,
+ "artifact" => nil, "to" => "draft" }
+ ]
+ raise Error, "workflow-creator normalized graph is invalid"
+ end
+ task = row["task"]
+ unless row["creation_only_task_count"] == 0 && row["task_count"] == 1 &&
+ task == {
+ "slug" => WORKFLOW_CREATOR_TASK_SLUG,
+ "workflow" => "editorial",
+ "first_created" => true,
+ "retry_created" => false,
+ "run_count" => 1,
+ "current_stage" => "1-research"
+ } &&
+ row["external_actions"] == []
+ raise Error, "workflow-creator proof contains an unauthorized side effect"
+ end
+ end
end
class Verifier
@@ -462,6 +584,7 @@ def call
HiveLiveAgentProof.verify_file_record!(artifact_root, name, record)
end
validate_platforms!(manifest)
+ validate_workflow_creator!(manifest)
findings = HiveLiveAgentProof.secret_findings(File.read(path))
raise Error, "proof secret scan failed: #{findings.join(', ')}" unless findings.empty?
@@ -509,5 +632,26 @@ def validate_platforms!(manifest)
end
end
end
+
+ def validate_workflow_creator!(manifest)
+ row = @attestation["workflow_creator"]
+ unless row.is_a?(Hash) && row["schema"] == "hive-live-workflow-creator-evidence" &&
+ row["platform"] == "openclaw" && row["candidate_sha"] == @candidate_sha &&
+ row["result"] == "passed" && row.dig("skill", "canonical_digest") == manifest["canonical_digest"] &&
+ row.dig("secret_scan", "status") == "passed" && row.dig("cleanup", "status") == "passed"
+ raise Error, "workflow-creator attested evidence is incomplete"
+ end
+ unless HiveLiveAgentProof.valid_native_activation?("openclaw", row["native_activation"])
+ raise Error, "workflow-creator attested native activation evidence is invalid"
+ end
+ unless row["prompt_sha256"] == Digest::SHA256.hexdigest(WORKFLOW_CREATOR_PROMPT) &&
+ row["task_prompt_sha256"] == Digest::SHA256.hexdigest(WORKFLOW_CREATOR_TASK_PROMPT) &&
+ row["hive_commands"] == WORKFLOW_CREATOR_COMMANDS &&
+ row["creation_only_task_count"] == 0 && row["task_count"] == 1 &&
+ row["task"].is_a?(Hash) && row["task"]["retry_created"] == false &&
+ row["task"]["run_count"] == 1 && row["external_actions"] == []
+ raise Error, "workflow-creator attested contract is invalid"
+ end
+ end
end
end
diff --git a/schemas/hive-decide.v1.json b/schemas/hive-decide.v1.json
new file mode 100644
index 0000000000..5bb59e229c
--- /dev/null
+++ b/schemas/hive-decide.v1.json
@@ -0,0 +1,74 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "urn:hive:schema:decide:v1",
+ "title": "hive-decide v1",
+ "oneOf": [
+ { "$ref": "#/$defs/SuccessPayload" },
+ { "$ref": "#/$defs/ErrorPayload" }
+ ],
+ "$defs": {
+ "SuccessPayload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schema", "schema_version", "ok", "applied", "noop", "slug", "workflow",
+ "from_stage", "outcome", "decision_id", "note", "decided_at", "completed",
+ "artifact", "artifact_path", "to", "current_stage", "task_folder", "next_action"
+ ],
+ "properties": {
+ "schema": { "const": "hive-decide" },
+ "schema_version": { "const": 1 },
+ "ok": { "const": true },
+ "applied": { "type": "boolean" },
+ "noop": { "type": "boolean" },
+ "slug": { "type": "string" },
+ "workflow": { "type": "string" },
+ "from_stage": { "type": "string", "pattern": "^[0-9]+-[a-z0-9][a-z0-9-]*$" },
+ "outcome": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
+ "decision_id": { "type": "string", "pattern": "^[0-9a-f]{16}$" },
+ "note": { "type": ["string", "null"] },
+ "decided_at": { "type": "string", "format": "date-time" },
+ "completed": { "type": "boolean" },
+ "artifact": { "type": ["string", "null"] },
+ "artifact_path": { "type": ["string", "null"] },
+ "to": { "type": ["string", "null"] },
+ "current_stage": { "type": "string", "pattern": "^[0-9]+-[a-z0-9][a-z0-9-]*$" },
+ "task_folder": { "type": "string" },
+ "next_action": {
+ "type": "object",
+ "additionalProperties": true,
+ "required": ["kind"],
+ "properties": {
+ "kind": { "enum": ["run", "needs_input", "no_op"] },
+ "command": { "type": ["string", "null"] },
+ "reason": { "type": "string" }
+ }
+ }
+ }
+ },
+ "ErrorPayload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["schema", "schema_version", "ok", "error_class", "error_kind", "exit_code", "message"],
+ "properties": {
+ "schema": { "const": "hive-decide" },
+ "schema_version": { "const": 1 },
+ "ok": { "const": false },
+ "error_class": { "type": "string" },
+ "error_kind": {
+ "enum": [
+ "wrong_stage", "ambiguous_slug", "invalid_task_path", "concurrent_run",
+ "config", "git", "internal", "error"
+ ]
+ },
+ "exit_code": { "type": "integer", "minimum": 0, "maximum": 255 },
+ "message": { "type": "string" },
+ "candidates": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
+ "current_stage": { "type": "string" },
+ "target_stage": { "type": "string" },
+ "holder": { "type": "object", "additionalProperties": true },
+ "lock_path": { "type": "string" }
+ }
+ }
+ }
+}
diff --git a/schemas/hive-init-preview.v1.json b/schemas/hive-init-preview.v1.json
new file mode 100644
index 0000000000..3f8b901027
--- /dev/null
+++ b/schemas/hive-init-preview.v1.json
@@ -0,0 +1,132 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-init-preview.v1.json",
+ "title": "hive init minimal preview (v1)",
+ "description": "Resolved, no-write plan emitted by hive init --new-workflow ID --minimal --preview --json.",
+ "oneOf": [
+ { "$ref": "#/$defs/SuccessPayload" },
+ { "$ref": "#/$defs/ErrorPayload" }
+ ],
+ "$defs": {
+ "SuccessPayload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schema", "schema_version", "ok", "preview", "minimal", "project", "path",
+ "default_branch", "workflow", "profile", "template", "project_files",
+ "state_worktree", "context_integration", "global_registration", "services",
+ "background_automation", "task_creation"
+ ],
+ "properties": {
+ "schema": { "const": "hive-init-preview" },
+ "schema_version": { "const": 1 },
+ "ok": { "const": true },
+ "preview": { "const": true },
+ "minimal": { "const": true },
+ "project": { "type": "string" },
+ "path": { "type": "string" },
+ "default_branch": { "type": "string" },
+ "workflow": { "type": "string" },
+ "profile": { "const": "minimal" },
+ "template": { "const": "blank" },
+ "project_files": {
+ "type": "array",
+ "minItems": 1,
+ "items": { "type": "string" },
+ "uniqueItems": true
+ },
+ "state_worktree": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["branch", "path", "config_path", "workflow_descriptor", "workflow_instruction"],
+ "properties": {
+ "branch": { "const": "hive/state" },
+ "path": { "type": "string" },
+ "config_path": { "type": "string" },
+ "workflow_descriptor": { "type": "string" },
+ "workflow_instruction": { "type": "string" }
+ }
+ },
+ "context_integration": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["llm_wiki", "agent_instructions", "post_commit_hook", "scheduler"],
+ "properties": {
+ "llm_wiki": { "const": true },
+ "agent_instructions": { "const": true },
+ "post_commit_hook": { "const": true },
+ "scheduler": { "const": false }
+ }
+ },
+ "global_registration": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["enabled", "config_path", "name", "status"],
+ "properties": {
+ "enabled": { "const": true },
+ "config_path": { "type": "string" },
+ "name": { "type": "string" },
+ "status": { "const": "available" }
+ }
+ },
+ "services": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["daemon_install", "daemon_autostart", "babysitter_timer", "llm_wiki_timer"],
+ "properties": {
+ "daemon_install": { "const": false },
+ "daemon_autostart": { "const": false },
+ "babysitter_timer": { "const": false },
+ "llm_wiki_timer": { "const": false }
+ }
+ },
+ "background_automation": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["daemon_dispatch", "patrol", "refactor_patrol", "adhoc_auto_fix", "babysitter"],
+ "properties": {
+ "daemon_dispatch": { "const": false },
+ "patrol": { "const": false },
+ "refactor_patrol": { "const": false },
+ "adhoc_auto_fix": { "const": false },
+ "babysitter": { "const": false }
+ }
+ },
+ "task_creation": { "const": false }
+ }
+ },
+ "ErrorPayload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schema", "schema_version", "ok", "error_class", "error_kind", "exit_code",
+ "message", "minimal", "preview", "project", "path", "workflow"
+ ],
+ "properties": {
+ "schema": { "const": "hive-init-preview" },
+ "schema_version": { "const": 1 },
+ "ok": { "const": false },
+ "error_class": { "type": "string" },
+ "error_kind": {
+ "enum": [
+ "usage", "already_initialized", "project_name_collision",
+ "not_git_repository", "main_checkout_required", "dirty_worktree",
+ "concurrent_run", "config", "git", "internal", "error"
+ ]
+ },
+ "exit_code": { "type": "integer", "minimum": 0, "maximum": 255 },
+ "message": { "type": "string" },
+ "minimal": { "const": true },
+ "preview": { "const": true },
+ "project": { "type": "string" },
+ "path": { "type": "string" },
+ "workflow": { "type": "string" },
+ "value": { "type": "string" },
+ "suggested_id": { "type": "string" },
+ "existing_path": { "type": "string" },
+ "holder": { "type": "object", "additionalProperties": true },
+ "lock_path": { "type": "string" }
+ }
+ }
+ }
+}
diff --git a/schemas/hive-init.v2.json b/schemas/hive-init.v2.json
index fba570e580..683d8a7a4f 100644
--- a/schemas/hive-init.v2.json
+++ b/schemas/hive-init.v2.json
@@ -5,9 +5,43 @@
"description": "Stable success contract emitted by `hive init --json`. V2 adds the explicit fresh-project architecture patrol discovery choice while leaving auto-fix and issue-filing policy in project config.",
"oneOf": [
{ "$ref": "#/$defs/SuccessPayload" },
- { "$ref": "#/$defs/AlreadyInitializedPayload" }
+ { "$ref": "#/$defs/AlreadyInitializedPayload" },
+ { "$ref": "#/$defs/ErrorPayload" }
],
"$defs": {
+ "ErrorPayload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schema", "schema_version", "ok", "error_class", "error_kind", "exit_code",
+ "message", "minimal", "preview", "project", "path", "workflow"
+ ],
+ "properties": {
+ "schema": { "const": "hive-init" },
+ "schema_version": { "const": 2 },
+ "ok": { "const": false },
+ "error_class": { "type": "string" },
+ "error_kind": {
+ "enum": [
+ "usage", "already_initialized", "project_name_collision",
+ "not_git_repository", "main_checkout_required", "dirty_worktree",
+ "concurrent_run", "config", "git", "internal", "error"
+ ]
+ },
+ "exit_code": { "type": "integer", "minimum": 0, "maximum": 255 },
+ "message": { "type": "string" },
+ "minimal": { "const": true },
+ "preview": { "const": false },
+ "project": { "type": "string" },
+ "path": { "type": "string" },
+ "workflow": { "type": "string" },
+ "value": { "type": "string" },
+ "suggested_id": { "type": "string" },
+ "existing_path": { "type": "string" },
+ "holder": { "type": "object", "additionalProperties": true },
+ "lock_path": { "type": "string" }
+ }
+ },
"AlreadyInitializedPayload": {
"type": "object",
"additionalProperties": false,
@@ -31,6 +65,7 @@
"hive_state_path": { "type": "string" },
"descriptor_path": { "type": "string" },
"instruction_path": { "type": "string" },
+ "minimal": { "const": true },
"workflow": { "type": "string" }
}
},
@@ -168,6 +203,7 @@
"hive_state_path": { "type": "string" },
"descriptor_path": { "type": "string" },
"instruction_path": { "type": "string" },
+ "minimal": { "const": true },
"workflow": { "type": "string" },
"hints": {
"type": "array",
diff --git a/schemas/hive-new.v1.json b/schemas/hive-new.v1.json
new file mode 100644
index 0000000000..20cfb6815b
--- /dev/null
+++ b/schemas/hive-new.v1.json
@@ -0,0 +1,73 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-new.v1.json",
+ "title": "hive new output (v1)",
+ "description": "Machine-readable task creation with optional state-wide idempotency.",
+ "oneOf": [
+ { "$ref": "#/$defs/SuccessPayload" },
+ { "$ref": "#/$defs/ErrorPayload" }
+ ],
+ "$defs": {
+ "HumanOutcome": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name", "complete", "artifact", "to"],
+ "properties": {
+ "name": { "type": "string" },
+ "complete": { "type": "boolean" },
+ "artifact": { "type": ["string", "null"] },
+ "to": { "type": ["string", "null"] }
+ }
+ },
+ "NextAction": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["kind", "command", "outcomes"],
+ "properties": {
+ "kind": { "type": "string" },
+ "command": { "type": ["string", "null"] },
+ "outcomes": { "type": "array", "items": { "$ref": "#/$defs/HumanOutcome" } }
+ }
+ },
+ "SuccessPayload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schema", "schema_version", "ok", "created", "slug", "workflow",
+ "current_stage", "task_folder", "next_action"
+ ],
+ "properties": {
+ "schema": { "const": "hive-new" },
+ "schema_version": { "const": 1 },
+ "ok": { "const": true },
+ "created": { "type": "boolean" },
+ "slug": { "type": "string" },
+ "workflow": { "type": "string" },
+ "current_stage": { "type": "string" },
+ "task_folder": { "type": "string" },
+ "next_action": { "$ref": "#/$defs/NextAction" }
+ }
+ },
+ "ErrorPayload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schema", "schema_version", "ok", "error_class", "error_kind",
+ "exit_code", "message"
+ ],
+ "properties": {
+ "schema": { "const": "hive-new" },
+ "schema_version": { "const": 1 },
+ "ok": { "const": false },
+ "error_class": { "type": "string" },
+ "error_kind": { "enum": ["usage", "config", "concurrent_run", "internal", "error"] },
+ "exit_code": { "type": "integer" },
+ "message": { "type": "string" },
+ "value": { "type": ["string", "integer"] },
+ "idempotency_key": { "type": "string" },
+ "holder": { "type": "object", "additionalProperties": true },
+ "lock_path": { "type": "string" }
+ }
+ }
+ }
+}
diff --git a/schemas/hive-operational-status.v3.json b/schemas/hive-operational-status.v3.json
index f732bb18d1..0058c24caa 100644
--- a/schemas/hive-operational-status.v3.json
+++ b/schemas/hive-operational-status.v3.json
@@ -226,10 +226,14 @@
"position": {
"type": "object",
"additionalProperties": false,
- "required": ["stage", "marker"],
+ "required": ["stage", "marker", "allowed_outcomes"],
"properties": {
"stage": { "type": "string" },
- "marker": { "type": "string" }
+ "marker": { "type": "string" },
+ "allowed_outcomes": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/HumanOutcome" }
+ }
}
},
"liveness": {
@@ -328,6 +332,17 @@
}
}
},
+ "HumanOutcome": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name", "complete", "artifact", "to"],
+ "properties": {
+ "name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
+ "complete": { "type": "boolean" },
+ "artifact": { "type": ["string", "null"] },
+ "to": { "type": ["string", "null"], "pattern": "^[a-z0-9][a-z0-9-]*$" }
+ }
+ },
"Reason": {
"type": "object",
"additionalProperties": false,
diff --git a/schemas/hive-run.v2.json b/schemas/hive-run.v2.json
index 0457934604..c3749d7248 100644
--- a/schemas/hive-run.v2.json
+++ b/schemas/hive-run.v2.json
@@ -115,6 +115,11 @@
"type": "string"
}
},
+ "allowed_outcomes": {
+ "description": "Descriptor-declared outcomes when the current stage requires a human decision.",
+ "type": "array",
+ "items": { "$ref": "#/$defs/HumanOutcome" }
+ },
"rebase": {
"description": "Outcome of the auto-rebase pre-step (Hive::Rebase). Always present. `attempted` is true only when the rebase machinery ran past the disabled/no-worktree guards. `succeeded` is true on clean rebase (or no-op when commits_behind==0). `reason` is null on success and a string (snake_case) naming the skip-or-failure cause otherwise. `post_rebase_warnings` carries non-fatal warnings produced after a successful rebase (e.g., `worktree.yml` execute_base_head write failure); always an array, empty when none.",
"type": "object",
@@ -163,6 +168,7 @@
"disabled",
"cli_override",
"manual_steering",
+ "human_stage",
"no_worktree",
"pre_existing_rebase",
"dirty_worktree",
@@ -189,6 +195,17 @@
}
}
},
+ "HumanOutcome": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name", "complete", "artifact", "to"],
+ "properties": {
+ "name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
+ "complete": { "type": "boolean" },
+ "artifact": { "type": ["string", "null"] },
+ "to": { "type": ["string", "null"], "pattern": "^[a-z0-9][a-z0-9-]*$" }
+ }
+ },
"ErrorPayload": {
"type": "object",
"additionalProperties": false,
diff --git a/schemas/hive-status.v7.json b/schemas/hive-status.v7.json
index 2cfaa20255..07a4c36c69 100644
--- a/schemas/hive-status.v7.json
+++ b/schemas/hive-status.v7.json
@@ -120,7 +120,7 @@
"commit_generation", "current_attempt", "conditions", "condition_history",
"evidence", "closure", "condition_overrides", "condition_gate", "condition_migration", "condition_provenance",
"shadow_audit", "condition_warning", "unanswered_questions", "action",
- "action_label", "suggested_command",
+ "action_label", "suggested_command", "outcomes",
"next_action", "diagnostic"
],
"properties": {
@@ -267,6 +267,10 @@
},
"action_label": { "type": "string" },
"suggested_command": { "type": ["string", "null"] },
+ "outcomes": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/HumanOutcome" }
+ },
"next_action": {
"type": ["object", "null"],
"additionalProperties": true,
@@ -285,6 +289,17 @@
}
}
},
+ "HumanOutcome": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name", "complete", "artifact", "to"],
+ "properties": {
+ "name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
+ "complete": { "type": "boolean" },
+ "artifact": { "type": ["string", "null"] },
+ "to": { "type": ["string", "null"], "pattern": "^[a-z0-9][a-z0-9-]*$" }
+ }
+ },
"ImplementationIdentity": {
"description": "Generation-scoped implementation ownership projected from the task journal. Status reads are pure: preview entries describe the policy that would be used if the stage launched now, while resolved entries came from launch events.",
"type": "object",
diff --git a/schemas/hive-workflow-new.v1.json b/schemas/hive-workflow-new.v1.json
index b9c17eb0cd..65f25c0056 100644
--- a/schemas/hive-workflow-new.v1.json
+++ b/schemas/hive-workflow-new.v1.json
@@ -114,6 +114,10 @@
},
"description": "Present only for missing/unknown subcommand usage errors: the valid subcommand verbs (e.g. [\"new\"]), so an agent discovers them without regexing `message`."
},
+ "suggested_id": {
+ "type": "string",
+ "description": "The deterministic first available workflow id after a reserved-id or scaffold collision."
+ },
"holder": {
"type": "object",
"description": "Present only for concurrent_run: metadata about the lock holder.",
diff --git a/schemas/hive-workflow-validate.v1.json b/schemas/hive-workflow-validate.v1.json
new file mode 100644
index 0000000000..cc806dd67e
--- /dev/null
+++ b/schemas/hive-workflow-validate.v1.json
@@ -0,0 +1,106 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-workflow-validate.v1.json",
+ "title": "hive workflow validate output (v1)",
+ "description": "Strictly read-only validation through direct authored, built-in, and managed workflow lookups.",
+ "oneOf": [
+ { "$ref": "#/$defs/SuccessPayload" },
+ { "$ref": "#/$defs/ErrorPayload" }
+ ],
+ "$defs": {
+ "Diagnostic": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["message"],
+ "properties": {
+ "message": { "type": "string" }
+ }
+ },
+ "Stage": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name", "dir", "kind", "state_file", "instruction_path", "input"],
+ "properties": {
+ "name": { "type": "string" },
+ "dir": { "type": "string" },
+ "kind": { "type": ["string", "null"] },
+ "state_file": { "type": ["string", "null"] },
+ "instruction_path": { "type": ["string", "null"] },
+ "input": { "type": ["string", "null"] }
+ }
+ },
+ "AutomaticEdge": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["from", "to"],
+ "properties": {
+ "from": { "type": "string" },
+ "to": { "type": "string" }
+ }
+ },
+ "HumanOutcome": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["stage", "name", "complete", "artifact", "to"],
+ "properties": {
+ "stage": { "type": "string" },
+ "name": { "type": "string" },
+ "complete": { "type": "boolean" },
+ "artifact": { "type": ["string", "null"] },
+ "to": { "type": ["string", "null"] }
+ }
+ },
+ "SuccessPayload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schema", "schema_version", "ok", "valid", "id", "origin",
+ "descriptor_path", "instruction_paths", "stages", "automatic_edges",
+ "human_outcomes", "diagnostics"
+ ],
+ "properties": {
+ "schema": { "const": "hive-workflow-validate" },
+ "schema_version": { "const": 1 },
+ "ok": { "const": true },
+ "valid": { "const": true },
+ "id": { "type": "string" },
+ "origin": { "enum": ["built_in", "authored", "managed"] },
+ "descriptor_path": { "type": ["string", "null"] },
+ "instruction_paths": {
+ "type": "array",
+ "items": { "type": "string" },
+ "uniqueItems": true
+ },
+ "stages": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/Stage" } },
+ "automatic_edges": { "type": "array", "items": { "$ref": "#/$defs/AutomaticEdge" } },
+ "human_outcomes": { "type": "array", "items": { "$ref": "#/$defs/HumanOutcome" } },
+ "diagnostics": { "type": "array", "maxItems": 0 }
+ }
+ },
+ "ErrorPayload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schema", "schema_version", "ok", "valid", "id", "error_class",
+ "error_kind", "exit_code", "message", "diagnostics"
+ ],
+ "properties": {
+ "schema": { "const": "hive-workflow-validate" },
+ "schema_version": { "const": 1 },
+ "ok": { "const": false },
+ "valid": { "const": false },
+ "id": { "type": "string" },
+ "error_class": { "type": "string" },
+ "error_kind": { "enum": ["usage", "config", "git", "concurrent_run", "error"] },
+ "exit_code": { "type": "integer" },
+ "message": { "type": "string" },
+ "diagnostics": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/Diagnostic" } },
+ "value": { "type": "string" },
+ "expected": { "type": "array", "items": { "type": "string" } },
+ "suggested_id": { "type": "string" },
+ "holder": { "type": "object", "additionalProperties": true },
+ "lock_path": { "type": "string" }
+ }
+ }
+ }
+}
diff --git a/skills/hive/SKILL.md b/skills/hive/SKILL.md
index 0fd8fd8b4a..71967f1d96 100644
--- a/skills/hive/SKILL.md
+++ b/skills/hive/SKILL.md
@@ -1,6 +1,6 @@
---
name: hive
-description: Operate Hive task pipelines and pre-release QA through its native CLI. Use when an agent needs to answer current Hive or Hive-bench status, identify the exact active task and blocker owner, follow work through bounded transitions, inspect candidate QA evidence, advance a fresh routine recommendation, diagnose recovery state, initialize or configure Hive, or explain what requires operator action across registered projects.
+description: Operate Hive task pipelines and pre-release QA through its native CLI. Use when an agent needs to answer current Hive or Hive-bench status, identify the exact active task and blocker owner, follow work through bounded transitions, create a new project-local workflow from an ordinary-language request, inspect candidate QA evidence, advance a fresh routine recommendation, diagnose recovery state, initialize or configure Hive, or explain what requires operator action across registered projects.
---
# Hive Operations
@@ -36,6 +36,9 @@ Ask before destructive or administrative changes, marker clearing, force/bypass
## Load the relevant reference
+- Read [workflow-creator.md](references/workflow-creator.md) when ordinary language asks to create a new project-local workflow. This focused route is the `hive-workflow-creator` capability inside the single canonical `/hive` skill; it is not a second skill or package.
+- Read [workflow-creator-example.md](references/workflow-creator-example.md) for the accepted research → draft → approval editorial example.
+- Read [workflow-schema.md](references/workflow-schema.md), [workflow-stage-design.md](references/workflow-stage-design.md), [workflow-checkpoints.md](references/workflow-checkpoints.md), [workflow-permissions.md](references/workflow-permissions.md), [workflow-testing.md](references/workflow-testing.md), and [workflow-common-mistakes.md](references/workflow-common-mistakes.md) only as needed while authoring or diagnosing a newly scaffolded descriptor.
- Read [status-and-watch.md](references/status-and-watch.md) for status interpretation, reporting, compatibility, and native watch semantics.
- Read [workflow-actions.md](references/workflow-actions.md) for task creation, direct workflow verbs, daemon ownership, closed actions, and completion checks.
- Read [recovery.md](references/recovery.md) for diagnosis, coordinator ownership, provider holds, stale workers, migration, and guarded recovery.
diff --git a/skills/hive/references/safety.md b/skills/hive/references/safety.md
index 8b89180bf7..46e0e552a8 100644
--- a/skills/hive/references/safety.md
+++ b/skills/hive/references/safety.md
@@ -10,10 +10,16 @@ Explain scope, affected state, and recovery before running:
- Foreground or unbounded streams such as daemon or bot tails when a bounded native status/watch answer is sufficient.
- Any external message, PR mutation beyond the user’s request, publication, deployment, tag, package release, or version change.
- Workflow install/update/remove/publish, `setup-agents`, manual patrol starts,
- and outbound `digest` or `bench submit` operations.
+ and outbound `bench submit` operations.
Routine read-only inspection, `hive watch`, `hive doctor`, and a fresh `hive act` descriptor with `confirmation_required: false` do not require another prompt.
+Creating a new workflow authorizes only the newly scaffolded descriptor and
+instruction paths. It never authorizes edits to an existing workflow or task
+creation. Fresh minimal initialization requires its own disclosed preview and
+one explicit confirmation. External publication remains separately gated even
+when a human outcome marks an artifact publish-ready.
+
No-write Honeycomb workflow previews (`workflow install|update|remove` with
`--dry-run --json`) are read-only. Do not generalize that exemption: patrol
and refactor-patrol dry-runs still launch agents and remain consent-gated.
diff --git a/skills/hive/references/setup-and-platforms.md b/skills/hive/references/setup-and-platforms.md
index 406ed6c0b8..7f52d66a0d 100644
--- a/skills/hive/references/setup-and-platforms.md
+++ b/skills/hive/references/setup-and-platforms.md
@@ -106,6 +106,14 @@ the user reviews and approves enrollment, ask them to run `hive init .` in
their own real terminal so they can change those defaults. Do not run it
headlessly on their behalf or invent bypass flags.
+The workflow-creator route has one narrower fresh-project exception. It must
+first show the no-write `hive init --new-workflow ID --minimal --preview
+--json` payload and receive one explicit confirmation. The matching minimal
+execution disables patrol, architecture patrol, ad-hoc auto-fix, daemon
+dispatch/autostart, babysitting, optional reviewers, and service/timer setup;
+it retains core hive/state, registration, and required context hooks. Do not
+substitute ordinary non-TTY init for this preview/confirmation contract.
+
OpenClaw installs the public ClawHub projection separately:
```bash
diff --git a/skills/hive/references/workflow-checkpoints.md b/skills/hive/references/workflow-checkpoints.md
new file mode 100644
index 0000000000..426ffab186
--- /dev/null
+++ b/skills/hive/references/workflow-checkpoints.md
@@ -0,0 +1,20 @@
+# Workflow checkpoints
+
+Checkpoints are durable human stages, not comments in an agent instruction.
+Add one only when the request names a decision or the next action would be
+irreversible, externally visible, privileged, or otherwise high consequence.
+
+Declare a small closed set of named outcomes. Each outcome either completes
+with a verified artifact or returns to an existing stage. Hive records the
+decision with its stage visit identity, note, artifact, and timestamp.
+
+Operate a waiting checkpoint with:
+
+```bash
+hive decide TARGET OUTCOME --from STAGE --decision-id DECISION_ID --note "REASON" --json
+```
+
+Read `DECISION_ID` from the waiting stage's `hive run --json` response. The
+stage and visit observations make retries safe. Repeating the same decision is
+a no-op; a stale or conflicting decision is rejected. Do not bypass a
+checkpoint with marker edits, a forced move, or an inferred external action.
diff --git a/skills/hive/references/workflow-common-mistakes.md b/skills/hive/references/workflow-common-mistakes.md
new file mode 100644
index 0000000000..17187d0959
--- /dev/null
+++ b/skills/hive/references/workflow-common-mistakes.md
@@ -0,0 +1,22 @@
+# Workflow creator common mistakes
+
+- Editing an existing descriptor after discovering a collision. Stop and
+ propose Hive’s deterministic available ID instead.
+- Scaffolding before checking the installed version, project, and ID inventory.
+- Treating neutral blank scaffolding as permission to select a research,
+ writing, or coding template by keyword alone.
+- Adding stage indices to YAML. Array order is authoritative.
+- Adding `input` to an ordinary agent stage when its instruction can name the
+ prior artifact; `input` belongs to supported consumers such as human/council
+ stages.
+- Emitting agent/model on every stage instead of inheriting project choices.
+- Adding checkpoints for harmless local work, or omitting them before a named
+ material decision.
+- Turning “before publishing” into an inferred publish stage. Approval may mark
+ an artifact publish-ready but never publishes it.
+- Claiming success after YAML parsing alone. Production workflow resolution and
+ `hive workflow validate` are required.
+- Creating or running a task when the original request asked only for the
+ workflow, or retrying a moved task without its stable idempotency key.
+- Using force, overwriting generated paths not returned by the scaffold, or
+ repairing invalid output in unrelated existing files.
diff --git a/skills/hive/references/workflow-creator-example.md b/skills/hive/references/workflow-creator-example.md
new file mode 100644
index 0000000000..d55188cb06
--- /dev/null
+++ b/skills/hive/references/workflow-creator-example.md
@@ -0,0 +1,48 @@
+# Workflow creator example: editorial approval
+
+Request: “Create a three-stage editorial workflow that researches, drafts,
+and requires approval before publishing.”
+
+The accepted interpretation is exactly `research -> draft -> approval`. There
+is no inferred publish stage and no external publication action. Research and
+draft inherit the project agent/model and use ordinary local `yolo`
+permissions. Approval is a durable human stage.
+
+```yaml
+id: editorial
+stages:
+ - name: research
+ kind: agent
+ state_file: research.md
+ instruction: editorial/research.md
+ permissions: yolo
+ - name: draft
+ kind: agent
+ state_file: draft.md
+ instruction: editorial/draft.md
+ permissions: yolo
+ - name: approval
+ kind: human
+ state_file: approval.md
+ input: draft.md
+ outcomes:
+ approve:
+ complete: true
+ artifact: draft.md
+ reject:
+ to: draft
+```
+
+`approve` requires a non-empty `draft.md`, records it as publish-ready, and
+completes from approval. “Publish-ready” is an artifact status, not authority
+to publish. `reject` records the decision, returns the same task to `draft`,
+and resets draft to `WAITING` so stale completion cannot re-advance it.
+
+Required validation facts:
+
+- stages: research, draft, approval;
+- automatic edges: research to draft, draft to approval;
+- human outcomes: approval.approve completes with draft.md;
+ approval.reject returns to draft;
+- no task for a creation-only request; and
+- no publish stage, destination, command, or external side effect.
diff --git a/skills/hive/references/workflow-creator.md b/skills/hive/references/workflow-creator.md
new file mode 100644
index 0000000000..0c3b3bc647
--- /dev/null
+++ b/skills/hive/references/workflow-creator.md
@@ -0,0 +1,130 @@
+# Natural-language workflow creator
+
+This is the `hive-workflow-creator` contract inside the canonical `/hive`
+skill. It creates one new owner-authored project workflow. It never edits,
+repairs, overwrites, or partially merges an existing workflow.
+
+## Gate before mutation
+
+The minimum Hive version: {{HIVE_VERSION}}. First run `hive version` and parse
+the bare semantic version. If it is older, stop before inspecting or writing
+project workflow paths and say exactly: “Hive {{HIVE_VERSION}} or newer is
+required; run `hive update`, then retry.” Do not invent a compatibility adapter.
+
+After the version passes, run `hive workflow list --json` in the target project
+and inspect `.hive-state/config.yml` when present. Record the project’s current
+agent/model choices, built-ins, managed workflows, and owner-authored IDs.
+Infer a safe lowercase-hyphen ID. A reserved or existing ID is a hard stop:
+leave every file byte-identical and report Hive’s `suggested_id` (or the first
+available numeric suffix obtained from the same ID inventory). Never silently
+switch IDs and continue.
+
+Determine whether the project is initialized before scaffolding. For a fresh
+target, obtain the authoritative preview with:
+
+```bash
+hive init --new-workflow ID --minimal --preview --json
+```
+
+Summarize its project files, hive/state worktree, global registration,
+wiki/context hooks, and explicitly disabled services and automation. Wait for
+one explicit confirmation. If confirmation is declined, absent, or ambiguous,
+stop and leave the target unchanged. After approval, run the same command
+without `--preview`. Never add a force option, select a starter template, or
+initialize an existing target through the minimal route.
+
+For an initialized project, scaffold only after all gates pass:
+
+```bash
+hive workflow new ID --json
+```
+
+Use a built-in sample only when its semantics genuinely match the request;
+otherwise keep the neutral blank scaffold. Modify only `descriptor_path` and
+the newly returned instruction directory. Treat any collision returned by the
+CLI as authoritative and stop with its deterministic alternative.
+
+## Infer, populate, and validate
+
+Translate the original request into the smallest complete graph:
+
+- Preserve the requested stage order and use sequential automatic edges by default.
+- Infer one durable artifact/state file per stage and reusable instruction text for agent stages.
+- Omit stage `agent` and `model` when project inheritance is sufficient.
+- Use `permissions: yolo` for ordinary local agent work unless the request or a high-consequence boundary requires less authority.
+- Add a human stage only for a materially necessary decision or irreversible/high-consequence boundary. Never infer branching, specialist models, or external destinations.
+- Never publish externally, deploy, message, tag, or release from an inferred stage. A separate destination and explicit authorization are mandatory.
+
+Write only the newly scaffolded paths, then run:
+
+```bash
+hive workflow validate ID --json
+```
+
+Success requires `valid: true`, the intended ordered stages and kinds, exact
+automatic edges, exact human outcomes, and every instruction path present.
+On any diagnostic, stop and report it; do not claim success or create a task.
+
+After successful validation, commit the populated graph through Hive so the
+write shares the project commit lock with daemon and task writers:
+
+```bash
+hive workflow commit "$ID"
+```
+
+The commit is part of workflow creation. If it fails, stop and report it; do
+not claim success or create a task from an uncommitted graph.
+
+## Task side-effect boundary
+
+No task by default. A creation-only request ends after validation and the
+populated-graph commit, then prints an exact shell-quoted next command. Render
+every dynamic argv element with POSIX `Shellwords.escape` semantics. In
+particular, never put the request in double quotes: surround it with single
+quotes and replace each embedded `'` with the exact shell sequence `'"'"'`, so
+`$()`, backticks, variables, glob characters, and newlines
+remain literal bytes:
+
+```bash
+hive new 'PROJECT' --workflow 'ID' 'ORIGINAL REQUEST'
+```
+
+Only when the original request explicitly says to create or run a task, derive
+one stable opaque key and keep it unchanged across retries. The
+`workflow-creator:v1` derivation is canonical:
+
+1. Project is `File.realpath(PROJECT_PATH)`.
+2. Workflow is Hive's normalized ID from the scaffold result.
+3. Request is valid UTF-8 normalized to Unicode NFC, with CRLF/CR converted to
+ LF, leading/trailing whitespace stripped, and every remaining whitespace run
+ collapsed to one ASCII space.
+4. Hash the UTF-8 bytes of `JSON.generate([project, workflow, request])` with
+ SHA-256 and use `workflow-creator:v1:<64 lowercase hex>` as `KEY`.
+
+Do not add timestamps, random bytes, agent/session IDs, slugs, or retry counts.
+Create at most once with the same POSIX shell-quoting rule:
+
+```bash
+hive new 'PROJECT' --workflow 'ID' --idempotency-key 'KEY' --json 'ORIGINAL REQUEST'
+```
+
+If the original request also explicitly said run, invoke the reported first
+stage only when `created: true`; `created: false` means a retry and must not
+dispatch that stage again. Then query `hive status --operational --json` and
+report the slug, current stage, daemon disposition, and expected next
+transition from live state.
+
+## Required completion summary
+
+Always report:
+
+- created files (descriptor plus every instruction/reference path);
+- neutral scaffold or the genuinely reused template;
+- applied defaults, including ID, stage order, artifacts, inheritance,
+ permissions, automatic transitions, and every checkpoint/outcome;
+- validation result and normalized graph;
+- task side effect (`none`, `created: true`, or `created: false`);
+- the exact next command when no task was created; and
+- any question or explicit choice that remains material.
+
+Do not describe a workflow as created until Hive has loaded and validated it.
diff --git a/skills/hive/references/workflow-permissions.md b/skills/hive/references/workflow-permissions.md
new file mode 100644
index 0000000000..d7c8fa2130
--- /dev/null
+++ b/skills/hive/references/workflow-permissions.md
@@ -0,0 +1,16 @@
+# Workflow permissions
+
+For ordinary local agent stages created from natural language, use
+`permissions: yolo`; this preserves Hive’s normal local execution semantics.
+Omit stage agent/model fields to inherit project choices.
+
+Do not silently grant external authority. Publishing, deployment, messaging,
+credential use, production mutation, release operations, and destination
+selection require an explicit destination and authorization outside inferred
+workflow creation. Represent an approval request as a human stage; do not hide
+it in an agent prompt.
+
+Use a narrower supported permission scope only when the request clearly needs
+it and the selected agent can enforce it. If enforcement capability or the
+required directories/tools are unclear, ask one material question rather than
+inventing a scope that may be ineffective.
diff --git a/skills/hive/references/workflow-schema.md b/skills/hive/references/workflow-schema.md
new file mode 100644
index 0000000000..9bb87cb66b
--- /dev/null
+++ b/skills/hive/references/workflow-schema.md
@@ -0,0 +1,34 @@
+# Owner-authored workflow schema
+
+Descriptors live at `<hive_state_path>/workflows/ID.yml`; referenced agent
+instructions live below `<hive_state_path>/workflows/ID/`. Use the current
+format only. Stage indices are array order and are not authored as keys.
+
+Every stage declares a safe `name`, `kind`, and relative `state_file`.
+Agent stages declare exactly one of `instruction` or `skill`. Omit agent/model
+to inherit project choices. Ordinary transitions are sequential array edges.
+
+A human stage is closed and non-executable:
+
+```yaml
+- name: approval
+ kind: human
+ state_file: approval.md
+ input: draft.md
+ outcomes:
+ approve:
+ complete: true
+ artifact: draft.md
+ reject:
+ to: draft
+```
+
+Each outcome declares exactly one of `complete: true` or `to: STAGE`.
+Completing outcomes require an artifact. Returning outcomes cannot declare an
+artifact. Outcome and target names must be safe and targets must exist in the
+same descriptor. Human stages cannot declare agent, model, permissions,
+instruction, skill, budgets, timeouts, reviewers, councils, workspaces,
+handoffs, conditions, or executable commands.
+
+Treat `hive workflow validate ID --json` as authoritative for normalized
+stages, automatic edges, human outcomes, and instruction paths.
diff --git a/skills/hive/references/workflow-stage-design.md b/skills/hive/references/workflow-stage-design.md
new file mode 100644
index 0000000000..8334bd09c6
--- /dev/null
+++ b/skills/hive/references/workflow-stage-design.md
@@ -0,0 +1,19 @@
+# Workflow stage design
+
+Use the fewest stages that preserve distinct ownership, durable artifacts, or
+material decisions. A verb phrase in the request is not automatically a stage.
+
+- Split work when it produces a reusable artifact consumed later, needs a
+ different actor/permission boundary, or must wait durably for a person.
+- Keep ordinary transformations sequential. Do not infer speculative branches.
+- Give each agent stage one focused instruction: inputs, required output,
+ completion criteria, and explicit non-goals.
+- Prefer project agent/model inheritance. Specialize only when the request
+ materially requires a capability the inherited choice cannot supply.
+- Use safe lowercase-hyphen stage names and distinct state files.
+- A terminal human approval uses `complete: true` with a required non-empty
+ artifact basename; it does not need a synthetic “done” or “publish” stage.
+
+Before writing, restate the inferred ordered graph, artifacts, inheritance,
+permissions, and decision points. Ask only when two plausible interpretations
+would materially change behavior or consequences.
diff --git a/skills/hive/references/workflow-testing.md b/skills/hive/references/workflow-testing.md
new file mode 100644
index 0000000000..fbfa6bef2f
--- /dev/null
+++ b/skills/hive/references/workflow-testing.md
@@ -0,0 +1,22 @@
+# Workflow creator testing
+
+Validate the descriptor with Hive before reporting success:
+
+```bash
+hive workflow validate ID --json
+```
+
+Check `valid`, origin, descriptor path, ordered stages/kinds, instruction
+paths, automatic edges, and human outcomes. Validation must not change the
+project tree, hive/state commit, tasks, registry, services, or timers.
+
+For a human checkpoint, exercise both outcomes in a disposable task: completing
+requires a non-empty artifact; returning records the decision, moves to the
+declared stage, and leaves it waiting. Verify no agent dispatch occurs while a
+human stage waits.
+
+For an explicitly requested task, repeat the same idempotency key after the
+task moves stages. The response must return one slug with `created: false`.
+Reuse with different input/workflow must fail and leave one task. Finish with a
+fresh operational status snapshot; hermetic validation is primary and live
+agent proof is an additional, credential-gated attestation.
diff --git a/skills/hive/skill.json b/skills/hive/skill.json
index 0c1ef5669f..71bdcd15db 100644
--- a/skills/hive/skill.json
+++ b/skills/hive/skill.json
@@ -5,6 +5,14 @@
"name": "hive",
"version": "0.1.3",
"references": [
+ "references/workflow-creator.md",
+ "references/workflow-creator-example.md",
+ "references/workflow-schema.md",
+ "references/workflow-stage-design.md",
+ "references/workflow-checkpoints.md",
+ "references/workflow-permissions.md",
+ "references/workflow-testing.md",
+ "references/workflow-common-mistakes.md",
"references/status-and-watch.md",
"references/workflow-actions.md",
"references/recovery.md",
diff --git a/templates/project_config.yml.erb b/templates/project_config.yml.erb
index 82dbf8fe6a..b18493632a 100644
--- a/templates/project_config.yml.erb
+++ b/templates/project_config.yml.erb
@@ -235,6 +235,9 @@ review:
# here; disabled reviewers are omitted from the array entirely. The
# deep-merge replaces `review.reviewers` wholesale, so editing this
# list later replaces it (no per-element merge).
+<% if enabled_reviewers.empty? -%>
+ reviewers: []
+<% else -%>
reviewers:
<% if enabled_reviewers.include?("claude-ce-code-review") -%>
- name: claude-ce-code-review
@@ -276,6 +279,7 @@ review:
budget_usd: 50
timeout_sec: 7200
<% end -%>
+<% end -%>
# Hive daemon (ADR-024): auto-advances tasks through the pipeline up to
# human-input gates. When `enabled: true`, `hive daemon start` polls
@@ -367,6 +371,9 @@ patrol:
review:
max_context_files: 4
max_owned_files: 4
+<% if patrol_reviewers.empty? -%>
+ reviewers: []
+<% else -%>
reviewers:
<% if patrol_reviewers.include?("codex-native-review") -%>
# Native `codex review` — one cheap, tuned, read-only pass whose
@@ -398,6 +405,7 @@ patrol:
budget_usd: 50
timeout_sec: 5400
<% end -%>
+<% end -%>
# Architecture patrol is independent from ordinary patrol. Fresh init
# recommends discovery with confined auto-fix/PR attempts and GitHub issues as
diff --git a/test/integration/cli_usage_error_json_test.rb b/test/integration/cli_usage_error_json_test.rb
index 892975af1d..b69f8c6e45 100644
--- a/test/integration/cli_usage_error_json_test.rb
+++ b/test/integration/cli_usage_error_json_test.rb
@@ -55,6 +55,7 @@ def test_json_usage_errors_emit_envelopes_before_command_dispatch
cases = [
[ %w[run --json], "hive-run", {} ],
[ %w[approve --json], "hive-approve", {} ],
+ [ %w[decide task approve --json], "hive-decide", {} ],
[ %w[markers clear --json], "hive-markers-clear", {} ],
[ %w[drop --json], "hive-drop", {} ],
[ %w[findings --json], "hive-findings", {} ],
@@ -211,8 +212,8 @@ def test_bare_workflow_json_usage_error_uses_workflow_new_envelope
assert_equal "UsageError", payload["error_class"]
assert_equal "usage", payload["error_kind"]
assert_equal Hive::ExitCodes::USAGE, payload["exit_code"]
- assert_equal "missing SUBCOMMAND (expected: new, install, list, update, remove, publish)", payload["message"]
- assert_equal %w[new install list update remove publish], payload["expected"]
+ assert_equal "missing SUBCOMMAND (expected: new, validate, commit, install, list, update, remove, publish)", payload["message"]
+ assert_equal %w[new validate commit install list update remove publish], payload["expected"]
end
end
@@ -223,7 +224,7 @@ def test_bare_workflow_human_usage_error_names_expected_subcommand
refute status.success?
assert_equal Hive::ExitCodes::USAGE, status.exitstatus
assert_empty out
- assert_equal "hive workflow: missing SUBCOMMAND (expected: new, install, list, update, remove, publish)\n", err
+ assert_equal "hive workflow: missing SUBCOMMAND (expected: new, validate, commit, install, list, update, remove, publish)\n", err
end
end
@@ -258,6 +259,17 @@ def test_too_many_positionals_workflow_json_usage_error_uses_workflow_new_envelo
end
end
+ def test_workflow_validate_arity_error_uses_validate_envelope
+ with_tmp_global_config do |home|
+ assert_pre_dispatch_error(
+ home,
+ %w[workflow validate editorial extra --json],
+ schema: "hive-workflow-validate",
+ error_kind: "usage"
+ )
+ end
+ end
+
# Human-mode (non-`--json`) sibling of the case above. bin/hive branches its
# stderr prefix on whether a JSON envelope was emitted (`hive: ...` only when
# JSON rode out); without --json the raw Thor arity prose must surface on
diff --git a/test/integration/cli_version_test.rb b/test/integration/cli_version_test.rb
index 15b8aed4ff..2cc872dc64 100644
--- a/test/integration/cli_version_test.rb
+++ b/test/integration/cli_version_test.rb
@@ -14,6 +14,40 @@ def test_bin_hive_version_outputs_version
assert_equal "#{Hive::VERSION}\n", out
end
+ def test_strict_no_write_routes_skip_scheduler_reconciliation
+ with_tmp_global_config do |home|
+ with_tmp_git_repo do |project_root|
+ capture_io { Hive::Commands::Init.new(project_root, agent_skill_preflight: false).call }
+ assert_startup_reconcile(project_root, home, %w[workflow validate coding --json], expected: false)
+ end
+
+ with_tmp_git_repo do |project_root|
+ assert_startup_reconcile(
+ project_root, home,
+ [ "init", "--new-workflow", "editorial", "--minimal", "--preview", "--json" ],
+ expected: false
+ )
+ end
+
+ with_tmp_git_repo do |project_root|
+ assert_startup_reconcile(
+ project_root, home,
+ [ "init", "--new-workflow", "workflow", "--minimal", "--preview", "--json" ],
+ expected: false
+ )
+ end
+
+ with_tmp_git_repo do |project_root|
+ capture_io { Hive::Commands::Init.new(project_root, agent_skill_preflight: false).call }
+ assert_startup_reconcile(
+ project_root, home, %w[workflow --json validate coding], expected: false
+ )
+ end
+
+ assert_startup_reconcile(Dir.pwd, home, [ "--version" ], expected: true)
+ end
+ end
+
def test_bin_hive_help_after_option_value_shows_command_usage
out, err, status = Open3.capture3(
RbConfig.ruby, "-Ilib", "bin/hive", "approve", "--from", "2-brainstorm", "--help"
@@ -189,6 +223,32 @@ def run_status_json!(*args, home:)
JSON.parse(out)
end
+ def assert_startup_reconcile(chdir, home, args, expected:)
+ with_tmp_dir do |dir|
+ probe = File.join(dir, "reconciled")
+ preload = File.join(dir, "scheduler_probe.rb")
+ File.write(preload, <<~RUBY)
+ require "hive/llm_wiki_bootstrap"
+ Hive::LlmWikiBootstrap::Scheduler.define_singleton_method(:reconcile_existing!) do |**|
+ File.write(ENV.fetch("HIVE_RECONCILE_PROBE"), "called")
+ end
+ RUBY
+ env = {
+ "HIVE_HOME" => home,
+ "HOME" => home,
+ "PATH" => ENV.fetch("PATH", ""),
+ "HIVE_RECONCILE_PROBE" => probe,
+ "RUBYOPT" => [ ENV["RUBYOPT"], "-r#{preload}" ].compact.join(" ")
+ }
+ bin = File.expand_path("../../bin/hive", __dir__)
+ lib = File.expand_path("../../lib", __dir__)
+ out, err, status = Open3.capture3(env, RbConfig.ruby, "-I#{lib}", bin, *args, chdir: chdir)
+
+ assert status.success?, "#{args.join(' ')} failed: #{out}\n#{err}"
+ assert_equal expected, File.exist?(probe)
+ end
+ end
+
def assert_new_idea_includes(dir, text)
idea_paths = Dir[File.join(dir, ".hive-state", "stages", "1-inbox", "*", "idea.md")]
assert_equal 1, idea_paths.size, "expected one captured idea, got #{idea_paths.inspect}"
diff --git a/test/integration/decide_test.rb b/test/integration/decide_test.rb
new file mode 100644
index 0000000000..c3347816c0
--- /dev/null
+++ b/test/integration/decide_test.rb
@@ -0,0 +1,900 @@
+require "test_helper"
+require "json"
+require "json_schemer"
+require "hive/commands/approve"
+require "hive/commands/decide"
+require "hive/commands/init"
+require "hive/commands/new"
+require "hive/commands/run"
+require "hive/commands/status"
+require "hive/daemon/policy"
+require "hive/cli"
+
+class DecideTest < Minitest::Test
+ include HiveTestHelper
+
+ SLUG_PATTERN = "editorial-probe-*".freeze
+
+ def test_latest_record_ignores_malformed_audit_lines
+ with_tmp_dir do |dir|
+ path = File.join(dir, "approval.md")
+ File.write(path, <<~TEXT)
+ <!-- HIVE_DECISION_V1 {not-json} -->
+ <!-- HIVE_DECISION_V1 {"decision_id":"valid","outcome":"approve"} -->
+ TEXT
+
+ assert_equal "valid", Hive::Commands::Decide.latest_record(path).fetch("decision_id")
+ assert_nil Hive::Commands::Decide.latest_record(File.join(dir, "missing.md"))
+ end
+ end
+
+ def test_state_reader_rejects_file_swaps_invalid_utf8_and_nofollow_errors
+ with_tmp_dir do |dir|
+ path = File.join(dir, "approval.md")
+ File.binwrite(path, "\xFF".b)
+ error = assert_raises(Hive::InvalidTaskPath) do
+ Hive::Commands::Decide.read_state_file(path)
+ end
+ assert_includes error.message, "valid UTF-8"
+
+ File.write(path, "owner approval\n")
+ observed = File.lstat(path)
+ swapped_stat = Struct.new(:dev, :ino) do
+ def file? = true
+ end.new(observed.dev, observed.ino + 1)
+ swapped_file = Object.new
+ swapped_file.define_singleton_method(:stat) { swapped_stat }
+ swapped_file.define_singleton_method(:read) { "replacement\n" }
+
+ with_replaced_singleton_method(
+ File, :open, ->(_path, *_args, &block) { block.call(swapped_file) }
+ ) do
+ error = assert_raises(Hive::InvalidTaskPath) do
+ Hive::Commands::Decide.read_state_file(path)
+ end
+ assert_includes error.message, "changed while opening it"
+ end
+
+ with_replaced_singleton_method(
+ File, :open, ->(*_args) { raise Errno::ELOOP, path }
+ ) do
+ error = assert_raises(Hive::InvalidTaskPath) do
+ Hive::Commands::Decide.read_state_file(path)
+ end
+ assert_includes error.message, "must be a regular file, not a symlink"
+ end
+ end
+ end
+
+ def test_restore_files_removes_a_path_created_after_snapshot
+ with_tmp_dir do |dir|
+ path = File.join(dir, "created.md")
+ File.write(path, "created")
+
+ Hive::Commands::Decide.new("task", "approve", from: "approval").send(
+ :restore_files, dir,
+ { "created.md" => { existed: false, body: nil } }
+ )
+
+ refute File.exist?(path)
+ end
+ end
+
+ def test_error_kinds_and_argument_guards_are_typed
+ command = Hive::Commands::Decide.new("task", "approve", from: "approval")
+ cases = [
+ [ Hive::WrongStage.new("wrong"), "wrong_stage" ],
+ [
+ Hive::AmbiguousSlug.new("ambiguous", slug: "task", candidates: []),
+ "ambiguous_slug"
+ ],
+ [ Hive::InvalidTaskPath.new("bad"), "invalid_task_path" ],
+ [ Hive::ConcurrentRunError.new("busy"), "concurrent_run" ],
+ [ Hive::ConfigError.new("config"), "config" ],
+ [ Hive::GitError.new("git"), "git" ],
+ [ Hive::InternalError.new("internal"), "internal" ],
+ [ RuntimeError.new("other"), "error" ]
+ ]
+ cases.each do |error, kind|
+ assert_equal kind, command.envelope_error_kind(error)
+ end
+
+ assert_raises(Hive::InvalidTaskPath) do
+ Hive::Commands::Decide.new(
+ "task", "approve", from: "", decision_id: "a" * 16, note: "ok"
+ ).send(:validate_arguments!)
+ end
+ assert_raises(Hive::InvalidTaskPath) do
+ Hive::Commands::Decide.new(
+ "task", "", from: "approval", decision_id: "a" * 16
+ ).send(:validate_arguments!)
+ end
+ assert_raises(Hive::InvalidTaskPath) do
+ Hive::Commands::Decide.new(
+ "task", "approve", from: "approval", decision_id: "a" * 16, note: 123
+ )
+ .send(:validate_arguments!)
+ end
+ assert_raises(Hive::InvalidTaskPath) do
+ Hive::Commands::Decide.new("task", "approve", from: "approval")
+ .send(:validate_arguments!)
+ end
+ end
+
+ def test_entering_human_stage_waits_without_dispatch_and_surfaces_outcomes
+ with_editorial_task do |_dir, approval, slug|
+ task = Hive::Task.new(approval)
+ marker = Hive::Markers.current(task.state_file)
+ action = Hive::TaskAction.for(task, marker)
+
+ assert_equal :waiting, marker.name
+ assert_match(/\A[0-9a-f]{16}\z/, marker.attrs.fetch("decision_id"))
+ assert_equal "needs_input", action.key
+ assert_nil action.command
+ assert_equal %w[approve reject], action.allowed_outcomes.map { |entry| entry.fetch("name") }
+ assert_equal :skip, Hive::Daemon::Policy.decide(
+ action: action.key, stage: "3-approval", workflow: "editorial",
+ command: action.command, state_file_mtime: File.mtime(task.state_file),
+ last_dispatched_state_file_mtime: nil, now: Time.now
+ )
+
+ out, = capture_io { Hive::Commands::Run.new(slug, json: true).call }
+ payload = JSON.parse(out)
+ assert_equal true, payload.fetch("ok")
+ assert_equal "waiting", payload.fetch("marker")
+ assert_equal "human_decision_required", payload.dig("next_action", "reason")
+ assert_includes payload.dig("next_action", "decide_with"),
+ "--decision-id #{marker.attrs.fetch('decision_id')}"
+ assert_equal %w[approve reject], payload.fetch("allowed_outcomes").map { |entry| entry.fetch("name") }
+ assert_schema_valid("hive-run", payload)
+ end
+ end
+
+ def test_approve_records_publish_ready_artifact_and_completes_idempotently
+ with_editorial_task do |dir, approval, slug|
+ decided_at = Time.now.utc
+ out, = capture_io do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id_for(approval),
+ note: "Ready for the editor", json: true, clock: -> { decided_at }
+ ).call
+ end
+ payload = JSON.parse(out)
+
+ assert_equal true, payload.fetch("ok")
+ assert_equal true, payload.fetch("applied")
+ assert_equal true, payload.fetch("completed")
+ assert_equal "draft.md", payload.fetch("artifact")
+ assert_equal "3-approval", payload.fetch("current_stage")
+ assert_equal decided_at.iso8601, payload.fetch("decided_at")
+ assert_schema_valid("hive-decide", payload)
+
+ task = Hive::Task.new(approval)
+ assert_equal :complete, Hive::Markers.current(task.state_file).name
+ assert_equal "archived", Hive::TaskAction.for(
+ task, Hive::Markers.current(task.state_file)
+ ).key
+ assert_equal decided_at.iso8601, Hive::TaskMeta.read(approval).fetch(:completed_at)
+ status_out, = capture_io { Hive::Commands::Status.new(json: true).call }
+ status_row = JSON.parse(status_out).fetch("projects").flat_map { |project|
+ project.fetch("tasks")
+ }.find { |row| row.fetch("slug") == slug }
+ assert_equal "archived", status_row.fetch("action")
+ record = Hive::Commands::Decide.latest_record(task.state_file)
+ assert_equal "approve", record.fetch("outcome")
+ assert_equal "Ready for the editor", record.fetch("note")
+ assert_equal "draft.md", record.fetch("artifact")
+ assert_equal "publish_ready", record.fetch("artifact_status")
+ assert_equal "approval", record.fetch("from")
+ assert_equal decided_at.iso8601, record.fetch("decided_at")
+ assert File.file?(File.join(approval, "draft.md"))
+ refute Dir.exist?(File.join(dir, ".hive-state", "stages", "4-publish"))
+
+ retry_out, = capture_io do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: record.fetch("decision_id"),
+ note: "Ready for the editor", json: true
+ ).call
+ end
+ retry_payload = JSON.parse(retry_out)
+ assert_equal false, retry_payload.fetch("applied")
+ assert_equal true, retry_payload.fetch("noop")
+ assert_equal record.fetch("decision_id"), retry_payload.fetch("decision_id")
+
+ assert_raises(Hive::WrongStage) do
+ Hive::Commands::Decide.new(
+ slug, "reject", from: "approval", decision_id: record.fetch("decision_id")
+ ).call
+ end
+ end
+ end
+
+ def test_approve_requires_non_empty_artifact_without_mutation
+ with_editorial_task do |_dir, approval, slug|
+ draft = File.join(approval, "draft.md")
+ File.write(draft, "")
+ before = File.binread(File.join(approval, "approval.md"))
+
+ error = assert_raises(Hive::WrongStage) do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id_for(approval)
+ ).call
+ end
+
+ assert_includes error.message, "non-empty artifact"
+ assert_equal before, File.binread(File.join(approval, "approval.md"))
+ assert_equal :waiting, Hive::Markers.current(File.join(approval, "approval.md")).name
+ assert File.directory?(approval)
+ end
+ end
+
+ def test_approve_refuses_an_artifact_symlink_outside_the_task
+ with_editorial_task do |_dir, approval, slug|
+ with_tmp_dir do |outside|
+ external = File.join(outside, "external-draft.md")
+ File.write(external, "# External bytes\n")
+ draft = File.join(approval, "draft.md")
+ File.delete(draft)
+ File.symlink(external, draft)
+
+ error = assert_raises(Hive::WrongStage) do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id_for(approval)
+ ).call
+ end
+
+ assert_includes error.message, "non-empty artifact"
+ assert_equal "# External bytes\n", File.read(external)
+ assert File.symlink?(draft)
+ assert_equal :waiting, Hive::Markers.current(File.join(approval, "approval.md")).name
+ assert_nil Hive::Commands::Decide.latest_record(File.join(approval, "approval.md"))
+ end
+ end
+ end
+
+ def test_approve_refuses_an_artifact_swapped_while_opening
+ with_editorial_task do |_dir, approval, slug|
+ draft = File.join(approval, "draft.md")
+ task = Hive::Task.new(approval)
+ stage = task.workflow.stage_named("approval")
+ outcome = stage.outcomes.fetch("approve")
+ observed = File.lstat(draft)
+ swapped_stat = Struct.new(:dev, :ino) do
+ def file? = true
+ end.new(observed.dev, observed.ino + 1)
+ swapped_file = Object.new
+ swapped_file.define_singleton_method(:stat) { swapped_stat }
+ swapped_file.define_singleton_method(:read) { "# raced bytes\n" }
+ command = Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id_for(approval)
+ )
+
+ with_replaced_singleton_method(
+ File, :open, ->(_path, *_args, &block) { block.call(swapped_file) }
+ ) do
+ error = assert_raises(Hive::WrongStage) do
+ command.send(
+ :validate_publishable_artifact!, draft, approval, stage, outcome
+ )
+ end
+ assert_includes error.message, "requires non-empty artifact"
+ end
+ end
+ end
+
+ def test_decide_refuses_a_symlinked_human_state_file
+ with_editorial_task do |dir, approval, slug|
+ with_tmp_dir do |outside|
+ state = File.join(approval, "approval.md")
+ external = File.join(outside, "external-approval.md")
+ observed_id = decision_id_for(approval)
+ external_body = "private review\n<!-- WAITING decision_id=#{observed_id} -->\n"
+ File.write(external, external_body)
+ File.delete(state)
+ File.symlink(external, state)
+ head_before = run!("git", "-C", File.join(dir, ".hive-state"), "rev-parse", "HEAD")
+
+ error = assert_raises(Hive::InvalidTaskPath) do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: observed_id
+ ).send(:do_call)
+ end
+
+ assert_includes error.message, "must be a regular file, not a symlink"
+ assert_equal external_body, File.read(external)
+ assert File.symlink?(state)
+ assert_equal head_before,
+ run!("git", "-C", File.join(dir, ".hive-state"), "rev-parse", "HEAD")
+ end
+ end
+ end
+
+ def test_concurrent_identical_completion_returns_one_apply_and_one_noop
+ with_editorial_task do |_dir, approval, slug|
+ decision_id = decision_id_for(approval)
+ ready = Queue.new
+ release = Queue.new
+ commands = 2.times.map do
+ command = Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id
+ )
+ original = command.method(:complete!)
+ command.define_singleton_method(:complete!) do |*args|
+ ready << true
+ release.pop
+ original.call(*args)
+ end
+ command
+ end
+ threads = commands.map do |command|
+ Thread.new do
+ begin
+ command.send(:do_call)
+ rescue StandardError => e
+ e
+ end
+ end
+ end
+ 2.times { ready.pop }
+ 2.times { release << true }
+ results = threads.map(&:value)
+
+ assert results.none?(Exception), results.inspect
+ assert_equal 1, results.count { |payload| payload.fetch("applied") }
+ assert_equal 1, results.count { |payload| payload.fetch("noop") }
+ assert_equal 1, File.read(File.join(approval, "approval.md")).scan(
+ Hive::Commands::Decide::RECORD_RE
+ ).size
+ end
+ end
+
+ def test_self_target_outcome_records_and_mints_a_fresh_decision
+ with_registered_workflow(revision_workflow) do
+ with_tmp_global_config do
+ with_tmp_git_repo do |dir|
+ capture_io { Hive::Commands::Init.new(dir, agent_skill_preflight: false).call }
+ project = File.basename(dir)
+ capture_io do
+ Hive::Commands::New.new(
+ project, "review this", workflow: "revision", slug_override: "revision-task"
+ ).call!
+ end
+ folder = File.join(dir, ".hive-state", "stages", "1-approval", "revision-task")
+ old_id = decision_id_for(folder)
+ out, = capture_io do
+ Hive::Commands::Decide.new(
+ "revision-task", "revise", from: "approval",
+ decision_id: old_id, json: true
+ ).call
+ end
+ payload = JSON.parse(out)
+ marker = Hive::Markers.current(File.join(folder, "approval.md"))
+
+ assert_equal true, payload.fetch("applied")
+ assert_equal false, payload.fetch("completed")
+ assert_equal "1-approval", payload.fetch("current_stage")
+ assert_equal "needs_input", payload.dig("next_action", "kind")
+ assert_equal :waiting, marker.name
+ refute_equal old_id, marker.attrs.fetch("decision_id")
+ assert_equal old_id, Hive::Commands::Decide.latest_record(
+ File.join(folder, "approval.md")
+ ).fetch("decision_id")
+ assert_schema_valid("hive-decide", payload)
+
+ retry_payload = Hive::Commands::Decide.new(
+ "revision-task", "revise", from: "approval",
+ decision_id: old_id
+ ).send(:do_call)
+ assert_equal false, retry_payload.fetch("applied")
+ assert_equal true, retry_payload.fetch("noop")
+ end
+ end
+ end
+ end
+
+ def test_self_target_outcome_restores_state_when_commit_is_interrupted
+ with_registered_workflow(revision_workflow) do
+ with_tmp_global_config do
+ with_tmp_git_repo do |dir|
+ capture_io { Hive::Commands::Init.new(dir, agent_skill_preflight: false).call }
+ project = File.basename(dir)
+ capture_io do
+ Hive::Commands::New.new(
+ project, "review this", workflow: "revision", slug_override: "revision-task"
+ ).call!
+ end
+ folder = File.join(dir, ".hive-state", "stages", "1-approval", "revision-task")
+ state = File.join(folder, "approval.md")
+ before = File.binread(state)
+ fake_ops = Object.new
+ fake_ops.define_singleton_method(:hive_commit) { |**| raise Interrupt, "stop" }
+ fake_ops.define_singleton_method(:run_git!) { |*| nil }
+
+ with_replaced_singleton_method(Hive::GitOps, :new, ->(_root) { fake_ops }) do
+ assert_raises(Interrupt) do
+ Hive::Commands::Decide.new(
+ "revision-task", "revise", from: "approval",
+ decision_id: decision_id_for(folder)
+ ).send(:do_call)
+ end
+ end
+
+ assert_equal before, File.binread(state)
+ assert_nil Hive::Commands::Decide.latest_record(state)
+ assert_equal :waiting, Hive::Markers.current(state).name
+ end
+ end
+ end
+ end
+
+ def test_invalid_waiting_identity_and_non_human_from_are_rejected
+ with_editorial_task do |_dir, approval, slug|
+ Hive::Markers.set(File.join(approval, "approval.md"), :waiting)
+ error = assert_raises(Hive::WrongStage) do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: "a" * 16
+ ).send(:do_call)
+ end
+ assert_includes error.message, "decision observation is stale"
+
+ error = assert_raises(Hive::InvalidTaskPath) do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "draft", decision_id: "a" * 16
+ ).send(:do_call)
+ end
+ assert_includes error.message, "not a human stage"
+ end
+ end
+
+ def test_decision_requires_the_human_stage_to_still_be_waiting
+ with_editorial_task do |_dir, approval, slug|
+ Hive::Markers.set(File.join(approval, "approval.md"), :complete)
+
+ error = assert_raises(Hive::WrongStage) do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: "a" * 16
+ ).send(:do_call)
+ end
+
+ assert_includes error.message, "is not awaiting a decision"
+ end
+ end
+
+ def test_decision_from_a_moved_task_without_a_record_is_stale
+ with_editorial_task do |dir, approval, slug|
+ draft = File.join(dir, ".hive-state", "stages", "2-draft", slug)
+ FileUtils.mkdir_p(File.dirname(draft))
+ File.rename(approval, draft)
+
+ error = assert_raises(Hive::WrongStage) do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id_for(draft)
+ ).send(:do_call)
+ end
+ assert_includes error.message, "--from expected 3-approval"
+ end
+ end
+
+ def test_approve_rolls_back_decision_file_when_commit_fails
+ with_editorial_task do |_dir, approval, slug|
+ state = File.join(approval, "approval.md")
+ before = File.binread(state)
+ fake_ops = Object.new
+ fake_ops.define_singleton_method(:hive_commit) { |**| raise Hive::GitError, "commit failed" }
+ fake_ops.define_singleton_method(:run_git!) { |*| raise Hive::GitError, "restage failed" }
+
+ with_replaced_singleton_method(Hive::GitOps, :new, ->(_root) { fake_ops }) do
+ error = assert_raises(Hive::GitError) do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id_for(approval)
+ ).send(:do_call)
+ end
+ assert_includes error.message, "commit failed"
+ end
+
+ assert_equal before, File.binread(state)
+ assert_nil Hive::Commands::Decide.latest_record(state)
+ assert_equal :waiting, Hive::Markers.current(state).name
+ refute Hive::TaskMeta.read(approval).key?(:completed_at)
+ end
+ end
+
+ def test_approve_rolls_back_decision_and_completion_time_when_commit_is_interrupted
+ with_editorial_task do |_dir, approval, slug|
+ state = File.join(approval, "approval.md")
+ state_before = File.binread(state)
+ meta_before = File.binread(File.join(approval, Hive::TaskMeta::FILENAME))
+ fake_ops = Object.new
+ fake_ops.define_singleton_method(:hive_commit) { |**| raise Interrupt, "stop" }
+ fake_ops.define_singleton_method(:run_git!) { |*| nil }
+
+ with_replaced_singleton_method(Hive::GitOps, :new, ->(_root) { fake_ops }) do
+ assert_raises(Interrupt) do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id_for(approval)
+ ).send(:do_call)
+ end
+ end
+
+ assert_equal state_before, File.binread(state)
+ assert_equal meta_before, File.binread(File.join(approval, Hive::TaskMeta::FILENAME))
+ assert_nil Hive::Commands::Decide.latest_record(state)
+ assert_equal :waiting, Hive::Markers.current(state).name
+ refute Hive::TaskMeta.read(approval).key?(:completed_at)
+ end
+ end
+
+ def test_reject_rolls_back_both_state_files_when_move_fails
+ with_editorial_task do |dir, approval, slug|
+ approval_before = File.binread(File.join(approval, "approval.md"))
+ draft_before = File.binread(File.join(approval, "draft.md"))
+ destination = File.join(dir, ".hive-state", "stages", "2-draft", slug)
+
+ replacement = lambda do |_target, **kwargs|
+ fake = Object.new
+ fake.define_singleton_method(:call) do
+ kwargs.fetch(:observation_guard).call(Hive::Task.new(approval))
+ FileUtils.mkdir_p(File.dirname(destination))
+ File.rename(approval, destination)
+ raise Hive::GitError, "move failed"
+ end
+ fake
+ end
+ with_replaced_singleton_method(Hive::Commands::Approve, :new, replacement) do
+ error = assert_raises(Hive::GitError) do
+ Hive::Commands::Decide.new(
+ slug, "reject", from: "approval", decision_id: decision_id_for(approval)
+ ).send(:do_call)
+ end
+ assert_includes error.message, "move failed"
+ end
+
+ assert_equal approval_before, File.binread(File.join(destination, "approval.md"))
+ assert_equal draft_before, File.binread(File.join(destination, "draft.md"))
+ assert_nil Hive::Commands::Decide.latest_record(File.join(destination, "approval.md"))
+ end
+ end
+
+ def test_reject_rolls_back_both_state_files_when_move_is_interrupted
+ with_editorial_task do |dir, approval, slug|
+ approval_before = File.binread(File.join(approval, "approval.md"))
+ draft_before = File.binread(File.join(approval, "draft.md"))
+ destination = File.join(dir, ".hive-state", "stages", "2-draft", slug)
+
+ replacement = lambda do |_target, **kwargs|
+ fake = Object.new
+ fake.define_singleton_method(:call) do
+ kwargs.fetch(:observation_guard).call(Hive::Task.new(approval))
+ FileUtils.mkdir_p(File.dirname(destination))
+ File.rename(approval, destination)
+ raise Interrupt, "stop"
+ end
+ fake
+ end
+ with_replaced_singleton_method(Hive::Commands::Approve, :new, replacement) do
+ assert_raises(Interrupt) do
+ Hive::Commands::Decide.new(
+ slug, "reject", from: "approval", decision_id: decision_id_for(approval)
+ ).send(:do_call)
+ end
+ end
+
+ assert_equal approval_before, File.binread(File.join(destination, "approval.md"))
+ assert_equal draft_before, File.binread(File.join(destination, "draft.md"))
+ assert_nil Hive::Commands::Decide.latest_record(File.join(destination, "approval.md"))
+ end
+ end
+
+ def test_reject_wrong_stage_after_local_mutation_restores_and_reraises
+ with_editorial_task do |_dir, approval, slug|
+ state = File.join(approval, "approval.md")
+ draft = File.join(approval, "draft.md")
+ state_before = File.binread(state)
+ draft_before = File.binread(draft)
+
+ replacement = lambda do |_target, **kwargs|
+ fake = Object.new
+ fake.define_singleton_method(:call) do
+ kwargs.fetch(:observation_guard).call(Hive::Task.new(approval))
+ raise Hive::WrongStage, "another transition won"
+ end
+ fake
+ end
+
+ with_replaced_singleton_method(Hive::Commands::Approve, :new, replacement) do
+ error = assert_raises(Hive::WrongStage) do
+ Hive::Commands::Decide.new(
+ slug, "reject", from: "approval", decision_id: decision_id_for(approval)
+ ).send(:do_call)
+ end
+ assert_includes error.message, "another transition won"
+ end
+
+ assert_equal state_before, File.binread(state)
+ assert_equal draft_before, File.binread(draft)
+ assert_nil Hive::Commands::Decide.latest_record(state)
+ end
+ end
+
+ def test_reject_wrong_stage_after_identical_concurrent_move_returns_noop
+ with_editorial_task do |dir, approval, slug|
+ command = Hive::Commands::Decide.new(
+ slug, "reject", from: "approval", decision_id: decision_id_for(approval)
+ )
+ task = Hive::Task.new(approval)
+ stage = task.workflow.stage_named("approval")
+ outcome = stage.outcomes.fetch("reject")
+ record = command.send(
+ :decision_record, task, stage, outcome, decision_id_for(approval)
+ )
+ destination = File.join(dir, ".hive-state", "stages", "2-draft", slug)
+
+ replacement = lambda do |_target, **_kwargs|
+ fake = Object.new
+ fake.define_singleton_method(:call) do
+ FileUtils.mkdir_p(File.dirname(destination))
+ File.rename(approval, destination)
+ command.send(
+ :write_decision_record, File.join(destination, "approval.md"), record
+ )
+ Hive::Markers.set(File.join(destination, "draft.md"), :waiting)
+ raise Hive::WrongStage, "another transition won"
+ end
+ fake
+ end
+
+ payload = with_replaced_singleton_method(
+ Hive::Commands::Approve, :new, replacement
+ ) { command.send(:do_call) }
+
+ assert_equal true, payload.fetch("noop")
+ assert_equal false, payload.fetch("applied")
+ assert_equal "2-draft", payload.fetch("current_stage")
+ assert_equal "reject", Hive::Commands::Decide.latest_record(
+ File.join(destination, "approval.md")
+ ).fetch("outcome")
+ end
+ end
+
+ def test_stale_decision_identity_is_rejected_under_lock
+ with_editorial_task do |_dir, approval, _slug|
+ task = Hive::Task.new(approval)
+ stage = task.workflow.stage_named("approval")
+
+ error = assert_raises(Hive::WrongStage) do
+ Hive::Commands::Decide.new("unused", "approve", from: "approval")
+ .send(:validate_current_decision!, task, stage, "rotated")
+ end
+ assert_includes error.message, "decision observation is stale"
+ end
+ end
+
+ def test_plain_success_reports_decision_and_current_stage
+ with_editorial_task do |_dir, approval, slug|
+ out, = capture_io do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id_for(approval)
+ ).call
+ end
+
+ assert_includes out, "hive: decided #{slug} approval=approve"
+ assert_includes out, "current_stage: 3-approval"
+ end
+ end
+
+ def test_reject_records_decision_returns_to_draft_and_retries_as_noop
+ with_editorial_task do |dir, approval, slug|
+ observed_id = decision_id_for(approval)
+ out, = capture_io do
+ Hive::CLI.start(
+ [
+ "decide", slug, "reject", "--from", "3-approval",
+ "--decision-id", observed_id, "--note", "Strengthen the lead", "--json"
+ ]
+ )
+ end
+ payload = JSON.parse(out)
+ draft = File.join(dir, ".hive-state", "stages", "2-draft", slug)
+
+ assert_equal true, payload.fetch("applied")
+ assert_equal false, payload.fetch("completed")
+ assert_equal "2-draft", payload.fetch("current_stage")
+ assert File.directory?(draft)
+ refute File.exist?(approval)
+ assert_equal :waiting, Hive::Markers.current(File.join(draft, "draft.md")).name
+ record = Hive::Commands::Decide.latest_record(File.join(draft, "approval.md"))
+ assert_equal "reject", record.fetch("outcome")
+ assert_equal "Strengthen the lead", record.fetch("note")
+ assert_equal "draft", record.fetch("to")
+
+ retry_out, = capture_io do
+ Hive::Commands::Decide.new(
+ slug, "reject", from: "approval", decision_id: observed_id,
+ note: "Strengthen the lead", json: true
+ ).call
+ end
+ retry_payload = JSON.parse(retry_out)
+ assert_equal true, retry_payload.fetch("noop")
+ assert_equal false, retry_payload.fetch("applied")
+ assert_equal "2-draft", retry_payload.fetch("current_stage")
+
+ assert_raises(Hive::WrongStage) do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: observed_id
+ ).call
+ end
+ end
+ end
+
+ def test_marker_only_artifact_is_not_publish_ready
+ with_editorial_task do |_dir, approval, slug|
+ draft = File.join(approval, "draft.md")
+ File.write(draft, "<!-- COMPLETE -->\n<!-- WAITING decision_id=aaaaaaaaaaaaaaaa -->\n")
+
+ error = assert_raises(Hive::WrongStage) do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id_for(approval)
+ ).call
+ end
+
+ assert_includes error.message, "non-empty artifact"
+ assert_equal :waiting, Hive::Markers.current(File.join(approval, "approval.md")).name
+ end
+ end
+
+ def test_artifact_is_rechecked_under_the_decision_lock
+ with_editorial_task do |_dir, approval, slug|
+ draft = File.join(approval, "draft.md")
+ command = Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id_for(approval)
+ )
+ original = command.method(:validate_current_decision!)
+ command.define_singleton_method(:validate_current_decision!) do |task, stage, decision_id|
+ original.call(task, stage, decision_id)
+ File.write(draft, "")
+ end
+
+ assert_raises(Hive::WrongStage) { command.call }
+ assert_equal :waiting, Hive::Markers.current(File.join(approval, "approval.md")).name
+ assert_nil Hive::Commands::Decide.latest_record(File.join(approval, "approval.md"))
+ end
+ end
+
+ def test_decision_from_an_earlier_visit_is_rejected_after_reentry
+ with_editorial_task do |_dir, approval, slug|
+ old_id = decision_id_for(approval)
+ Hive::Markers.set(
+ File.join(approval, "approval.md"), :waiting, "decision_id" => "b" * 16
+ )
+
+ error = assert_raises(Hive::WrongStage) do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: old_id
+ ).call
+ end
+
+ assert_includes error.message, "decision observation is stale"
+ assert_equal "b" * 16, decision_id_for(approval)
+ end
+ end
+
+ def test_completed_human_stage_run_reports_completion_without_outcomes
+ with_editorial_task do |_dir, approval, slug|
+ capture_io do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id_for(approval)
+ ).call
+ end
+
+ out, = capture_io { Hive::Commands::Run.new(slug, json: true).call }
+ payload = JSON.parse(out)
+ assert_equal "complete", payload.fetch("marker")
+ assert_equal "human_stage_complete", payload.dig("next_action", "reason")
+ refute payload.key?("allowed_outcomes")
+ refute payload.dig("next_action").key?("allowed_outcomes")
+ refute payload.dig("next_action").key?("decide_with")
+ end
+ end
+
+ def test_completed_human_stage_plain_run_reports_recorded_outcome
+ with_editorial_task do |_dir, approval, slug|
+ capture_io do
+ Hive::Commands::Decide.new(
+ slug, "approve", from: "approval", decision_id: decision_id_for(approval)
+ ).call
+ end
+
+ out, = capture_io { Hive::Commands::Run.new(slug).call }
+
+ assert_includes out, "complete: human workflow outcome recorded"
+ refute_includes out, "outcomes:"
+ end
+ end
+
+ def test_status_and_operational_status_expose_human_outcomes
+ with_editorial_task do |_dir, _approval, slug|
+ status_out, = capture_io { Hive::Commands::Status.new(json: true).call }
+ row = JSON.parse(status_out).fetch("projects").flat_map { |project| project.fetch("tasks") }
+ .find { |task| task.fetch("slug") == slug }
+ assert_equal "needs_input", row.fetch("action")
+ assert_nil row.fetch("suggested_command")
+ assert_equal %w[approve reject], row.fetch("outcomes").map { |entry| entry.fetch("name") }
+ assert_schema_valid("hive-status", JSON.parse(status_out))
+
+ operational_out, = capture_io { Hive::Commands::Status.new(json: true, operational: true).call }
+ operational = JSON.parse(operational_out)
+ projected = operational.fetch("tasks").find { |task| task.dig("identity", "slug") == slug }
+ assert_equal "waiting_on_you", projected.fetch("state")
+ assert_equal %w[approve reject], projected.dig("position", "allowed_outcomes").map { |entry| entry.fetch("name") }
+ assert_nil projected.fetch("action")
+ assert_schema_valid("hive-operational-status", operational)
+ end
+ end
+
+ private
+
+ def decision_id_for(folder)
+ Hive::Markers.current(File.join(folder, "approval.md")).attrs.fetch("decision_id")
+ end
+
+ def editorial_workflow
+ Hive::Workflow.new(
+ id: :editorial,
+ stages: [
+ Hive::Workflow::Stage.new(name: "research", index: 1, state_file: "research.md", kind: :agent, skill: "/research"),
+ Hive::Workflow::Stage.new(name: "draft", index: 2, state_file: "draft.md", kind: :agent, skill: "/draft"),
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 3, state_file: "approval.md", kind: :human, input: "draft.md",
+ outcomes: {
+ "approve" => Hive::Workflow::Outcome.new(name: "approve", complete: true, artifact: "draft.md"),
+ "reject" => Hive::Workflow::Outcome.new(name: "reject", to: "draft")
+ }.freeze
+ )
+ ]
+ )
+ end
+
+ def revision_workflow
+ Hive::Workflow.new(
+ id: :revision,
+ stages: [
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 1, state_file: "approval.md", kind: :human,
+ outcomes: {
+ "revise" => Hive::Workflow::Outcome.new(name: "revise", to: "approval")
+ }.freeze
+ )
+ ]
+ )
+ end
+
+ def with_editorial_task
+ descriptor = editorial_workflow
+ with_registered_workflow(descriptor) do
+ with_tmp_global_config do
+ with_tmp_git_repo do |dir|
+ capture_io { Hive::Commands::Init.new(dir, agent_skill_preflight: false).call }
+ project = File.basename(dir)
+ capture_io { Hive::Commands::New.new(project, "editorial probe", workflow: "editorial").call }
+ research = Dir[File.join(dir, ".hive-state", "stages", "1-research", SLUG_PATTERN)].fetch(0)
+ slug = File.basename(research)
+ Hive::Markers.set(File.join(research, "research.md"), :complete)
+ capture_io { Hive::Commands::Approve.new(slug, from: "research").call }
+ draft = File.join(dir, ".hive-state", "stages", "2-draft", slug)
+ File.write(File.join(draft, "draft.md"), "# Draft\n\nPublishable copy.\n")
+ Hive::Markers.set(File.join(draft, "draft.md"), :complete)
+ capture_io { Hive::Commands::Approve.new(slug, from: "draft").call }
+ approval = File.join(dir, ".hive-state", "stages", "3-approval", slug)
+
+ yield dir, approval, slug
+ end
+ end
+ end
+ end
+
+ def assert_schema_valid(name, payload)
+ schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path(name))))
+ errors = schemer.validate(payload).to_a
+ assert_empty errors, "#{name} payload errors: #{errors.map { |error| error['error'] }.inspect}"
+ end
+end
diff --git a/test/integration/init_minimal_test.rb b/test/integration/init_minimal_test.rb
new file mode 100644
index 0000000000..0c1a717e8c
--- /dev/null
+++ b/test/integration/init_minimal_test.rb
@@ -0,0 +1,455 @@
+require "test_helper"
+require "json"
+require "json_schemer"
+require "hive/commands/init"
+require "hive/commands/workflow"
+
+class InitMinimalTest < Minitest::Test
+ include HiveTestHelper
+
+ def setup
+ super
+ Hive::Workflows::Project.reset!
+ end
+
+ def teardown
+ Hive::Workflows::Project.reset!
+ super
+ end
+
+ def test_preview_is_machine_readable_and_leaves_every_target_unchanged
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ before = repository_snapshot(project_root)
+ global_path = Hive::Config.global_config_path
+ global_before = File.exist?(global_path) ? File.binread(global_path) : nil
+
+ out, err = capture_io do
+ Hive::Commands::Init.new(
+ project_root, new_workflow: "editorial", minimal: true, preview: true,
+ json: true, agent_skill_preflight: false
+ ).call
+ end
+ payload = JSON.parse(out)
+
+ assert_empty err
+ assert_equal before, repository_snapshot(project_root)
+ refute File.exist?(File.join(project_root, ".hive-state"))
+ assert_equal global_before, File.exist?(global_path) ? File.binread(global_path) : nil
+ assert_equal true, payload.fetch("preview")
+ assert_equal true, payload.fetch("minimal")
+ assert_equal "editorial", payload.fetch("workflow")
+ assert_equal false, payload.dig("services", "daemon_install")
+ assert_equal false, payload.dig("background_automation", "patrol")
+ assert_equal true, payload.dig("context_integration", "llm_wiki")
+ assert_equal File.basename(project_root), payload.dig("global_registration", "name")
+ assert_equal "available", payload.dig("global_registration", "status")
+ schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-init-preview"))))
+ assert_empty schemer.validate(payload).to_a
+ end
+ end
+ end
+
+ def test_execute_uses_same_minimal_profile_and_scaffolds_loadable_workflow
+ with_tmp_global_config do |global_root|
+ with_tmp_git_repo do |project_root|
+ out, err = capture_io do
+ Hive::Commands::Init.new(
+ project_root, new_workflow: "editorial", minimal: true,
+ json: true, agent_skill_preflight: false
+ ).call
+ end
+ payload = JSON.parse(out)
+
+ assert_empty err
+ assert_equal true, payload.fetch("minimal")
+ init_schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-init"))))
+ assert_empty init_schemer.validate(payload).to_a
+ config = Hive::Config.load(project_root)
+ assert_equal "editorial", config.fetch("default_workflow")
+ assert_equal "off", config.dig("patrol", "mode")
+ assert_equal false, config.dig("refactor_patrol", "enabled")
+ assert_equal false, config.dig("review", "adhoc", "fix")
+ assert_equal false, config.dig("daemon", "enabled")
+ assert_equal false, config.dig("babysitter", "enabled")
+ global_config = YAML.safe_load(File.read(File.join(global_root, "config.yml")))
+ refute global_config.key?("daemon"), "minimal init must not install or persist daemon autostart"
+ refute File.exist?(File.join(global_root, ".config", "systemd", "user"))
+
+ validation = Hive::Commands::Workflow.new(
+ "validate", "editorial", project_root: project_root, json: false, stdout: StringIO.new
+ ).call!
+ assert_equal true, validation.fetch("valid")
+ end
+ end
+ end
+
+ def test_execute_does_not_install_the_llm_wiki_scheduler
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ installs = []
+ with_replaced_singleton_method(
+ Hive::LlmWikiBootstrap::Scheduler, :install, ->(root) { installs << root }
+ ) do
+ capture_io do
+ Hive::Commands::Init.new(
+ project_root, new_workflow: "editorial", minimal: true,
+ agent_skill_preflight: false
+ ).call
+ end
+ end
+
+ assert_empty installs
+ end
+ end
+ end
+
+ def test_interrupted_scaffold_commit_rolls_back_minimal_initialization
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ before = repository_snapshot(project_root)
+ replacement = ->(*_args, **_kwargs) { raise Interrupt, "stop" }
+
+ with_replaced_singleton_method(
+ Hive::Commands::Workflow, :commit_workflow_scaffold, replacement
+ ) do
+ assert_raises(Interrupt) do
+ Hive::Commands::Init.new(
+ project_root, new_workflow: "editorial", minimal: true,
+ agent_skill_preflight: false
+ ).call
+ end
+ end
+
+ assert_equal before, repository_snapshot(project_root)
+ refute File.exist?(File.join(project_root, ".hive-state"))
+ refute Hive::Config.registered_projects.any? { |project|
+ File.expand_path(project.fetch("path")) == project_root
+ }
+ end
+ end
+ end
+
+ def test_execute_refuses_managed_project_symlinks_without_writing_through_them
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ outside = File.join(File.dirname(project_root), "outside-agents.md")
+ File.write(outside, "outside choice\n")
+ managed = File.join(project_root, "AGENTS.md")
+ File.symlink(outside, managed)
+ run!("git", "-C", project_root, "add", "AGENTS.md")
+ run!("git", "-C", project_root, "commit", "-m", "track project choice", "--quiet")
+
+ error = assert_raises(Hive::ConfigError) do
+ capture_io do
+ Hive::Commands::Init.new(
+ project_root, new_workflow: "editorial", minimal: true,
+ agent_skill_preflight: false
+ ).call
+ end
+ end
+
+ assert_includes error.message, "must not be a symlink"
+ assert_equal "outside choice\n", File.read(outside)
+ assert File.symlink?(managed)
+ refute File.exist?(File.join(project_root, ".hive-state"))
+ end
+ end
+ end
+
+ def test_minimal_refuses_a_dangling_hive_state_symlink
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ hive_state = File.join(project_root, ".hive-state")
+ outside = File.join(File.dirname(project_root), "missing-hive-state")
+ File.symlink(outside, hive_state)
+
+ error = assert_raises(Hive::AlreadyInitialized) do
+ Hive::Commands::Init.new(
+ project_root, new_workflow: "editorial", minimal: true, preview: true,
+ agent_skill_preflight: false
+ ).call
+ end
+
+ assert_includes error.message, "fresh target"
+ assert File.symlink?(hive_state)
+ refute File.exist?(outside)
+ end
+ end
+ end
+
+ def test_minimal_rejects_force_missing_workflow_and_initialized_target
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ assert_raises(Hive::Commands::Workflow::UsageError) do
+ Hive::Commands::Init.new(project_root, minimal: true, force: true, new_workflow: "editorial").call
+ end
+ assert_raises(Hive::Commands::Workflow::UsageError) do
+ Hive::Commands::Init.new(project_root, minimal: true).call
+ end
+
+ capture_io do
+ Hive::Commands::Init.new(
+ project_root, minimal: true, new_workflow: "editorial", agent_skill_preflight: false
+ ).call
+ end
+ out, = capture_io do
+ assert_raises(Hive::AlreadyInitialized) do
+ Hive::Commands::Init.new(
+ project_root, minimal: true, new_workflow: "another",
+ json: true, agent_skill_preflight: false
+ ).call
+ end
+ end
+ payload = JSON.parse(out)
+ assert_equal "already_initialized", payload.fetch("error_kind")
+ assert_schema_valid("hive-init", payload)
+ end
+ end
+ end
+
+ def test_minimal_rejects_invalid_flag_combinations_and_collisions
+ assert_raises(ArgumentError) do
+ Hive::Commands::Init.new(".", minimal: :yes)
+ end
+ assert_raises(Hive::Commands::Workflow::UsageError) do
+ Hive::Commands::Init.new(".", preview: true).send(:validate_minimal_arguments!)
+ end
+ assert_raises(Hive::Commands::Workflow::UsageError) do
+ Hive::Commands::Init.new(
+ ".", minimal: true, new_workflow: "editorial", refactor_patrol: true
+ ).send(:validate_minimal_arguments!)
+ end
+
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ collision = ->(_id, project_root:) { project_root == File.expand_path(project_root) }
+ suggestion = ->(_id, project_root:) { "editorial-2" }
+ with_replaced_singleton_method(Hive::Commands::Workflow, :scaffold_collision?, collision) do
+ with_replaced_singleton_method(Hive::Commands::Workflow, :available_id, suggestion) do
+ error = assert_raises(Hive::Commands::Workflow::UsageError) do
+ Hive::Commands::Init.new(
+ project_root, minimal: true, new_workflow: "editorial",
+ agent_skill_preflight: false
+ ).call
+ end
+ assert_equal "editorial-2", error.suggested_id
+ end
+ end
+ end
+ end
+ end
+
+ def test_minimal_refuses_to_replace_an_unrelated_same_name_registration
+ with_tmp_global_config do
+ with_tmp_dir do |root|
+ first = File.join(root, "first", "shared")
+ second = File.join(root, "second", "shared")
+ initialize_git_repo(first)
+ initialize_git_repo(second)
+ Hive::Config.register_project(name: "shared", path: first)
+
+ out, = capture_io do
+ assert_raises(Hive::Commands::Init::ProjectRegistrationCollision) do
+ Hive::Commands::Init.new(
+ second, minimal: true, preview: true, new_workflow: "editorial",
+ json: true, agent_skill_preflight: false
+ ).call
+ end
+ end
+ payload = JSON.parse(out)
+
+ assert_equal false, payload.fetch("ok")
+ assert_equal "project_name_collision", payload.fetch("error_kind")
+ assert_equal first, payload.fetch("existing_path")
+ assert_equal first, Hive::Config.find_project("shared").fetch("path")
+ schemer = JSONSchemer.schema(
+ JSON.parse(File.read(Hive::Schemas.schema_path("hive-init-preview")))
+ )
+ assert_empty schemer.validate(payload).to_a
+ end
+ end
+ end
+
+ def test_minimal_rechecks_registration_inside_the_locked_write
+ with_tmp_global_config do
+ with_tmp_dir do |root|
+ existing = File.join(root, "existing", "shared")
+ target = File.join(root, "target", "shared")
+ initialize_git_repo(existing)
+ initialize_git_repo(target)
+
+ command = Hive::Commands::Init.new(
+ target, minimal: true, new_workflow: "editorial",
+ json: true, agent_skill_preflight: false
+ )
+ original = command.method(:ensure_minimal_registration_available!)
+ command.define_singleton_method(:ensure_minimal_registration_available!) do
+ original.call
+ Hive::Config.register_project(name: "shared", path: existing)
+ end
+
+ out, = capture_io do
+ assert_raises(Hive::Commands::Init::ProjectRegistrationCollision) { command.call }
+ end
+ payload = JSON.parse(out)
+
+ assert_equal "project_name_collision", payload.fetch("error_kind")
+ assert_equal existing, payload.fetch("existing_path")
+ assert_equal existing, Hive::Config.find_project("shared").fetch("path")
+ refute File.exist?(File.join(target, ".hive-state"))
+ assert_schema_valid("hive-init", payload)
+ end
+ end
+ end
+
+ def test_minimal_json_failures_use_typed_command_envelopes
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ File.write(File.join(project_root, "README.md"), "dirty\n")
+ out, = capture_io do
+ assert_raises(Hive::Commands::Init::PreconditionError) do
+ Hive::Commands::Init.new(
+ project_root, minimal: true, new_workflow: "editorial",
+ json: true, agent_skill_preflight: false
+ ).call
+ end
+ end
+ payload = JSON.parse(out)
+ assert_equal "hive-init", payload.fetch("schema")
+ assert_equal "dirty_worktree", payload.fetch("error_kind")
+ assert_equal false, payload.fetch("preview")
+ assert_schema_valid("hive-init", payload)
+ end
+
+ with_tmp_git_repo do |project_root|
+ out, = capture_io do
+ assert_raises(Hive::Commands::Workflow::UsageError) do
+ Hive::Commands::Init.new(
+ project_root, minimal: true, preview: true, new_workflow: "Bad_ID",
+ json: true, agent_skill_preflight: false
+ ).call
+ end
+ end
+ payload = JSON.parse(out)
+ assert_equal "hive-init-preview", payload.fetch("schema")
+ assert_equal "usage", payload.fetch("error_kind")
+ assert_equal "Bad_ID", payload.fetch("value")
+ assert_schema_valid("hive-init-preview", payload)
+ end
+ end
+ end
+
+ def test_minimal_error_envelope_classifies_shared_failures_and_suggestions
+ command = Hive::Commands::Init.new(
+ ".", minimal: true, new_workflow: "editorial", json: true
+ )
+ {
+ Hive::ConcurrentRunError.new("busy") => "concurrent_run",
+ Hive::ConfigError.new("config") => "config",
+ Hive::GitError.new("git") => "git",
+ Hive::InternalError.new("internal") => "internal",
+ RuntimeError.new("other") => "error"
+ }.each do |error, kind|
+ assert_equal kind, command.send(:envelope_error_kind, error)
+ end
+
+ usage = Hive::Commands::Workflow::UsageError.new(
+ "workflow exists", value: "editorial", suggested_id: "editorial-2"
+ )
+ extras = command.send(:envelope_extras_for, usage)
+ assert_equal "editorial", extras.fetch("value")
+ assert_equal "editorial-2", extras.fetch("suggested_id")
+ end
+
+ def test_minimal_git_preconditions_remain_typed_for_automation
+ with_tmp_dir do |project_root|
+ command = Hive::Commands::Init.new(
+ project_root, minimal: true, new_workflow: "editorial", json: true
+ )
+ failure = Object.new
+ failure.define_singleton_method(:success?) { false }
+
+ with_replaced_singleton_method(
+ Open3, :capture3, ->(*_args) { [ "", "not a repository", failure ] }
+ ) do
+ error = assert_raises(Hive::Commands::Init::PreconditionError) do
+ command.send(:validate_git_repo!)
+ end
+ assert_equal "not_git_repository", error.error_kind
+ end
+
+ success = Object.new
+ success.define_singleton_method(:success?) { true }
+ with_replaced_singleton_method(
+ Open3, :capture3, ->(*_args) { [ "../shared.git\n", "", success ] }
+ ) do
+ error = assert_raises(Hive::Commands::Init::PreconditionError) do
+ command.send(:validate_git_repo!)
+ end
+ assert_equal "main_checkout_required", error.error_kind
+ end
+ end
+ end
+
+ def test_plain_preview_reports_plan_and_broken_pipe_is_harmless
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ out, = capture_io do
+ Hive::Commands::Init.new(
+ project_root, new_workflow: "editorial", minimal: true, preview: true,
+ agent_skill_preflight: false
+ ).call
+ end
+ assert_includes out, "minimal init preview"
+ assert_includes out, "workflow: editorial"
+ assert_includes out, "automation: disabled"
+
+ command = Hive::Commands::Init.new(
+ project_root, new_workflow: "editorial", minimal: true, preview: true,
+ agent_skill_preflight: false
+ )
+ command.define_singleton_method(:minimal_preview_payload) do |_ops, _id|
+ { "project_files" => [], "workflow" => "editorial" }
+ end
+ broken = Object.new
+ broken.define_singleton_method(:write) { |_body| raise Errno::EPIPE }
+ original = $stdout
+ begin
+ $stdout = broken
+ assert_nil command.send(
+ :emit_minimal_preview, Hive::GitOps.new(project_root), "editorial"
+ )
+ ensure
+ $stdout = original
+ end
+ end
+ end
+ end
+
+ private
+
+ def initialize_git_repo(path)
+ FileUtils.mkdir_p(path)
+ run!("git", "-C", path, "init", "-b", "master", "--quiet")
+ run!("git", "-C", path, "config", "user.email", "test@example.com")
+ run!("git", "-C", path, "config", "user.name", "Test")
+ File.write(File.join(path, "README.md"), "test\n")
+ run!("git", "-C", path, "add", ".")
+ run!("git", "-C", path, "commit", "-m", "initial", "--quiet")
+ end
+
+ def assert_schema_valid(name, payload)
+ schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path(name))))
+ errors = schemer.validate(payload).to_a
+ assert_empty errors, "#{name} payload errors: #{errors.map { |error| error['error'] }.inspect}"
+ end
+
+ def repository_snapshot(project_root)
+ {
+ head: run!("git", "-C", project_root, "rev-parse", "HEAD").strip,
+ status: run!("git", "-C", project_root, "status", "--porcelain"),
+ branches: run!("git", "-C", project_root, "branch", "--format=%(refname:short)")
+ }
+ end
+end
diff --git a/test/integration/new_idempotency_test.rb b/test/integration/new_idempotency_test.rb
new file mode 100644
index 0000000000..784aa8586a
--- /dev/null
+++ b/test/integration/new_idempotency_test.rb
@@ -0,0 +1,550 @@
+require "test_helper"
+require "json"
+require "json_schemer"
+require "hive/commands/approve"
+require "hive/commands/init"
+require "hive/commands/new"
+require "hive/commands/workflow"
+require "hive/task_meta"
+
+class NewIdempotencyTest < Minitest::Test
+ include HiveTestHelper
+
+ def test_error_kind_maps_config_concurrency_and_internal_failures
+ command = Hive::Commands::New.new("project", "task", idempotency_key: "key", json: true)
+ cases = [
+ [ Hive::ConfigError.new("config"), "config" ],
+ [ Hive::Commands::New::ProjectConfigUnreadable.new("config"), "config" ],
+ [ Hive::Commands::New::UnregisteredProjectWorkflow.new("config"), "config" ],
+ [ Hive::ConcurrentRunError.new("busy"), "concurrent_run" ],
+ [ Hive::InternalError.new("internal"), "internal" ],
+ [ RuntimeError.new("other"), "error" ]
+ ]
+
+ cases.each do |error, kind|
+ assert_equal kind, command.envelope_error_kind(error)
+ end
+ end
+
+ def test_retry_returns_original_task_after_it_moves
+ with_initialized_project do |project_root, project|
+ first = create_json(
+ project, "draft launch post", key: "workflow-creator:editorial:v1", slug: "editorial-task"
+ )
+ assert_equal true, first.fetch("created")
+ folder = File.join(project_root, ".hive-state", "stages", "1-inbox", "editorial-task")
+ Hive::Commands::Approve.new(
+ folder, to: "2-brainstorm", from: "1-inbox", force: true, quiet: true
+ ).call
+
+ retry_payload = create_json(
+ project, "draft launch post", key: "workflow-creator:editorial:v1", slug: "editorial-task"
+ )
+
+ assert_equal false, retry_payload.fetch("created")
+ assert_equal "editorial-task", retry_payload.fetch("slug")
+ assert_equal "2-brainstorm", retry_payload.fetch("current_stage")
+ assert_equal 1, idempotent_tasks(project_root).size
+ schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-new"))))
+ assert_empty schemer.validate(retry_payload).to_a
+ end
+ end
+
+ def test_reusing_key_for_different_input_is_rejected_without_a_second_task
+ with_initialized_project do |project_root, project|
+ create_json(project, "first request", key: "creator:stable", slug: "first-task")
+
+ error = assert_raises(Hive::Commands::New::IdempotencyConflict) do
+ Hive::Commands::New.new(
+ project, "different request", slug_override: "second-task",
+ idempotency_key: "creator:stable", json: true
+ ).call!
+ end
+
+ assert_includes error.message, "different input or workflow"
+ assert_equal 1, idempotent_tasks(project_root).size
+
+ out, err, status = with_captured_exit do
+ Hive::Commands::New.new(
+ project, "different request", slug_override: "second-task",
+ idempotency_key: "creator:stable", json: true
+ ).call
+ end
+ payload = JSON.parse(out)
+ assert_equal Hive::ExitCodes::USAGE, status
+ assert_empty err
+ assert_equal "usage", payload.fetch("error_kind")
+ schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-new"))))
+ assert_empty schemer.validate(payload).to_a
+ assert_equal 1, idempotent_tasks(project_root).size
+ end
+ end
+
+ def test_invalid_idempotency_keys_are_rejected
+ with_initialized_project do |_project_root, project|
+ [ "", " ", "x" * 513 ].each do |key|
+ error = assert_raises(Hive::Commands::New::IdempotencyConflict) do
+ Hive::Commands::New.new(
+ project, "task", slug_override: "invalid-key-task",
+ idempotency_key: key, json: true
+ ).call!
+ end
+ assert_includes error.message, "idempotency key must be"
+ end
+ end
+ end
+
+ def test_attachment_content_participates_in_the_fingerprint
+ with_initialized_project do |project_root, project|
+ with_tmp_dir do |dir|
+ source = File.join(dir, "source.png")
+ File.binwrite(source, "png-bytes")
+
+ payload = create_json_with(
+ project, "task with image", key: "creator:attachment", slug: "attachment-task",
+ attachments: [ [ source, "source.png" ] ]
+ )
+ metadata = Hive::TaskMeta.read(payload.fetch("task_folder"))
+
+ assert_equal true, payload.fetch("created")
+ assert_match(/\A[0-9a-f]{64}\z/, metadata.fetch(:input_fingerprint))
+ assert_equal "png-bytes",
+ File.binread(File.join(project_root, ".hive-state", "stages", "1-inbox",
+ "attachment-task", "assets", "source.png"))
+ end
+ end
+ end
+
+ def test_idempotent_attachment_fingerprint_and_task_use_one_byte_snapshot
+ with_initialized_project do |project_root, project|
+ with_tmp_dir do |dir|
+ source = File.join(dir, "source.png")
+ File.binwrite(source, "original-bytes")
+ command = Hive::Commands::New.new(
+ project, "task with raced image", slug_override: "raced-attachment-task",
+ idempotency_key: "creator:raced-attachment", json: true,
+ attachments: [ [ source, "source.png" ] ]
+ )
+ original_lookup = command.method(:find_idempotent_task!)
+ command.define_singleton_method(:find_idempotent_task!) do |*args|
+ File.binwrite(source, "changed-after-fingerprint")
+ original_lookup.call(*args)
+ end
+
+ out, = capture_io { command.call! }
+ payload = JSON.parse(out)
+ captured = File.join(payload.fetch("task_folder"), "assets", "source.png")
+
+ assert_equal "original-bytes", File.binread(captured)
+
+ File.binwrite(source, "original-bytes")
+ retry_payload = create_json_with(
+ project, "task with raced image", key: "creator:raced-attachment",
+ slug: "raced-attachment-task", attachments: [ [ source, "source.png" ] ]
+ )
+ assert_equal false, retry_payload.fetch("created")
+ assert_equal 1, idempotent_tasks(project_root).size
+ end
+ end
+ end
+
+ def test_idempotent_attachment_copy_rejects_a_corrupted_snapshot_destination
+ with_initialized_project do |project_root, project|
+ with_tmp_dir do |dir|
+ source = File.join(dir, "source.png")
+ File.binwrite(source, "trusted-bytes")
+ original_cp = FileUtils.method(:cp)
+ corrupting_cp = lambda do |from, to, *args, **kwargs|
+ original_cp.call(from, to, *args, **kwargs)
+ File.binwrite(to, "corrupted-after-copy") if to.include?("/assets/")
+ end
+
+ with_replaced_singleton_method(FileUtils, :cp, corrupting_cp) do
+ error = assert_raises(Hive::Commands::New::InvalidAttachmentError) do
+ Hive::Commands::New.new(
+ project, "task with corrupted image",
+ slug_override: "corrupted-attachment-task",
+ idempotency_key: "creator:corrupted-attachment", json: true,
+ attachments: [ [ source, "source.png" ] ]
+ ).call!
+ end
+ assert_includes error.message, "attachment snapshot changed"
+ assert_equal "source.png", error.value
+ end
+
+ refute Dir.exist?(
+ File.join(
+ project_root, ".hive-state", "stages", "1-inbox",
+ "corrupted-attachment-task"
+ )
+ )
+ end
+ end
+ end
+
+ def test_idempotent_attachment_cleanup_tolerates_an_already_missing_snapshot
+ with_tmp_dir do |dir|
+ source = File.join(dir, "source.png")
+ File.binwrite(source, "trusted-bytes")
+ command = Hive::Commands::New.new(
+ "project", "task", idempotency_key: "creator:cleanup",
+ attachments: [ [ source, "source.png" ] ]
+ )
+ command.send(:prepare_idempotent_attachments!)
+ snapshot_dir = command.instance_variable_get(:@attachment_snapshot_dir)
+ FileUtils.remove_entry_secure(snapshot_dir)
+
+ assert_nil command.send(:cleanup_prepared_attachments!)
+ assert_nil command.instance_variable_get(:@attachment_snapshot_dir)
+ assert_nil command.instance_variable_get(:@prepared_attachments)
+ end
+ end
+
+ def test_commit_failure_removes_the_uncommitted_task
+ with_initialized_project do |project_root, project|
+ fake_ops = Object.new
+ runner = method(:run!)
+ hive_state = File.join(project_root, ".hive-state")
+ fake_ops.define_singleton_method(:hive_commit) do |**|
+ runner.call("git", "-C", hive_state, "add", "--",
+ "stages/1-inbox/failed-task")
+ raise Hive::GitError, "commit failed"
+ end
+ fake_ops.define_singleton_method(:run_git!) do |*args|
+ runner.call("git", *args)
+ end
+
+ with_replaced_singleton_method(Hive::GitOps, :new, ->(_root) { fake_ops }) do
+ error = assert_raises(Hive::GitError) do
+ Hive::Commands::New.new(
+ project, "will fail", slug_override: "failed-task",
+ idempotency_key: "creator:commit-failure", json: true
+ ).call!
+ end
+ assert_includes error.message, "commit failed"
+ end
+
+ refute Dir.exist?(
+ File.join(project_root, ".hive-state", "stages", "1-inbox", "failed-task")
+ )
+ assert_empty run!("git", "-C", File.join(project_root, ".hive-state"),
+ "diff", "--cached", "--name-only")
+ end
+ end
+
+ def test_interrupted_commit_removes_the_uncommitted_task
+ with_initialized_project do |project_root, project|
+ fake_ops = Object.new
+ runner = method(:run!)
+ hive_state = File.join(project_root, ".hive-state")
+ fake_ops.define_singleton_method(:hive_commit) do |**|
+ runner.call("git", "-C", hive_state, "add", "--",
+ "stages/1-inbox/interrupted-task")
+ raise Interrupt, "stop"
+ end
+ fake_ops.define_singleton_method(:run_git!) { |*args| runner.call("git", *args) }
+
+ with_replaced_singleton_method(Hive::GitOps, :new, ->(_root) { fake_ops }) do
+ assert_raises(Interrupt) do
+ Hive::Commands::New.new(
+ project, "will stop", slug_override: "interrupted-task",
+ idempotency_key: "creator:interrupted", json: true
+ ).call!
+ end
+ end
+
+ refute Dir.exist?(
+ File.join(hive_state, "stages", "1-inbox", "interrupted-task")
+ )
+ assert_empty run!("git", "-C", hive_state, "diff", "--cached", "--name-only")
+ end
+ end
+
+ def test_non_idempotent_commit_failure_also_clears_staged_state
+ with_initialized_project do |project_root, project|
+ fake_ops = Object.new
+ runner = method(:run!)
+ hive_state = File.join(project_root, ".hive-state")
+ fake_ops.define_singleton_method(:hive_commit) do |**|
+ runner.call("git", "-C", hive_state, "add", "--",
+ "stages/1-inbox/failed-legacy-task")
+ raise Hive::GitError, "commit failed"
+ end
+ fake_ops.define_singleton_method(:run_git!) { |*args| runner.call("git", *args) }
+
+ with_replaced_singleton_method(Hive::GitOps, :new, ->(_root) { fake_ops }) do
+ assert_raises(Hive::GitError) do
+ Hive::Commands::New.new(
+ project, "will fail", slug_override: "failed-legacy-task"
+ ).call!
+ end
+ end
+
+ refute Dir.exist?(
+ File.join(project_root, ".hive-state", "stages", "1-inbox", "failed-legacy-task")
+ )
+ assert_empty run!("git", "-C", File.join(project_root, ".hive-state"),
+ "diff", "--cached", "--name-only")
+ end
+ end
+
+ def test_owner_authored_workflow_content_participates_in_fingerprint
+ with_initialized_project do |project_root, project|
+ create_authored_workflow(project_root, "editorial")
+ create_json_with(
+ project, "same request", key: "creator:authored-content", slug: "first-authored",
+ workflow: "editorial"
+ )
+ File.write(
+ File.join(project_root, ".hive-state", "workflows", "editorial", "work.md"),
+ "Changed owner instructions.\n"
+ )
+ Hive::Workflows::Project.reset!
+
+ error = assert_raises(Hive::Commands::New::IdempotencyConflict) do
+ Hive::Commands::New.new(
+ project, "same request", slug_override: "second-authored",
+ workflow: "editorial", idempotency_key: "creator:authored-content", json: true
+ ).call!
+ end
+
+ assert_includes error.message, "different input or workflow"
+ assert_equal 1, idempotent_tasks(project_root).size
+ end
+ end
+
+ def test_owner_authored_workflow_change_during_creation_aborts_candidate
+ with_initialized_project do |project_root, project|
+ create_authored_workflow(project_root, "editorial")
+ instruction = File.join(
+ project_root, ".hive-state", "workflows", "editorial", "work.md"
+ )
+ command = Hive::Commands::New.new(
+ project, "same request", slug_override: "raced-authored",
+ workflow: "editorial", idempotency_key: "creator:authored-race", json: true
+ )
+ original_lookup = command.method(:find_idempotent_task!)
+ command.define_singleton_method(:find_idempotent_task!) do |*args|
+ File.write(instruction, "Changed during task creation.\n")
+ original_lookup.call(*args)
+ end
+
+ error = assert_raises(Hive::ConcurrentRunError) { command.call! }
+
+ assert_includes error.message, "owner-authored workflow changed"
+ refute Dir.exist?(
+ File.join(project_root, ".hive-state", "stages", "1-inbox", "raced-authored")
+ )
+ assert_empty idempotent_tasks(project_root)
+ end
+ end
+
+ def test_unreadable_task_metadata_fails_idempotency_closed
+ with_initialized_project do |project_root, project|
+ capture_io do
+ Hive::Commands::New.new(
+ project, "legacy task", slug_override: "legacy-task"
+ ).call!
+ end
+ meta = File.join(
+ project_root, ".hive-state", "stages", "1-inbox", "legacy-task", "meta.yml"
+ )
+ File.write(meta, "id: [\n")
+
+ error = assert_raises(Hive::Commands::New::IdempotencyConflict) do
+ Hive::Commands::New.new(
+ project, "new task", slug_override: "must-not-exist",
+ idempotency_key: "creator:fail-closed", json: true
+ ).call!
+ end
+
+ assert_includes error.message, "cannot prove idempotency"
+ refute Dir.exist?(
+ File.join(project_root, ".hive-state", "stages", "1-inbox", "must-not-exist")
+ )
+ end
+ end
+
+ def test_post_ownership_eexist_cleans_the_candidate
+ with_initialized_project do |project_root, project|
+ replacement = lambda do |*|
+ raise Errno::EEXIST, "metadata collision"
+ end
+
+ with_replaced_singleton_method(Hive::TaskMeta, :write, replacement) do
+ assert_raises(Errno::EEXIST) do
+ Hive::Commands::New.new(
+ project, "candidate", slug_override: "owned-candidate",
+ idempotency_key: "creator:owned-candidate", json: true
+ ).call!
+ end
+ end
+
+ refute Dir.exist?(
+ File.join(project_root, ".hive-state", "stages", "1-inbox", "owned-candidate")
+ )
+ end
+ end
+
+ def test_idempotent_candidate_is_created_with_workflow_then_commit_locks_held
+ with_initialized_project do |project_root, project|
+ command = Hive::Commands::New.new(
+ project, "same task", slug_override: "locked-task",
+ idempotency_key: "creator:race", json: true
+ )
+ original = command.method(:create_task_candidate!)
+ observe_lock = method(:other_process_lock_state)
+ command.define_singleton_method(:create_task_candidate!) do |*args, **kwargs|
+ commit_lock = File.join(project_root, ".hive-state", ".commit-lock")
+ workflow_lock = File.join(
+ project_root, ".hive-state", "workflows", ".mutation.lock"
+ )
+ raise "candidate creation ran outside workflow mutation lock" unless
+ observe_lock.call(workflow_lock) == "blocked"
+ raise "candidate creation ran outside commit lock" unless
+ observe_lock.call(commit_lock) == "blocked"
+
+ original.call(*args, **kwargs)
+ end
+
+ out, = capture_io { command.call! }
+ payload = JSON.parse(out)
+
+ assert_equal true, payload.fetch("created")
+ assert Dir.exist?(
+ File.join(project_root, ".hive-state", "stages", "1-inbox", "locked-task")
+ )
+ end
+ end
+
+ def test_same_slug_with_a_different_key_never_reuses_or_removes_the_first_task
+ with_initialized_project do |project_root, project|
+ first = create_json(
+ project, "first task", key: "creator:first", slug: "shared-slug"
+ )
+
+ error = assert_raises(Hive::Commands::New::SlugCollisionError) do
+ Hive::Commands::New.new(
+ project, "second task", slug_override: "shared-slug",
+ idempotency_key: "creator:second", json: true
+ ).call!
+ end
+
+ assert_includes error.message, "slug collision"
+ assert_includes File.read(File.join(first.fetch("task_folder"), "idea.md")), "first task"
+ metadata = Hive::TaskMeta.read(first.fetch("task_folder"))
+ assert_equal "creator:first", metadata.fetch(:idempotency_key)
+ assert_equal 1, idempotent_tasks(project_root).size
+ end
+ end
+
+ def test_multiple_tasks_with_the_same_key_fail_closed
+ with_initialized_project do |project_root, project|
+ %w[first second].each do |slug|
+ capture_io do
+ Hive::Commands::New.new(
+ project, "#{slug} task", slug_override: "#{slug}-task"
+ ).call!
+ end
+ folder = File.join(project_root, ".hive-state", "stages", "1-inbox", "#{slug}-task")
+ Hive::TaskMeta.rewrite(
+ folder, idempotency_key: "creator:duplicate", input_fingerprint: "fingerprint"
+ )
+ end
+
+ error = assert_raises(Hive::Commands::New::IdempotencyConflict) do
+ Hive::Commands::New.new(
+ project, "third task", slug_override: "third-task",
+ idempotency_key: "creator:duplicate", json: true
+ ).call!
+ end
+ assert_includes error.message, "multiple tasks"
+ assert_equal 2, idempotent_tasks(project_root).size
+ end
+ end
+
+ def test_plain_retry_reports_existing_task_and_next_command
+ with_initialized_project do |_project_root, project|
+ create_json(project, "plain retry", key: "creator:plain", slug: "plain-task")
+
+ out, = capture_io do
+ Hive::Commands::New.new(
+ project, "plain retry", slug_override: "ignored-task",
+ idempotency_key: "creator:plain"
+ ).call!
+ end
+
+ assert_includes out, "idempotent task already exists"
+ assert_includes out, "next: hive brainstorm"
+ end
+ end
+
+ def test_legacy_creation_does_not_write_idempotency_metadata
+ with_initialized_project do |project_root, project|
+ capture_io { Hive::Commands::New.new(project, "ordinary task", slug_override: "ordinary-task").call! }
+ folder = File.join(project_root, ".hive-state", "stages", "1-inbox", "ordinary-task")
+ metadata = File.read(File.join(folder, "meta.yml"))
+
+ refute_includes metadata, "idempotency_key"
+ refute_includes metadata, "input_fingerprint"
+ end
+ end
+
+ private
+
+ def with_initialized_project
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ capture_io do
+ Hive::Commands::Init.new(project_root, agent_skill_preflight: false).call
+ end
+ yield project_root, File.basename(project_root)
+ end
+ end
+ end
+
+ def create_json(project, text, key:, slug:)
+ create_json_with(project, text, key: key, slug: slug)
+ end
+
+ def create_json_with(project, text, key:, slug:, attachments: [], workflow: nil)
+ out, err = capture_io do
+ Hive::Commands::New.new(
+ project, text, slug_override: slug, idempotency_key: key, json: true,
+ attachments: attachments, workflow: workflow
+ ).call!
+ end
+ assert_empty err
+ JSON.parse(out)
+ end
+
+ def create_authored_workflow(project_root, id)
+ Hive::Commands::Workflow.new!(
+ id, project_root: project_root, stdout: StringIO.new
+ )
+ end
+
+ def idempotent_tasks(project_root)
+ Dir.glob(File.join(project_root, ".hive-state", "stages", "*", "*", "meta.yml")).select do |path|
+ Hive::TaskMeta.read(File.dirname(path))[:idempotency_key]
+ end
+ end
+
+ def other_process_lock_state(path)
+ reader, writer = IO.pipe
+ pid = fork do
+ reader.close
+ acquired = File.open(path, File::RDWR | File::CREAT, 0o644) do |lock|
+ lock.flock(File::LOCK_EX | File::LOCK_NB)
+ end
+ writer.write(acquired ? "acquired" : "blocked")
+ writer.close
+ exit! 0
+ end
+ writer.close
+ state = reader.read
+ Process.wait(pid)
+ reader.close
+ state
+ end
+end
diff --git a/test/integration/new_test.rb b/test/integration/new_test.rb
index 040e62eba2..bce1091ac8 100644
--- a/test/integration/new_test.rb
+++ b/test/integration/new_test.rb
@@ -91,6 +91,37 @@ def test_new_workflow_override_with_agent_entry_seeds_markerless_state
end
end
+ def test_new_workflow_with_human_entry_seeds_identified_waiting_marker
+ descriptor = Hive::Workflow.new(
+ id: :human_entry,
+ stages: [
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 1, state_file: "approval.md", kind: :human,
+ outcomes: {
+ "approve" => Hive::Workflow::Outcome.new(
+ name: "approve", complete: true, artifact: "approval.md"
+ )
+ }.freeze
+ )
+ ]
+ )
+ with_registered_workflow(descriptor) do
+ with_tmp_global_config do
+ with_tmp_git_repo do |dir|
+ setup_project { Hive::Commands::Init.new(dir, agent_skill_preflight: false).call }
+ project = File.basename(dir)
+
+ capture_io { Hive::Commands::New.new(project, "human entry", workflow: "human_entry").call }
+
+ folder = Dir[File.join(dir, ".hive-state", "stages", "1-approval", "human-entry-*")].fetch(0)
+ marker = Hive::Markers.current(File.join(folder, "approval.md"))
+ assert_equal :waiting, marker.name
+ assert_match(/\A[0-9a-f]{16}\z/, marker.attrs.fetch("decision_id"))
+ end
+ end
+ end
+ end
+
def test_new_single_stage_workflow_prints_run_hint_without_move
with_registered_workflow(single_stage_workflow) do
with_tmp_global_config do
diff --git a/test/integration/new_wrapper_argv_test.rb b/test/integration/new_wrapper_argv_test.rb
index 25384fa482..dbfeadf34d 100644
--- a/test/integration/new_wrapper_argv_test.rb
+++ b/test/integration/new_wrapper_argv_test.rb
@@ -190,6 +190,28 @@ def test_json_boolean_after_project_is_lifted_but_new_stays_plain_text
end
end
+ def test_idempotency_key_after_project_enables_machine_readable_retry
+ with_workflow_project do |home, project_root, project|
+ argv = [
+ "new", project, "--workflow", WORKFLOW_ID,
+ "--idempotency-key", "creator:wrapper:v1", "--json", "idempotent wrapper task"
+ ]
+ first_out, first_err, first_status = run_hive(home, *argv, chdir: project_root)
+ retry_out, retry_err, retry_status = run_hive(home, *argv, chdir: project_root)
+
+ assert first_status.success?, first_err
+ assert retry_status.success?, retry_err
+ first = JSON.parse(first_out)
+ retry_payload = JSON.parse(retry_out)
+ assert_equal true, first.fetch("created")
+ assert_equal false, retry_payload.fetch("created")
+ assert_equal first.fetch("slug"), retry_payload.fetch("slug")
+ folder = only_task_folder(project_root)
+ assert_equal "creator:wrapper:v1", Hive::TaskMeta.read(folder).fetch(:idempotency_key)
+ refute_includes File.read(File.join(folder, "idea.md")), "--idempotency-key"
+ end
+ end
+
def test_missing_text_after_lifted_workflow_fails_without_creating_task
with_workflow_project do |home, project_root, project|
_out, err, status = run_hive(home, "new", project, "--workflow", WORKFLOW_ID, chdir: project_root)
diff --git a/test/integration/tui_new_idea_attachments_smoke_test.rb b/test/integration/tui_new_idea_attachments_smoke_test.rb
index ee42b9e695..8e24b5f083 100644
--- a/test/integration/tui_new_idea_attachments_smoke_test.rb
+++ b/test/integration/tui_new_idea_attachments_smoke_test.rb
@@ -116,7 +116,7 @@ def test_paste_three_images_submit_and_brainstorm_sees_asset_refs
writer.write("\r")
writer.flush
- grid_buffer = read_until(reader, deadline_seconds: 10.0) { |buf| buf.include?("bug-here") }
+ grid_buffer = read_until(reader, deadline_seconds: 30.0) { |buf| buf.include?("bug-here") }
assert_includes grid_buffer, "bug-here",
"post-submit grid must show the bug-here slug row"
@@ -238,7 +238,7 @@ def test_ctrl_v_stages_clipboard_image_and_submit_persists_asset
writer.write("\r")
writer.flush
- grid_buffer = read_until(reader, deadline_seconds: 10.0) { |buf| buf.include?("ctrl-v-image") }
+ grid_buffer = read_until(reader, deadline_seconds: 30.0) { |buf| buf.include?("ctrl-v-image") }
assert_includes grid_buffer, "ctrl-v-image",
"post-submit grid must show the ctrl-v-image slug row"
diff --git a/test/integration/workflow_command_test.rb b/test/integration/workflow_command_test.rb
new file mode 100644
index 0000000000..506d69017a
--- /dev/null
+++ b/test/integration/workflow_command_test.rb
@@ -0,0 +1,350 @@
+require "test_helper"
+require "json"
+require "json_schemer"
+require "hive/commands/init"
+require "hive/commands/workflow"
+require "hive/commands/workflow/validate"
+
+class WorkflowCommandTest < Minitest::Test
+ include HiveTestHelper
+
+ def setup
+ super
+ Hive::Workflows::Project.reset!
+ end
+
+ def teardown
+ Hive::Workflows::Project.reset!
+ super
+ end
+
+ def test_validate_reports_exact_editorial_graph_without_writing
+ with_initialized_project do |project_root|
+ descriptor = write_editorial_workflow(project_root)
+ before = project_snapshot(project_root)
+ before_head = state_head(project_root)
+ stdout = StringIO.new
+
+ payload = Hive::Commands::Workflow.new(
+ "validate", "editorial", project_root: project_root, json: true, stdout: stdout
+ ).call!
+
+ assert_equal before, project_snapshot(project_root)
+ assert_equal before_head, state_head(project_root)
+ assert_equal true, payload.fetch("valid")
+ assert_equal "authored", payload.fetch("origin")
+ assert_equal descriptor, payload.fetch("descriptor_path")
+ assert_equal %w[research draft approval], payload.fetch("stages").map { |row| row.fetch("name") }
+ assert_equal %w[research draft], payload.fetch("automatic_edges").map { |row| row.fetch("from") }
+ assert_equal [
+ [ "approval", "approve", true, nil ],
+ [ "approval", "reject", false, "draft" ]
+ ], payload.fetch("human_outcomes").map { |row|
+ [ row.fetch("stage"), row.fetch("name"), row.fetch("complete"), row["to"] ]
+ }
+
+ schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-workflow-validate"))))
+ assert_empty schemer.validate(payload).to_a
+ end
+ end
+
+ def test_validate_rejects_missing_instruction_without_mutation
+ with_initialized_project do |project_root|
+ descriptor = write_editorial_workflow(project_root)
+ File.delete(File.join(File.dirname(descriptor), "editorial", "draft.md"))
+ before = project_snapshot(project_root)
+ before_head = state_head(project_root)
+
+ out, err, status = with_captured_exit do
+ Hive::Commands::Workflow.new(
+ "validate", "editorial", project_root: project_root, json: true
+ ).call
+ end
+
+ assert_equal Hive::ExitCodes::CONFIG, status
+ assert_empty err
+ payload = JSON.parse(out)
+ assert_equal false, payload.fetch("valid")
+ assert_includes payload.fetch("message"), "draft.md"
+ assert_equal before, project_snapshot(project_root)
+ assert_equal before_head, state_head(project_root)
+ schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-workflow-validate"))))
+ assert_empty schemer.validate(payload).to_a
+ end
+ end
+
+ def test_validate_loads_built_in_through_project_resolution
+ with_initialized_project do |project_root|
+ payload = Hive::Commands::Workflow.new(
+ "validate", "coding", project_root: project_root, json: false, stdout: StringIO.new
+ ).call!
+
+ assert_equal "built_in", payload.fetch("origin")
+ assert_nil payload.fetch("descriptor_path")
+ assert_equal Hive::Workflows::Coding::DESCRIPTOR.stage_names,
+ payload.fetch("stages").map { |row| row.fetch("name") }
+ end
+ end
+
+ def test_validate_defaults_missing_hive_state_configuration_without_writing
+ with_tmp_dir do |project_root|
+ validator = Hive::Commands::Workflow::Validate.new(
+ "coding", project_root: project_root, stdout: StringIO.new
+ )
+ config_path = File.join(project_root, "config.yml")
+ read = ->(_root) { [ config_path, {} ] }
+ normalize = ->(data, _source, **_options) { data }
+
+ workflows = with_replaced_singleton_method(
+ Hive::Config, :read_project_config, read
+ ) do
+ with_replaced_singleton_method(
+ Hive::Config, :normalize_legacy_project_config, normalize
+ ) { validator.send(:workflows_dir) }
+ end
+
+ assert_equal File.join(project_root, ".hive-state", "workflows"), workflows
+ refute File.exist?(File.join(project_root, ".hive-state"))
+ end
+ end
+
+ def test_validate_rejects_an_authored_descriptor_claiming_a_built_in_id
+ with_initialized_project do |project_root|
+ editorial = write_editorial_workflow(project_root)
+ shadow = File.join(File.dirname(editorial), "coding.yml")
+ File.write(
+ shadow,
+ File.read(editorial).sub(/^id: editorial$/, "id: coding")
+ )
+
+ error = assert_raises(Hive::ConfigError) do
+ Hive::Commands::Workflow::Validate.new(
+ "coding", project_root: project_root, stdout: StringIO.new
+ ).call!
+ end
+
+ assert_includes error.message, "collides with registered workflow"
+ assert_includes error.message, "coding"
+ end
+ end
+
+ def test_validate_unknown_workflow_lists_authored_managed_and_built_in_ids
+ with_initialized_project do |project_root|
+ descriptor = write_editorial_workflow(project_root)
+ workflows = File.dirname(descriptor)
+ managed = File.join(workflows, "managed-flow")
+ FileUtils.mkdir_p(managed)
+ File.write(
+ File.join(managed, Hive::WorkflowPackage::ManagedStore::LOCK_FILE),
+ "{}\n"
+ )
+
+ error = assert_raises(Hive::Workflows::UnknownWorkflow) do
+ Hive::Commands::Workflow::Validate.new(
+ "missing-flow", project_root: project_root, stdout: StringIO.new
+ ).call!
+ end
+
+ assert_includes error.message, "editorial"
+ assert_includes error.message, "managed-flow"
+ assert_includes error.message, "coding"
+ end
+ end
+
+ def test_validate_never_calls_the_production_project_config_loader
+ with_initialized_project do |project_root|
+ write_editorial_workflow(project_root)
+ forbidden = lambda do |_root|
+ raise "strict read-only validation called Config.load"
+ end
+
+ with_replaced_singleton_method(Hive::Config, :load, forbidden) do
+ payload = Hive::Commands::Workflow::Validate.new(
+ "editorial", project_root: project_root, stdout: StringIO.new
+ ).call!
+ assert_equal true, payload.fetch("valid")
+ assert_equal "authored", payload.fetch("origin")
+ end
+ end
+ end
+
+ def test_validate_rejects_invalid_id_and_unreadable_instruction
+ with_initialized_project do |project_root|
+ error = assert_raises(Hive::Commands::Workflow::UsageError) do
+ Hive::Commands::Workflow::Validate.new(
+ "Not Valid", project_root: project_root
+ ).call!
+ end
+ assert_includes error.message, "invalid workflow id"
+
+ instruction = File.join(project_root, "missing.md")
+ stage = Struct.new(:instruction, :name).new(instruction, "draft")
+ workflow = [ stage ]
+ workflow.define_singleton_method(:id) { :editorial }
+ validator = Hive::Commands::Workflow::Validate.new(
+ "editorial", project_root: project_root
+ )
+ error = assert_raises(Hive::ConfigError) do
+ validator.send(:validate_instruction_paths!, workflow)
+ end
+ assert_includes error.message, "missing instruction"
+ end
+ end
+
+ def test_validate_reports_managed_origin_and_plain_summary
+ with_initialized_project do |project_root|
+ stdout = StringIO.new
+ validator = Hive::Commands::Workflow::Validate.new(
+ "managed-flow", project_root: project_root, stdout: stdout
+ )
+ validator.instance_variable_set(:@descriptor_path, nil)
+ assert_equal "managed", validator.send(:origin)
+
+ payload = {
+ "stages" => [ { "name" => "work" } ]
+ }
+ validator.send(:emit, payload)
+ assert_includes stdout.string, "managed-flow is valid (managed)"
+ assert_includes stdout.string, "stages: work"
+ end
+ end
+
+ def test_validate_resolves_managed_workflow_through_no_write_selection
+ with_initialized_project do |project_root|
+ workflow = Hive::Workflows::Registry.default
+ fake_store = Object.new
+ test = self
+ fake_store.define_singleton_method(:selected_read_only) do |name|
+ test.assert_equal "managed-flow", name
+ {
+ "source_commit" => "a" * 40,
+ "manifest_digest" => "b" * 64,
+ "configuration_digest" => "c" * 64
+ }
+ end
+ fake_store.define_singleton_method(:workflow) do |name, _commit, _manifest, **options|
+ test.assert_equal "managed-flow", name
+ test.assert_equal false, options.fetch(:verify_profiles)
+ workflow
+ end
+
+ with_replaced_singleton_method(
+ Hive::WorkflowPackage::ManagedStore, :new, ->(_state) { fake_store }
+ ) do
+ payload = Hive::Commands::Workflow::Validate.new(
+ "managed-flow", project_root: project_root, stdout: StringIO.new
+ ).call!
+ assert_equal "managed", payload.fetch("origin")
+ end
+ end
+ end
+
+ def test_workflow_maps_unknown_workflow_to_usage
+ command = Hive::Commands::Workflow.new(
+ "validate", "missing", project_root: ".", stdout: StringIO.new
+ )
+ error = Hive::Workflows::UnknownWorkflow.new("missing")
+
+ assert_equal Hive::Schemas::WorkflowNewErrorKind::USAGE,
+ command.send(:error_kind_for, error)
+ end
+
+ def test_commit_refuses_a_managed_workflow
+ with_tmp_dir do |project_root|
+ validation = Object.new
+ validation.define_singleton_method(:call!) do
+ {
+ "origin" => "managed",
+ "descriptor_path" => File.join(project_root, "managed-flow.yml")
+ }
+ end
+
+ with_replaced_singleton_method(
+ Hive::Commands::Workflow::Validate, :new, ->(*_args, **_kwargs) { validation }
+ ) do
+ error = assert_raises(Hive::Commands::Workflow::UsageError) do
+ Hive::Commands::Workflow.new(
+ "commit", "managed-flow", project_root: project_root,
+ stdout: StringIO.new
+ ).call!
+ end
+ assert_includes error.message, "requires an owner-authored workflow"
+ assert_includes error.message, "managed"
+ end
+ end
+ end
+
+ def test_new_collision_proposes_deterministic_available_id
+ with_initialized_project do |project_root|
+ Hive::Commands::Workflow.new!("editorial", project_root: project_root, stdout: StringIO.new)
+ Hive::Commands::Workflow.new!("editorial-2", project_root: project_root, stdout: StringIO.new)
+ before = project_snapshot(project_root)
+
+ error = assert_raises(Hive::Commands::Workflow::UsageError) do
+ Hive::Commands::Workflow.new!("editorial", project_root: project_root, stdout: StringIO.new)
+ end
+
+ assert_equal "editorial-3", error.suggested_id
+ assert_includes error.message, "try \"editorial-3\""
+ assert_equal before, project_snapshot(project_root)
+ end
+ end
+
+ private
+
+ def with_initialized_project
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ capture_io { Hive::Commands::Init.new(project_root, agent_skill_preflight: false).call }
+ yield project_root
+ end
+ end
+ end
+
+ def write_editorial_workflow(project_root)
+ workflows = File.join(project_root, ".hive-state", "workflows")
+ instructions = File.join(workflows, "editorial")
+ FileUtils.mkdir_p(instructions)
+ File.write(File.join(instructions, "research.md"), "Research the request.\n")
+ File.write(File.join(instructions, "draft.md"), "Draft from research.md.\n")
+ descriptor = File.join(workflows, "editorial.yml")
+ File.write(descriptor, <<~YAML)
+ id: editorial
+ stages:
+ - name: research
+ kind: agent
+ state_file: research.md
+ instruction: editorial/research.md
+ permissions: yolo
+ - name: draft
+ kind: agent
+ state_file: draft.md
+ instruction: editorial/draft.md
+ permissions: yolo
+ - name: approval
+ kind: human
+ state_file: approval.md
+ input: draft.md
+ outcomes:
+ approve:
+ complete: true
+ artifact: draft.md
+ reject:
+ to: draft
+ YAML
+ descriptor
+ end
+
+ def project_snapshot(project_root)
+ root = File.join(project_root, ".hive-state", "workflows")
+ Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).sort.filter_map do |path|
+ next if File.directory?(path)
+
+ [ path.delete_prefix("#{root}/"), File.binread(path) ]
+ end
+ end
+
+ def state_head(project_root)
+ run!("git", "-C", File.join(project_root, ".hive-state"), "rev-parse", "HEAD").strip
+ end
+end
diff --git a/test/integration/workflow_creator_e2e_test.rb b/test/integration/workflow_creator_e2e_test.rb
new file mode 100644
index 0000000000..13092b7ebf
--- /dev/null
+++ b/test/integration/workflow_creator_e2e_test.rb
@@ -0,0 +1,412 @@
+require "test_helper"
+require "json"
+require "json_schemer"
+require "rubygems/version"
+require "hive/agent_skills/canonical_skill"
+require "hive/commands/approve"
+require "hive/commands/decide"
+require "hive/commands/init"
+require "hive/commands/new"
+require "hive/commands/status"
+require "hive/commands/workflow"
+require "hive/task_meta"
+
+class WorkflowCreatorE2ETest < Minitest::Test
+ include HiveTestHelper
+
+ EDITORIAL_PROMPT =
+ "Create a three-stage editorial workflow that researches, drafts, and requires approval before publishing".freeze
+
+ def setup
+ super
+ Hive::Workflows::Project.reset!
+ end
+
+ def teardown
+ Hive::Workflows::Project.reset!
+ super
+ end
+
+ def test_ae1_creation_only_uses_scaffold_and_validation_without_a_task
+ with_initialized_project do |project_root, _project|
+ commands = []
+ payload = create_editorial(project_root, commands: commands)
+
+ assert_equal [
+ [ "version" ],
+ [ "workflow", "list", "--json" ],
+ [ "workflow", "new", "editorial" ],
+ [ "workflow", "validate", "editorial", "--json" ],
+ [ "workflow", "commit", "editorial" ]
+ ], commands
+ assert_equal %w[research draft approval], payload.fetch("stages").map { |stage| stage.fetch("name") }
+ assert_equal [
+ [ "research", "draft" ],
+ [ "draft", "approval" ]
+ ], payload.fetch("automatic_edges").map { |edge| edge.values_at("from", "to") }
+ assert_equal [
+ [ "approval", "approve", true, "draft.md", nil ],
+ [ "approval", "reject", false, nil, "draft" ]
+ ], payload.fetch("human_outcomes").map { |outcome|
+ outcome.values_at("stage", "name", "complete", "artifact", "to")
+ }
+ assert_empty task_folders(project_root)
+ assert_schema_valid("hive-workflow-validate", payload)
+ committed = run!(
+ "git", "-C", File.join(project_root, ".hive-state"),
+ "show", "--format=", "--name-only", "HEAD"
+ )
+ assert_includes committed, "workflows/editorial.yml"
+ assert_includes committed, "workflows/editorial/research.md"
+ assert_includes committed, "workflows/editorial/draft.md"
+
+ creator = Hive::AgentSkills::CanonicalSkill.new.rendered_canonical_files
+ .fetch("references/workflow-creator.md")
+ assert_includes creator, "No task by default"
+ assert_includes creator, "hive new 'PROJECT' --workflow 'ID'"
+ assert_includes creator, "Never publish externally"
+ assert_operator creator.index("hive workflow new"), :<, creator.index("hive workflow validate")
+ assert_operator creator.index("hive workflow validate"), :<,
+ creator.index('hive workflow commit "$ID"')
+ assert_operator creator.index('hive workflow commit "$ID"'), :<,
+ creator.index("hive new 'PROJECT' --workflow 'ID'")
+ end
+ end
+
+ def test_creator_refuses_an_old_hive_before_inspection_or_mutation
+ with_initialized_project do |project_root, _project|
+ before = repository_snapshot(project_root)
+ commands = []
+
+ result = create_editorial(
+ project_root, commands: commands, installed_version: "0.0.1"
+ )
+
+ assert_equal :version_refused, result
+ assert_equal [ [ "version" ] ], commands
+ assert_equal before, repository_snapshot(project_root)
+ refute File.exist?(workflow_descriptor(project_root))
+ end
+ end
+
+ def test_creator_refuses_unconfirmed_fresh_init_without_mutation
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ before = repository_snapshot(project_root)
+ commands = []
+
+ result = preview_fresh_creator(
+ project_root, confirmed: false, commands: commands
+ )
+
+ assert_equal :confirmation_required, result
+ assert_equal [
+ [ "version" ],
+ [ "init", "--new-workflow", "editorial", "--minimal", "--preview", "--json" ]
+ ], commands
+ assert_equal before, repository_snapshot(project_root)
+ refute File.exist?(workflow_descriptor(project_root))
+ end
+ end
+ end
+
+ def test_ae1_and_ae2_approval_and_rejection_are_durable_and_closed
+ with_initialized_project do |project_root, project|
+ create_editorial(project_root)
+
+ approved = create_task(project, "approved-editorial", key: "creator:approved")
+ approval = advance_to_approval(project_root, approved.fetch("slug"))
+ approve_payload = decide(approved.fetch("slug"), "approve", note: "Publish-ready")
+ record = Hive::Commands::Decide.latest_record(File.join(approval, "approval.md"))
+
+ assert_equal true, approve_payload.fetch("completed")
+ assert_equal "draft.md", approve_payload.fetch("artifact")
+ assert_equal "approve", record.fetch("outcome")
+ assert_equal "publish_ready", record.fetch("artifact_status")
+ assert_equal "draft.md", record.fetch("artifact")
+ refute Dir.exist?(File.join(project_root, ".hive-state", "stages", "4-publish"))
+
+ rejected = create_task(project, "rejected-editorial", key: "creator:rejected")
+ rejected_approval = advance_to_approval(project_root, rejected.fetch("slug"))
+ reject_payload = decide(rejected.fetch("slug"), "reject", note: "Strengthen the evidence")
+ draft = task_folder(project_root, "2-draft", rejected.fetch("slug"))
+ reject_record = Hive::Commands::Decide.latest_record(File.join(draft, "approval.md"))
+
+ assert_equal false, reject_payload.fetch("completed")
+ assert_equal "2-draft", reject_payload.fetch("current_stage")
+ assert_equal :waiting, Hive::Markers.current(File.join(draft, "draft.md")).name
+ assert_equal "reject", reject_record.fetch("outcome")
+ assert_equal "draft", reject_record.fetch("to")
+ refute Dir.exist?(rejected_approval)
+ end
+ end
+
+ def test_ae3_collision_is_byte_identical_and_proposes_available_id
+ with_initialized_project do |project_root, _project|
+ create_editorial(project_root)
+ before = workflow_snapshot(project_root)
+
+ error = assert_raises(Hive::Commands::Workflow::UsageError) do
+ Hive::Commands::Workflow.new!(
+ "editorial", project_root: project_root, stdout: StringIO.new
+ )
+ end
+
+ assert_equal "editorial-2", error.suggested_id
+ assert_includes error.message, 'try "editorial-2"'
+ assert_equal before, workflow_snapshot(project_root)
+ end
+ end
+
+ def test_ae4_fresh_minimal_preview_is_no_write_until_confirmed
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ before = repository_snapshot(project_root)
+ preview_out, preview_err = capture_io do
+ Hive::Commands::Init.new(
+ project_root, new_workflow: "editorial", minimal: true, preview: true,
+ json: true, agent_skill_preflight: false
+ ).call
+ end
+ preview = JSON.parse(preview_out)
+
+ assert_empty preview_err
+ assert_equal before, repository_snapshot(project_root)
+ assert_equal false, preview.fetch("task_creation")
+ assert_equal false, preview.dig("services", "daemon_install")
+ assert_equal false, preview.dig("background_automation", "daemon_dispatch")
+ assert_schema_valid("hive-init-preview", preview)
+
+ execute_out, execute_err = capture_io do
+ Hive::Commands::Init.new(
+ project_root, new_workflow: "editorial", minimal: true,
+ json: true, agent_skill_preflight: false
+ ).call
+ end
+ execute = JSON.parse(execute_out)
+ assert_empty execute_err
+ assert_equal true, execute.fetch("minimal")
+
+ author_editorial(project_root)
+ validation = validate_editorial(project_root)
+ commit_editorial(project_root)
+ assert_equal %w[research draft approval], validation.fetch("stages").map { |stage| stage.fetch("name") }
+ assert_empty task_folders(project_root)
+ end
+ end
+ end
+
+ def test_ae5_explicit_task_creation_is_idempotent_after_movement
+ with_initialized_project do |project_root, project|
+ create_editorial(project_root)
+ key = "workflow-creator:editorial:stable"
+ first = create_task(project, "idempotent-editorial", key: key)
+ research = task_folder(project_root, "1-research", first.fetch("slug"))
+ Hive::Markers.set(File.join(research, "research.md"), :complete)
+ capture_io { Hive::Commands::Approve.new(first.fetch("slug"), from: "research").call }
+
+ retry_payload = create_task(project, "idempotent-editorial", key: key)
+ status_out, = capture_io { Hive::Commands::Status.new(json: true, operational: true).call }
+ operational = JSON.parse(status_out)
+ live = operational.fetch("tasks").find do |task|
+ task.dig("identity", "slug") == first.fetch("slug")
+ end
+
+ assert_equal true, first.fetch("created")
+ assert_equal false, retry_payload.fetch("created")
+ assert_equal first.fetch("slug"), retry_payload.fetch("slug")
+ assert_equal "2-draft", retry_payload.fetch("current_stage")
+ assert_equal 1, idempotent_task_folders(project_root, key).size
+ assert_equal "2-draft", live.dig("position", "stage")
+ assert_schema_valid("hive-new", retry_payload)
+ assert_schema_valid("hive-operational-status", operational)
+ end
+ end
+
+ def test_invalid_generated_yaml_stops_before_task_creation
+ with_initialized_project do |project_root, _project|
+ Hive::Commands::Workflow.new!(
+ "editorial", project_root: project_root, stdout: StringIO.new
+ )
+ descriptor = workflow_descriptor(project_root)
+ File.write(descriptor, "id: editorial\nstages: [\n")
+ before = workflow_snapshot(project_root)
+
+ assert_raises(Hive::ConfigError) { validate_editorial(project_root) }
+ assert_equal before, workflow_snapshot(project_root)
+ assert_empty task_folders(project_root)
+ end
+ end
+
+ private
+
+ def with_initialized_project
+ with_tmp_global_config do
+ with_tmp_git_repo do |project_root|
+ capture_io { Hive::Commands::Init.new(project_root, agent_skill_preflight: false).call }
+ yield project_root, File.basename(project_root)
+ end
+ end
+ end
+
+ def create_editorial(project_root, commands: nil, installed_version: Hive::VERSION)
+ commands&.push([ "version" ])
+ return :version_refused if Gem::Version.new(installed_version) < Gem::Version.new(Hive::VERSION)
+
+ commands&.push([ "workflow", "list", "--json" ])
+ Hive::Commands::Workflow.new(
+ "list", project_root: project_root, json: true, stdout: StringIO.new
+ ).call!
+ commands&.push([ "workflow", "new", "editorial" ])
+ Hive::Commands::Workflow.new!(
+ "editorial", project_root: project_root, stdout: StringIO.new
+ )
+ author_editorial(project_root)
+ commands&.push([ "workflow", "validate", "editorial", "--json" ])
+ validation = validate_editorial(project_root)
+ commands&.push([ "workflow", "commit", "editorial" ])
+ commit_editorial(project_root)
+ validation
+ end
+
+ def preview_fresh_creator(project_root, confirmed:, commands:)
+ commands.push([ "version" ])
+ commands.push(
+ [ "init", "--new-workflow", "editorial", "--minimal", "--preview", "--json" ]
+ )
+ capture_io do
+ Hive::Commands::Init.new(
+ project_root, new_workflow: "editorial", minimal: true, preview: true,
+ json: true, agent_skill_preflight: false
+ ).call
+ end
+ return :confirmation_required unless confirmed
+
+ raise "confirmed fresh creator execution is covered by AE4"
+ end
+
+ def author_editorial(project_root)
+ descriptor = workflow_descriptor(project_root)
+ instruction_dir = File.join(File.dirname(descriptor), "editorial")
+ FileUtils.rm_f(File.join(instruction_dir, "work.md"))
+ File.write(File.join(instruction_dir, "research.md"), "Research the request and write research.md.\n")
+ File.write(File.join(instruction_dir, "draft.md"), "Use research.md to write a publish-ready draft.md.\n")
+ File.write(descriptor, <<~YAML)
+ id: editorial
+ stages:
+ - name: research
+ kind: agent
+ state_file: research.md
+ instruction: editorial/research.md
+ permissions: yolo
+ - name: draft
+ kind: agent
+ state_file: draft.md
+ instruction: editorial/draft.md
+ permissions: yolo
+ - name: approval
+ kind: human
+ state_file: approval.md
+ input: draft.md
+ outcomes:
+ approve:
+ complete: true
+ artifact: draft.md
+ reject:
+ to: draft
+ YAML
+ Hive::Workflows::Project.reset!
+ end
+
+ def validate_editorial(project_root)
+ Hive::Commands::Workflow.new(
+ "validate", "editorial", project_root: project_root,
+ json: true, stdout: StringIO.new
+ ).call!
+ end
+
+ def commit_editorial(project_root)
+ Hive::Commands::Workflow.new(
+ "commit", "editorial", project_root: project_root, stdout: StringIO.new
+ ).call!
+ end
+
+ def create_task(project, slug, key:)
+ out, err = capture_io do
+ Hive::Commands::New.new(
+ project, EDITORIAL_PROMPT, slug_override: slug, workflow: "editorial",
+ idempotency_key: key, json: true
+ ).call!
+ end
+ assert_empty err
+ JSON.parse(out)
+ end
+
+ def advance_to_approval(project_root, slug)
+ research = task_folder(project_root, "1-research", slug)
+ Hive::Markers.set(File.join(research, "research.md"), :complete)
+ capture_io { Hive::Commands::Approve.new(slug, from: "research").call }
+ draft = task_folder(project_root, "2-draft", slug)
+ File.write(File.join(draft, "draft.md"), "# Draft\n\nPublishable copy.\n")
+ Hive::Markers.set(File.join(draft, "draft.md"), :complete)
+ capture_io { Hive::Commands::Approve.new(slug, from: "draft").call }
+ task_folder(project_root, "3-approval", slug)
+ end
+
+ def decide(slug, outcome, note:)
+ task = Hive::TaskResolver.new(slug).resolve
+ decision_id = Hive::Markers.current(task.state_file).attrs.fetch("decision_id")
+ out, err = capture_io do
+ Hive::Commands::Decide.new(
+ slug, outcome, from: "approval", decision_id: decision_id, note: note, json: true
+ ).call
+ end
+ assert_empty err
+ payload = JSON.parse(out)
+ assert_schema_valid("hive-decide", payload)
+ payload
+ end
+
+ def workflow_descriptor(project_root)
+ File.join(project_root, ".hive-state", "workflows", "editorial.yml")
+ end
+
+ def task_folder(project_root, stage, slug)
+ File.join(project_root, ".hive-state", "stages", stage, slug)
+ end
+
+ def task_folders(project_root)
+ Dir.glob(File.join(project_root, ".hive-state", "stages", "*", "*")).select do |folder|
+ File.file?(File.join(folder, "meta.yml"))
+ end
+ end
+
+ def idempotent_task_folders(project_root, key)
+ task_folders(project_root).select do |folder|
+ Hive::TaskMeta.read(folder)[:idempotency_key] == key
+ end
+ end
+
+ def workflow_snapshot(project_root)
+ root = File.join(project_root, ".hive-state", "workflows")
+ Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).sort.filter_map do |path|
+ next if File.directory?(path)
+
+ [ path.delete_prefix("#{root}/"), File.binread(path) ]
+ end
+ end
+
+ def repository_snapshot(project_root)
+ {
+ "head" => run!("git", "-C", project_root, "rev-parse", "HEAD").strip,
+ "status" => run!("git", "-C", project_root, "status", "--porcelain"),
+ "branches" => run!("git", "-C", project_root, "branch", "--format=%(refname:short)")
+ }
+ end
+
+ def assert_schema_valid(name, payload)
+ schema = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path(name))))
+ errors = schema.validate(payload).to_a
+ assert_empty errors, "#{name}: #{errors.inspect}"
+ end
+end
diff --git a/test/smoke/live_hive_workflow_creator_smoke_test.rb b/test/smoke/live_hive_workflow_creator_smoke_test.rb
new file mode 100644
index 0000000000..440a5093d4
--- /dev/null
+++ b/test/smoke/live_hive_workflow_creator_smoke_test.rb
@@ -0,0 +1,602 @@
+require "test_helper"
+require "digest"
+require "json"
+require "open3"
+require "pathname"
+require "rbconfig"
+require "timeout"
+require "yaml"
+require_relative "../../packaging/live_agent_skills/proof"
+
+# Authenticated, opt-in proof that OpenClaw discovers the exact candidate
+# /hive skill and follows its workflow-creator route. The success oracle is the
+# controlled Hive command stream plus the authored files and validation graph;
+# agent prose is never retained or trusted.
+class LiveHiveWorkflowCreatorSmokeTest < Minitest::Test
+ include HiveTestHelper
+
+ TIMEOUT_SEC = 240
+ PASSTHROUGH_ENV = %w[
+ PATH LANG LC_ALL TERM TMPDIR SSL_CERT_FILE SSL_CERT_DIR NODE_EXTRA_CA_CERTS
+ HTTP_PROXY HTTPS_PROXY NO_PROXY http_proxy https_proxy no_proxy
+ ].freeze
+
+ def test_openclaw_creates_and_validates_editorial_workflow_without_a_task
+ availability!(ENV["HIVE_LIVE_AGENT_SKILLS"] == "1",
+ "set HIVE_LIVE_AGENT_SKILLS=1 to run authenticated workflow-creator proof")
+ candidate_sha = ENV["HIVE_CANDIDATE_SHA"].to_s.downcase
+ artifacts = ENV["HIVE_PROOF_ARTIFACTS"].to_s
+ evidence_path = ENV["HIVE_CREATOR_EVIDENCE_PATH"].to_s
+ credential = ENV["OPENAI_API_KEY"].to_s
+ binary = find_executable("openclaw")
+ availability!(HiveLiveAgentProof::SAFE_SHA.match?(candidate_sha),
+ "HIVE_CANDIDATE_SHA must be a full commit SHA")
+ availability!(File.directory?(artifacts), "HIVE_PROOF_ARTIFACTS is missing")
+ availability!(!evidence_path.empty?, "HIVE_CREATOR_EVIDENCE_PATH is missing")
+ availability!(!credential.empty?, "OPENAI_API_KEY is unavailable")
+ availability!(!binary.nil?, "openclaw binary is not on PATH")
+
+ root = Dir.mktmpdir("hive-live-workflow-creator")
+ FileUtils.chmod(0o700, root)
+ evidence = base_evidence(candidate_sha)
+ failure = nil
+ begin
+ manifest = validate_candidate_artifacts!(artifacts, candidate_sha)
+ skill_root = extract_openclaw_skill!(root, artifacts, manifest)
+ workspace, environment, destination = prepare_openclaw_home(
+ root, skill_root, credential
+ )
+ audit_path = File.join(root, "hive-argv.jsonl")
+ validation_path = File.join(root, "workflow-validation.json")
+ task_proof_path = File.join(root, "task-proof.json")
+ install_controlled_hive(workspace, root, audit_path, validation_path, task_proof_path)
+ environment["PATH"] = [ File.join(root, "bin"), environment.fetch("PATH") ].join(File::PATH_SEPARATOR)
+
+ discovery = discover_openclaw_skill(binary, environment, workspace, destination, credential)
+ stdout, stderr, status = run_bounded(
+ environment,
+ [
+ binary, "agent", "--local", "--agent", "main",
+ "--message", HiveLiveAgentProof::WORKFLOW_CREATOR_PROMPT,
+ "--timeout", "180", "--json"
+ ],
+ chdir: workspace
+ )
+ assert status.success?,
+ "OpenClaw workflow creator failed: #{redact(stderr, credential).lines.first(12).join}\n" \
+ "#{redact(stdout, credential).lines.first(12).join}"
+
+ assert_equal HiveLiveAgentProof::WORKFLOW_CREATOR_COMMANDS.first(5),
+ read_audited_commands(audit_path)
+ validation = JSON.parse(File.read(validation_path))
+ assert_equal true, validation.fetch("valid")
+ created_files = created_file_records(workspace)
+ assert_equal HiveLiveAgentProof::WORKFLOW_CREATOR_FILES,
+ created_files.map { |record| record.fetch("path") }.sort
+ assert_equal 0, task_count(workspace)
+ task_stdout, task_stderr, task_status = run_bounded(
+ environment,
+ [
+ binary, "agent", "--local", "--agent", "main",
+ "--message", HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_PROMPT,
+ "--timeout", "180", "--json"
+ ],
+ chdir: workspace
+ )
+ assert task_status.success?,
+ "OpenClaw workflow task retry failed: #{redact(task_stderr, credential).lines.first(12).join}\n" \
+ "#{redact(task_stdout, credential).lines.first(12).join}"
+
+ commands = read_audited_commands(audit_path)
+ assert_equal HiveLiveAgentProof::WORKFLOW_CREATOR_COMMANDS, commands
+ task_proof = JSON.parse(File.read(task_proof_path))
+ assert_equal 1, task_count(workspace)
+ findings = HiveLiveAgentProof.secret_findings("#{stdout}\n#{stderr}", exact_secrets: [ credential ])
+ findings.concat(
+ HiveLiveAgentProof.secret_findings(
+ "#{task_stdout}\n#{task_stderr}", exact_secrets: [ credential ]
+ )
+ )
+ assert_empty findings, "OpenClaw retained output contains credential material"
+
+ projection = JSON.parse(File.read(File.join(skill_root, ".hive-skill.json")))
+ evidence.merge!(
+ "result" => "passed",
+ "prompt_sha256" => Digest::SHA256.hexdigest(HiveLiveAgentProof::WORKFLOW_CREATOR_PROMPT),
+ "task_prompt_sha256" =>
+ Digest::SHA256.hexdigest(HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_PROMPT),
+ "agent" => {
+ "binary" => "openclaw",
+ "native_output_sha256" => Digest::SHA256.hexdigest("#{stdout}\n#{task_stdout}"),
+ "native_stderr_sha256" => Digest::SHA256.hexdigest("#{stderr}\n#{task_stderr}"),
+ "discovery_sha256" => Digest::SHA256.hexdigest(JSON.generate(discovery))
+ },
+ "skill" => {
+ "skill_version" => projection.fetch("skill_version"),
+ "canonical_digest" => projection.fetch("canonical_digest"),
+ "projection_manifest_sha256" =>
+ Digest::SHA256.file(File.join(skill_root, ".hive-skill.json")).hexdigest
+ },
+ "native_activation" => {
+ "kind" => HiveLiveAgentProof::NATIVE_ACTIVATION_KINDS.fetch("openclaw"),
+ "invocation" => HiveLiveAgentProof::INVOCATIONS.fetch("openclaw")
+ },
+ "hive_commands" => commands,
+ "created_files" => created_files,
+ "validation" => normalized_validation(validation),
+ "creation_only_task_count" => 0,
+ "task_count" => 1,
+ "task" => task_proof,
+ "external_actions" => [],
+ "secret_scan" => { "status" => "passed", "scanner" => "hive-live-agent-proof/v1" }
+ )
+ rescue Minitest::Assertion, StandardError => e
+ failure = e
+ evidence["result"] = "failed"
+ evidence["failure"] = redact(e.message.to_s, credential).byteslice(0, 1_000)
+ evidence["secret_scan"] ||= { "status" => "passed", "scanner" => "hive-live-agent-proof/v1" }
+ ensure
+ FileUtils.rm_rf(root)
+ evidence["cleanup"] = { "status" => File.exist?(root) ? "failed" : "passed" }
+ write_evidence(evidence_path, evidence)
+ end
+ raise failure if failure
+ end
+
+ def test_controlled_hive_enforces_the_editorial_graph_and_command_order
+ with_tmp_dir do |root|
+ workspace = File.join(root, "workspace")
+ FileUtils.mkdir_p(workspace)
+ audit_path = File.join(root, "audit.jsonl")
+ validation_path = File.join(root, "validation.json")
+ task_proof_path = File.join(root, "task-proof.json")
+ binary = install_controlled_hive(
+ workspace, root, audit_path, validation_path, task_proof_path
+ )
+
+ run_controlled!(binary, workspace, "version")
+ run_controlled!(binary, workspace, "workflow", "list", "--json")
+ scaffold = JSON.parse(run_controlled!(binary, workspace, "workflow", "new", "editorial", "--json"))
+ author_editorial(scaffold)
+ validation = JSON.parse(
+ run_controlled!(binary, workspace, "workflow", "validate", "editorial", "--json")
+ )
+ run_controlled!(binary, workspace, "workflow", "commit", "editorial")
+ first = JSON.parse(
+ run_controlled!(binary, workspace, *HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_NEW_ARGV)
+ )
+ run_controlled!(binary, workspace, "run", HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_SLUG)
+ retry_payload = JSON.parse(
+ run_controlled!(binary, workspace, *HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_NEW_ARGV)
+ )
+ run_controlled!(binary, workspace, "status", "--operational", "--json")
+ task = JSON.parse(File.read(task_proof_path))
+
+ assert_equal HiveLiveAgentProof::WORKFLOW_CREATOR_COMMANDS, read_audited_commands(audit_path)
+ assert_equal %w[research draft approval], validation.fetch("stages").map { |stage| stage.fetch("name") }
+ assert_equal true, validation.fetch("valid")
+ assert_equal true, first.fetch("created")
+ assert_equal false, retry_payload.fetch("created")
+ assert_equal 1, task.fetch("run_count")
+ assert_equal 1, task_count(workspace)
+ end
+ end
+
+ private
+
+ def availability!(condition, message)
+ return if condition
+ raise Minitest::Assertion, message if ENV["HIVE_RELEASE_GATE"] == "1"
+
+ skip message
+ end
+
+ def base_evidence(candidate_sha)
+ {
+ "schema" => "hive-live-workflow-creator-evidence",
+ "schema_version" => 1,
+ "platform" => "openclaw",
+ "candidate_sha" => candidate_sha,
+ "result" => "failed"
+ }
+ end
+
+ def validate_candidate_artifacts!(root, candidate_sha)
+ manifest = HiveLiveAgentProof.read_json(File.join(root, "artifact-manifest.json"))
+ assert_equal "hive-live-agent-candidate-artifacts", manifest.fetch("schema")
+ assert_equal candidate_sha, manifest.fetch("candidate_sha")
+ manifest.fetch("files").each do |name, record|
+ HiveLiveAgentProof.verify_file_record!(root, name, record)
+ end
+ manifest
+ end
+
+ def extract_openclaw_skill!(root, artifacts, manifest)
+ archive_name = "hive-agent-skills-#{manifest.fetch('candidate_sha')}.tar.gz"
+ archive = HiveLiveAgentProof.relative_file!(artifacts, archive_name)
+ listing, list_error, list_status = Open3.capture3("tar", "-tzf", archive)
+ assert list_status.success?, "cannot list skill archive: #{list_error}"
+ entries = listing.lines.map(&:strip).reject(&:empty?)
+ assert entries.all? { |entry| safe_archive_entry?(entry) },
+ "skill archive contains an unsafe path"
+
+ extract = File.join(root, "projections")
+ FileUtils.mkdir_p(extract, mode: 0o700)
+ _out, extract_error, extract_status = Open3.capture3("tar", "-xzf", archive, "-C", extract)
+ assert extract_status.success?, "cannot extract skill archive: #{extract_error}"
+ skill_root = File.join(extract, "openclaw", "hive")
+ projection = JSON.parse(File.read(File.join(skill_root, ".hive-skill.json")))
+ assert_equal "openclaw", projection.fetch("platform")
+ assert_equal manifest.fetch("skill_version"), projection.fetch("skill_version")
+ assert_equal manifest.fetch("canonical_digest"), projection.fetch("canonical_digest")
+ projection.fetch("files").each do |relative, digest|
+ path = HiveLiveAgentProof.relative_file!(skill_root, relative)
+ assert_equal digest, Digest::SHA256.file(path).hexdigest
+ end
+ skill_root
+ end
+
+ def safe_archive_entry?(entry)
+ normalized = entry.delete_prefix("./")
+ !normalized.empty? && !normalized.start_with?("/") &&
+ !Pathname.new(normalized).each_filename.include?("..")
+ end
+
+ def prepare_openclaw_home(root, skill_root, credential)
+ home = File.join(root, "home")
+ workspace = File.join(root, "workspace")
+ state = File.join(home, ".openclaw")
+ destination = File.join(workspace, "skills", "hive")
+ FileUtils.mkdir_p([ state, workspace, File.dirname(destination) ], mode: 0o700)
+ FileUtils.cp_r(skill_root, destination, preserve: false)
+ model = ENV["HIVE_LIVE_MODEL"].to_s
+ availability!(!model.empty?, "HIVE_LIVE_MODEL is required for OpenClaw proof")
+ config = {
+ "agents" => {
+ "defaults" => { "workspace" => workspace, "model" => { "primary" => model } }
+ },
+ "tools" => { "profile" => "coding" }
+ }
+ config_path = File.join(state, "openclaw.json")
+ secure_write(config_path, JSON.pretty_generate(config) + "\n")
+ environment = PASSTHROUGH_ENV.to_h { |name| [ name, ENV[name] ] }.compact
+ environment.merge!(
+ "HOME" => home,
+ "OPENAI_API_KEY" => credential,
+ "OPENCLAW_STATE_DIR" => state,
+ "OPENCLAW_CONFIG_PATH" => config_path,
+ "HIVE_LIVE_PROOF" => "1"
+ )
+ [ workspace, environment, destination ]
+ end
+
+ def discover_openclaw_skill(binary, environment, workspace, destination, credential)
+ stdout, stderr, status = run_bounded(
+ environment, [ binary, "skills", "info", "hive", "--json" ],
+ chdir: workspace, timeout: 60
+ )
+ assert status.success?, "OpenClaw skill discovery failed: #{redact(stderr, credential)}"
+ info = JSON.parse(stdout)
+ assert_equal "hive", info.fetch("name")
+ assert_equal true, info.fetch("eligible")
+ assert_equal true, info.fetch("userInvocable")
+ assert_equal File.realpath(File.join(destination, "SKILL.md")),
+ File.realpath(info.fetch("filePath"))
+ {
+ "kind" => "openclaw-skills-info",
+ "name" => info.fetch("name"),
+ "file_path_sha256" => Digest::SHA256.hexdigest(info.fetch("filePath"))
+ }
+ end
+
+ def install_controlled_hive(workspace, root, audit_path, validation_path, task_proof_path)
+ state = File.join(workspace, ".hive-state")
+ workflows = File.join(state, "workflows")
+ FileUtils.mkdir_p(workflows, mode: 0o700)
+ secure_write(
+ File.join(state, "config.yml"),
+ YAML.dump(
+ "project_name" => "workflow-creator-proof",
+ "hive_state_path" => state,
+ "agents" => { "default" => "codex" },
+ "models" => { "default" => "project-default" }
+ )
+ )
+ bin_dir = File.join(root, "bin")
+ FileUtils.mkdir_p(bin_dir, mode: 0o700)
+ script = <<~RUBY
+ #!#{RbConfig.ruby}
+ require "json"
+ require "fileutils"
+ require "yaml"
+
+ allowed = #{HiveLiveAgentProof::WORKFLOW_CREATOR_COMMANDS.inspect}
+ audit = #{audit_path.dump}
+ ordinal = File.file?(audit) ? File.foreach(audit).count : 0
+ unless ARGV == allowed[ordinal]
+ warn "workflow-creator proof expected \#{allowed[ordinal].inspect}, got \#{ARGV.inspect}"
+ exit 64
+ end
+ File.open(audit, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |file|
+ file.puts(JSON.generate("argv" => ARGV, "ordinal" => ordinal + 1))
+ end
+
+ workspace = #{workspace.dump}
+ workflows = File.join(workspace, ".hive-state", "workflows")
+ descriptor = File.join(workflows, "editorial.yml")
+ instruction_dir = File.join(workflows, "editorial")
+ task_dir = File.join(
+ workspace, ".hive-state", "stages", "1-research",
+ #{HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_SLUG.dump}
+ )
+ case ARGV
+ when ["version"]
+ puts #{Hive::VERSION.dump}
+ when ["workflow", "list", "--json"]
+ puts JSON.generate(
+ "schema" => "hive-workflow-list", "schema_version" => 2,
+ "ok" => true, "workflows" => []
+ )
+ when ["workflow", "new", "editorial", "--json"]
+ abort "workflow already exists" if File.exist?(descriptor) || File.exist?(instruction_dir)
+ FileUtils.mkdir_p(instruction_dir)
+ File.write(File.join(instruction_dir, "work.md"), "Describe this stage.\\n")
+ File.write(descriptor, <<~YAML)
+ id: editorial
+ stages:
+ - name: work
+ kind: agent
+ state_file: work.md
+ instruction: editorial/work.md
+ permissions: yolo
+ YAML
+ puts JSON.generate(
+ "schema" => "hive-workflow-new", "schema_version" => 1, "ok" => true,
+ "id" => "editorial", "descriptor_path" => descriptor,
+ "instruction_path" => File.join(instruction_dir, "work.md"),
+ "next" => "hive new workflow-creator-proof --workflow editorial \\"<your idea>\\""
+ )
+ when ["workflow", "validate", "editorial", "--json"]
+ expected = {
+ "id" => "editorial",
+ "stages" => [
+ {
+ "name" => "research", "kind" => "agent", "state_file" => "research.md",
+ "instruction" => "editorial/research.md", "permissions" => "yolo"
+ },
+ {
+ "name" => "draft", "kind" => "agent", "state_file" => "draft.md",
+ "instruction" => "editorial/draft.md", "permissions" => "yolo"
+ },
+ {
+ "name" => "approval", "kind" => "human", "state_file" => "approval.md",
+ "input" => "draft.md",
+ "outcomes" => {
+ "approve" => { "complete" => true, "artifact" => "draft.md" },
+ "reject" => { "to" => "draft" }
+ }
+ }
+ ]
+ }
+ actual = YAML.safe_load(File.read(descriptor), aliases: false)
+ abort "editorial descriptor differs from the accepted graph" unless actual == expected
+ %w[research draft].each do |name|
+ path = File.join(instruction_dir, "\#{name}.md")
+ abort "missing or empty instruction \#{path}" unless File.file?(path) && !File.read(path).strip.empty?
+ end
+ expected_files = %w[draft.md research.md]
+ actual_files = Dir.children(instruction_dir).sort
+ abort "unexpected editorial instruction files: \#{actual_files.inspect}" unless actual_files == expected_files
+ payload = {
+ "schema" => "hive-workflow-validate", "schema_version" => 1,
+ "valid" => true, "id" => "editorial", "origin" => "authored",
+ "descriptor_path" => descriptor,
+ "instruction_paths" => expected_files.map { |name| File.join(instruction_dir, name) },
+ "stages" => [
+ { "name" => "research", "kind" => "agent" },
+ { "name" => "draft", "kind" => "agent" },
+ { "name" => "approval", "kind" => "human" }
+ ],
+ "automatic_edges" => [
+ { "from" => "research", "to" => "draft" },
+ { "from" => "draft", "to" => "approval" }
+ ],
+ "human_outcomes" => [
+ { "stage" => "approval", "name" => "approve", "complete" => true,
+ "artifact" => "draft.md", "to" => nil },
+ { "stage" => "approval", "name" => "reject", "complete" => false,
+ "artifact" => nil, "to" => "draft" }
+ ]
+ }
+ File.write(#{validation_path.dump}, JSON.pretty_generate(payload) + "\\n")
+ puts JSON.generate(payload)
+ when ["workflow", "commit", "editorial"]
+ abort "workflow was not validated before commit" unless File.file?(#{validation_path.dump})
+ puts "hive: committed populated workflow editorial"
+ when #{HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_NEW_ARGV.inspect}
+ created = !File.directory?(task_dir)
+ if created
+ FileUtils.mkdir_p(task_dir)
+ File.write(File.join(task_dir, "research.md"), "<!-- WAITING -->\\n")
+ File.write(
+ File.join(task_dir, "meta.yml"),
+ YAML.dump(
+ "slug" => #{HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_SLUG.dump},
+ "workflow" => "editorial",
+ "idempotency_key" => #{HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_KEY.dump}
+ )
+ )
+ proof = {
+ "slug" => #{HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_SLUG.dump},
+ "workflow" => "editorial", "first_created" => true,
+ "retry_created" => nil, "run_count" => 0, "current_stage" => "1-research"
+ }
+ else
+ proof = JSON.parse(File.read(#{task_proof_path.dump}))
+ proof["retry_created"] = false
+ end
+ File.write(#{task_proof_path.dump}, JSON.pretty_generate(proof) + "\\n")
+ puts JSON.generate(
+ "schema" => "hive-new", "schema_version" => 1, "ok" => true,
+ "created" => created, "slug" => proof.fetch("slug"), "workflow" => "editorial",
+ "current_stage" => "1-research", "task_folder" => task_dir,
+ "next_action" => {
+ "kind" => "run", "command" => "hive run \#{proof.fetch('slug')}"
+ }
+ )
+ when ["run", #{HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_SLUG.dump}]
+ abort "task is missing" unless File.directory?(task_dir)
+ proof = JSON.parse(File.read(#{task_proof_path.dump}))
+ abort "first stage was dispatched more than once" unless proof.fetch("run_count") == 0
+ proof["run_count"] = 1
+ File.write(#{task_proof_path.dump}, JSON.pretty_generate(proof) + "\\n")
+ puts JSON.generate(
+ "schema" => "hive-run", "schema_version" => 1, "ok" => true,
+ "slug" => proof.fetch("slug"), "current_stage" => "1-research"
+ )
+ when ["status", "--operational", "--json"]
+ proof = JSON.parse(File.read(#{task_proof_path.dump}))
+ abort "idempotent retry did not occur" unless proof["retry_created"] == false
+ File.write(#{task_proof_path.dump}, JSON.pretty_generate(proof) + "\\n")
+ puts JSON.generate(
+ "schema" => "hive-operational-status", "schema_version" => 1,
+ "tasks" => [
+ {
+ "identity" => { "slug" => proof.fetch("slug") },
+ "position" => { "stage" => "1-research" },
+ "runtime" => { "daemon_disposition" => "manual" },
+ "transition" => { "next" => "draft" }
+ }
+ ]
+ )
+ end
+ RUBY
+ path = File.join(bin_dir, "hive")
+ secure_write(path, script)
+ FileUtils.chmod(0o700, path)
+ path
+ end
+
+ def author_editorial(scaffold)
+ descriptor = scaffold.fetch("descriptor_path")
+ instruction_dir = File.dirname(scaffold.fetch("instruction_path"))
+ FileUtils.rm_f(File.join(instruction_dir, "work.md"))
+ secure_write(File.join(instruction_dir, "research.md"), "Research and write research.md.\n")
+ secure_write(File.join(instruction_dir, "draft.md"), "Draft from research.md into draft.md.\n")
+ secure_write(descriptor, <<~YAML)
+ id: editorial
+ stages:
+ - name: research
+ kind: agent
+ state_file: research.md
+ instruction: editorial/research.md
+ permissions: yolo
+ - name: draft
+ kind: agent
+ state_file: draft.md
+ instruction: editorial/draft.md
+ permissions: yolo
+ - name: approval
+ kind: human
+ state_file: approval.md
+ input: draft.md
+ outcomes:
+ approve:
+ complete: true
+ artifact: draft.md
+ reject:
+ to: draft
+ YAML
+ end
+
+ def run_controlled!(binary, workspace, *argv)
+ stdout, stderr, status = Open3.capture3(binary, *argv, chdir: workspace)
+ assert status.success?, stderr
+ stdout
+ end
+
+ def read_audited_commands(path)
+ return [] unless File.file?(path)
+
+ File.readlines(path, chomp: true).map { |line| JSON.parse(line).fetch("argv") }
+ end
+
+ def normalized_validation(payload)
+ {
+ "valid" => payload.fetch("valid"),
+ "stages" => payload.fetch("stages").map { |stage| stage.fetch("name") },
+ "automatic_edges" =>
+ payload.fetch("automatic_edges").map { |edge| edge.values_at("from", "to") },
+ "human_outcomes" => payload.fetch("human_outcomes")
+ }
+ end
+
+ def created_file_records(workspace)
+ HiveLiveAgentProof::WORKFLOW_CREATOR_FILES.map do |relative|
+ path = File.join(workspace, relative)
+ assert File.file?(path), "missing created file #{relative}"
+ {
+ "path" => relative,
+ "sha256" => Digest::SHA256.file(path).hexdigest,
+ "size" => File.size(path)
+ }
+ end
+ end
+
+ def task_count(workspace)
+ Dir.glob(File.join(workspace, ".hive-state", "stages", "*", "*"))
+ .count { |path| File.directory?(path) }
+ end
+
+ def run_bounded(environment, argv, chdir:, timeout: TIMEOUT_SEC)
+ stdout = +""
+ stderr = +""
+ status = nil
+ Open3.popen3(environment, *argv, chdir: chdir, pgroup: true, unsetenv_others: true) do |stdin, out, err, wait|
+ stdin.close
+ out_reader = Thread.new { out.read }
+ err_reader = Thread.new { err.read }
+ begin
+ Timeout.timeout(timeout) { status = wait.value }
+ rescue Timeout::Error
+ Process.kill("TERM", -wait.pid)
+ status = wait.value
+ raise Minitest::Assertion, "#{File.basename(argv.first)} exceeded #{timeout}s proof timeout"
+ ensure
+ stdout = out_reader.value
+ stderr = err_reader.value
+ end
+ end
+ [ stdout, stderr, status ]
+ end
+
+ def redact(text, *secrets)
+ redacted = text.to_s.dup
+ secrets.flatten.compact.reject(&:empty?).each { |secret| redacted.gsub!(secret, "[REDACTED]") }
+ HiveLiveAgentProof::SECRET_PATTERNS.each { |pattern| redacted.gsub!(pattern, "[REDACTED]") }
+ redacted
+ end
+
+ def secure_write(path, body)
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
+ File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |file| file.write(body) }
+ end
+
+ def write_evidence(path, evidence)
+ return if path.to_s.empty?
+
+ findings = HiveLiveAgentProof.secret_findings(JSON.generate(evidence))
+ evidence["secret_scan"] = {
+ "status" => "failed", "scanner" => "hive-live-agent-proof/v1"
+ } unless findings.empty?
+ HiveLiveAgentProof.write_json(path, evidence)
+ end
+
+ def find_executable(name)
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
+ path = File.join(directory, name)
+ return File.expand_path(path) if File.file?(path) && File.executable?(path)
+ end
+ nil
+ end
+end
diff --git a/test/unit/agent_skills/canonical_skill_test.rb b/test/unit/agent_skills/canonical_skill_test.rb
index 55d53c2480..bde42a9e06 100644
--- a/test/unit/agent_skills/canonical_skill_test.rb
+++ b/test/unit/agent_skills/canonical_skill_test.rb
@@ -17,13 +17,38 @@ def test_canonical_metadata_frontmatter_and_references_are_strict
assert_match(/\A[0-9a-f]{64}\z/, skill.canonical_digest)
assert_equal %w[description name], skill.frontmatter.keys.sort
assert_operator skill.body.lines.size, :<, 120
- assert_equal 6, skill.reference_paths.size
+ assert_equal 14, skill.reference_paths.size
assert skill.reference_paths.all? { |path| path.start_with?("references/") }
refute_includes skill.rendered_canonical_files.values.join("\n"), "{{HIVE_VERSION}}"
assert_includes skill.rendered_canonical_files.fetch("references/setup-and-platforms.md"),
"/v#{Hive::VERSION}/install.sh"
end
+ def test_workflow_creator_contract_gates_mutation_and_reports_every_side_effect
+ files = Hive::AgentSkills::CanonicalSkill.new.rendered_canonical_files
+ route = files.fetch("SKILL.md")
+ creator = files.fetch("references/workflow-creator.md")
+
+ assert_includes route, "hive-workflow-creator"
+ assert_includes creator, "minimum Hive version: #{Hive::VERSION}"
+ assert_operator creator.index("hive version"), :<, creator.index("hive workflow list --json")
+ assert_operator creator.index("hive workflow list --json"), :<, creator.index("hive workflow new")
+ assert_operator creator.index("hive workflow validate"), :<, creator.index("hive workflow commit")
+ assert_operator creator.index("hive workflow commit"), :<, creator.index("hive new")
+ assert_includes creator, "No task by default"
+ assert_includes creator, "created files"
+ assert_includes creator, "applied defaults"
+ assert_includes creator, "validation result"
+ assert_includes creator, "hive update"
+ assert_includes creator, "idempotency-key"
+ assert_includes creator, "workflow-creator:v1:<64 lowercase hex>"
+ assert_includes creator, "JSON.generate([project, workflow, request])"
+ assert_includes creator, "POSIX `Shellwords.escape` semantics"
+ assert_includes creator, %q(replace each embedded `'` with the exact shell sequence `'"'"'`)
+ assert_includes creator, "Never publish externally"
+ refute_match(/hive init[^\n]*--force/, creator)
+ end
+
def test_four_platform_projections_share_payload_but_keep_thin_wrappers
skill = Hive::AgentSkills::CanonicalSkill.new
projections = %w[openclaw claude codex pi].to_h do |platform|
diff --git a/test/unit/cli_test.rb b/test/unit/cli_test.rb
index 730ad6a0cd..effba71054 100644
--- a/test/unit/cli_test.rb
+++ b/test/unit/cli_test.rb
@@ -96,7 +96,7 @@ def test_init_forget_prune_update_uninstall_and_migrate_pass_options
assert_equal [ "/tmp/project" ], calls.first.fetch(:args)
assert_equal({
force: true, json: true, workflow: "content_fixture",
- new_workflow: nil, refactor_patrol: nil
+ new_workflow: nil, refactor_patrol: nil, minimal: false, preview: false
},
calls.first.fetch(:kwargs))
assert_equal :call, calls.last
@@ -107,12 +107,22 @@ def test_init_forget_prune_update_uninstall_and_migrate_pass_options
assert_equal [ "/tmp/project" ], calls.first.fetch(:args)
assert_equal({
force: false, json: false, workflow: nil,
- new_workflow: "writing", refactor_patrol: nil
+ new_workflow: "writing", refactor_patrol: nil, minimal: false, preview: false
},
calls.first.fetch(:kwargs))
assert_equal :call, calls.last
end
+ with_command_new_stub(Hive::Commands::Init) do |calls|
+ Hive::CLI.start([
+ "init", "/tmp/project", "--new-workflow", "writing", "--minimal", "--preview", "--json"
+ ])
+ assert_equal true, calls.first.fetch(:kwargs).fetch(:minimal)
+ assert_equal true, calls.first.fetch(:kwargs).fetch(:preview)
+ assert_equal true, calls.first.fetch(:kwargs).fetch(:json)
+ assert_equal :call, calls.last
+ end
+
with_command_new_stub(Hive::Commands::Init) do |calls|
Hive::CLI.start([ "init", "/tmp/project", "--no-refactor-patrol" ])
assert_equal false, calls.first.fetch(:kwargs).fetch(:refactor_patrol)
@@ -250,13 +260,25 @@ def test_new_task_dispatches_joined_text_and_rejects_blank_text
with_command_new_stub(Hive::Commands::New) do |calls|
Hive::CLI.start([ "new", "proj", "build", "thing" ])
assert_equal [ "proj", "build thing" ], calls.first.fetch(:args)
- assert_equal({ base: nil, depends_on: nil, workflow: nil }, calls.first.fetch(:kwargs))
+ assert_equal(
+ { base: nil, depends_on: nil, workflow: nil, idempotency_key: nil, json: false },
+ calls.first.fetch(:kwargs)
+ )
end
with_command_new_stub(Hive::Commands::New) do |calls|
- Hive::CLI.start([ "new", "proj", "--depends-on", "base-task", "--workflow", "content_fixture", "build", "thing" ])
+ Hive::CLI.start([
+ "new", "proj", "--depends-on", "base-task", "--workflow", "content_fixture",
+ "--idempotency-key", "creator:v1", "--json", "build", "thing"
+ ])
assert_equal [ "proj", "build thing" ], calls.first.fetch(:args)
- assert_equal({ base: nil, depends_on: "base-task", workflow: "content_fixture" }, calls.first.fetch(:kwargs))
+ assert_equal(
+ {
+ base: nil, depends_on: "base-task", workflow: "content_fixture",
+ idempotency_key: "creator:v1", json: true
+ },
+ calls.first.fetch(:kwargs)
+ )
end
_out, err, status = with_captured_exit { Hive::CLI.start([ "new", "proj" ]) }
diff --git a/test/unit/commands/approve_test.rb b/test/unit/commands/approve_test.rb
index 7197097469..023d638703 100644
--- a/test/unit/commands/approve_test.rb
+++ b/test/unit/commands/approve_test.rb
@@ -39,6 +39,122 @@ def test_observation_guard_runs_under_commit_and_task_locks_before_move
end
end
+ def test_restore_human_destination_handles_existing_new_and_missing_folders
+ with_tmp_dir do |root|
+ source = File.join(root, "source")
+ destination = File.join(root, "destination")
+ FileUtils.mkdir_p(source)
+ FileUtils.mkdir_p(destination)
+ current = task(folder: source)
+ state = File.join(source, "approval.md")
+ File.write(state, "changed")
+
+ command.send(
+ :restore_human_destination!, current, destination,
+ { state_file: "approval.md", existed: true, body: "original" }
+ )
+ assert_equal "original", File.read(state)
+
+ FileUtils.rm_rf(source)
+ destination_state = File.join(destination, "approval.md")
+ File.write(destination_state, "created")
+ command.send(
+ :restore_human_destination!, current, destination,
+ { state_file: "approval.md", existed: false, body: nil }
+ )
+ refute File.exist?(destination_state)
+
+ FileUtils.rm_rf(destination)
+ assert_nil command.send(
+ :restore_human_destination!, current, destination,
+ { state_file: "approval.md", existed: false, body: nil }
+ )
+ assert_nil command.send(:restore_human_destination!, current, destination, nil)
+ end
+ end
+
+ def test_initialize_human_destination_refuses_symlinked_state_file
+ with_tmp_dir do |root|
+ source = File.join(root, "source")
+ outside = File.join(root, "outside.md")
+ FileUtils.mkdir_p(source)
+ File.write(outside, "external bytes\n")
+ File.symlink(outside, File.join(source, "approval.md"))
+ current = task(folder: source, workflow: human_workflow)
+
+ error = assert_raises(Hive::InvalidTaskPath) do
+ command.send(:initialize_human_destination!, current, "2-approval")
+ end
+
+ assert_includes error.message, "must be a regular file, not a symlink"
+ assert_equal "external bytes\n", File.read(outside)
+ assert File.symlink?(File.join(source, "approval.md"))
+ end
+ end
+
+ def test_human_state_snapshot_rejects_file_swaps_and_nofollow_errors
+ with_tmp_dir do |root|
+ path = File.join(root, "approval.md")
+ File.write(path, "owner approval\n")
+ observed = File.lstat(path)
+ swapped_stat = Struct.new(:dev, :ino) do
+ def file? = true
+ end.new(observed.dev, observed.ino + 1)
+ swapped_file = Object.new
+ swapped_file.define_singleton_method(:stat) { swapped_stat }
+ swapped_file.define_singleton_method(:read) { "replacement\n" }
+
+ with_replaced_singleton_method(
+ File, :open, ->(_path, *_args, &block) { block.call(swapped_file) }
+ ) do
+ error = assert_raises(Hive::InvalidTaskPath) do
+ command.send(:read_human_state_snapshot!, path, "approval.md")
+ end
+ assert_includes error.message, "changed while entering the stage"
+ end
+
+ with_replaced_singleton_method(
+ File, :open, ->(*_args) { raise Errno::ELOOP, path }
+ ) do
+ error = assert_raises(Hive::InvalidTaskPath) do
+ command.send(:read_human_state_snapshot!, path, "approval.md")
+ end
+ assert_includes error.message, "must be a regular file, not a symlink"
+ end
+ end
+ end
+
+ def test_interrupted_human_stage_commit_restores_move_and_destination_state
+ with_tmp_dir do |root|
+ state = File.join(root, ".hive-state")
+ source = File.join(state, "stages", "1-draft", "slug-260522-abcd")
+ destination = File.join(state, "stages", "2-approval", "slug-260522-abcd")
+ FileUtils.mkdir_p(source)
+ File.write(File.join(source, "draft.md"), "# Draft\n")
+ original = "# Approval instructions\n"
+ File.write(File.join(source, "approval.md"), original)
+ current = task(
+ folder: source, hive_state_path: state, project_root: root,
+ stage_index: 1, stage_name: "draft", workflow: human_workflow
+ )
+ cmd = command
+ cmd.define_singleton_method(:record_hive_commit) { |*| raise Interrupt, "stop" }
+ fake_ops = Object.new
+ fake_ops.define_singleton_method(:run_git!) { |*| nil }
+
+ with_replaced_singleton_method(Hive::GitOps, :new, ->(*) { fake_ops }) do
+ assert_raises(Interrupt) do
+ cmd.send(:perform_move_and_commit, current, "2-approval")
+ end
+ end
+
+ assert File.directory?(source)
+ refute File.exist?(destination)
+ assert_equal original, File.binread(File.join(source, "approval.md"))
+ assert_equal :none, Hive::Markers.current(File.join(source, "approval.md")).name
+ end
+ end
+
def test_inert_terminal_entry_writes_completed_at_with_the_move
with_tmp_dir do |root|
state = File.join(root, ".hive-state")
@@ -204,6 +320,25 @@ def terminal_workflow
)
end
+ def human_workflow
+ Hive::Workflow.new(
+ id: :editorial,
+ stages: [
+ Hive::Workflow::Stage.new(
+ name: "draft", index: 1, state_file: "draft.md", kind: :agent
+ ),
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 2, state_file: "approval.md", kind: :human,
+ outcomes: {
+ "approve" => Hive::Workflow::Outcome.new(
+ name: "approve", complete: true, artifact: "draft.md"
+ )
+ }.freeze
+ )
+ ]
+ )
+ end
+
def failing_status
status = Object.new
status.define_singleton_method(:success?) { false }
diff --git a/test/unit/commands/init/prompts_test.rb b/test/unit/commands/init/prompts_test.rb
index a8f85ad046..f7b879a312 100644
--- a/test/unit/commands/init/prompts_test.rb
+++ b/test/unit/commands/init/prompts_test.rb
@@ -122,6 +122,23 @@ def test_non_tty_returns_recommended_defaults
assert_equal all_defaults, answers
end
+ def test_minimal_defaults_disable_optional_automation
+ answers = Hive::Commands::Init::Prompts.minimal_defaults
+
+ assert_equal [], answers.fetch("enabled_reviewers")
+ assert_equal [], answers.fetch("patrol_reviewers")
+ assert_equal "off", answers.fetch("patrol_mode")
+ assert_equal false, answers.fetch("adhoc_auto_fix")
+ assert_equal false, answers.fetch("refactor_patrol_enabled")
+ assert_equal false, answers.fetch("daemon_enabled")
+ assert_equal false, answers.fetch("babysitter_enabled")
+ assert_equal false, answers.fetch("daemon_autostart")
+ assert_equal Hive::Commands::Init::Prompts::LIMIT_KEYS.sort,
+ answers.fetch("budgets").keys.sort
+ assert_equal Hive::Commands::Init::Prompts::LIMIT_KEYS.sort,
+ answers.fetch("timeouts").keys.sort
+ end
+
def test_non_tty_recommends_refactor_patrol_without_arming_legacy_config
prompts, _output = make_prompts("", tty: false)
answers = prompts.collect
diff --git a/test/unit/commands/run_test.rb b/test/unit/commands/run_test.rb
index 2c0b9b31a7..4b6fb8eb1f 100644
--- a/test/unit/commands/run_test.rb
+++ b/test/unit/commands/run_test.rb
@@ -657,6 +657,33 @@ def test_report_text_covers_manual_and_stale_guidance
assert_includes out, "manual steering active"
end
+ def test_report_text_human_stage_lists_decision_outcomes
+ outcomes = {
+ "approve" => Hive::Workflow::Outcome.new(name: "approve", complete: true, artifact: "draft.md"),
+ "reject" => Hive::Workflow::Outcome.new(name: "reject", to: "draft")
+ }.freeze
+ workflow = Hive::Workflow.new(
+ id: :editorial,
+ stages: [
+ Hive::Workflow::Stage.new(
+ name: "draft", index: 1, state_file: "draft.md", kind: :agent
+ ),
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 2, state_file: "approval.md", kind: :human,
+ outcomes: outcomes
+ )
+ ]
+ )
+ t = task(stage_name: "approval", stage_index: 2, workflow: workflow)
+
+ out, = capture_io do
+ command.send(:report_text, t, {}, marker(:waiting))
+ end
+
+ assert_includes out, "outcomes: approve, reject"
+ assert_includes out, "hive decide some-slug <outcome> --from approval --decision-id"
+ end
+
def test_report_text_review_error_prints_phase_and_reason_before_raising
run = command
t = task
diff --git a/test/unit/commands/workflow_new_test.rb b/test/unit/commands/workflow_new_test.rb
index e8effe4b9f..76bbac4213 100644
--- a/test/unit/commands/workflow_new_test.rb
+++ b/test/unit/commands/workflow_new_test.rb
@@ -60,6 +60,73 @@ def test_scaffolds_blank_workflow_descriptor_and_instruction
end
end
+ def test_commit_validates_and_commits_populated_authored_workflow
+ with_initialized_project do |project_root|
+ Hive::Commands::Workflow.new!(
+ "my-flow", project_root: project_root, stdout: StringIO.new
+ )
+ instruction = File.join(
+ project_root, ".hive-state", "workflows", "my-flow", "work.md"
+ )
+ File.write(instruction, "Write the owner-defined deliverable.\n")
+ stdout = StringIO.new
+
+ payload = Hive::Commands::Workflow.new(
+ "commit", "my-flow", project_root: project_root, stdout: stdout
+ ).call!
+
+ assert_equal true, payload.fetch("committed")
+ assert_includes stdout.string, "committed populated workflow my-flow"
+ state = File.join(project_root, ".hive-state")
+ assert_equal "hive: workflows/my-flow defined",
+ run!("git", "-C", state, "log", "-1", "--format=%s").strip
+ assert_equal "Write the owner-defined deliverable.\n",
+ run!("git", "-C", state, "show", "HEAD:workflows/my-flow/work.md")
+ assert_empty run!(
+ "git", "-C", state, "status", "--porcelain", "--",
+ "workflows/my-flow.yml", "workflows/my-flow"
+ )
+
+ retry_stdout = StringIO.new
+ retry_payload = Hive::Commands::Workflow.new(
+ "commit", "my-flow", project_root: project_root, stdout: retry_stdout
+ ).call!
+ assert_equal false, retry_payload.fetch("committed")
+ assert_includes retry_stdout.string, "populated workflow my-flow is already committed"
+ end
+ end
+
+ def test_failed_populated_workflow_commit_clears_its_staged_entries
+ with_initialized_project do |project_root|
+ Hive::Commands::Workflow.new!(
+ "my-flow", project_root: project_root, stdout: StringIO.new
+ )
+ state = File.join(project_root, ".hive-state")
+ instruction = File.join(state, "workflows", "my-flow", "work.md")
+ File.write(instruction, "Changed owner instructions.\n")
+ runner = method(:run!)
+ fake_ops = Object.new
+ fake_ops.define_singleton_method(:hive_commit) do |pathspecs:, **|
+ runner.call("git", "-C", state, "add", "--", *pathspecs)
+ raise Hive::GitError, "commit failed after staging"
+ end
+ fake_ops.define_singleton_method(:run_git!) { |*args| runner.call("git", *args) }
+
+ with_replaced_singleton_method(Hive::GitOps, :new, ->(_root) { fake_ops }) do
+ assert_raises(Hive::GitError) do
+ Hive::Commands::Workflow.new(
+ "commit", "my-flow", project_root: project_root, stdout: StringIO.new
+ ).call!
+ end
+ end
+
+ assert_equal "Changed owner instructions.\n", File.read(instruction)
+ assert_empty run!("git", "-C", state, "diff", "--cached", "--name-only")
+ assert_includes run!("git", "-C", state, "status", "--porcelain"),
+ "workflows/my-flow/work.md"
+ end
+ end
+
def test_scaffolds_keyword_like_id_with_quoted_descriptor_so_it_parses
# `no`/`yes`/`off` are valid SAFE_SLUG ids that YAML.safe_load coerces to
# booleans when the descriptor's `id:` is emitted unquoted, failing
@@ -181,6 +248,48 @@ def test_refuses_to_overwrite_existing_scaffold
end
end
+ def test_refuses_dangling_descriptor_and_instruction_symlink_collisions
+ with_initialized_project do |project_root|
+ workflows = File.join(project_root, ".hive-state", "workflows")
+ outside = File.join(project_root, "outside-target")
+ FileUtils.mkdir_p(workflows)
+
+ {
+ "linked-descriptor" => File.join(workflows, "linked-descriptor.yml"),
+ "linked-instructions" => File.join(workflows, "linked-instructions")
+ }.each do |id, collision|
+ File.symlink(outside, collision)
+ error = assert_raises(Hive::Commands::Workflow::UsageError) do
+ Hive::Commands::Workflow.new!(id, project_root: project_root, stdout: StringIO.new)
+ end
+
+ assert_includes error.message, "already exists"
+ assert File.symlink?(collision)
+ refute File.exist?(outside)
+ end
+ end
+ end
+
+ def test_refuses_a_symlinked_workflow_scaffold_root
+ with_initialized_project do |project_root|
+ workflows = File.join(project_root, ".hive-state", "workflows")
+ outside = File.join(project_root, "outside-workflows")
+ FileUtils.rm_rf(workflows)
+ FileUtils.mkdir_p(outside)
+ File.symlink(outside, workflows)
+
+ error = assert_raises(Hive::ConfigError) do
+ Hive::Commands::Workflow.new!(
+ "my-flow", project_root: project_root, stdout: StringIO.new
+ )
+ end
+
+ assert_includes error.message, "scaffold root"
+ assert File.symlink?(workflows)
+ assert_empty Dir.children(outside)
+ end
+ end
+
def test_json_error_payload
with_initialized_project do |project_root|
out, err, status = with_captured_exit do
@@ -240,7 +349,7 @@ def test_json_envelopes_validate_against_published_schema
Hive::Commands::Workflow.new(nil, nil, project_root: project_root, json: true).call
end
missing_payload = JSON.parse(missing_out)
- assert_equal %w[new install list update remove publish], missing_payload.fetch("expected")
+ assert_equal %w[new validate commit install list update remove publish], missing_payload.fetch("expected")
missing_errors = schemer.validate(missing_payload).map { |e| e["error"] }
assert_empty missing_errors,
"hive-workflow-new usage ErrorPayload (with `expected`) must validate " \
@@ -251,7 +360,7 @@ def test_json_envelopes_validate_against_published_schema
end
unknown_payload = JSON.parse(unknown_out)
assert_equal "bogus", unknown_payload.fetch("value")
- assert_equal %w[new install list update remove publish], unknown_payload.fetch("expected")
+ assert_equal %w[new validate commit install list update remove publish], unknown_payload.fetch("expected")
unknown_errors = schemer.validate(unknown_payload).map { |e| e["error"] }
assert_empty unknown_errors,
"hive-workflow-new usage ErrorPayload (with `value` AND `expected`) must " \
@@ -302,6 +411,55 @@ def test_json_git_error_payload_classifies_git
end
end
+ def test_failed_scaffold_commit_clears_its_staged_index_entries
+ with_initialized_project do |project_root|
+ hive_state = File.join(project_root, ".hive-state")
+ runner = method(:run!)
+ fake_ops = Object.new
+ fake_ops.define_singleton_method(:hive_commit) do |pathspecs:, **|
+ runner.call("git", "-C", hive_state, "add", "--", *pathspecs)
+ raise Hive::GitError, "commit failed after staging"
+ end
+ fake_ops.define_singleton_method(:run_git!) { |*args| runner.call("git", *args) }
+
+ with_replaced_singleton_method(Hive::GitOps, :new, ->(_root) { fake_ops }) do
+ assert_raises(Hive::GitError) do
+ Hive::Commands::Workflow.new!(
+ "failed-flow", project_root: project_root, stdout: StringIO.new
+ )
+ end
+ end
+
+ refute File.exist?(File.join(hive_state, "workflows", "failed-flow.yml"))
+ assert_empty run!("git", "-C", hive_state, "diff", "--cached", "--name-only")
+ end
+ end
+
+ def test_interrupted_scaffold_commit_clears_staged_entries_and_owned_files
+ with_initialized_project do |project_root|
+ hive_state = File.join(project_root, ".hive-state")
+ runner = method(:run!)
+ fake_ops = Object.new
+ fake_ops.define_singleton_method(:hive_commit) do |pathspecs:, **|
+ runner.call("git", "-C", hive_state, "add", "--", *pathspecs)
+ raise Interrupt, "stop"
+ end
+ fake_ops.define_singleton_method(:run_git!) { |*args| runner.call("git", *args) }
+
+ with_replaced_singleton_method(Hive::GitOps, :new, ->(_root) { fake_ops }) do
+ assert_raises(Interrupt) do
+ Hive::Commands::Workflow.new!(
+ "interrupted-flow", project_root: project_root, stdout: StringIO.new
+ )
+ end
+ end
+
+ refute File.exist?(File.join(hive_state, "workflows", "interrupted-flow.yml"))
+ refute File.exist?(File.join(hive_state, "workflows", "interrupted-flow"))
+ assert_empty run!("git", "-C", hive_state, "diff", "--cached", "--name-only")
+ end
+ end
+
def test_json_concurrent_run_error_payload_classifies_concurrent
with_initialized_project do |project_root|
out, err, status = with_replaced_singleton_method(
@@ -322,14 +480,14 @@ def test_json_concurrent_run_error_payload_classifies_concurrent
def test_json_disk_write_error_rides_the_envelope_as_error_kind
with_initialized_project do |project_root|
out, err, status = with_replaced_singleton_method(
- FileUtils, :mkdir_p, ->(*_args) { raise Errno::EACCES, "denied" }
+ Dir, :mkdir, ->(*_args) { raise Errno::EACCES, "denied" }
) do
with_captured_exit do
Hive::Commands::Workflow.new("new", "disk-flow", project_root: project_root, json: true).call
end
end
- # The disk fault from write_scaffold!'s mkdir_p must ride the JSON envelope
+ # The disk fault from write_scaffold!'s exclusive mkdir must ride the JSON envelope
# (error_kind=error, no raw backtrace on stderr), not escape to bin/hive.
assert_empty err
payload = JSON.parse(out)
@@ -434,6 +592,32 @@ def test_scaffold_files_rolls_back_when_generated_descriptor_fails_validation
end
end
+ def test_scaffold_race_preserves_a_foreign_descriptor
+ with_initialized_project do |project_root|
+ workflows = File.join(project_root, ".hive-state", "workflows")
+ instruction_dir = File.join(workflows, "raced-flow")
+ descriptor = File.join(workflows, "raced-flow.yml")
+ original_mkdir = Dir.method(:mkdir)
+
+ replacement = lambda do |path, *args|
+ result = original_mkdir.call(path, *args)
+ File.write(descriptor, "foreign descriptor\n") if path == instruction_dir
+ result
+ end
+ with_replaced_singleton_method(Dir, :mkdir, replacement) do
+ assert_raises(Errno::EEXIST) do
+ Hive::Commands::Workflow.scaffold_files!("raced-flow", project_root: project_root)
+ end
+ end
+
+ assert_equal "foreign descriptor\n", File.read(descriptor)
+ refute File.exist?(instruction_dir),
+ "rollback should remove only the instruction directory this invocation created"
+ ensure
+ FileUtils.rm_f(descriptor) if descriptor
+ end
+ end
+
def test_rolls_back_files_when_generated_descriptor_fails_validation
with_initialized_project do |project_root|
error = Hive::ConfigError.new("boom")
@@ -459,7 +643,7 @@ def test_call_reports_unknown_subcommand_as_usage
assert_equal Hive::ExitCodes::USAGE, status
assert_empty out
- assert_equal "hive workflow: unknown workflow subcommand \"save\" (expected: new, install, list, update, remove, publish)\n", err
+ assert_equal "hive workflow: unknown workflow subcommand \"save\" (expected: new, validate, commit, install, list, update, remove, publish)\n", err
end
end
@@ -471,7 +655,7 @@ def test_call_reports_missing_subcommand_as_usage
assert_equal Hive::ExitCodes::USAGE, status
assert_empty out
- assert_equal "hive workflow: missing SUBCOMMAND (expected: new, install, list, update, remove, publish)\n", err
+ assert_equal "hive workflow: missing SUBCOMMAND (expected: new, validate, commit, install, list, update, remove, publish)\n", err
end
end
@@ -488,8 +672,8 @@ def test_json_reports_missing_subcommand_with_expected_values
assert_equal "UsageError", payload.fetch("error_class")
assert_equal "usage", payload.fetch("error_kind")
assert_equal Hive::ExitCodes::USAGE, payload.fetch("exit_code")
- assert_equal "missing SUBCOMMAND (expected: new, install, list, update, remove, publish)", payload.fetch("message")
- assert_equal %w[new install list update remove publish], payload.fetch("expected")
+ assert_equal "missing SUBCOMMAND (expected: new, validate, commit, install, list, update, remove, publish)", payload.fetch("message")
+ assert_equal %w[new validate commit install list update remove publish], payload.fetch("expected")
refute payload.key?("value")
end
end
@@ -507,9 +691,9 @@ def test_json_reports_unknown_subcommand_with_value_and_expected_values
assert_equal "UsageError", payload.fetch("error_class")
assert_equal "usage", payload.fetch("error_kind")
assert_equal Hive::ExitCodes::USAGE, payload.fetch("exit_code")
- assert_equal "unknown workflow subcommand \"bogus\" (expected: new, install, list, update, remove, publish)", payload.fetch("message")
+ assert_equal "unknown workflow subcommand \"bogus\" (expected: new, validate, commit, install, list, update, remove, publish)", payload.fetch("message")
assert_equal "bogus", payload.fetch("value")
- assert_equal %w[new install list update remove publish], payload.fetch("expected")
+ assert_equal %w[new validate commit install list update remove publish], payload.fetch("expected")
end
end
@@ -570,6 +754,23 @@ def test_rollback_scaffold_swallows_epipe_from_the_cleanup_warning
end
end
+ def test_rollback_scaffold_supports_legacy_path_sets_without_ownership_metadata
+ with_tmp_dir do |root|
+ instruction_dir = File.join(root, "legacy-flow")
+ descriptor = File.join(root, "legacy-flow.yml")
+ FileUtils.mkdir_p(instruction_dir)
+ File.write(File.join(instruction_dir, "work.md"), "work\n")
+ File.write(descriptor, "id: legacy-flow\n")
+
+ Hive::Commands::Workflow.rollback_scaffold(
+ instruction_dir: instruction_dir, descriptor: descriptor
+ )
+
+ refute File.exist?(instruction_dir)
+ refute File.exist?(descriptor)
+ end
+ end
+
private
def with_initialized_project
diff --git a/test/unit/gemspec_test.rb b/test/unit/gemspec_test.rb
index 1729ed117c..272dc85370 100644
--- a/test/unit/gemspec_test.rb
+++ b/test/unit/gemspec_test.rb
@@ -43,6 +43,24 @@ def test_gem_package_includes_every_canonical_hive_skill_asset
expected.each { |path| assert_includes spec.files, path }
end
+ def test_gem_package_includes_workflow_creator_references
+ spec = Gem::Specification.load(GEMSPEC_PATH)
+ references = %w[
+ workflow-creator.md
+ workflow-creator-example.md
+ workflow-schema.md
+ workflow-stage-design.md
+ workflow-checkpoints.md
+ workflow-permissions.md
+ workflow-testing.md
+ workflow-common-mistakes.md
+ ]
+
+ references.each do |name|
+ assert_includes spec.files, "skills/hive/references/#{name}"
+ end
+ end
+
def test_gem_package_includes_metadata_for_managed_web_path_dependency
spec = Gem::Specification.load(GEMSPEC_PATH)
diff --git a/test/unit/llm_wiki_bootstrap_test.rb b/test/unit/llm_wiki_bootstrap_test.rb
index 8d40931e18..e278dbf347 100644
--- a/test/unit/llm_wiki_bootstrap_test.rb
+++ b/test/unit/llm_wiki_bootstrap_test.rb
@@ -35,4 +35,35 @@ def test_primary_worktree_root_reports_git_resolution_failure
assert_match(/cannot resolve primary Git worktree/, error.message)
assert_match(/not a Git repository/, error.message)
end
+
+ def test_managed_path_validation_rejects_a_regular_file_parent
+ with_tmp_dir do |root|
+ parent = File.join(root, ".llm-wiki")
+ File.write(parent, "not a directory\n")
+
+ error = assert_raises(Hive::ConfigError) do
+ Hive::LlmWikiBootstrap.validate_project_managed_paths!(root)
+ end
+
+ assert_includes error.message, "managed parent"
+ assert_includes error.message, "must be a directory"
+ assert_equal "not a directory\n", File.read(parent)
+ end
+ end
+
+ def test_managed_file_write_translates_nofollow_errors
+ with_tmp_dir do |root|
+ path = File.join(root, ".llm-wiki", "config.json")
+
+ with_replaced_singleton_method(
+ File, :open, ->(*_args) { raise Errno::ELOOP, path }
+ ) do
+ error = assert_raises(Hive::ConfigError) do
+ Hive::LlmWikiBootstrap.write_file(path, "{}\n")
+ end
+ assert_includes error.message, "managed file"
+ assert_includes error.message, "must not be a symlink"
+ end
+ end
+ end
end
diff --git a/test/unit/openclaw_skills_test.rb b/test/unit/openclaw_skills_test.rb
index 07fa04304d..4646794dcc 100644
--- a/test/unit/openclaw_skills_test.rb
+++ b/test/unit/openclaw_skills_test.rb
@@ -57,6 +57,30 @@ def test_projection_uses_native_status_watch_and_progressive_references
refute_match(/pgrep\s+-af|kill\s+-0|while\s+:/, text)
end
+ def test_projection_routes_natural_language_workflow_creation_inside_hive
+ root = ROOT.join("hive")
+ creator_references = %w[
+ workflow-creator.md
+ workflow-creator-example.md
+ workflow-schema.md
+ workflow-stage-design.md
+ workflow-checkpoints.md
+ workflow-permissions.md
+ workflow-testing.md
+ workflow-common-mistakes.md
+ ]
+
+ creator_references.each do |name|
+ assert root.join("references", name).file?, name
+ end
+
+ text = projection_text
+ assert_includes text, "hive-workflow-creator"
+ assert_includes text, "hive workflow validate ID --json"
+ assert_includes text, "No task by default"
+ assert_includes text, "Never publish externally"
+ end
+
def test_projection_keeps_recovery_and_release_authority_guarded
text = projection_text
normalized_text = text.gsub(/\s+/, " ")
diff --git a/test/unit/operational_status_test.rb b/test/unit/operational_status_test.rb
index 13c200af9b..c1d30ea069 100644
--- a/test/unit/operational_status_test.rb
+++ b/test/unit/operational_status_test.rb
@@ -786,6 +786,33 @@ def test_human_and_nonrecoverable_elevated_states_have_no_action_but_error_has_r
assert_equal "unavailable", projected.dig("error", "recovery", "status")
end
+ def test_human_outcomes_are_preserved_in_operational_position
+ human = task(action: "needs_input", slug: "approval", stage: "3-approval")
+ human["workflow"] = "editorial"
+ human["outcomes"] = [
+ { "name" => "approve", "complete" => true, "artifact" => "draft.md", "to" => nil },
+ { "name" => "reject", "complete" => false, "artifact" => nil, "to" => "draft" }
+ ]
+
+ projected = project(status_payload(human)).fetch("tasks").first
+
+ assert_equal human.fetch("outcomes"), projected.dig("position", "allowed_outcomes")
+ assert_equal "waiting_on_you", projected.fetch("state")
+ assert_nil projected.fetch("action")
+ end
+
+ def test_completed_human_stage_is_archived
+ human = task(
+ action: "archived", slug: "approved", stage: "3-approval", marker: "complete"
+ )
+ result = project(status_payload(human))
+
+ assert_equal 0, result.dig("summary", "active")
+ assert_equal 1, result.dig("summary", "archived")
+ assert_equal 1, result.dig("archive", "count")
+ assert_empty result.fetch("tasks")
+ end
+
def test_payload_validates_against_published_schema
result = project(status_payload(task(action: "ready_to_plan", slug: "valid")))
schema = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-operational-status"))))
diff --git a/test/unit/packaging/live_agent_proof_test.rb b/test/unit/packaging/live_agent_proof_test.rb
index cc08eb137b..2eb726f6f1 100644
--- a/test/unit/packaging/live_agent_proof_test.rb
+++ b/test/unit/packaging/live_agent_proof_test.rb
@@ -90,8 +90,9 @@ def test_attestor_and_verifier_accept_only_exact_proven_artifacts
with_tmp_dir do |dir|
artifacts = prepare_artifacts(dir)
evidence = prepare_evidence(dir, artifacts)
+ creator_evidence = prepare_creator_evidence(dir, artifacts)
proof = File.join(dir, "proof")
- result = attest(artifacts, evidence, proof)
+ result = attest(artifacts, evidence, creator_evidence, proof)
verified = HiveLiveAgentProof::Verifier.new(
proof_dir: proof, candidate_sha: SHA, workflow_revision: WORKFLOW_SHA,
@@ -107,9 +108,10 @@ def test_attestor_rejects_missing_skipped_or_unsafe_platform_evidence
with_tmp_dir do |dir|
artifacts = prepare_artifacts(dir)
evidence = prepare_evidence(dir, artifacts)
+ creator_evidence = prepare_creator_evidence(dir, artifacts)
FileUtils.rm_f(File.join(evidence, "pi.json"))
error = assert_raises(HiveLiveAgentProof::Error) do
- attest(artifacts, evidence, File.join(dir, "proof-missing"))
+ attest(artifacts, evidence, creator_evidence, File.join(dir, "proof-missing"))
end
assert_includes error.message, "exactly"
@@ -119,7 +121,7 @@ def test_attestor_rejects_missing_skipped_or_unsafe_platform_evidence
row["result"] = "skipped"
HiveLiveAgentProof.write_json(row_path, row)
error = assert_raises(HiveLiveAgentProof::Error) do
- attest(artifacts, evidence, File.join(dir, "proof-skipped"))
+ attest(artifacts, evidence, creator_evidence, File.join(dir, "proof-skipped"))
end
assert_includes error.message, "invalid"
@@ -127,7 +129,7 @@ def test_attestor_rejects_missing_skipped_or_unsafe_platform_evidence
row["hive_commands"] << [ "act", "resume", "proof:live-agent-skill" ]
HiveLiveAgentProof.write_json(row_path, row)
error = assert_raises(HiveLiveAgentProof::Error) do
- attest(artifacts, evidence, File.join(dir, "proof-unsafe"))
+ attest(artifacts, evidence, creator_evidence, File.join(dir, "proof-unsafe"))
end
assert_includes error.message, "exactly one operational status"
@@ -139,18 +141,46 @@ def test_attestor_rejects_missing_skipped_or_unsafe_platform_evidence
}
HiveLiveAgentProof.write_json(row_path, row)
error = assert_raises(HiveLiveAgentProof::Error) do
- attest(artifacts, evidence, File.join(dir, "proof-generic-activation"))
+ attest(artifacts, evidence, creator_evidence, File.join(dir, "proof-generic-activation"))
end
assert_includes error.message, "native activation"
end
end
+ def test_attestor_rejects_workflow_creator_mutation_or_graph_drift
+ with_tmp_dir do |dir|
+ artifacts = prepare_artifacts(dir)
+ evidence = prepare_evidence(dir, artifacts)
+ creator_evidence = prepare_creator_evidence(dir, artifacts)
+ row_path = File.join(creator_evidence, "openclaw-workflow-creator.json")
+ row = JSON.parse(File.read(row_path))
+ row["task_count"] = 2
+ HiveLiveAgentProof.write_json(row_path, row)
+
+ error = assert_raises(HiveLiveAgentProof::Error) do
+ attest(artifacts, evidence, creator_evidence, File.join(dir, "proof-task"))
+ end
+ assert_includes error.message, "unauthorized side effect"
+
+ creator_evidence = prepare_creator_evidence(dir, artifacts)
+ row_path = File.join(creator_evidence, "openclaw-workflow-creator.json")
+ row = JSON.parse(File.read(row_path))
+ row["validation"]["stages"] << "publish"
+ HiveLiveAgentProof.write_json(row_path, row)
+ error = assert_raises(HiveLiveAgentProof::Error) do
+ attest(artifacts, evidence, creator_evidence, File.join(dir, "proof-publish"))
+ end
+ assert_includes error.message, "normalized graph"
+ end
+ end
+
def test_verifier_fails_closed_on_check_digest_identity_and_artifact_substitution
with_tmp_dir do |dir|
artifacts = prepare_artifacts(dir)
evidence = prepare_evidence(dir, artifacts)
+ creator_evidence = prepare_creator_evidence(dir, artifacts)
proof = File.join(dir, "proof")
- result = attest(artifacts, evidence, proof)
+ result = attest(artifacts, evidence, creator_evidence, proof)
assert_raises(HiveLiveAgentProof::Error) do
verifier(proof, "0" * 64).call
@@ -243,11 +273,66 @@ def prepare_evidence(dir, artifacts)
evidence
end
- def attest(artifacts, evidence, proof)
+ def prepare_creator_evidence(dir, artifacts)
+ evidence = File.join(dir, "creator-evidence")
+ FileUtils.rm_rf(evidence)
+ FileUtils.mkdir_p(evidence)
+ manifest = JSON.parse(File.read(File.join(artifacts, "artifact-manifest.json")))
+ row = {
+ "schema" => "hive-live-workflow-creator-evidence",
+ "schema_version" => 1,
+ "platform" => "openclaw",
+ "candidate_sha" => SHA,
+ "result" => "passed",
+ "prompt_sha256" => Digest::SHA256.hexdigest(HiveLiveAgentProof::WORKFLOW_CREATOR_PROMPT),
+ "task_prompt_sha256" =>
+ Digest::SHA256.hexdigest(HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_PROMPT),
+ "skill" => {
+ "skill_version" => manifest.fetch("skill_version"),
+ "canonical_digest" => manifest.fetch("canonical_digest")
+ },
+ "native_activation" => {
+ "kind" => HiveLiveAgentProof::NATIVE_ACTIVATION_KINDS.fetch("openclaw"),
+ "invocation" => HiveLiveAgentProof::INVOCATIONS.fetch("openclaw")
+ },
+ "hive_commands" => HiveLiveAgentProof::WORKFLOW_CREATOR_COMMANDS,
+ "created_files" => HiveLiveAgentProof::WORKFLOW_CREATOR_FILES.map do |path|
+ { "path" => path, "sha256" => "c" * 64, "size" => 10 }
+ end,
+ "validation" => {
+ "valid" => true,
+ "stages" => %w[research draft approval],
+ "automatic_edges" => [ %w[research draft], %w[draft approval] ],
+ "human_outcomes" => [
+ { "stage" => "approval", "name" => "approve", "complete" => true,
+ "artifact" => "draft.md", "to" => nil },
+ { "stage" => "approval", "name" => "reject", "complete" => false,
+ "artifact" => nil, "to" => "draft" }
+ ]
+ },
+ "creation_only_task_count" => 0,
+ "task_count" => 1,
+ "task" => {
+ "slug" => HiveLiveAgentProof::WORKFLOW_CREATOR_TASK_SLUG,
+ "workflow" => "editorial",
+ "first_created" => true,
+ "retry_created" => false,
+ "run_count" => 1,
+ "current_stage" => "1-research"
+ },
+ "external_actions" => [],
+ "secret_scan" => { "status" => "passed" },
+ "cleanup" => { "status" => "passed" }
+ }
+ HiveLiveAgentProof.write_json(File.join(evidence, "openclaw-workflow-creator.json"), row)
+ evidence
+ end
+
+ def attest(artifacts, evidence, creator_evidence, proof)
HiveLiveAgentProof::Attestor.new(
candidate_sha: SHA, workflow_revision: WORKFLOW_SHA, repository: REPOSITORY,
run_id: "42", run_attempt: "1", artifact_dir: artifacts,
- evidence_dir: evidence, output_dir: proof
+ evidence_dir: evidence, creator_evidence_dir: creator_evidence, output_dir: proof
).call
end
diff --git a/test/unit/release_contract_test.rb b/test/unit/release_contract_test.rb
index 84e5697587..7da26d7c7d 100644
--- a/test/unit/release_contract_test.rb
+++ b/test/unit/release_contract_test.rb
@@ -45,7 +45,12 @@ def test_public_credibility_copy_matches_current_capabilities
assert_includes workflows, "built-in `coding`, `content`, and `bench` workflows"
assert_includes workflows, "hive workflow install honeycomb/architecture --yes"
- assert_includes workflows, "The last stage may be `kind: terminal`, `kind: agent`, or `kind: council`."
+ assert_includes workflows, "/hive create a three-stage editorial workflow"
+ assert_includes workflows, "hive workflow validate editorial --json"
+ assert_includes workflows, "hive decide <task> approve --from approval --decision-id <decision-id>"
+ assert_includes workflows, "The last stage may be `kind: terminal`, `kind: agent`, `kind: council`, or `kind: human`."
+ assert_includes workflows, "No task is created by workflow creation alone."
+ assert_includes readme, "natural language"
%w[OpenClaw Grok hive-bench Honeycomb].each { |capability| assert_includes readme, capability }
assert_includes readme, "### Hive web (default native experience)"
@@ -72,6 +77,7 @@ def test_agent_skill_release_metadata_is_derived_from_authoritative_inputs
projection = JSON.parse(File.read(File.join(ROOT, "openclaw/skills/hive/.hive-skill.json")))
setup = File.read(File.join(ROOT, "openclaw/skills/hive/references/setup-and-platforms.md"))
publish_docs = read("openclaw/README.md")
+ release_docs = read("docs/RELEASING.md")
assert_equal canonical.version, openclaw.fetch("version")
assert_equal canonical.version, projection.fetch("skill_version")
@@ -80,6 +86,9 @@ def test_agent_skill_release_metadata_is_derived_from_authoritative_inputs
refute_match(%r{/v(?!#{Regexp.escape(Hive::VERSION)}/)[0-9]+\.[0-9]+\.[0-9]+/install\.sh}, setup)
assert_includes publish_docs, "skills/hive/skill.json"
refute_match(/--version\s+\d+\.\d+\.\d+/, publish_docs)
+ assert_includes release_docs, "hive-site #23116"
+ assert_includes release_docs, "does not block this repository"
+ assert_match(/Do\s+not describe current-main workflow-creator commands as stable/, release_docs)
end
def test_live_agent_proof_is_protected_exact_sha_and_four_surface
@@ -100,7 +109,8 @@ def test_live_agent_proof_is_protected_exact_sha_and_four_surface
assert_equal %w[claude codex openclaw pi], matrix.map { |row| row.fetch("platform") }.sort
assert_equal "live-agent-skills-${{ matrix.platform }}", jobs.fetch("live-agent").fetch("environment")
assert_equal false, jobs.fetch("live-agent").dig("strategy", "fail-fast")
- assert_equal [ "validate", "build", "live-agent" ], jobs.fetch("attest").fetch("needs")
+ assert_equal [ "validate", "build", "live-agent", "live-workflow-creator" ],
+ jobs.fetch("attest").fetch("needs")
assert_includes body, "retention-days: 7"
assert_includes body, 'name: "live-agent-skills"'
assert_includes body, "checks: write"
@@ -134,6 +144,18 @@ def test_live_agent_proof_is_protected_exact_sha_and_four_surface
assert_includes candidate_install_body, "install_candidate_gem.sh"
assert_includes candidate_install_body, '"$RUNNER_TEMP/proven-gems/bin/hive" --version'
refute_includes candidate_install_body, 'gem install "$gem_file"'
+
+ creator = jobs.fetch("live-workflow-creator")
+ assert_equal [ "validate", "build" ], creator.fetch("needs")
+ assert_equal "live-agent-skills-openclaw", creator.fetch("environment")
+ creator_step = creator.fetch("steps").find do |step|
+ step["name"] == "Run authenticated workflow-creator proof"
+ end
+ refute_nil creator_step
+ assert_equal "${{ secrets.OPENAI_API_KEY }}", creator_step.dig("env", "OPENAI_API_KEY")
+ assert_includes creator_step.fetch("run"), "live_hive_workflow_creator_smoke_test.rb"
+ assert_includes body, "workflow-creator-evidence-openclaw"
+ assert_includes body, "creator-evidence"
end
def test_tag_release_selects_and_reverifies_exact_pre_tag_candidate
diff --git a/test/unit/schema_files_test.rb b/test/unit/schema_files_test.rb
index be8274cdcb..81876118d6 100644
--- a/test/unit/schema_files_test.rb
+++ b/test/unit/schema_files_test.rb
@@ -8,6 +8,7 @@
require "hive/commands/daemon"
require "hive/commands/drop"
require "hive/commands/doctor"
+require "hive/commands/decide"
require "hive/commands/forget"
require "hive/commands/init"
require "hive/commands/patrol"
@@ -15,6 +16,8 @@
require "hive/commands/run"
require "hive/commands/stage_action"
require "hive/commands/status"
+require "hive/agent_skills/inspector"
+require "hive/agent_skills/provisioner"
require "hive/patrol/candidate_selector"
require "hive/patrol/reviewer"
require "hive/commands/setup_agents"
@@ -30,6 +33,15 @@
# 3. Pin the same required-key set the producer code emits, so a producer
# change without a schema update fails at test time.
class SchemaFilesTest < Minitest::Test
+ def test_hive_decide_schema_matches_success_payload_contract
+ path = Hive::Schemas.schema_path("hive-decide")
+ assert File.exist?(path)
+ doc = JSON.parse(File.read(path))
+ assert_equal Hive::Commands::Decide::SUCCESS_KEYS.sort,
+ doc.dig("$defs", "SuccessPayload", "required").sort
+ assert_equal 1, doc.dig("$defs", "SuccessPayload", "properties", "schema_version", "const")
+ end
+
def test_native_web_schema_files_accept_versioned_success_and_error_shapes
service = {
"platform" => "linux", "unit_path" => "/tmp/hive-web.service",
diff --git a/test/unit/task_action_generic_test.rb b/test/unit/task_action_generic_test.rb
index 0592d38e1d..3451cee0fe 100644
--- a/test/unit/task_action_generic_test.rb
+++ b/test/unit/task_action_generic_test.rb
@@ -100,6 +100,24 @@ def test_council_marker_to_action_matrix
assert_equal "hive approve #{SLUG} --from 2-review", complete_middle.command
end
+ def test_human_stage_waits_for_a_named_decision_and_never_dispatches
+ action = action_for("approval", :waiting, { "decision_id" => "a" * 16 }, descriptor: human_workflow)
+
+ assert_equal "needs_input", action.key
+ assert_equal "Awaiting human decision", action.label
+ assert_nil action.command
+ assert_equal [
+ { "name" => "approve", "complete" => true, "artifact" => "draft.md", "to" => nil },
+ { "name" => "reject", "complete" => false, "artifact" => nil, "to" => "draft" }
+ ], action.allowed_outcomes
+ assert_equal :skip, policy_decision(action)
+
+ completed = action_for("approval", :complete, descriptor: human_workflow)
+ assert_equal "archived", completed.key
+ assert_equal "Archived", completed.label
+ assert_nil completed.command
+ end
+
def test_terminal_agent_complete_requires_non_empty_deliverable
missing = action_for_terminal_active(:agent, write_deliverable: false)
assert_equal "error", missing.fetch(:key)
@@ -322,6 +340,22 @@ def council_workflow
)
end
+ def human_workflow
+ Hive::Workflow.new(
+ id: :human_status,
+ stages: [
+ Hive::Workflow::Stage.new(name: "draft", index: 1, state_file: "draft.md", kind: :agent, skill: "/draft"),
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 2, state_file: "approval.md", kind: :human,
+ outcomes: {
+ "approve" => Hive::Workflow::Outcome.new(name: "approve", complete: true, artifact: "draft.md"),
+ "reject" => Hive::Workflow::Outcome.new(name: "reject", to: "draft")
+ }.freeze
+ )
+ ]
+ )
+ end
+
def terminal_active_workflow(kind)
terminal = if kind == :council
Hive::Workflow::Stage.new(
diff --git a/test/unit/task_action_test.rb b/test/unit/task_action_test.rb
index 88d4b7fced..9a6611dd0e 100644
--- a/test/unit/task_action_test.rb
+++ b/test/unit/task_action_test.rb
@@ -730,7 +730,7 @@ def test_command_shellescapes_slug_with_special_characters
def test_payload_has_expected_keys
task = fake_task(stage_name: "brainstorm", stage_index: 2)
payload = Hive::TaskAction.for(task, marker(:complete)).payload
- assert_equal %w[command key label next_action].sort, payload.keys.sort
+ assert_equal %w[command key label next_action outcomes].sort, payload.keys.sort
assert_equal "ready_to_plan", payload["key"]
end
diff --git a/test/unit/task_meta_test.rb b/test/unit/task_meta_test.rb
index 35cf27c63d..d0961ffdb5 100644
--- a/test/unit/task_meta_test.rb
+++ b/test/unit/task_meta_test.rb
@@ -361,7 +361,7 @@ def test_malformed_yaml_warns_that_depends_on_and_workflow_were_dropped
result = nil
_out, err = capture_io { result = Hive::TaskMeta.read(dir) }
assert_equal Hive::TaskMeta.empty, result
- assert_match(/depends_on, workflow, base_branch dropped; managed provenance dropped/, err,
+ assert_match(/depends_on, workflow, base_branch dropped; idempotency dropped; managed provenance dropped/, err,
"a YAML parse failure must warn that depends_on (and the " \
"workflow selector) were dropped")
end
@@ -403,6 +403,33 @@ def test_update_display_name_preserves_id_slug_dependency_and_workflow
end
end
+ def test_idempotency_metadata_round_trips_and_survives_rewrites
+ with_tmp_dir do |dir|
+ fingerprint = "a" * 64
+ Hive::TaskMeta.write(
+ dir, id: 7, slug: "keep-slug", display_name: nil,
+ idempotency_key: "creator:v1", input_fingerprint: fingerprint
+ )
+
+ Hive::TaskMeta.update_display_name(dir, "Readable Name")
+ meta = Hive::TaskMeta.read(dir)
+
+ assert_equal "creator:v1", meta.fetch(:idempotency_key)
+ assert_equal fingerprint, meta.fetch(:input_fingerprint)
+ assert_includes File.read(File.join(dir, "meta.yml")), "idempotency_key: creator:v1"
+ end
+ end
+
+ def test_idempotency_metadata_requires_key_and_fingerprint_together
+ with_tmp_dir do |dir|
+ assert_raises(ArgumentError) do
+ Hive::TaskMeta.write(
+ dir, id: 1, slug: "task", display_name: nil, idempotency_key: "creator:v1"
+ )
+ end
+ end
+ end
+
def test_update_id_preserves_slug_display_name_dependency_and_workflow
with_tmp_dir do |dir|
Hive::TaskMeta.write(
diff --git a/test/unit/workflow_package/managed_store_test.rb b/test/unit/workflow_package/managed_store_test.rb
index 286e5916f9..24da947496 100644
--- a/test/unit/workflow_package/managed_store_test.rb
+++ b/test/unit/workflow_package/managed_store_test.rb
@@ -319,6 +319,36 @@ def test_malformed_locks_are_visible_directly_and_reported_by_selection_enumerat
end
end
+ def test_read_only_selection_does_not_create_a_lock_or_reconcile_a_journal
+ with_tmp_dir do |dir|
+ store = Hive::WorkflowPackage::ManagedStore.new(File.join(dir, ".hive-state"))
+ package = File.join(dir, "package")
+ resolution = write_package(package, "a" * 40)
+ store.place_generation(package, resolution)
+ store.activate(resolution)
+
+ mutation_lock = File.join(store.workflows_dir, ".mutation.lock")
+ FileUtils.rm_f(mutation_lock)
+ lock_bytes = File.binread(store.lock_path("demo"))
+ journal = Hive::WorkflowPackage::TransactionJournal.new(store.workflows_dir)
+ journal.write(
+ "schema_version" => 1,
+ "phase" => "prepared",
+ "lock_path" => store.lock_path("demo"),
+ "old_lock" => nil,
+ "new_lock" => lock_bytes
+ )
+ journal_bytes = File.binread(journal.path)
+
+ selection = store.selected_read_only("demo")
+
+ assert_equal "demo", selection.fetch("name")
+ refute File.exist?(mutation_lock)
+ assert_equal lock_bytes, File.binread(store.lock_path("demo"))
+ assert_equal journal_bytes, File.binread(journal.path)
+ end
+ end
+
def test_selection_enumeration_reports_missing_configuration_and_keeps_healthy_sibling
assert_invalid_configuration_isolated("unavailable") do |_store, _digest, _path|
nil
diff --git a/test/unit/workflow_test.rb b/test/unit/workflow_test.rb
index 1f94a9cbf3..983241d677 100644
--- a/test/unit/workflow_test.rb
+++ b/test/unit/workflow_test.rb
@@ -58,10 +58,12 @@ def test_stage_defaults_optional_descriptor_fields
assert_nil stage.reviewers
assert_nil stage.council
assert_nil stage.deliverable
+ assert_nil stage.outcomes
end
def test_known_kinds_include_coding_runtime_primitives
assert_includes Hive::Workflow::KNOWN_KINDS, :council
+ assert_includes Hive::Workflow::KNOWN_KINDS, :human
assert_includes Hive::Workflow::KNOWN_KINDS, :execute
assert_includes Hive::Workflow::KNOWN_KINDS, :review_council
assert_includes Hive::Workflow::KNOWN_KINDS, :finalize
@@ -71,6 +73,118 @@ def test_known_kinds_include_coding_runtime_primitives
refute_includes Hive::Workflow::KNOWN_KINDS, :marker
end
+ def test_human_stage_exposes_immutable_named_outcomes
+ approve = Hive::Workflow::Outcome.new(
+ name: "approve", complete: true, artifact: "draft.md"
+ )
+ reject = Hive::Workflow::Outcome.new(name: "reject", to: "draft")
+ outcomes = { "approve" => approve, "reject" => reject }.freeze
+ workflow = Hive::Workflow.new(
+ id: :editorial,
+ stages: [
+ Hive::Workflow::Stage.new(
+ name: "draft", index: 1, state_file: "draft.md", kind: :agent
+ ),
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 2, state_file: "approval.md", kind: :human,
+ input: "draft.md", outcomes: outcomes
+ )
+ ]
+ )
+
+ approval = workflow.stage_named("approval")
+ assert_same outcomes, approval.outcomes
+ assert approval.outcomes.frozen?
+ assert approval.outcomes.fetch("approve").frozen?
+ assert approval.outcomes.fetch("approve").terminal?
+ refute approval.outcomes.fetch("reject").terminal?
+ assert_equal "draft", approval.outcomes.fetch("reject").to
+ end
+
+ def test_workflow_rejects_human_outcome_target_outside_descriptor
+ error = assert_raises(ArgumentError) do
+ Hive::Workflow.new(
+ id: :editorial,
+ stages: [
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 1, state_file: "approval.md", kind: :human,
+ outcomes: {
+ "reject" => Hive::Workflow::Outcome.new(name: "reject", to: "draft")
+ }.freeze
+ )
+ ]
+ )
+ end
+
+ assert_includes error.message, 'stage "approval" outcome "reject" targets unknown stage "draft"'
+ end
+
+ def test_workflow_defensively_rejects_invalid_runtime_human_outcome_shapes
+ valid = Hive::Workflow::Stage.new(
+ name: "draft", index: 1, state_file: "draft.md", kind: :agent
+ )
+ cases = [
+ [
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 2, state_file: "approval.md", kind: :human, outcomes: nil
+ ),
+ "must declare at least one outcome"
+ ],
+ [
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 2, state_file: "approval.md", kind: :human,
+ outcomes: { "approve" => Object.new }.freeze
+ ),
+ 'outcome "approve" is invalid'
+ ],
+ [
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 2, state_file: "approval.md", kind: :human,
+ outcomes: {
+ "approve" => Hive::Workflow::Outcome.new(name: "approve", complete: true, to: "draft")
+ }.freeze
+ ),
+ "must declare exactly one of complete or to"
+ ],
+ [
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 2, state_file: "approval.md", kind: :human,
+ outcomes: {
+ "approve" => Hive::Workflow::Outcome.new(name: "approve", complete: true)
+ }.freeze
+ ),
+ "must declare an artifact when complete"
+ ],
+ [
+ Hive::Workflow::Stage.new(
+ name: "approval", index: 2, state_file: "approval.md", kind: :human,
+ outcomes: {
+ "reject" => Hive::Workflow::Outcome.new(name: "reject", to: "draft", artifact: "draft.md")
+ }.freeze
+ ),
+ "artifact is only valid for a completing outcome"
+ ]
+ ]
+
+ cases.each do |stage, message|
+ error = assert_raises(ArgumentError) do
+ Hive::Workflow.new(id: :editorial, stages: [ valid, stage ])
+ end
+ assert_includes error.message, message
+ end
+
+ non_human = Hive::Workflow::Stage.new(
+ name: "draft", index: 1, state_file: "draft.md", kind: :agent,
+ outcomes: {
+ "approve" => Hive::Workflow::Outcome.new(name: "approve", complete: true, artifact: "draft.md")
+ }.freeze
+ )
+ error = assert_raises(ArgumentError) do
+ Hive::Workflow.new(id: :editorial, stages: [ non_human ])
+ end
+ assert_includes error.message, "non-human stage"
+ end
+
def test_stage_carries_user_descriptor_instruction_and_permissions
stage = Hive::Workflow::Stage.new(
name: "work",
diff --git a/test/unit/workflows/descriptor_parser_test.rb b/test/unit/workflows/descriptor_parser_test.rb
index 2afe59af89..a876feb507 100644
--- a/test/unit/workflows/descriptor_parser_test.rb
+++ b/test/unit/workflows/descriptor_parser_test.rb
@@ -180,6 +180,124 @@ def test_skill_backed_agent_stage_is_valid
assert_equal "/ship", workflow.stage_named("work").skill
end
+ def test_editorial_human_stage_parses_closed_outcomes
+ workflow = Hive::Workflows::DescriptorParser.parse_hash(
+ {
+ "id" => "editorial",
+ "stages" => [
+ { "name" => "research", "kind" => "agent", "state_file" => "research.md", "skill" => "/research" },
+ { "name" => "draft", "kind" => "agent", "state_file" => "draft.md", "skill" => "/draft" },
+ {
+ "name" => "approval",
+ "kind" => "human",
+ "state_file" => "approval.md",
+ "input" => "draft.md",
+ "outcomes" => {
+ "approve" => { "complete" => true, "artifact" => "draft.md" },
+ "reject" => { "to" => "draft" }
+ }
+ }
+ ]
+ },
+ path: "/tmp/editorial.yml"
+ )
+
+ assert_equal %w[research draft approval], workflow.stage_names
+ approval = workflow.stage_named("approval")
+ assert_equal :human, approval.kind
+ assert_equal "draft.md", approval.input
+ assert_equal %w[approve reject], approval.outcomes.keys
+ assert_equal true, approval.outcomes.fetch("approve").complete
+ assert_equal "draft.md", approval.outcomes.fetch("approve").artifact
+ assert_equal "draft", approval.outcomes.fetch("reject").to
+ assert approval.outcomes.frozen?
+ end
+
+ def test_human_outcomes_reject_invalid_shapes_and_targets
+ invalid = [
+ [ { "approve" => { "complete" => true, "to" => "draft", "artifact" => "draft.md" } }, "exactly one" ],
+ [ { "approve" => { "artifact" => "draft.md" } }, "exactly one" ],
+ [ { "approve" => { "complete" => false, "artifact" => "draft.md" } }, "complete must be true" ],
+ [ { "approve" => { "complete" => true } }, "artifact must be a non-empty string" ],
+ [ { "reject" => { "to" => "draft", "artifact" => "draft.md" } }, "artifact is only valid" ],
+ [ { "bad name" => { "to" => "draft" } }, "outcome name" ],
+ [ { "reject" => { "to" => "missing" } }, "targets unknown stage" ],
+ [ { "reject" => { "to" => "draft", "command" => "publish" } }, "unknown key" ]
+ ]
+
+ invalid.each do |outcomes, message|
+ error = assert_config_error(
+ {
+ "id" => "bad",
+ "stages" => [
+ { "name" => "draft", "kind" => "agent", "state_file" => "draft.md", "skill" => "/draft" },
+ {
+ "name" => "approval", "kind" => "human", "state_file" => "approval.md",
+ "input" => "draft.md", "outcomes" => outcomes
+ }
+ ]
+ },
+ path: "/tmp/bad.yml"
+ )
+
+ assert_includes error.message, message, "expected #{outcomes.inspect} to report #{message.inspect}"
+ assert_includes error.message, "approval"
+ end
+ end
+
+ def test_human_stage_rejects_agent_settings_and_non_human_outcomes
+ %w[agent model permissions instruction skill effort budget_usd timeout_sec reviewers council deliverable workspace handoff conditions mapping_role mapping_contract].each do |field|
+ value =
+ case field
+ when "budget_usd", "timeout_sec" then 1
+ when "reviewers" then []
+ when "council", "conditions" then {}
+ else "value"
+ end
+ error = assert_config_error(
+ {
+ "id" => "bad",
+ "stages" => [
+ {
+ "name" => "approval", "kind" => "human", "state_file" => "approval.md",
+ "outcomes" => { "approve" => { "complete" => true, "artifact" => "draft.md" } },
+ field => value
+ }
+ ]
+ },
+ path: "/tmp/bad.yml"
+ )
+ assert_includes error.message, field
+ assert_includes error.message, "human stage"
+ end
+
+ error = assert_config_error(
+ {
+ "id" => "bad",
+ "stages" => [
+ {
+ "name" => "draft", "kind" => "agent", "state_file" => "draft.md", "skill" => "/draft",
+ "outcomes" => { "approve" => { "complete" => true, "artifact" => "draft.md" } }
+ }
+ ]
+ },
+ path: "/tmp/bad.yml"
+ )
+ assert_includes error.message, "outcomes is only valid on a human stage"
+ end
+
+ def test_human_stage_requires_at_least_one_outcome
+ [ nil, {} ].each do |outcomes|
+ stage = { "name" => "approval", "kind" => "human", "state_file" => "approval.md" }
+ stage["outcomes"] = outcomes unless outcomes.nil?
+ error = assert_config_error(
+ { "id" => "bad", "stages" => [ stage ] },
+ path: "/tmp/bad.yml"
+ )
+ assert_includes error.message, "human stage must declare at least one outcome"
+ end
+ end
+
def test_stage_conditions_select_registered_semantics_only
workflow = Hive::Workflows::DescriptorParser.parse_hash(
{
@@ -817,7 +935,7 @@ def test_parser_terminal_last_stage_validation_reports_internal_kind_drift
parser.send(:validate_terminal_last_stage!, [ stage ])
end
- assert_includes error.message, 'last stage "execute" must be terminal, agent, or council'
+ assert_includes error.message, 'last stage "execute" must be terminal, agent, council, or human'
end
def test_council_stage_requires_reviewers
@@ -988,7 +1106,7 @@ def test_unknown_kind_is_rejected
path: "/tmp/bad.yml"
)
- assert_includes error.message, 'stage 1 kind "marker" must be agent, council, or terminal'
+ assert_includes error.message, 'stage 1 kind "marker" must be agent, council, human, or terminal'
# The coding runtime kinds (execute/review_council/finalize) are Ruby-only:
# only Workflows::Coding declares them via Stage.new, and parse_kind must keep
@@ -1004,7 +1122,7 @@ def test_unknown_kind_is_rejected
path: "/tmp/bad.yml"
)
- assert_includes runtime_error.message, %(stage 1 kind "#{coding_runtime_kind}" must be agent, council, or terminal),
+ assert_includes runtime_error.message, %(stage 1 kind "#{coding_runtime_kind}" must be agent, council, human, or terminal),
"YAML descriptors must not be able to declare Ruby-only coding runtime kind " \
"#{coding_runtime_kind.inspect}"
end
diff --git a/wiki/cli.md b/wiki/cli.md
index 5daf76dc24..866abdc886 100644
--- a/wiki/cli.md
+++ b/wiki/cli.md
@@ -90,6 +90,7 @@ contracts retain their established unversioned or schema-less shapes.
| `hive init [PROJECT_PATH] [--json] [--refactor-patrol\|--no-refactor-patrol]` | Bootstrap `.hive-state` plus managed llm-wiki context; fresh projects enable architecture discovery by default, and the boolean flag resolves it before any state write; `--json` emits `hive-init.v2` | `Hive::Commands::Init` | [[commands/init]] |
| `hive new PROJECT TEXT... [--workflow ID] [--depends-on TASK] [--base BRANCH] [--json]` | Create a task in `1-inbox/` of a registered project, writing the request plus `meta.yml`; `--base` records the exact non-stacked origin base for managed draft-PR workflows, and stdout uses the numeric task id when allocation succeeds | `Hive::Commands::New` | [[commands/new]] |
| `hive workflow new ID [--json]` | Scaffold a blank per-project workflow descriptor plus placeholder instruction under `<hive_state_path>/workflows/` | `Hive::Commands::Workflow` | [[commands/workflow]] |
+| `hive workflow commit ID` | Validate and commit a populated owner-authored workflow under the shared Hive state commit lock | `Hive::Commands::Workflow` | [[commands/workflow]] |
| `hive generate-name TARGET [--project NAME] [--stage STAGE]` | Generate and persist a short display title for a task; target resolves by path, slug, or numeric id | `Hive::Commands::GenerateName` | [[commands/generate-name]] |
| `hive status [--operational [--json] \| --full \| --diagnose SLUG ...]` | Default/`--operational` is the concise closed-state human view; `--operational --json` emits `hive-operational-status.v3`; bare `--json` emits complete `hive-status.v7`; `--full` retains the detailed table. Diagnosis keeps its existing bounded read/write surface. | `Hive::Commands::Status` | [[commands/status]] |
| `hive watch TARGET... [--project NAME] [--until settled\|completion] [--json-lines]` | Resolve at most 100 tasks once and emit only semantic initial/transition/warning/final events until a terminal condition, timeout, or event cap. Read-only; global `--json` is rejected. | `Hive::Commands::Watch` | [[commands/watch]] |
@@ -117,6 +118,7 @@ contracts retain their established unversioned or schema-less shapes.
| `hive run TARGET [--no-rebase]` | Lower-level dispatcher for a slug or task folder. `--no-rebase` skips the auto-rebase pre-step for one invocation (one-off override of `cfg.rebase.enabled`). | `Hive::Commands::Run` → stage runner | [[commands/run]] |
| `hive rebase-status TARGET` | Read-only inspector: reports whether the next `hive run` would attempt an auto-rebase, how many commits behind `origin/<default>` the worktree is, and which guard (if any) would short-circuit. Never mutates; never calls `git fetch`. | `Hive::Commands::RebaseStatus` | [[commands/rebase-status]] |
| `hive approve TARGET [--to STAGE] [--from STAGE]` | Move a task between stages + record a hive/state commit (agent-callable equivalent of shell `mv`; `--from` asserts current stage for retry idempotency) | `Hive::Commands::Approve` | [[commands/approve]] |
+| `hive decide TARGET OUTCOME --from STAGE --decision-id ID [--note TEXT] [--json]` | Apply one descriptor-declared human outcome to the observed stage visit; completing outcomes atomically verify a non-marker artifact. | `Hive::Commands::Decide` | [[commands/workflow]] |
| `hive drop TARGET [--project NAME] [--from STAGE]` | Hard-delete an active task: kill its recorded agent, close draft PR best-effort, remove task folder(s), logs, worktree, branch, and locks, then commit an audit record. Refuses `9-done`. | `Hive::Commands::Drop` | [[commands/drop]] |
| `hive findings TARGET [--pass N] [--stage STAGE]` | List GFM-checkbox findings in `reviews/ce-review-NN.md` (latest by default) | `Hive::Commands::Findings` | [[commands/findings]] |
| `hive accept-finding TARGET [ID...] [--severity S] [--all] [--stage STAGE]` | Tick `[x]` on review findings; selectors are unioned | `Hive::Commands::FindingToggle` (accept) | [[commands/findings]] |
diff --git a/wiki/commands/init.md b/wiki/commands/init.md
index 7ec0e535a0..0c20a371f4 100644
--- a/wiki/commands/init.md
+++ b/wiki/commands/init.md
@@ -4,7 +4,7 @@ type: command
source: lib/hive/commands/init.rb
created: 2026-04-25
updated: 2026-07-26
-tags: [command, bootstrap, git, prompts, llm-wiki, provisioning]
+tags: [command, bootstrap, git, prompts, preview, minimal, llm-wiki, provisioning]
---
**TLDR**: `hive init [PATH]` bootstraps a project for Hive and `--json` emits the resolved bootstrap contract. Alongside workflow, agents, review, patrol cadence, and daemon settings, fresh terminal and web setup recommend post-merge architecture patrol with an explicit default-yes answer. Headless callers can choose the same value before any write with `--refactor-patrol` or `--no-refactor-patrol`; enabling it also enables confined auto-fix/PR attempts and deduplicated GitHub issues as the fallback review surface. After the project transaction succeeds, interactive init diagnoses enabled manifest-managed agent skills and can delegate an accepted offer to the same setup engine as `hive setup-agents`; decline, non-TTY, and JSON flows only report remediation.
@@ -14,6 +14,8 @@ tags: [command, bootstrap, git, prompts, llm-wiki, provisioning]
```
hive init [PROJECT_PATH] [--force] [--json] [--workflow NAME] [--refactor-patrol|--no-refactor-patrol]
hive init --new-workflow ID [PROJECT_PATH]
+hive init --new-workflow ID --minimal --preview --json [PROJECT_PATH]
+hive init --new-workflow ID --minimal --json [PROJECT_PATH]
```
`PROJECT_PATH` defaults to `Dir.pwd`. `--force` skips the clean-tree check. `--json` emits the current `hive-init.v2` success document on stdout with project metadata and the resolved prompt answers; v1 remains a compatibility schema. `--workflow NAME` selects the project default workflow and is validated against `Hive::Workflows::Registry`; unknown names fail before disk writes and list valid names.
@@ -175,6 +177,32 @@ This branch is what the orphan worktree is initially based on, and what feature
- `test/unit/commands/init_test.rb` covers small init collaborators, including the `ProjectConfigBinding` complete-answer path, top-level and nested missing-key fail-fast contracts, rollback helper behavior, daemon-registration warning paths, and JSON summary EPIPE handling.
- `test/unit/commands/init/prompts_test.rb` covers prompt defaults and parsing, including the explicit default-yes architecture-patrol answer and the non-TTY summary. `test/integration/init_test.rb` and the hivebox setup tests pin terminal/web parity, explicit pre-write architecture-patrol selection, rejection of fresh-only selectors on every existing-project re-init path, config rendering, default-on fresh auto-fix/issue gates, explicit disablement, and inert legacy missing-block behavior. `test/unit/schema_files_test.rb` pins the current `schemas/hive-init.v2.json` projection, including `refactor_patrol_enabled`; v1 remains a compatibility schema.
+## Minimal workflow-creator profile
+
+`--minimal` is a non-interactive, fresh-target-only profile for an explicitly
+named `--new-workflow ID`. `--preview --json` emits
+`hive-init-preview.v1` after the same preflight and ID/collision resolution as
+execution, but writes nothing. The payload names planned project files, the
+Hive state worktree, context/wiki integration, global registration, services,
+background automation, and task creation.
+
+The minimal profile creates the core project/state registration and required
+context hooks while disabling patrol/refactor patrol, ad-hoc auto-fix, daemon
+dispatch/autostart, and babysitter/timer installation, including the llm-wiki
+scheduler timer. Bootstrap refuses pre-existing symlinks anywhere in its
+managed project paths and uses no-follow final writes, preserving outside
+targets and existing project choices. It never selects a
+starter template and rejects `--force`, missing `--new-workflow`, an existing
+Hive project/state branch, a non-fresh target, or a basename already registered
+to a different project path. The preview discloses the available registration
+name, and execution rechecks that name inside the locked registry write so a
+post-preview contender cannot be replaced. Minimal `--json` precondition,
+collision, already-initialized, and usage failures use the typed
+`hive-init-preview.v1` or `hive-init.v2` error envelope instead of prose. The
+natural-language creator presents this preview and waits for one explicit
+confirmation before invoking the same command without `--preview`; unanswered
+or declined confirmation therefore leaves the directory unchanged.
+
## Backlinks
- [[cli]] · [[commands/run]] · [[commands/doctor]] · [[commands/setup-agents]]
diff --git a/wiki/commands/new.md b/wiki/commands/new.md
index 7c575c13d1..55ddcdd158 100644
--- a/wiki/commands/new.md
+++ b/wiki/commands/new.md
@@ -3,8 +3,8 @@ title: hive new
type: command
source: bin/hive, lib/hive/commands/new.rb, templates/idea.md.erb
created: 2026-04-25
-updated: 2026-07-21
-tags: [command, capture, slug, task-id, commit-lock, workflow, dependencies, base-branch]
+updated: 2026-07-24
+tags: [command, capture, slug, task-id, idempotency, json, commit-lock, workflow, dependencies, base-branch]
---
**TLDR**: `hive new PROJECT TEXT...` captures an idea: derives a slug, resolves the effective workflow (`--workflow` override, project `default_workflow`, then `coding`), scaffolds the descriptor's entry-stage folder plus state file and `meta.yml`, commits it on the `hive/state` branch, and best-effort starts display-name generation after the commit.
@@ -123,6 +123,8 @@ slug: add-inbox-filter-260603-abcd
display_name:
depends_on: api:base-task-260716-abcd
base_branch: main
+idempotency_key: workflow-creator:editorial:stable
+input_fingerprint: 3f...
```
`id` comes from the process-global counter at `Hive::Paths.task_counter_path` (`<state_home>/task-counter.yml`), protected by `<state_home>/.task-counter.lock`. `display_name` starts nil; `Hive::Task#display_label` falls back to the slug until name generation succeeds. `depends_on` is omitted when not supplied and remains the authoritative scheduling declaration when present. `workflow:` is omitted for plain coding captures, but set for explicit overrides and non-coding project defaults. `base_branch:` is omitted outside draft-PR workflows and is authoritative when present.
@@ -133,6 +135,41 @@ base_branch: main
- `test/integration/new_wrapper_argv_test.rb` drives the real `bin/hive` subprocess and pins wrapper-only argv behavior: canonical `PROJECT --workflow ID "text"`, before-project and trailing options, `--workflow=ID`, combined dependency/workflow flags, literal `--workflow` substrings, lifted-but-plain `--json`, missing text after lifted options, and unrecognized options as task text.
- `test/integration/tui_new_idea_attachments_test.rb` covers the TUI-internal rich submit path.
+## Idempotent machine-readable creation
+
+Automation may add `--idempotency-key KEY --json`. Hive stores the opaque key
+with a fingerprint of the normalized input, resolved workflow, dependency, and
+base branch. Idempotent attachments are copied once into a private temporary
+snapshot; the fingerprint and task asset both use those bytes, and the final
+copy is digest-verified. A pathname replacement or source edit between lookup
+and task creation therefore cannot make metadata describe different captured
+content. For owner-authored workflows the fingerprint also covers the
+descriptor and complete instruction tree, so changed owner content conflicts
+instead of reusing an older task. Hive rechecks that authored digest inside the
+commit boundary before lookup reuse and again before metadata publication; a
+mid-creation edit aborts and removes the candidate instead of binding a stale
+fingerprint to a different graph. Metadata admission fails closed if any
+candidate task metadata is malformed or unreadable. The managed-workflow
+mutation lock is acquired before the state commit lock, matching lifecycle
+writers. One commit lock now covers the state-wide key lookup, exclusive
+task-directory creation, metadata write, and commit. Same-slug contenders
+therefore cannot share a candidate directory, report two creations, overwrite
+one another, or remove a directory they did not create. A matching retry
+returns the original slug with `created: false` even after the task has moved;
+reuse for different input/workflow fails without a second task, and duplicate
+metadata is a fail-closed repair error.
+
+If a capture commit fails after staging, both idempotent and legacy paths
+remove the owned candidate and reset its exact state-index pathspec. Failures
+raised after directory ownership, including `EEXIST`, likewise remove only the
+owned candidate before propagating the original error.
+
+The `hive-new.v1` success payload contains `created`, `slug`, `workflow`,
+`current_stage`, `task_folder`, and a structured `next_action`. Callers without
+an idempotency key retain the existing capture behavior. The workflow creator
+uses this surface only when task creation was explicit in the original request;
+creation-only workflow authoring never invokes `hive new`.
+
## Backlinks
- [[cli]] · [[commands/run]] · [[stages/inbox]]
diff --git a/wiki/commands/workflow.md b/wiki/commands/workflow.md
index 7f2e6028f7..6334cea6d0 100644
--- a/wiki/commands/workflow.md
+++ b/wiki/commands/workflow.md
@@ -4,7 +4,7 @@ type: command
source: lib/hive/cli.rb, lib/hive/commands/workflow.rb, templates/workflows/
created: 2026-06-21
updated: 2026-07-24
-tags: [command, workflow, authoring, honeycomb, registry, archive, retention]
+tags: [command, workflow, authoring, validation, human-stage, honeycomb, registry, archive, retention]
---
**TLDR**: `hive workflow` manages two ownership domains: `new` scaffolds trusted project-authored descriptors, while `install`, `list`, `update`, and `remove` manage immutable reviewed Honeycomb generations; `publish` validates an authored descriptor and opens a registry PR whose status is only `pending_review`.
@@ -15,6 +15,8 @@ tags: [command, workflow, authoring, honeycomb, registry, archive, retention]
hive workflow new my-flow
hive workflow new my-flow --template research
hive workflow new my-flow --json
+hive workflow validate my-flow --json
+hive workflow commit my-flow
hive workflow install honeycomb/repo-brief --yes
hive workflow install honeycomb/repo-brief --yes --allow-escalation \
--mapping stages.research=codex,model=gpt-5.6-sol,effort=high \
@@ -258,6 +260,67 @@ pointing at the directory of instructions to define (the single-stage blank
still names its one `work.md`). An unknown `--template` is a USAGE error
listing the available names; with `--json` they ride the `expected` array.
+## Read-only validation and human outcomes
+
+`hive workflow validate ID --json` resolves authored descriptors, built-ins,
+and the currently selected managed generation through direct read-only
+lookups. It does not acquire the managed mutation lock, reconcile a selected
+pointer, or replay/clear a transaction journal. It validates descriptor YAML,
+referenced instructions, stage inputs/state files, automatic edges, and
+descriptor-declared human outcomes without creating a task or writing project
+or Hive state. The `hive-workflow-validate.v1` result reports the descriptor
+origin/path, ordered stages, instruction paths, automatic edges, human
+outcomes, and `valid`. Invalid input uses the same command-specific envelope
+with diagnostics, including malformed `--json` invocations.
+
+Project-authored descriptors may declare a durable `kind: human` stage:
+
+```yaml
+- name: approval
+ kind: human
+ state_file: approval.md
+ input: draft.md
+ outcomes:
+ approve:
+ complete: true
+ artifact: draft.md
+ reject:
+ to: draft
+```
+
+Every outcome has exactly one action: `complete: true` with a required
+non-empty artifact basename, or `to: STAGE`. Human stages reject
+agent/model/permissions/instruction/runner settings and are never dispatched
+by the daemon. Apply a decision with
+`hive decide TASK OUTCOME --from STAGE --decision-id DECISION_ID [--note TEXT] [--json]`.
+The waiting `hive run --json` response supplies that visit-specific ID. The
+expected stage and decision identity make matching retries no-ops and reject
+stale or conflicting decisions. Matching concurrent retries are rechecked
+under the decision lock and return one apply plus idempotent no-ops. Completing
+outcomes accept only a no-follow regular file inside the task folder. A
+self-targeting outcome records the decision, leaves the task in place, and
+mints a fresh waiting identity instead of reporting a false move. Human state
+files are also read no-follow with inode verification. A completing outcome
+writes its decision record and `completed_at` from one clock, classifies as
+archived, and participates in the descriptor's archive visibility retention;
+commit failure or interruption rolls both files back.
+
+Create-only natural-language requests are handled by the canonical `/hive`
+skill's `hive-workflow-creator` route. It gates on the installed version,
+inventories IDs, scaffolds only through this command (or the minimal init path),
+edits only returned new paths, validates here, commits the populated descriptor
+and instruction directory with `hive workflow commit ID`, reports all defaults, and creates no
+task unless the original request explicitly asks for one. The populated-graph
+commit is required because `workflow new` commits only the initial scaffold.
+Both `workflow validate` and minimal-init preview bypass startup scheduler
+reconciliation, preserving their strict no-write contract. Scaffold collision
+checks treat dangling descriptor or instruction symlinks as occupied paths.
+Creation claims the instruction directory and descriptor with exclusive
+filesystem operations; rollback removes only paths claimed by that invocation,
+so a concurrent or raced scaffold is never overwritten or deleted.
+If a scaffold commit fails after staging, Hive resets those exact index
+pathspecs under the commit lock before removing the generated files.
+
## Backlinks
- [[cli]]
diff --git a/wiki/gaps.md b/wiki/gaps.md
index b45b4fcad0..4359abb4e5 100644
--- a/wiki/gaps.md
+++ b/wiki/gaps.md
@@ -737,6 +737,21 @@ read-through alias has not been selected. Remove it only after warned projects
have had a migration window and release notes make the strict boundary
explicit; no version or publication decision is implied by the source change.
+## Workflow-creator protected OpenClaw proof awaits a credentialed run (2026-07-24)
+
+The natural-language workflow creator is covered hermetically through AE1–AE5,
+including real descriptor loading, human approve/reject decisions, minimal-init
+preview, collision refusal, and state-wide task idempotency. The protected
+OpenClaw workflow and fail-closed attestor are source/unit tested, but this
+implementation session had no release-gate OpenAI credential and therefore did
+not produce a trusted live attestation. Keep this gap open until the dedicated
+`live-workflow-creator` job succeeds for an exact protected-main candidate and
+the resulting Check Run binds the prompt, candidate skill digest, ordered Hive
+commands, exact editorial graph, zero task count, secret scan, and cleanup.
+The same proof must also attest one explicitly authorized task, one first-stage
+run, a no-op retry with the same idempotency key, and matching operational
+status.
+
## WorkLedger is an internal boundary, not a published format (2026-07-26)
`Hive::WorkLedger` now has clean-load, structural descriptor, durable append,
diff --git a/wiki/log.d/20260722-natural-language-workflow-creator.md b/wiki/log.d/20260722-natural-language-workflow-creator.md
new file mode 100644
index 0000000000..49211847bb
--- /dev/null
+++ b/wiki/log.d/20260722-natural-language-workflow-creator.md
@@ -0,0 +1,24 @@
+## 2026-07-22 — Natural-language workflow creator
+
+- Added the `hive-workflow-creator` route inside the one canonical `/hive`
+ AgentSkill. It inventories the installed version/project/workflow IDs,
+ scaffolds only through Hive, validates the normalized graph, reports every
+ inferred default, and creates no task unless the original request asks.
+- Added durable descriptor-declared human outcomes and
+ `hive decide TARGET OUTCOME --from STAGE [--note TEXT] [--json]`. Human
+ stages remain `WAITING`, are never daemon-dispatched, record idempotent
+ decisions, and can complete with an artifact or return to a named stage.
+- Added read-only `hive workflow validate ID --json`, no-write
+ `hive init --new-workflow ID --minimal --preview --json`, and idempotent
+ machine-readable `hive new ... --idempotency-key KEY --json`.
+- The editorial acceptance path is exactly
+ `research -> draft -> approval`: approve records non-empty `draft.md` as
+ publish-ready and completes; reject records the decision and resets draft to
+ `WAITING`. No publish stage or external action is inferred.
+- Added hermetic AE1–AE5 acceptance coverage plus a separate protected OpenClaw
+ proof whose attestation binds the exact candidate skill, prompt, commands,
+ created files, validation graph, creation-only no-task result, explicit
+ create/run no-op retry, operational status, secret scan, and cleanup.
+- Updated manual/natural-language workflow docs and the canonical OpenClaw
+ projection. Hive-site `#23116` remains a non-blocking wording handoff; this
+ change does not select a release version, publish, tag, or deploy.
diff --git a/wiki/log.d/20260724-workflow-creator-review-fix-pass-2.md b/wiki/log.d/20260724-workflow-creator-review-fix-pass-2.md
new file mode 100644
index 0000000000..56e82a5a0e
--- /dev/null
+++ b/wiki/log.d/20260724-workflow-creator-review-fix-pass-2.md
@@ -0,0 +1,17 @@
+## 2026-07-24 — Workflow creator review fix pass 2
+
+- Preserved strict no-write workflow validation and minimal-init preview at
+ executable startup, kept minimal init free of scheduler timers, rejected
+ managed-path and scaffold symlink redirection, and added a locked
+ `hive workflow commit ID` path for populated owner-authored graphs.
+- Made human decisions reject non-local or symlinked completion artifacts,
+ record self-target outcomes with a fresh decision identity, and return
+ idempotent no-ops for matching concurrent retries.
+- Made task creation fingerprint owner-authored workflow bytes, fail closed on
+ corrupt metadata, use the canonical workflow-mutation/state-commit lock
+ order, and clean both owned candidates and staged index entries on failure.
+ Applied the same exact-path index rollback to failed workflow scaffold and
+ populated-graph commits.
+- Specified exact POSIX request quoting and deterministic retry-key derivation,
+ then expanded hermetic creator acceptance to cover version/confirmation
+ refusals and durable populated-graph commit evidence.
diff --git a/wiki/log.d/20260724-workflow-creator-review-hardening.md b/wiki/log.d/20260724-workflow-creator-review-hardening.md
new file mode 100644
index 0000000000..576cd7e6cb
--- /dev/null
+++ b/wiki/log.d/20260724-workflow-creator-review-hardening.md
@@ -0,0 +1,17 @@
+## 2026-07-24 — Workflow creator review hardening
+
+- Bound every human decision to the current `decision_id`, moved completion
+ artifact verification under the task/commit locks, rejected marker-only
+ artifacts, and separated completed-human status from archived tasks.
+- Made human-stage entry reject symlinked state files and copy a stable,
+ no-follow snapshot into the destination.
+- Made `workflow validate` strictly read-only for authored, built-in, and
+ managed workflows, including prepared managed journals, and gave malformed
+ JSON invocations their command-specific schema.
+- Made minimal-init collisions and failures machine-readable without replacing
+ an unrelated same-basename registration.
+- Serialized idempotent lookup, exclusive candidate creation, and commit so
+ same-slug contenders cannot share or overwrite a task directory.
+- Required the natural-language creator to commit its populated workflow graph
+ after validation, and synchronized the canonical/OpenClaw guidance, public
+ docs, wiki, schemas, and focused regressions.
diff --git a/wiki/log.d/20260727T192524Z-human-workflow-archive-integration.md b/wiki/log.d/20260727T192524Z-human-workflow-archive-integration.md
new file mode 100644
index 0000000000..23f67b689e
--- /dev/null
+++ b/wiki/log.d/20260727T192524Z-human-workflow-archive-integration.md
@@ -0,0 +1,22 @@
+## 2026-07-27 — Human workflow archive integration
+
+- Routed completed human stages through the shared `archived` action and
+ descriptor retention path instead of maintaining a human-only completion
+ bucket.
+- Stamped `meta.yml completed_at` from the durable decision timestamp and made
+ state plus metadata rollback together on commit failure or interruption.
+- Refused symlinked human decision-state reads with no-follow inode checks.
+- Made strict no-write startup routing select the actual top-level command, so
+ a workflow id named `workflow` cannot trigger scheduler reconciliation during
+ minimal-init preview and option placement cannot hide workflow validation.
+- Bound idempotent attachment fingerprints and task assets to one private byte
+ snapshot, with a final digest check before metadata is committed.
+- Treated a dangling `.hive-state` symlink as an occupied minimal-init target
+ instead of allowing initialization to reach an unsafe path.
+- Made workflow scaffolds claim their instruction directory and descriptor
+ exclusively, and limited rollback to paths the invocation actually created,
+ so a concurrent creator cannot be overwritten or deleted.
+- Extended task creation, workflow scaffolding/commit, and minimal-init rollback
+ boundaries to interrupts as well as ordinary exceptions.
+- Rechecked owner-authored workflow content inside idempotent creation before
+ reuse and metadata publication, aborting raced descriptor/instruction edits.
diff --git a/wiki/modules/task_action.md b/wiki/modules/task_action.md
index 6f8f2db9a3..6800e15e67 100644
--- a/wiki/modules/task_action.md
+++ b/wiki/modules/task_action.md
@@ -4,7 +4,7 @@ type: module
source: lib/hive/task_action.rb
created: 2026-04-26
updated: 2026-07-25
-tags: [module, status, action, classifier, diagnostic]
+tags: [module, status, action, classifier, human-stage, diagnostic]
---
**TLDR**: Classifier that turns a `(Hive::Task, Hive::Markers::State)` pair into a user-facing action with a stable key (per `Hive::Schemas::TaskActionKind`), a human label for `hive status` output, a copy-paste-executable shell command, an optional structured `next_action` for row-specific recovery, and (since 2026-05-16) a bounded `diagnostic` payload for red recovery rows. Used by `hive status` (action grouping + `tasks[].action`/`diagnostic` JSON fields), `hive run` (`next_action.command` / `rerun_with`), `hive approve` (`next_action.command` after a successful advance), `hive accept-finding` / `hive reject-finding` (`next_action.command` after a toggle), and `hive status --diagnose` (`#diagnostic` is the local fallback path when no agent-written artifact is fresh).
@@ -140,6 +140,19 @@ For non-coding workflows, command emission bypasses the coding
emit `hive run <slug>` (plus `--project` when the status snapshot spans multiple
projects, and `--stage` for run rows only when a stage collision was reported).
+Human descriptor stages use the dedicated `human_needs_input` classification:
+their non-complete marker state is `NEEDS_INPUT` with label
+`Awaiting human decision`, no daemon-dispatch command, and a structured
+`outcomes` list. The operator transition is deliberately separate from generic
+approval:
+`hive decide TASK OUTCOME --from STAGE --decision-id DECISION_ID`. A completed human stage
+uses the shared `archived` action even though its folder remains in the terminal
+human-stage directory. The completing decision writes `completed_at` from the
+same clock as its durable decision record, so descriptor retention applies
+without a separate human-only completion state. An identical decision retry is
+a no-op while stale or conflicting decisions fail against the expected stage
+and visit-specific decision ID.
+
`--project <name>` is appended whenever `project_count > 1` so multi-project status output emits unambiguous commands.
The slug is `Shellwords.shelljoin`-escaped so a slug containing shell metacharacters can't break the suggested command.
@@ -160,6 +173,7 @@ adapter before a baseline; downstream consumers do not invent condition state.
| `lib/hive/commands/status.rb` | `annotate_actions` calls `TaskAction.for` per row and routes by `action_key` for grouping. JSON `tasks[].action`/`action_label`/`suggested_command`/`next_action` come from this. |
| `lib/hive/commands/run.rb` | `friendly_command` and `approve_action` delegate; `next_action.command` and `rerun_with` use the workflow form. |
| `lib/hive/commands/approve.rb` | `json_next_action` builds the post-advance command via `TaskAction.for(post_move_task)` so the user lands on a runnable form for the new stage. |
+| `lib/hive/commands/decide.rb` | Applies a descriptor-declared human outcome and reports the post-decision action/state. |
| `lib/hive/commands/stage_action.rb` | `success_payload` includes a `next_action` block built from TaskAction. |
## Why a class, not a hash lookup?
diff --git a/wiki/modules/workflows.md b/wiki/modules/workflows.md
index 39ba50a84c..0e0eb35f62 100644
--- a/wiki/modules/workflows.md
+++ b/wiki/modules/workflows.md
@@ -4,7 +4,7 @@ type: module
source: lib/hive/workflows.rb, lib/hive/workflow.rb, lib/hive/workflows/registry.rb, lib/hive/workflows/coding.rb, lib/hive/workflows/content.rb, lib/hive/workflows/bench.rb, lib/hive/workflows/descriptor_parser.rb, lib/hive/workflows/loader.rb, lib/hive/workflows/project.rb, lib/hive/workflow_package/
created: 2026-04-26
updated: 2026-07-26
-tags: [module, workflow, verbs, selection, honeycomb, registry, archive, retention]
+tags: [module, workflow, verbs, selection, human-stage, outcomes, honeycomb, registry, archive, retention]
---
**TLDR**: The coding, content, bench, and project-authored workflows are described as ordered `Hive::Workflow` value objects whose stages carry directory names, state files, incoming advance verbs, runner metadata, optional instruction files, optional permission specs, per-stage agent/model/effort overrides, council reviewer configs, terminal deliverables, and an archive visibility retention policy. `Hive::Workflows::Registry.default` still returns the coding descriptor, and the legacy public constants (`Hive::Stages::DIRS`, `Hive::Task::STAGE_NAMES` / `STATE_FILES`, `Hive::Workflows::VERBS`) are derived from it at load time. `Hive::Task` resolves a per-task descriptor from `meta.yml workflow:` or project `default_workflow`, `Hive::WorkflowSelection` centralizes CLI validation and valid-name listing, `Hive::Workflows::Registry.all` exposes the live descriptor set for built-in, runtime/test, and active-project registrations, and `Hive::Stages::Resolver` consumes `kind: :agent` / `kind: :council` as fallbacks for non-coding stage names while coding's bespoke runners remain name-authoritative only for `:coding`. Coding's descriptor now uses runtime primitive kinds (`:execute`, `:review_council`, `:finalize`) for the worktree-coupled stages; the old `:marker` descriptor kind is retired.
@@ -57,7 +57,9 @@ Per-project descriptors live under `<hive_state_path>/workflows/*.yml`, defaulti
- council stages require a `reviewers:` list; each reviewer declares exactly one of `skill`, `instruction`, `prompt`, or `command`, plus optional agent/model/effort/permissions/output basename. The `council:` block carries `quorum`, `max_rounds`, `exit_rule`, `on_max_rounds` (`wait` by default or `complete` for a bounded downstream delivery), `triage_output`, and optional `revise`.
- `instruction:` paths are resolved relative to the descriptor directory and stored on the stage as absolute paths.
- `permissions:` values are validated through `Hive::PermissionScope` at load time and later passed to the generic agent runner as the explicit permission spec.
-- the last stage may be inert, agent, or council. Active terminal stages require both `COMPLETE` and a non-empty deliverable before `TaskAction` classifies them as archived.
+- the last stage may be inert, agent, council, or human. Active terminal stages
+ require `COMPLETE` and their declared non-empty deliverable/artifact before
+ `TaskAction` classifies them as archived.
- `workspace: worktree` plus `handoff: draft_pr` is one closed terminal-agent
contract. Both fields must appear together and both `state_file` and
`deliverable` must equal task-root `fix-report.md`. Parser and managed-package
@@ -111,7 +113,7 @@ The upgrade path was live-smoked on the existing hive-bench state checkout on
`coding` tasks all remained resolvable, with all nine pre-migration status rows
still visible and the unrelated dirty-state fingerprint unchanged.
-`hive workflow new ID` (see [[commands/workflow]]) scaffolds the minimal `inbox -> work -> done` descriptor plus `work.md` instruction and commits those files to `hive/state`. The only richer shipped scaffold is `--template research`; Architecture and Writing are installed as full reviewed Honeycomb packages so their agent-slot configuration remains operator-owned.
+`hive workflow new ID` (see [[commands/workflow]]) scaffolds the minimal `inbox -> work -> done` descriptor plus `work.md` instruction and commits those initial files to `hive/state`. After editing, the natural-language creator validates and invokes `hive workflow commit ID`, which commits the populated descriptor/instruction directory under the shared state commit lock before it reports success or creates a task. The only richer shipped scaffold is `--template research`; Architecture and Writing are installed as full reviewed Honeycomb packages so their agent-slot configuration remains operator-owned.
## Managed Honeycomb overlay
@@ -323,6 +325,32 @@ Hermetic coverage lives in `test/unit/workflows/content_test.rb`,
`test/integration/content_workflow_stage_test.rb`, and
`test/integration/content_workflow_e2e_test.rb`.
+## Durable human stages
+
+`Hive::Workflow::Stage` accepts `kind: :human` plus immutable named
+`outcomes`. The owner-authored parser exposes only closed directional actions:
+an outcome must either complete the workflow or target another stage, and a
+completing outcome may name an artifact. Unsafe outcome names, unknown keys,
+missing/duplicate actions, unknown targets, and agent-only settings fail while
+the descriptor is loaded.
+
+A human stage has no runner. Entering it leaves the state file `WAITING`, and
+status exposes `NEEDS_INPUT` with the descriptor's allowed outcomes.
+`Hive::Commands::Decide` writes a durable decision record with outcome, note,
+artifact/target, and timestamp. A completing decision verifies its declared
+artifact, stamps `meta.yml completed_at` from the same clock, and exposes the
+task through the shared archive/retention path. Decision-state reads refuse
+symlinks and verify the opened inode. State, metadata, and moves roll back
+together when a commit fails or is interrupted. A returning decision moves the
+same task under the task/state locks and resets the target state file to
+`WAITING`, preventing a stale completion marker from immediately advancing it
+again.
+
+The accepted editorial graph is exactly
+`research -> draft -> approval`: `approve` completes with non-empty
+`draft.md` recorded as publish-ready, while `reject` returns to `draft`.
+There is no publish stage or executable outcome action.
+
## Backlinks
- [[commands/run]] — workflow verb dispatch via `Hive::Commands::StageAction`
diff --git a/wiki/testing.md b/wiki/testing.md
index b7e4a14ce0..d76d54466d 100644
--- a/wiki/testing.md
+++ b/wiki/testing.md
@@ -18,6 +18,33 @@ credentials; the live-agent workflow is an optional diagnostic that directly
exercises native OpenClaw, Claude, Codex, and Pi discovery/use when credentials
are available and can never replace a deterministic blocking row.
+The natural-language workflow creator has a hermetic primary acceptance gate
+in `test/integration/workflow_creator_e2e_test.rb`. It exercises AE1–AE5 through
+the real CLI collaborators: exact editorial approve/reject semantics,
+byte-identical collision refusal, no-write minimal-init preview followed by
+confirmed execution, explicit old-version and unconfirmed-preview refusals,
+durable populated-graph commit evidence, no-task default behavior, and
+state-wide idempotent task retry after movement. Focused parser, decision, validation, init, task-meta,
+skill projection, schema, package, and release-contract tests own the smaller
+contracts, including visit-bound decision IDs, marker-only/concurrently changed
+artifact rejection, no-follow human-state entry, read-only managed validation,
+minimal-init registration collisions, symlink refusal, timer disablement, and
+JSON failures, serialized idempotent creation with index rollback and authored
+content fingerprints, concurrent/self-target decision behavior, and the
+distinct completed-human action.
+
+`test/smoke/live_hive_workflow_creator_smoke_test.rb` is a separate,
+OpenClaw-only protected proof. The exact candidate projection receives the
+editorial prompt in a disposable initialized project. Its controlled Hive
+surface permits only version, workflow inventory, scaffold, validation, and
+the populated-graph commit before task creation.
+Attestation verifies the prompt digest, native `/hive` discovery, ordered argv,
+created-file digests, exact normalized graph, zero tasks after creation-only,
+then one created-and-run slug plus a no-op retry with the same idempotency key,
+operational status, no external actions, secret scanning, and cleanup. Missing
+provider credentials make this live gate explicitly unavailable; they never
+turn a skipped test into release evidence.
+
## Local feedback loop
During implementation, run the smallest relevant test files directly: