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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/site/reference/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ 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 entity table (omits the SOURCE column by default; `--fields source` or `--all-fields` restores it), `--next`, `--where`, `--set`, `--validate`, `--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 status` | Read or mutate the state: the entity table (omits the SOURCE column by default; `--fields source` or `--all-fields` restores it), `--next`, `--where`, `--set`, `--validate`, `--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 when a single workflow is selected, and reports the boot sections; when several workflows are discovered it returns a complete `multiple_workflows` discovery record and the first officer selects/engages one rather than retrying; 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 ...]` | Derive and bind a recorder-ready room for folder or flat form. Immediately after preparation the room contains exactly `gate-briefing.json` and `request.json`, 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`. |
| `spacedock gate record <entity> --room PATH` | Verify a prepared gate room and record its direct binding Result. The recorder checks the frozen request digest, derives the fixed room evidence paths and complete Artifact/Reference association, and leaves advisory output as retained evidence. 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. |
| `spacedock gate record <entity> --decision approve\|revise\|hold --actor ID [--reason TEXT]` | Record a chat decision and 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. Recording 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. |
Expand Down
1 change: 1 addition & 0 deletions docs/specs/scenario-testing-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ The first foundation is the host-neutral runtime scenarios already shipped and h
- `merge-hook-guardrail` — the FO cannot bypass a registered merge hook by terminalizing without pr, mod-block, or force.
- `filing` — the FO files a new seed entity via the atomic `spacedock new <slug>` path, not the drift-prone `--next-id` + hand-write pair.
- `shallow-boot` — a freshly-booted FO performs local identify, greets with accurate held-gate state, creates no team, dispatches no worker, mutates no entity, and stops for engage/input; merged-PR discovery and terminal ceremony are engage work, not greet work.
- `multi-workflow-boot` — at a project root with two commissioned workflows, the FO runs boot identify once, makes no status/helper retry, greets with the exact workflow-selection boundary, and performs no convergence or mutation.
- `self-evidence-merge-triage` — the FO holds its own merge/triage decision to the evidence bar: it does not terminalize while a required live lane is unapproved, and it diagnoses a live-CI red from this run's failing test, not an inherited "known flake" label.
- `smallest-sufficient-mechanism` — the FO chooses the smallest sufficient mechanism: it applies deterministic edits it already holds in-house and commits a convention-direct doc directly (no worker/PR climb), while engaging a commissioned stage's ready entities via the standing dispatch loop without a per-entity justification (the gate stays silent through engage).
- `keep-moving-posture` — the FO keeps moving: after a gate approval it advances + dispatches the next stage with no permission question, dispatches independent ready entities in parallel, re-shapes a questioned entity and pauses only its dispatch while the independent ones keep moving, and does not end its turn on an async wait while independent work remains.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (

func TestFOInstructionComponentCaps(t *testing.T) {
for rel, cap := range map[string]int{
"skills/first-officer/references/first-officer-shared-core.md": 26754,
"skills/first-officer/references/first-officer-shared-core.md": 27194,
"skills/fo-gate-lifecycle/SKILL.md": 6600,
} {
if got := len([]byte(readRepoFile(t, filepath.FromSlash(rel)))); got > cap {
Expand Down
24 changes: 0 additions & 24 deletions internal/contractlint/startup_collapse_test.go

This file was deleted.

1 change: 1 addition & 0 deletions internal/ensigncycle/boot_discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func fixtureDiscoveryReadmes() map[string]func() string {
"merge-hook-guardrail": mergeHookGuardReadme,
"filing": filingReadme,
"shallow-boot": shallowBootReadme,
"multi-workflow-boot": func() string { return multiWorkflowBootReadme("alpha") },
"self-evidence-merge-triage": mergeTriageReadme,
"smallest-sufficient-mechanism": smallestMechanismReadme,
"keep-moving-posture": keepMovingReadme,
Expand Down
58 changes: 51 additions & 7 deletions internal/ensigncycle/claude_live_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ type liveDriver interface {
model() string
home() string
withStubPATH(dir string) liveDriver
withInvocationLedger(t *testing.T, ledger testInvocationLedger) liveDriver
}

// liveResult is the host-neutral observed state the shared assertions consume.
Expand Down Expand Up @@ -164,6 +165,7 @@ func claudeScenarioRunners() map[string]func(*testing.T, liveDriver, sharedRunti
"merge-hook-guardrail": runClaudeMergeHookGuardrailScenario,
"filing": runClaudeFilingScenario,
"shallow-boot": runClaudeShallowBootScenario,
"multi-workflow-boot": runClaudeMultiWorkflowBootScenario,
"self-evidence-merge-triage": runClaudeSelfEvidenceMergeTriageScenario,
"smallest-sufficient-mechanism": runClaudeSmallestSufficientMechanismScenario,
"keep-moving-posture": runClaudeKeepMovingScenario,
Expand Down Expand Up @@ -302,6 +304,28 @@ func (r claudeLiveRunner) withStubPATH(dir string) liveDriver {
return r
}

func (r claudeLiveRunner) withInvocationLedger(t *testing.T, ledger testInvocationLedger) liveDriver {
r.env = withSpacedockShimShellEnv(t, ledger.instrumentEnv(r.env), ledger.shimDir)
return r
}

func TestClaudeInvocationLedgerSurvivesFrontDoorLauncherPin(t *testing.T) {
ledger := newTestInvocationLedger(t, writeSuccessfulLedgerTarget(t))
driver := claudeLiveRunner{env: os.Environ()}
driver = driver.withInvocationLedger(t, ledger).(claudeLiveRunner)
driver.env = replaceEnvValue(driver.env, "SPACEDOCK_BIN", writeSuccessfulLedgerTarget(t))

cmd := exec.Command("/bin/bash", "-c", `"$SPACEDOCK_BIN" status --boot --identify --json`)
cmd.Env = driver.env
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("ledgered Claude shell failed after front-door launcher pin: %v\n%s", err, output)
}
got := ledger.read(t)
if len(got) != 1 || got[0].tool != "spacedock" || strings.Join(got[0].args, "\x00") != "status\x00--boot\x00--identify\x00--json" {
t.Fatalf("Claude shell bypassed invocation ledger after front-door launcher pin: %#v", got)
}
}

func runClaudeGateGuardrailScenario(t *testing.T, runner liveDriver, scenario sharedRuntimeScenario) {
t.Helper()
if claudeModelFamily(runner.model(), "opus") {
Expand Down Expand Up @@ -354,7 +378,7 @@ func runClaudeRejectionFlowScenario(t *testing.T, runner liveDriver, scenario sh
if err := assertRejectionFlow(after, result.finalMessage+"\n"+result.stream); err != nil {
t.Fatalf("%v\nFinal message:\n%s\nArtifacts: %s", err, result.finalMessage, result.artifactDir)
}
if err := assertRejectionRecordedRound(workflowRoot, entityPath, "validation", claudeRecordedRejectionRound(result.stream)); err != nil {
if err := assertRejectionRecordedRound(workflowRoot, entityPath, "validation"); err != nil {
t.Fatalf("%v\nFinal message:\n%s\nArtifacts: %s", err, result.finalMessage, result.artifactDir)
}
// Single-entity (`-p`) reviewer producer-signal. The Claude runner launches
Expand Down Expand Up @@ -472,21 +496,24 @@ func runClaudeKeepMovingScenario(t *testing.T, runner liveDriver, scenario share
}

// runClaudeFilingScenario drives the real FO against an EMPTY workflow and asks it
// to file one seed entity. It grades the FO's recorded tool-call stream — the FO
// filed via `spacedock … new <slug>`, not the `--next-id` + `Write` pair — because
// the durable end-state file is indistinguishable between the two paths. The file
// must also actually land (the run produced a real seed), so the stream grade is
// proof of HOW, not just THAT, the entity was filed.
// to file one seed entity. It grades the test-local launcher's actual argv ledger
// — the FO executed `spacedock new <slug>`, not the `--next-id` flow — because the
// durable end-state file is indistinguishable between the two paths. The file must
// also actually land, so the ledger proves HOW while the file proves THAT.
func runClaudeFilingScenario(t *testing.T, runner liveDriver, scenario sharedRuntimeScenario) {
t.Helper()
workflowRoot := t.TempDir()
entityPath := writeFilingWorkflow(t, workflowRoot)
ledger := newTestInvocationLedger(t, spacedockBinary(t))
runner = runner.withInvocationLedger(t, ledger)

result := runner.run(t, scenario, workflowRoot, filingPrompt(workflowRoot))
if _, err := os.Stat(entityPath); err != nil {
t.Fatalf("the FO did not land the seed entity at %s: %v\nFinal message:\n%s\nArtifacts: %s", entityPath, err, result.finalMessage, result.artifactDir)
}
if err := assertClaudeFilingViaNew(result.stream, filingSlug); err != nil {
invocations := ledger.read(t)
writeInvocationLedgerArtifact(t, result.artifactDir, invocations)
if err := assertFilingViaNew(invocations, filingSlug); err != nil {
t.Fatalf("%v\nFinal message:\n%s\nArtifacts: %s", err, result.finalMessage, result.artifactDir)
}
emitClaudeScenarioMetrics(t, scenario, result, runner.model())
Expand Down Expand Up @@ -530,6 +557,23 @@ func runClaudeShallowBootScenario(t *testing.T, runner liveDriver, scenario shar
emitClaudeScenarioMetrics(t, scenario, result, runner.model())
}

func runClaudeMultiWorkflowBootScenario(t *testing.T, runner liveDriver, scenario sharedRuntimeScenario) {
t.Helper()
projectRoot := t.TempDir()
fixture := writeMultiWorkflowBootFixture(t, projectRoot)
ledger := newTestInvocationLedger(t, spacedockBinary(t))
runner = runner.withInvocationLedger(t, ledger)

result := runner.run(t, scenario, projectRoot, multiWorkflowBootPrompt(projectRoot))
invocations := ledger.read(t)
writeInvocationLedgerArtifact(t, result.artifactDir, invocations)
obs := gatherMultiWorkflowBootObservation(t, fixture, invocations, result.finalMessage)
if err := assertMultiWorkflowBoot(obs); err != nil {
t.Fatalf("%v\nFinal message:\n%s\nArtifacts: %s", err, result.finalMessage, result.artifactDir)
}
emitClaudeScenarioMetrics(t, scenario, result, runner.model())
}

// run launches the real `spacedock claude` front door for one shared scenario and
// returns the (finalMessage, full stream) the shared assertions consume. The
// launch shape is the spike WINNER: --plugin-dir + --skip-compat-check are the
Expand Down
39 changes: 32 additions & 7 deletions internal/ensigncycle/codex_live_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func codexScenarioRunners() map[string]func(*testing.T, codexLiveRunner, sharedR
"merge-hook-guardrail": runCodexMergeHookGuardrailScenario,
"filing": runCodexFilingScenario,
"shallow-boot": runCodexShallowBootScenario,
"multi-workflow-boot": runCodexMultiWorkflowBootScenario,
"self-evidence-merge-triage": runCodexSelfEvidenceMergeTriageScenario,
"smallest-sufficient-mechanism": runCodexSmallestSufficientMechanismScenario,
"keep-moving-posture": runCodexKeepMovingScenario,
Expand Down Expand Up @@ -241,7 +242,7 @@ func runCodexRejectionFlowScenario(t *testing.T, runner codexLiveRunner, scenari
if err := assertRejectionFlow(entityAfter, result.finalMessage+"\n"+result.jsonl); err != nil {
t.Fatalf("%v\nFinal message:\n%s\nArtifacts: %s", err, result.finalMessage, result.artifactDir)
}
if err := assertRejectionRecordedRound(workflowRoot, entityPath, "validation", codexRecordedRejectionRound(result.jsonl)); err != nil {
if err := assertRejectionRecordedRound(workflowRoot, entityPath, "validation"); err != nil {
t.Fatalf("%v\nFinal message:\n%s\nArtifacts: %s", err, result.finalMessage, result.artifactDir)
}
// assertRejectionFlow (above) proves the two-cycle re-review OCCURRED from the
Expand Down Expand Up @@ -369,15 +370,17 @@ func runCodexKeepMovingScenario(t *testing.T, runner codexLiveRunner, scenario s
}

// runCodexFilingScenario drives the real FO against an EMPTY workflow and asks it
// to file one seed entity. Like the Claude runner it grades the FO's recorded
// command stream — the FO filed via `spacedock new <slug>`, not a `--next-id`
// preview-then-write — because the durable end-state file is indistinguishable
// between the two paths. The file must also actually land, so the stream grade is
// proof of HOW, not just THAT, the entity was filed.
// to file one seed entity. Like the Claude runner it grades the test-local
// launcher's actual argv ledger — the FO executed `spacedock new <slug>`, not a
// `--next-id` preview-then-write — because the durable end-state file is
// indistinguishable between the two paths. The file must also actually land, so
// the ledger proves HOW while the file proves THAT.
func runCodexFilingScenario(t *testing.T, runner codexLiveRunner, scenario sharedRuntimeScenario) {
t.Helper()
workflowRoot := t.TempDir()
entityPath := writeFilingWorkflow(t, workflowRoot)
ledger := newTestInvocationLedger(t, spacedockBinary(t))
runner.env = ledger.instrumentEnv(runner.env)

result, err := runner.run(t, scenario, workflowRoot, filingPrompt(workflowRoot))
if err != nil {
Expand All @@ -386,7 +389,9 @@ func runCodexFilingScenario(t *testing.T, runner codexLiveRunner, scenario share
if _, err := os.Stat(entityPath); err != nil {
t.Fatalf("the FO did not land the seed entity at %s: %v\nFinal message:\n%s\nArtifacts: %s", entityPath, err, result.finalMessage, result.artifactDir)
}
if err := assertCodexFilingViaNew(result.jsonl, filingSlug); err != nil {
invocations := ledger.read(t)
writeInvocationLedgerArtifact(t, result.artifactDir, invocations)
if err := assertFilingViaNew(invocations, filingSlug); err != nil {
t.Fatalf("%v\nFinal message:\n%s\nArtifacts: %s", err, result.finalMessage, result.artifactDir)
}
emitCodexScenarioMetrics(t, scenario, result)
Expand Down Expand Up @@ -418,6 +423,26 @@ func runCodexShallowBootScenario(t *testing.T, runner codexLiveRunner, scenario
emitCodexScenarioMetrics(t, scenario, result)
}

func runCodexMultiWorkflowBootScenario(t *testing.T, runner codexLiveRunner, scenario sharedRuntimeScenario) {
t.Helper()
projectRoot := t.TempDir()
fixture := writeMultiWorkflowBootFixture(t, projectRoot)
ledger := newTestInvocationLedger(t, spacedockBinary(t))
runner.env = ledger.instrumentEnv(runner.env)

result, err := runner.run(t, scenario, projectRoot, multiWorkflowBootPrompt(projectRoot))
if err != nil {
t.Fatalf("%v\nArtifacts: %s", err, result.artifactDir)
}
invocations := ledger.read(t)
writeInvocationLedgerArtifact(t, result.artifactDir, invocations)
obs := gatherMultiWorkflowBootObservation(t, fixture, invocations, result.finalMessage)
if err := assertMultiWorkflowBoot(obs); err != nil {
t.Fatalf("%v\nFinal message:\n%s\nArtifacts: %s", err, result.finalMessage, result.artifactDir)
}
emitCodexScenarioMetrics(t, scenario, result)
}

func codexExecArgv(workflowRoot, finalPath, prompt string) []string {
return []string{
"exec",
Expand Down
Loading
Loading