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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/site/reference/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,11 @@ The first officer runs these against workflow state as it moves entities; you op

| Command | What it does |
|---------|--------------|
| `spacedock status` | Read or mutate the state: the default entity table is sorted by later workflow stage first then score descending, paginated to 25 rows by default in the human table (`--page N`, `--limit N`, `--limit 0` for all) while `--json` returns every row unless `--page`/`--limit` selects a window, omits the SOURCE column by default (`--fields source` or `--all-fields` restores it), `--next`, `--where`, `--set`, `--validate` (which also warns when a flat entity holds gate rooms, and when a retained `room-ref` no longer resolves), `--boot` (with `--identify`, the first officer's Startup identify — discovers the managed workflow(s), folds in the stage taxonomy and canonical ready-gate scheduling rows, and reports the boot sections; each row carries only `id`, `slug`, `current`, and `readiness`, while entity read and gate commands provide the complete decision record at engage; a gate stage alone is not ready; PR_STATE is a local `pr:` view, live PR state is checked at engage; local reads only, no mutation), `--read <ref-or-path>` (a file's structured frontmatter — including the nested `stages:` taxonomy, and projectable with `--fields` — plus a heading offset/lines map, for section-scoped reads; with `--checklist` / `--ac-scan` it extracts a stage report's checklist items with line ranges and per-AC evidence citations for the first officer's gate prep; `--stage` defaults to the entity's current `status` when omitted (so a bare `--read <entity> --checklist` reads the current stage's report), and `--stage X` reads a non-current stage) |
| `spacedock gate prepare <entity> --question TEXT --artifact REVIEW.md --summary TEXT [--reference FILE ...]` | At an actionable current workflow stage (`gate: true` and nonterminal), derive and bind a recorder-ready room for folder or flat form. Where the workflow README declares `entity-form: folder`, a flat entity that holds no rooms yet is refused instead, because the room would land in a `<slug>/` companion and bind a ref that breaks if the entity later becomes `<slug>/index.md`; file it as `<slug>/index.md`. A workflow that declares no form accepts either shape, and a flat entity that already holds rooms is grandfathered under the declaration and prepares as before. At an ungated or terminal stage, preparation exits nonzero before changing the entity or review tree and names the rejected stage. Immediately after successful preparation the room contains exactly `index.json`, the canonical Briefing, with no copied sources or association. Selected files are exact local `git-root://<main\|state>/<full-commit>/<path>` objects with raw SHA-256 revisions; there is no fetch, ref requirement, or worktree fallback. Success prints `room`, `briefing`, `digest`, and `state=open`; the caller commits this prepared binding once before presentation. |
| `spacedock status` | Read or mutate the state: the default entity table is sorted by later workflow stage first then score descending, paginated to 25 rows by default in the human table (`--page N`, `--limit N`, `--limit 0` for all) while `--json` returns every row unless `--page`/`--limit` selects a window, omits the SOURCE column by default (`--fields source` or `--all-fields` restores it), `--next`, `--where`, `--set`, `--validate` (which resolves canonical `@review/...` refs for both entity forms and warns when a retained ref no longer resolves), `--boot` (with `--identify`, the first officer's Startup identify — discovers the managed workflow(s), folds in the stage taxonomy and canonical ready-gate scheduling rows, and reports the boot sections; each row carries only `id`, `slug`, `current`, and `readiness`, while entity read and gate commands provide the complete decision record at engage; a gate stage alone is not ready; PR_STATE is a local `pr:` view, live PR state is checked at engage; local reads only, no mutation), `--read <ref-or-path>` (a file's structured frontmatter — including the nested `stages:` taxonomy, and projectable with `--fields` — plus a heading offset/lines map, for section-scoped reads; with `--checklist` / `--ac-scan` it extracts a stage report's checklist items with line ranges and per-AC evidence citations for the first officer's gate prep; `--stage` defaults to the entity's current `status` when omitted (so a bare `--read <entity> --checklist` reads the current stage's report), and `--stage X` reads a non-current stage) |
| `spacedock gate prepare <entity> --question TEXT --artifact REVIEW.md --summary TEXT [--reference FILE ...]` | At an actionable current workflow stage (`gate: true` and nonterminal), derive and bind a recorder-ready room for folder or flat form at `<slug>/review/...`, storing the form-independent `@review/...` ref. Frozen `./<slug>/review/...` and `./review/...` bindings retain their historical meaning; no migration or rewrite occurs. A declared `entity-form: folder` still refuses the first flat companion and grandfathers an existing one. At an ungated or terminal stage, preparation exits nonzero before changing the entity or review tree and names the rejected stage. Immediately after successful preparation the room contains exactly `index.json`, the canonical Briefing, with no copied sources or association. Selected files are exact local `git-root://<main\|state>/<full-commit>/<path>` objects with raw SHA-256 revisions; there is no fetch, ref requirement, or worktree fallback. Success prints `room`, `briefing`, `digest`, and `state=open`; the caller commits this prepared binding once before presentation. |
| `spacedock gate withdraw <entity> --reason TEXT` | Retire the selected current-stage open prepared attempt without a Resolution, provider evidence, application, room write, or status change. Attribution is always `agent:first-officer`; the next `gate prepare` appends a successor. |
| `spacedock gate record <entity> --decision approve\|revise\|hold --actor ID [--reason TEXT] [--consume]` | Record a chat decision and, for approve only, its derived one-use application. Supported chat actor IDs are `person:captain` and `agent:first-officer`. Delegated First Officer decisions require an evidence reason; the recorder does not accept or authenticate Captain-message text. A standalone close never advances status or dispatches. The current workflow stage must be an actionable gate, and the bound Briefing must use the canonical v1 stage-qualified identity and name that stage; malformed or mismatched identity fails without mutation. `--consume` is the shortest approval path: it sequences close, sync, consume, and sync in one call (usage error with `--decision revise\|hold`), each write appending a `sync=... phase=record\|consume` line. Do not run a separate state commit after a successful split-root close or consume write. Delegated First Officer decisions also require `--conn-quote` (the grant verbatim) and `--conn-source` (where it was given); citation flags are refused with `--actor person:captain`. |
| `spacedock gate record <entity> --round STAGE/CYCLE --briefing PATH/briefing.json --log PATH/briefing.review.jsonl` | For a folder-form entity (`<slug>/index.md`), publish one complete workflow-neutral correction round to the immutable derived room `review/<stage>/round-<cycle>` and update the current `review-round` pointer. The recorder retains canonical Briefing/log bytes and does not classify findings or write workflow body projections; `STAGE` must exist in the workflow taxonomy but may differ from current status for historical backfill. Flat entities are refused because review artifacts accumulate beside the entity. Exact replay is a no-op; divergence is refused. |
| `spacedock gate record <entity> --round STAGE/CYCLE --briefing PATH/briefing.json --log PATH/briefing.review.jsonl` | For flat or folder form, publish one complete workflow-neutral correction round to `<slug>/review/<stage>/round-<cycle>` and store `@review/<stage>/round-<cycle>`. Frozen folder `./review/...` pointers replay unchanged; no entity migration is needed. The recorder retains canonical Briefing/log bytes and does not classify findings or write workflow body projections; `STAGE` must exist in the workflow taxonomy but may differ from current status for historical backfill. Relative Artifacts must stay inside the ticket review home and cannot name the mutable entity. Exact replay is a no-op; divergence is refused. |
| `spacedock gate consume <entity>` | Spend a binding pending approval once and advance status atomically; stale approvals become superseded. A consumed nonterminal application becomes ordinary stage history. After the worker report is durable, one atomic terminal status write can complete that stage without `--force`. On an approval whose target stage is terminal, consume spends nothing and writes no status: it leaves the application `pending` and returns the route `approved-awaiting-merge` (idempotently, on repeat), and `merge guard` discovers/arms the delivery mechanism when it acts. For an ungated current-stage-to-terminal transition, finalize directly with `spacedock status --workflow-dir DIR --set SLUG status=TERMINAL completed verdict=PASSED worktree=`; do not use that route for a pending terminal-target approval, whose sole consumer remains `merge guard`. In a split-root workflow, a write (an advance or a stale-pending supersede) commits and syncs itself, appending a `sync=... phase=consume` line; a refusal or a terminal route performs no sync and emits no sync line. |
| `spacedock merge guard <slug> --verdict passed\|rejected` | Run the terminal merge ceremony and, with delivery proven, finalize: the sole terminal consumer of a pending terminal-target approval — the `mod-block` is cleared in its own step, then `application.state: consumed`, the terminal status, `verdict`, and `completed` move in one locked write, and the `pr` merge sentinel is retained through archive as durable delivery proof. |
| `spacedock merge guard <slug> --rework` | Delivery requires rework: write the pending terminal-target approval `pending→superseded`, route the entity through the record stage's declared `feedback-to`, and clear `pr`/`mod-block`. Refuses without a pending terminal approval, or with a missing/undefined/terminal `feedback-to`. |
Expand Down
37 changes: 17 additions & 20 deletions docs/specs/gate-resolution-frontmatter-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,21 +139,14 @@ The room layout is the same for both entity forms:
<state-root>/<slug>/review/<stage>/briefing-<attempt>/
```

but `room-ref` is written relative to the entity file's own directory, so folder form
binds `./review/...` while flat form binds `./<slug>/review/...`. Only the folder-form
ref is invariant under a later move of the entity. A workflow states which form it
keeps with `entity-form: folder` in its README frontmatter, and where that declaration
is present preparation refuses to create the first room beside a flat `<slug>.md`,
whose `<slug>/` companion would hold refs that break on conversion. A workflow that
declares no form accepts either shape and preparation refuses neither. Flat entities
that already hold rooms are grandfathered under the declaration, and their
slug-prefixed refs stay correct while they stay flat; converting one requires
`git mv <slug>.md <slug>/index.md` and rewriting every `room-ref: ./<slug>/` to
`room-ref: ./` in the same commit, and `status --validate` reports both the
grandfathered shape and any ref that stops resolving. State commit and archive
operations continue to treat the flat
Markdown plus companion directory as one literal path-scoped unit, including tracked
deletions and rollback, without sweeping siblings.
New bindings store `room-ref: @review/<stage>/briefing-<attempt>`. The reserved namespace
resolves below `<state-root>/<slug>/review/` for both entity forms and rejects empty,
absolute, non-normalized, dot-segment, traversal, and backslash paths. Existing flat
`./<slug>/review/...`, folder `./review/...`, and other legacy refs keep their meaning
and are never rewritten. A declared `entity-form: folder` still refuses the first flat
companion and grandfathers an existing one. No migration is required; `status --validate`
uses the same resolver, and state commit/archive continue to treat flat Markdown plus
its companion as one literal path-scoped unit without sweeping siblings.

Each selected source is a readable, committed, non-symlink regular file owned by the
workflow's `main` or distinct `state` Git history. Its closed identity is
Expand Down Expand Up @@ -216,11 +209,15 @@ append its authorized Cycle line before invoking the producer; the recorder pres
that body byte-for-byte. The published round is the durable evidence; `gate record
--round` reports every Resolution as advisory structural evidence on publication.

Round recording requires a folder-form entity at `<slug>/index.md`, so its accumulating
`review/` artifacts are scoped beside that entity. Flat entities refuse before locking
or writing; the recorder does not alter the approved derived room path to compensate.
`STAGE` must name a stage in the workflow definition, but need not equal current
`status`: explicit historical backfill remains supported.
Flat `<slug>.md` and folder `<slug>/index.md` entities publish to the same ticket review
home and store `@review/<stage>/round-<cycle>`. Frozen folder `./review/...` pointers
replay unchanged. The declared-folder policy applies to new flat round homes and
grandfathers an existing companion. `STAGE` must exist in the workflow definition but
may differ from current `status` for historical backfill.

A relative round Artifact is trusted only when its resolved regular path stays inside
`<state-root>/<slug>/`. Paths outside that home and the mutable entity (`<slug>.md` or
`<slug>/index.md`) refuse during publication and replay; `git-root://` identities are unchanged.

The room is immutable: exact whole-room replay is a whole-tree no-op; any different
Briefing, log, room shape, or pointer fails closed. New-room publication rolls back if
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/gate_one_file_room_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func TestGateOneFileRoomJourneyPublishesAndCompletes(t *testing.T) {
if binding.RequestDigest != "" {
t.Fatalf("binding carries request-digest %q", binding.RequestDigest)
}
if binding.RoomRef != "./task/review/validation/briefing-1" {
if binding.RoomRef != "@review/validation/briefing-1" {
t.Fatalf("room-ref=%q", binding.RoomRef)
}

Expand Down
63 changes: 63 additions & 0 deletions internal/cli/state_commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"testing"

"github.com/spacedock-dev/spacedock/internal/gates"
"github.com/spacedock-dev/spacedock/internal/status"
"github.com/spacedock-dev/spacedock/internal/testgit"
)
Expand Down Expand Up @@ -260,6 +261,68 @@ func TestStateCommitFlatIncludesExactCompanionDirectoryAndTrackedDeletions(t *te
}
}

func TestStateCommitMakesFlatRoundDurableInFreshHost(t *testing.T) {
bare, workflowA, workflowB, stateBranch := twoHostStateWorkflow(t)
checkoutA := filepath.Join(workflowA, ".spacedock-state")
checkoutB := filepath.Join(workflowB, ".spacedock-state")
hostA := filepath.Dir(filepath.Dir(workflowA))
const slug = "first-task"
entityA := filepath.Join(checkoutA, slug+".md")
writeEntity(t, workflowA, slug, "---\nid: first-task\nstatus: ideation\ntitle: First Task\n---\n# Flat round\n")
homeA := filepath.Join(checkoutA, slug)
if err := os.MkdirAll(homeA, 0o755); err != nil {
t.Fatal(err)
}
copyFixture := func(dst, name string) {
t.Helper()
body, err := os.ReadFile(filepath.Join("..", "gates", "testdata", "advisory-round", name))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, body, 0o644); err != nil {
t.Fatal(err)
}
}
copyFixture(filepath.Join(homeA, "candidate.patch"), "candidate.patch")
inputs := t.TempDir()
briefing, log := filepath.Join(inputs, "briefing.json"), filepath.Join(inputs, "briefing.review.jsonl")
copyFixture(briefing, "briefing.json")
copyFixture(log, "briefing.review.jsonl")
if err := gates.RecordSemantic(entityA, gates.RecordInput{Round: "ideation/1", BriefingPath: briefing, LogPath: log, WorkflowDir: workflowA}); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(checkoutA, "dirty-sibling.md"), []byte("do not commit\n"), 0o644); err != nil {
t.Fatal(err)
}
if code, _, errOut := runStateCommitCmd(t, hostA, workflowA, slug, "-m", "durable flat round"); code != 0 {
t.Fatalf("state commit exit=%d stderr=%q", code, errOut)
}
want := []string{
"first-task.md", "first-task/candidate.patch",
"first-task/review/ideation/round-1/briefing.json",
"first-task/review/ideation/round-1/briefing.review.jsonl",
}
if got := strings.Fields(git(t, checkoutA, "show", "--name-only", "--pretty=format:", "HEAD")); strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("flat round commit paths=%q want %q", got, want)
}
if _, ok := gitOK(t, bare, "cat-file", "-e", stateBranch+":dirty-sibling.md"); ok {
t.Fatal("dirty sibling reached the state remote")
}
git(t, checkoutB, "pull", "-q", "--rebase", "origin", stateBranch)
entityB := filepath.Join(checkoutB, slug+".md")
if _, err := gates.ValidateRoundFile(entityB, "ideation/1"); err != nil {
t.Fatalf("fresh-host round validation: %v", err)
}
roomB := filepath.Join(checkoutB, slug, "review", "ideation", "round-1")
before := git(t, checkoutB, "status", "--porcelain")
if err := gates.RecordSemantic(entityB, gates.RecordInput{Round: "ideation/1", BriefingPath: filepath.Join(roomB, "briefing.json"), LogPath: filepath.Join(roomB, "briefing.review.jsonl"), WorkflowDir: workflowB}); err != nil {
t.Fatalf("fresh-host exact replay: %v", err)
}
if after := git(t, checkoutB, "status", "--porcelain"); after != before {
t.Fatalf("exact replay changed checkout: before=%q after=%q", before, after)
}
}

func TestStateCommitFlatCompanionRenameIncludesSourceAndDestination(t *testing.T) {
_, workflow, _, _ := twoHostStateWorkflow(t)
checkout := filepath.Join(workflow, ".spacedock-state")
Expand Down
6 changes: 3 additions & 3 deletions internal/ensigncycle/claude_live_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,10 +330,10 @@ func runClaudeWithdrawnGateRecoveryScenario(t *testing.T, runner liveDriver, sce
if readFile(t, firstBriefingPath) != firstBriefing {
t.Fatalf("recovery rewrote withdrawn room bytes\nArtifacts: %s", result.artifactDir)
}
secondRoom := filepath.Join(filepath.Dir(fixture.entity), filepath.FromSlash(current.Briefing.RoomRef))
secondRoom, resolveErr := gates.ResolveRoomRef(fixture.entity, current.Briefing.RoomRef)
entries, err := os.ReadDir(secondRoom)
if err != nil || len(entries) != 1 || entries[0].Name() != "index.json" || !entries[0].Type().IsRegular() {
t.Fatalf("successor room is not the emitted one-file room: entries=%v err=%v\nArtifacts: %s", entries, err, result.artifactDir)
if resolveErr != nil || err != nil || len(entries) != 1 || entries[0].Name() != "index.json" || !entries[0].Type().IsRegular() {
t.Fatalf("successor room is not the emitted one-file room: resolve=%v entries=%v err=%v\nArtifacts: %s", resolveErr, entries, err, result.artifactDir)
}
if current.Briefing.RequestDigest != "" {
t.Fatalf("successor binding carries request-digest %q\nArtifacts: %s", current.Briefing.RequestDigest, result.artifactDir)
Expand Down
11 changes: 9 additions & 2 deletions internal/gates/io.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,18 @@ func validateRetainedAuthorityExcept(entityPath, workflowDir string, doc *Docume
// prepared room gets those two checks. A skip here gives the
// one-file room less validation than the two-file room had, and
// that inverts the point of the change.
if !preparedRoomBinding(entityPath, attempt.Briefing) {
prepared, err := preparedRoomBinding(entityPath, attempt.Briefing)
if err != nil {
return fmt.Errorf("attempt %s room-ref: %w", attempt.ID, err)
}
if !prepared {
continue
}
if attempt.Briefing.RequestDigest != "" {
room := filepath.Join(filepath.Dir(entityPath), filepath.FromSlash(attempt.Briefing.RoomRef))
room, err := ResolveRoomRef(entityPath, attempt.Briefing.RoomRef)
if err != nil {
return err
}
requestBytes, err := os.ReadFile(filepath.Join(room, "request.json"))
if err != nil {
return fmt.Errorf("attempt %s retained request.json: %w", attempt.ID, err)
Expand Down
Loading