diff --git a/docs/site/reference/command-reference.md b/docs/site/reference/command-reference.md index 42f8d175a..b8b924ece 100644 --- a/docs/site/reference/command-reference.md +++ b/docs/site/reference/command-reference.md @@ -43,7 +43,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`, `--read ` (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 --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 reports the boot sections; PR_STATE is a local `pr:` view, live PR state is checked at engage; local reads only, no mutation), `--read ` (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 --checklist` reads the current stage's report), and `--stage X` reads a non-current stage) | | `spacedock new` | Create an entity (`new [--folder] SLUG`) from a body on stdin | | `spacedock dispatch` | Build the worker dispatch artifacts (`dispatch build`, `dispatch show-stage-def`) | | `spacedock state` | Manage a [split-root workflow](../advanced/split-root-state.md)'s state checkout (`state init` resumes one on a fresh clone, `state new` births one) | diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 256965e7a..973df50ff 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -620,7 +620,7 @@ _spacedock() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" verbs="claude codex pi install doctor status new state merge completion dispatch --version --help" - status_flags="--workflow-dir --next --next-id --boot --validate --archived --json --quiet --new --folder --set --where --archive --resolve --short-id --discover --root" + status_flags="--workflow-dir --next --next-id --boot --identify --validate --archived --json --quiet --new --folder --set --where --archive --resolve --short-id --discover --root" if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "$verbs" -- "$cur") ) return 0 @@ -641,7 +641,7 @@ const zshCompletion = `#compdef spacedock _spacedock() { local -a verbs status_flags verbs=(claude codex pi install doctor status new state merge completion dispatch --version --help) - status_flags=(--workflow-dir --next --next-id --boot --validate --archived --json --quiet --new --folder --set --where --archive --resolve --short-id --discover --root) + status_flags=(--workflow-dir --next --next-id --boot --identify --validate --archived --json --quiet --new --folder --set --where --archive --resolve --short-id --discover --root) if (( CURRENT == 2 )); then compadd -- $verbs return diff --git a/internal/contractlint/startup_collapse_test.go b/internal/contractlint/startup_collapse_test.go new file mode 100644 index 000000000..fafe90af2 --- /dev/null +++ b/internal/contractlint/startup_collapse_test.go @@ -0,0 +1,62 @@ +// ABOUTME: AC-1 value check — the FO Startup recipe collapses to <=4 numbered prose +// ABOUTME: steps AND the boot-resident shared core is strictly smaller than its pre-change size. +package contractlint + +import ( + "os" + "path/filepath" + "regexp" + "testing" +) + +// preChangeSharedCoreBytes is the byte size of first-officer-shared-core.md +// measured immediately before the "boot identifies, engage converges" edits, on +// the post-vcm-merge branch this implementation opened from (`wc -c` == 26755). +// AC-1's value half asserts the post-change file is STRICTLY smaller: a shorter +// recipe that grew the file fails. +const preChangeSharedCoreBytes = 26755 + +// startupStepRe matches a top-level numbered Startup step: a line beginning with +// `N.` at column zero. Sub-bullets (indented `-`) and the discovery sub-cases are +// not top-level steps, so they do not count against the <=4 budget. +var startupStepRe = regexp.MustCompile(`(?m)^[0-9]+\. `) + +// startupBlock returns the `## Startup` section body (heading to the next `## `), +// so the step count is scoped to the recipe, not the whole file. +func startupBlock(t *testing.T, body string) string { + t.Helper() + loc := regexp.MustCompile(`(?m)^## Startup$`).FindStringIndex(body) + if loc == nil { + t.Fatal("shared core has no `## Startup` section") + } + rest := body[loc[1]:] + if end := regexp.MustCompile(`(?m)^## `).FindStringIndex(rest); end != nil { + rest = rest[:end[0]] + } + return rest +} + +// TestStartupRecipeCollapsedAndLeaner (AC-1) is the two-sided value check: the +// Startup recipe carries <=4 top-level numbered steps (down from 8) AND the whole +// boot-resident shared core is strictly fewer bytes than before the change. Both +// halves must hold — the sprint's principal prose-remover must not merely reshape +// the recipe while leaving the resident core the same size or larger. +func TestStartupRecipeCollapsedAndLeaner(t *testing.T) { + root := repoRoot(t) + data, err := os.ReadFile(filepath.Join(root, sharedCorePath())) + if err != nil { + t.Fatalf("read shared core: %v", err) + } + + steps := startupStepRe.FindAllString(startupBlock(t, string(data)), -1) + if len(steps) == 0 { + t.Fatal("Startup section has no top-level numbered steps — extractor bug; the count check would pass vacuously") + } + if len(steps) > 4 { + t.Errorf("Startup recipe has %d top-level numbered steps, want <=4", len(steps)) + } + + if got := len(data); got >= preChangeSharedCoreBytes { + t.Errorf("shared core is %d bytes, want strictly < %d (pre-change) — the collapse must shrink the resident core, not grow it", got, preChangeSharedCoreBytes) + } +} diff --git a/internal/ensigncycle/broad_search_detect_impl_test.go b/internal/ensigncycle/broad_search_detect_impl_test.go index 7967d98d7..151b22710 100644 --- a/internal/ensigncycle/broad_search_detect_impl_test.go +++ b/internal/ensigncycle/broad_search_detect_impl_test.go @@ -13,7 +13,7 @@ import ( // violation the captain observed (2026-06-14): after `spacedock status --discover` // returned zero workflows, an FO ran a broad `find`/`grep -r`/`ls -R` filesystem // sweep to hunt a workflow down instead of obeying the contract's terminal zero -// branch (Startup step 3: zero → report no workflow found and STOP). A broad sweep +// branch (Startup step 2: zero → report no workflow found and STOP). A broad sweep // at boot is both a discipline violation (the zero branch is report-and-stop) and a // cost/latency regression — the opposite of lean boot. // diff --git a/internal/ensigncycle/shallow_boot_measure_test.go b/internal/ensigncycle/shallow_boot_measure_test.go index 6c16dbc4e..0afbcf01b 100644 --- a/internal/ensigncycle/shallow_boot_measure_test.go +++ b/internal/ensigncycle/shallow_boot_measure_test.go @@ -13,7 +13,7 @@ import ( // (the FIRST status query / --set / id lookup / issue filing, the FIRST write to // main, or a dispatch failure — never at boot). present-gate is deliberately NOT // here — the greet legitimately presents a ready gate via -// Skill(skill="spacedock:present-gate") (Startup step 8), so the oracle keys on the +// Skill(skill="spacedock:present-gate") (Startup step 3), so the oracle keys on the // skill ARGUMENT, not on any Skill call. var deferredFOSkillNames = []string{"fo-status-viewer", "fo-write-core", "fo-dispatch-recovery"} diff --git a/internal/ensigncycle/wrong_root_detect_impl_test.go b/internal/ensigncycle/wrong_root_detect_impl_test.go index 4dfbb6a83..89f08643f 100644 --- a/internal/ensigncycle/wrong_root_detect_impl_test.go +++ b/internal/ensigncycle/wrong_root_detect_impl_test.go @@ -65,7 +65,7 @@ func detectWrongRootBoot(stream, fixtureRoot string) error { clean, strings.TrimSpace(b.Input.Command), target) } case "Read": - // The FO reads {workflow_dir}/README.md at boot (Startup step 4). A + // The FO reads {workflow_dir}/README.md at boot (the Startup boot read). A // workflow README read OUTSIDE the fixture means it booted the wrong // workflow. Contract skills live under {plugin_dir}/skills/...references/, // never a bare /README.md, so this does not flag a contract read. diff --git a/internal/ensigncycle/zero_discover_live_test.go b/internal/ensigncycle/zero_discover_live_test.go index 37b3bb4be..b49bf08ff 100644 --- a/internal/ensigncycle/zero_discover_live_test.go +++ b/internal/ensigncycle/zero_discover_live_test.go @@ -20,7 +20,7 @@ import ( // `commissioned-by: spacedock@` README), driven through the front door exactly like // TestLiveEnsignCycle. The captain observed (2026-06-14) an FO, after a zero // `status --discover`, run a broad find/grep filesystem sweep to hunt a workflow -// instead of obeying the contract's terminal zero branch (Startup step 3: zero → +// instead of obeying the contract's terminal zero branch (Startup step 2: zero → // report no workflow found and STOP). This test proves the real boot: // // (a) reaches its greet/no-workflow report WITHOUT a TeamCreate (no workflow to diff --git a/internal/status/boot.go b/internal/status/boot.go index a23c9f32f..7c114f82a 100644 --- a/internal/status/boot.go +++ b/internal/status/boot.go @@ -82,8 +82,11 @@ type prResult struct { // checkPRStates returns (status, results) for entities with a non-empty pr and // non-terminal status. Matches check_pr_states. status is "none", -// "gh not available", or "ok". -func checkPRStates(entities []*entity, stages []Stage, e env) (string, []prResult) { +// "gh not available", "ok", or (identify mode) "local". In identify mode the boot +// is a side-effect-free local read: it renders the stored `pr:` field as a local +// mirror (state "local", not-gh-checked) and makes NO `gh pr view` call — the live +// OPEN/MERGED/CLOSED state is filled in at «engage»'s convergence, not the greet. +func checkPRStates(entities []*entity, stages []Stage, e env, identify bool) (string, []prResult) { stageByName := map[string]Stage{} for _, s := range stages { stageByName[s.Name] = s @@ -102,6 +105,14 @@ func checkPRStates(entities []*entity, stages []Stage, e env) (string, []prResul return "none", nil } + if identify { + results := make([]prResult, 0, len(prEntities)) + for _, ent := range prEntities { + results = append(results, prResult{id: ent.fields["id"], slug: ent.fields["slug"], pr: ent.fields["pr"], state: "local"}) + } + return "local", results + } + ghPath := lookupExecutable("gh", e.get("PATH")) if ghPath == "" { return "gh not available", nil @@ -161,13 +172,21 @@ type bootData struct { // whether the safehouse binary resolves on PATH, so the operator sees the // execution-isolation posture before dispatching work. sandbox string + // Identify mode (the FO's opt-in local-identify boot). When set, the record + // folds the workflow discovery result and the stage taxonomy into the same + // envelope, PR_STATE is the local `pr:` mirror (checkPRStates skips gh), and the + // whole boot is a side-effect-free local read. discovery/stages are appended + // AFTER the existing key set so every prior key's order is preserved. + identify bool + discovery []string + stages []Stage } // gatherBoot runs every boot probe once and returns the result. NEXT_ID is // minted here (timestamp-dependent for sd-b32); on a minting error it returns // the error after the caller has emitted the stderr diagnostic. -func gatherBoot(probe claudeteam.TeamStateProbe, entities []*entity, stages []Stage, definitionDir, entityDir, gitRoot, idStyle string, e env, stderr io.Writer) (*bootData, error) { - d := &bootData{idStyle: idStyle, hooks: scanMods(definitionDir)} +func gatherBoot(probe claudeteam.TeamStateProbe, entities []*entity, stages []Stage, definitionDir, entityDir, gitRoot, idStyle string, e env, stderr io.Writer, identify bool) (*bootData, error) { + d := &bootData{idStyle: idStyle, hooks: scanMods(definitionDir), identify: identify} if idStyle == "slug" { d.nextID = "n/a (id-style: slug)" @@ -181,8 +200,15 @@ func gatherBoot(probe claudeteam.TeamStateProbe, entities []*entity, stages []St } d.orphans = scanOrphans(entities, gitRoot) - d.prStatus, d.prResults = checkPRStates(entities, stages, e) + d.prStatus, d.prResults = checkPRStates(entities, stages, e, identify) d.dispatchable = computeDispatchable(entities, stages) + // Identify mode folds the two hand-issued pre-greet reads — workflow discovery + // and the stage taxonomy — into this one record. Both are local reads (a + // filesystem walk; the already-parsed stages), so the boot stays side-effect-free. + if identify { + d.discovery = discoverWorkflows(gitRoot) + d.stages = stages + } // TEAM_STATE comes from the host-supplied probe. HOME resolution stays generic // here; only the ~/.claude read moves into the Claude seam. The hint for both // the present and absent cases is resolved here so the renderers carry no @@ -229,8 +255,8 @@ func gatherBoot(probe claudeteam.TeamStateProbe, entities []*entity, stages []St } // printBoot writes all boot sections in order. Matches print_boot. -func printBoot(probe claudeteam.TeamStateProbe, w io.Writer, entities []*entity, stages []Stage, definitionDir, entityDir, gitRoot, idStyle string, e env, stderr io.Writer) error { - d, err := gatherBoot(probe, entities, stages, definitionDir, entityDir, gitRoot, idStyle, e, stderr) +func printBoot(probe claudeteam.TeamStateProbe, w io.Writer, entities []*entity, stages []Stage, definitionDir, entityDir, gitRoot, idStyle string, e env, stderr io.Writer, identify bool) error { + d, err := gatherBoot(probe, entities, stages, definitionDir, entityDir, gitRoot, idStyle, e, stderr, identify) if err != nil { return err } @@ -273,12 +299,22 @@ func printBoot(probe claudeteam.TeamStateProbe, w io.Writer, entities []*entity, } } - // PR_STATE + // PR_STATE. Identify mode renders the local `pr:` mirror (state "local") under a + // labeled banner so the reader knows the live gh state is filled in at «engage». switch d.prStatus { case "none": fmt.Fprintln(w, "PR_STATE: none") case "gh not available": fmt.Fprintln(w, "PR_STATE: gh not available") + case "local": + fmt.Fprintln(w, "PR_STATE (local view — not gh-checked)") + row := func(a, b, c, d string) string { + return padRight(a, 6) + " " + padRight(b, 30) + " " + padRight(c, 8) + " " + d + } + fmt.Fprintln(w, row("ID", "SLUG", "PR", "STATE")) + for _, r := range d.prResults { + fmt.Fprintln(w, row(r.id, r.slug, r.pr, r.state)) + } default: fmt.Fprintln(w, "PR_STATE") row := func(a, b, c, d string) string { @@ -319,5 +355,18 @@ func printBoot(probe claudeteam.TeamStateProbe, w io.Writer, entities []*entity, // SANDBOX: appended last so every prior section's order is preserved. fmt.Fprintf(w, "SANDBOX: %s\n", d.sandbox) + + // Identify mode folds discovery + the stage taxonomy in, appended after every + // existing section so their order is untouched. + if d.identify { + fmt.Fprintln(w, "DISCOVERY") + for _, wf := range d.discovery { + fmt.Fprintln(w, wf) + } + fmt.Fprintln(w, "STAGES") + for _, s := range d.stages { + fmt.Fprintln(w, s.Name) + } + } return nil } diff --git a/internal/status/boot_identify_test.go b/internal/status/boot_identify_test.go new file mode 100644 index 000000000..bec85dd1b --- /dev/null +++ b/internal/status/boot_identify_test.go @@ -0,0 +1,233 @@ +// ABOUTME: --boot --identify (opt-in local identify) — folds discovery + taxonomy + +// ABOUTME: a local pr: mirror into the record, provably side-effect-free, uniform zero/one/many. +package status + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// identifyPRReadme declares a split-root slug workflow whose implementation stage +// is non-terminal, so a pr-bearing entity there surfaces in the PR mirror. +const identifyPRReadme = `--- +commissioned-by: spacedock@1 +id-style: slug +state: .spacedock-state +stages: + states: + - name: ideation + initial: true + - name: implementation + - name: review + terminal: true +--- + +# Identify Boot Workflow +` + +// writeRecordingGh writes a `gh` shim that appends to sentinelPath whenever it is +// invoked, so a test can prove `gh` was NEVER run by asserting the sentinel is +// absent afterward. It still prints a merge state, so a boot that DID shell out to +// gh would both leave the sentinel AND resolve a non-local pr_state. +func writeRecordingGh(t *testing.T, sentinelPath string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("gh recording shim is a POSIX shell script") + } + dir := t.TempDir() + script := "#!/bin/sh\n" + + "echo called >> " + sentinelPath + "\n" + + "echo MERGED\n" + if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return dir +} + +// TestBootIdentifyFoldsDiscoveryTaxonomyLocalPR (AC-2) asserts identify mode on a +// healthy split-root workflow exits 0 and emits one record carrying every existing +// boot section, the folded discovery result and stages taxonomy (appended AFTER the +// existing key set), and a LOCAL pr: mirror (pr-pending entity by number, status +// "local"), with no gh call. +func TestBootIdentifyFoldsDiscoveryTaxonomyLocalPR(t *testing.T) { + def, _ := buildSplitRoot(t, identifyPRReadme, map[string]string{ + "add-login.md": "---\nstatus: implementation\npr: \"#42\"\n---\n", + }) + env := pinnedEnv(t) + + out, errOut, code := runNative(t, def, env, "--workflow-dir", def, "--boot", "--identify", "--json") + if code != 0 { + t.Fatalf("--boot --identify --json exit=%d stderr=%q", code, errOut) + } + + // The existing key set, then the folded discovery + stages, all in order. + orderedKeys := []string{ + "command", "mods", "id_style", "next_id", + "orphans", "pr_state", "dispatchable", "team_state", + "state_backend", "definition_dir", "entity_dir", "entity_dir_present", + "sandbox", "discovery", "stages", + } + last := -1 + for _, key := range orderedKeys { + idx := strings.Index(out, `"`+key+`"`) + if idx < 0 { + t.Fatalf("identify record missing key %q\n%s", key, out) + } + if idx < last { + t.Fatalf("identify record key %q out of order (discovery/stages must append after the existing set)\n%s", key, out) + } + last = idx + } + + var rec struct { + Discovery []string `json:"discovery"` + Stages []struct { + Name string `json:"name"` + } `json:"stages"` + PRState struct { + Status string `json:"status"` + Entries []map[string]string `json:"entries"` + } `json:"pr_state"` + } + if err := json.Unmarshal([]byte(out), &rec); err != nil { + t.Fatalf("parse identify record: %v\n%s", err, out) + } + if len(rec.Discovery) != 1 { + t.Fatalf("discovery = %v, want the one discovered workflow", rec.Discovery) + } + if len(rec.Stages) != 3 || rec.Stages[0].Name != "ideation" { + t.Fatalf("stages taxonomy not folded in: %+v", rec.Stages) + } + if rec.PRState.Status != "local" { + t.Fatalf("pr_state.status = %q, want \"local\" (identify renders the local pr: mirror, no gh)", rec.PRState.Status) + } + if len(rec.PRState.Entries) != 1 || rec.PRState.Entries[0]["pr"] != "#42" { + t.Fatalf("pr_state entries = %+v, want the pr-pending entity by its stored #42", rec.PRState.Entries) + } + if rec.PRState.Entries[0]["state"] != "local" { + t.Fatalf("pr_state entry state = %q, want \"local\" (not-gh-checked)", rec.PRState.Entries[0]["state"]) + } +} + +// TestBootIdentifyIsSideEffectFree (AC-3) is the core boundary guarantee: identify +// mode makes NO gh call (a recording shim on PATH is never invoked, pr_state stays +// local) and NO mutation (the git-backed state checkout's HEAD and working tree are +// byte-identical before and after). +func TestBootIdentifyIsSideEffectFree(t *testing.T) { + def, state := buildSplitRoot(t, identifyPRReadme, map[string]string{ + "add-login.md": "---\nstatus: implementation\npr: \"#42\"\n---\n", + }) + // The state checkout is a real git repo so HEAD/tree can be diffed. + gitC(t, state, "init", "-q") + gitC(t, state, "config", "user.email", "t@t") + gitC(t, state, "config", "user.name", "t") + gitC(t, state, "add", "-A") + gitC(t, state, "commit", "-q", "-m", "seed") + + headBefore := gitOut(t, state, "rev-parse", "HEAD") + treeBefore := gitOut(t, state, "status", "--porcelain") + + sentinel := filepath.Join(t.TempDir(), "gh-was-called") + shimDir := writeRecordingGh(t, sentinel) + env := pinnedEnv(t) + // Prepend the shim dir so a boot that shells out to gh WOULD resolve + record it. + for i, kv := range env { + if strings.HasPrefix(kv, "PATH=") { + env[i] = "PATH=" + shimDir + string(os.PathListSeparator) + strings.TrimPrefix(kv, "PATH=") + } + } + + out, errOut, code := runNative(t, def, env, "--workflow-dir", def, "--boot", "--identify", "--json") + if code != 0 { + t.Fatalf("--boot --identify --json exit=%d stderr=%q", code, errOut) + } + + if _, err := os.Stat(sentinel); err == nil { + t.Fatal("identify boot invoked `gh` — the recording shim fired; the greet must make no network call") + } + if !strings.Contains(out, `"status":"local"`) { + t.Fatalf("pr_state is not the local mirror — a gh path was taken\n%s", out) + } + if got := gitOut(t, state, "rev-parse", "HEAD"); got != headBefore { + t.Fatalf("state checkout HEAD moved: %q -> %q — identify boot mutated the state repo", headBefore, got) + } + if got := gitOut(t, state, "status", "--porcelain"); got != treeBefore { + t.Fatalf("state checkout working tree changed: %q -> %q — identify boot wrote to the state checkout", treeBefore, got) + } +} + +// TestBootIdentifyUniformZeroOneMany (AC-5) asserts the uniform discovery contract +// with no N==1 eager convergence: an empty root reports no workflow and does NOT +// broad-search; a one-workflow root lists 1; a two-workflow root lists 2 — and in +// no case does any convergence run (identify never calls state ready/sweep, so the +// state checkouts are untouched — the same side-effect-free guarantee as AC-3). +func TestBootIdentifyUniformZeroOneMany(t *testing.T) { + // Zero: an empty git-less root discovers nothing → report-and-stop, no sweep. + empty := t.TempDir() + _, errOut, code := runNative(t, empty, pinnedEnv(t), "--boot", "--identify", "--json") + if code == 0 { + t.Fatalf("empty-root identify boot exited 0, want a no-workflow halt; stderr=%q", errOut) + } + if !strings.Contains(errOut, "do NOT search the filesystem") { + t.Fatalf("zero-discovery halt missing the no-broad-search directive: %q", errOut) + } + + // One: a root holding exactly one workflow → discovery list of 1. + oneRoot := t.TempDir() + buildWorkflowUnder(t, oneRoot, "wf-a") + if got := identifyDiscovery(t, oneRoot); len(got) != 1 { + t.Fatalf("one-workflow root: discovery = %v, want length 1", got) + } + + // Many: a root holding two workflows → discovery list of 2, no convergence. + twoRoot := t.TempDir() + buildWorkflowUnder(t, twoRoot, "wf-a") + buildWorkflowUnder(t, twoRoot, "wf-b") + if got := identifyDiscovery(t, twoRoot); len(got) != 2 { + t.Fatalf("two-workflow root: discovery = %v, want length 2", got) + } +} + +// buildWorkflowUnder materializes a commissioned split-root workflow named `name` +// under root, so discovery finds it. +func buildWorkflowUnder(t *testing.T, root, name string) { + t.Helper() + def := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Join(def, ".spacedock-state"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(def, "README.md"), identifyPRReadme) +} + +// identifyDiscovery runs `--boot --identify --json` at root (no --workflow-dir) and +// returns the discovery list, failing on a non-zero exit. +func identifyDiscovery(t *testing.T, root string) []string { + t.Helper() + out, errOut, code := runNative(t, root, pinnedEnv(t), "--boot", "--identify", "--json") + if code != 0 { + t.Fatalf("identify boot at %s exit=%d stderr=%q", root, code, errOut) + } + var rec struct { + Discovery []string `json:"discovery"` + } + if err := json.Unmarshal([]byte(out), &rec); err != nil { + t.Fatalf("parse identify discovery: %v\n%s", err, out) + } + return rec.Discovery +} + +// gitOut runs a read-only git subcommand in dir and returns trimmed stdout. +func gitOut(t *testing.T, dir string, args ...string) string { + t.Helper() + full := append([]string{"-C", dir}, args...) + out, err := exec.Command("git", full...).Output() + if err != nil { + t.Fatalf("git %s: %v", strings.Join(args, " "), err) + } + return strings.TrimSpace(string(out)) +} diff --git a/internal/status/cycle3_extract_test.go b/internal/status/cycle3_extract_test.go index 1e00bcf5c..e4543af24 100644 --- a/internal/status/cycle3_extract_test.go +++ b/internal/status/cycle3_extract_test.go @@ -674,11 +674,11 @@ func atoiT(t *testing.T, s string) int { return n } -// TestBootTaxonomySourceSufficient (AC-4 source-sufficiency) proves the rewritten -// FO Startup step 4 has a real source: a boot-shaped status --read docs/dev/README.md -// --json yields, in its stages array, every per-stage flag step 4 enumerates -// (initial/terminal/gate/worktree/feedback-to) for the five real stages — so the -// contract sentence that points step 4 at status --read is sufficient, proven by +// TestBootTaxonomySourceSufficient (AC-4 source-sufficiency) proves the FO Startup +// boot taxonomy has a real source: a status --read docs/dev/README.md --json yields, +// in its stages array, every per-stage flag the taxonomy carries +// (initial/terminal/gate/worktree/feedback-to) for the five real stages — the same +// stages source status --boot --identify folds into the boot record — proven by // EXERCISING the real output, not a prose-grep over the contract. // // This AC proves only source-sufficiency. The behavioral adoption — a booting FO diff --git a/internal/status/handlers.go b/internal/status/handlers.go index 6da1e0dc0..be2b5d847 100644 --- a/internal/status/handlers.go +++ b/internal/status/handlers.go @@ -338,7 +338,7 @@ func runSet(roots roots, set *setUpdate, args []string, whereFilters []whereFilt // runRead handles the table / --next / --boot / --validate read flows. Matches // the tail of main() after the mutation branches. func runRead(probe claudeteam.TeamStateProbe, roots roots, args []string, e env, whereFilters []whereFilter, - includeArchive, showNext, showBoot, showNextID, showValidate bool, + includeArchive, showNext, showBoot, showNextID, showValidate, identify bool, explicitFields []string, allFieldsFlag, asJSON, quiet bool, hasArchiveSlug, hasSet, hasResolve bool, stdout, stderr io.Writer) int { @@ -435,14 +435,14 @@ func runRead(probe claudeteam.TeamStateProbe, roots roots, args []string, e env, switch { case showBoot: if asJSON { - data, err := gatherBoot(probe, entities, stages, roots.definitionDir, roots.entityDir, gitRoot, idStyle, e, stderr) + data, err := gatherBoot(probe, entities, stages, roots.definitionDir, roots.entityDir, gitRoot, idStyle, e, stderr, identify) if err != nil { return 1 } emitJSON(stdout, bootJSON(data)) return 0 } - if err := printBoot(probe, stdout, entities, stages, roots.definitionDir, roots.entityDir, gitRoot, idStyle, e, stderr); err != nil { + if err := printBoot(probe, stdout, entities, stages, roots.definitionDir, roots.entityDir, gitRoot, idStyle, e, stderr, identify); err != nil { return 1 } case showNext: diff --git a/internal/status/json_commands.go b/internal/status/json_commands.go index b6343b5c8..78fca6558 100644 --- a/internal/status/json_commands.go +++ b/internal/status/json_commands.go @@ -207,6 +207,14 @@ func bootJSON(d *bootData) *jsonObj { // so every existing key's relative order is preserved for the FO's key-order parse. out.set("sandbox", d.sandbox) + // Identify mode folds the workflow discovery result and the stage taxonomy into + // the record, appended AFTER every existing key so the un-flagged --boot key set + // and its pinned order are byte-for-byte unchanged. Absent otherwise. + if d.identify { + out.setValue("discovery", jsonStrArr(d.discovery)) + out.setValue("stages", stagesJSONArr(d.stages)) + } + return out } diff --git a/internal/status/native_runner.go b/internal/status/native_runner.go index 7b7a3fe92..bbf5b5531 100644 --- a/internal/status/native_runner.go +++ b/internal/status/native_runner.go @@ -73,11 +73,23 @@ func dispatch(probe claudeteam.TeamStateProbe, args []string, dir string, e env, pipelineDir = e.get("PIPELINE_DIR") } if pipelineDir == "" && rootPath == "" { - resolved, rc := ResolveWorkflowDir(dir, stderr) - if rc != 0 { - return rc + // Identify boot folds the old separate discover step into --boot: it + // discovers from the git root itself. Zero halts (report-and-stop, no broad + // search); many lists the workflows and proceeds (the captain engages one); + // exactly one resolves to a deep boot below. + if contains(args, "--boot") && contains(args, "--identify") { + resolved, handled, rc := resolveIdentifyBootDir(dir, contains(args, "--json"), stdout, stderr) + if handled { + return rc + } + pipelineDir = resolved + } else { + resolved, rc := ResolveWorkflowDir(dir, stderr) + if rc != 0 { + return rc + } + pipelineDir = resolved } - pipelineDir = resolved } setResult, err := parseSetArgs(args) @@ -121,6 +133,7 @@ func dispatch(probe claudeteam.TeamStateProbe, args []string, dir string, e env, showNext := contains(args, "--next") showNextID := contains(args, "--next-id") showBoot := contains(args, "--boot") + identify := contains(args, "--identify") showValidate := contains(args, "--validate") asJSON := contains(args, "--json") quiet := contains(args, "--quiet") @@ -414,7 +427,7 @@ func dispatch(probe claudeteam.TeamStateProbe, args []string, dir string, e env, } // Read paths (table / next / boot / validate). - return runRead(probe, roots, args, e, whereFilters, includeArchive, showNext, showBoot, showNextID, showValidate, explicitFields, allFieldsFlag, asJSON, quiet, archiveSlug != "", setResult != nil, resolveRef != "", stdout, stderr) + return runRead(probe, roots, args, e, whereFilters, includeArchive, showNext, showBoot, showNextID, showValidate, identify, explicitFields, allFieldsFlag, asJSON, quiet, archiveSlug != "", setResult != nil, resolveRef != "", stdout, stderr) } // failOnValidationErrors prints validation errors to stderr and returns 1 when @@ -583,6 +596,39 @@ func discoverWorkflowDownward(dir string, stderr io.Writer) (string, int) { } } +// resolveIdentifyBootDir folds the old separate discover step into --boot +// --identify: it discovers workflows from the request dir's git root and either +// resolves the single workflow (returns it, handled=false) or terminally handles +// the zero/many cases (emits + returns handled=true with the exit code). Zero halts +// with the report-and-stop no-broad-search message (uniform with +// discoverWorkflowDownward's zero branch); many lists the discovered workflows and +// PROCEEDS with exit 0 — no eager convergence, the captain engages one via «engage». +func resolveIdentifyBootDir(dir string, asJSON bool, stdout, stderr io.Writer) (string, bool, int) { + root := dir + if out, err := runGitCmd(dir, "rev-parse", "--show-toplevel"); err == nil { + root = strings.TrimSpace(out) + } + workflows := discoverWorkflows(root) + switch len(workflows) { + case 0: + return "", true, errExit(stderr, "no commissioned Spacedock workflow found in "+dir+ + " — report this and stop; do NOT search the filesystem for one. "+ + "If a workflow exists elsewhere, point at it with --workflow-dir .") + case 1: + return workflows[0], false, 0 + default: + if asJSON { + emitJSON(stdout, newJSONObj().set("command", "boot").setValue("discovery", jsonStrArr(workflows))) + } else { + fmt.Fprintln(stdout, "DISCOVERY") + for _, w := range workflows { + fmt.Fprintln(stdout, w) + } + } + return "", true, 0 + } +} + // runDiscover handles --discover. Matches the --discover branch of main(). func runDiscover(args []string, dir string, stderr, stdout io.Writer) int { incompatibleFlags := map[string]bool{ diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 80b39d0bb..a2317d241 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -11,48 +11,37 @@ Shared first-officer semantics — the boot-resident core. The deferred status, - **Binary present but wrong version** — the version's major.minor is below the required minor (binary too old) or above it (these skills are too old — update the plugin), or the version token carries no major.minor at all (`dev` — an integer-era source build; rebuild it). ABORT with the mismatch message and run `${SPACEDOCK_BIN:-spacedock} doctor` for the per-class remedy. In every class, do NOT proceed to discovery or `--boot`. -2. Discover the project root with `git rev-parse --show-toplevel`. -3. Discover the workflow directory. Prefer an explicit user-provided path; otherwise `${SPACEDOCK_BIN:-spacedock} status --discover`: one path → use it; zero → report no workflow found and STOP; multiple → LIST the managed workflows in the greet and proceed; do NOT ask the captain to pick one before greeting (the captain acts on a chosen workflow later via «engage»(workflow)). In single-entity mode, fail with an ambiguity error. - - **block (zero discover):** do NOT broad-search the filesystem to hunt a workflow — no `find` / `grep -r` / `ls -R` / recursive Glob/Grep over the project root. Report no workflow and stop. (Code-gated by the `detectBroadSearchAtBoot` boot detector.) -4. Read the workflow stage taxonomy via `${SPACEDOCK_BIN:-spacedock} status --read {workflow_dir}/README.md --json` — its `stages` array carries stage names/ordering and the per-stage `initial`/`terminal`/`gate`/`worktree`/`feedback-to`/`agent` flags the greet and gate need, plus the mission line / entity labels (`entity-label` / `entity-label-plural`) / `id-style` from the flat `frontmatter` object. DEFER the README body (per-stage prose, proof policy, templates, CI docs); it loads only when its consuming phase runs (a dispatch copies a stage subsection via `show-stage-def`; the merge ceremony reads `merge:` policy). -5. `«state.boot»()` — read all startup information in one call. Consume it as JSON (every value a string); the human-formatted table is NOT rendered for the FO's own reasoning. The before-greet boot is all READS — none reads a mod file or creates a team. Sections: - - **MODS** (MODS-REPORT) — the `mods` map names which hooks are registered at which lifecycle point (startup, idle, merge). Reading the map does NOT open any mod file; it lets the greet *report* a registered hook (a pending merge-PR advancement, a comm-officer spawn) without opening the mod. Startup hooks run deferred: the comm-officer spawn defers to first dispatch (it needs a live team); the pr-merge advancement runs before-greet at the Merged-PR sweep below, gated on an actually-merged PR. - - **ID_STYLE** — `sequential`, `sd-b32`, or `slug`. - - **NEXT_ID** — strategy-dependent ID candidate (not a reservation for `sd-b32`; `n/a (id-style: slug)` for `slug`). - - **MIN_PREFIX** — `sd-b32` only; currently `MIN_PREFIX: 2`. - - **ORPHANS** — worktree fields cross-referenced against filesystem and git state. Report anomalies; do not auto-redispatch. - - **PR_STATE** — PR-pending entities with current live merge state. This is the boot-resident report the greet renders from; the Merged-PR sweep below advances a merged PR — it is not a read. - - **DISPATCHABLE** — entities ready for dispatch (same as `--next`). - - **TEAM_STATE** — whether a team is already present; the greet reports it but does NOT create one. - - **STATE_BACKEND** — `split-root` or `single-root`, the resolved entity dir, and whether it is present. -6. `«state.ensure-ready»()` — converge the split-root checkout to linked-and-integrated before any dispatch (the halt-gate + the pull-on-boot). A single-root workflow is a no-op. -7. `«state.sweep-merged»()` — advance every merged-PR entity to terminal at boot, before the greet. The common boot (no merged PR) reads zero mod files. -8. **Interactive vs headless.** Headless = a non-interactive launch (`-p` / `exec`); otherwise interactive. Compose the state summary (boot JSON + README frontmatter) as today, including `gh`-absent UNKNOWN PR status. - - **Interactive:** present the summary — the managed workflow(s) with their dispatchable / ready-gate counts — and hint `Use engage ` to act; then STOP for input. Do NOT auto-dispatch, and do NOT render a `present-gate` review at the greet: NAME any ready `gate: true` gate in the summary, but assemble its review only when «engage» reaches it. The expensive deferrals — gate assembly included — stay past the greet, reached on the captain's first «engage». - - **Headless:** do NOT greet-stop — drive every dispatchable entity through the event loop to its first `gate: true` stage or to terminal/blocked, then EXIT reporting each entity's stop reason. Stop AT gates (a gate is human-owned); do not resolve them. **When the stop reason is a `gate: true` stage, the FO MUST author the FULL gate review at that stop, for EACH gate, BEFORE exiting** — invoke `Skill(skill="spacedock:present-gate")` and render its complete template (the `Gate review:` heading, the chosen-direction prose, the checklist roll-up, and the `Decision:` prompt) per `## Completion and Gates`, as the interactive path does. A terse stop-reason line is NOT sufficient: the human who picks up the headless transcript decides from the authored `Gate review:` … `Decision:` content. The FO still does NOT resolve the gate headless (no verdict, no terminalize) — it presents and stops; only "given the conn" (below) resolves. +2. **Boot — local identify.** `${SPACEDOCK_BIN:-spacedock} status --boot --identify --json` runs the whole pre-greet identify in ONE call — project root, workflow discovery, the stage taxonomy, and the local boot sections — folded into one JSON record. Consume it as JSON (every value a string); the human table is NOT for the FO's own reasoning. Every part is a **local read** (filesystem, git-read, entity frontmatter, the host team-state probe): **no `gh`, no `state ready` pull, no sweep, no mod-file open, no team creation, no mutation** — a greet-only session writes nothing. The record self-describes its sections; read its keys, do not restate them here. PR_STATE is a **local `pr:` mirror, labeled not-gh-checked**; the live PR state fills in at «engage». Semantics are uniform across the discovered set: + - **zero discovery:** no managed workflow — report and STOP; do NOT broad-search the filesystem to hunt one (no `find` / `grep -r` / `ls -R` / recursive Glob/Grep over the project root; code-gated by the `detectBroadSearchAtBoot` boot detector). + - **one or many:** a LIST of the discovered workflow(s); one is a list of length 1 with no eager convergence. NAME them in the greet; the captain converges and acts on one via «engage»(workflow). Single-entity mode fails with an ambiguity error when many. + The record's counts and PR fields are a possibly-stale local view, labeled as such, until the first «engage». +3. **Interactive vs headless.** Headless = a non-interactive launch (`-p` / `exec`); otherwise interactive. Compose the state summary from the boot record. + - **Interactive:** present the summary — the managed workflow(s) with their dispatchable / ready-gate counts — and hint `Use engage ` to act; then STOP for input. Do NOT auto-dispatch, and do NOT render a `present-gate` review at the greet: NAME any ready `gate: true` gate in the summary, but assemble its review only when «engage» reaches it — the expensive deferrals, gate assembly included, stay past the greet, reached on the captain's first «engage». + - **Headless:** do NOT greet-stop — drive every dispatchable entity through the event loop (converging each workflow at its first «engage») to its first `gate: true` stage or to terminal/blocked, then EXIT reporting each entity's stop reason. Stop AT gates (a gate is human-owned); do not resolve them. **When the stop reason is a `gate: true` stage, the FO MUST author the FULL gate review at that stop, for EACH gate, BEFORE exiting** — invoke `Skill(skill="spacedock:present-gate")` and render its complete template (the `Gate review:` heading, the chosen-direction prose, the checklist roll-up, and the `Decision:` prompt) per `## Completion and Gates`, as the interactive path does. A terse stop-reason line is NOT sufficient: the human who picks up the headless transcript decides from the authored `Gate review:` … `Decision:` content. The FO still does NOT resolve the gate headless (no verdict, no terminalize) — it presents and stops; only "given the conn" (below) resolves. - **Headless + given the conn to auto-approve (prose):** additionally resolve gates **per `## Completion and Gates`** and drive to terminal. The grant must be a phrase you can QUOTE from the prompt ("auto-approve gates" / "drive to done" / "you have the conn", per `skills/commission/SKILL.md`); a bare "Drive the workflow" is NOT a grant — present and stop. -## «engage»(workflow): run the event loop to its stopping condition for one named workflow +## «engage»(workflow): converge one named workflow, then run its event loop to a stopping condition -- **trigger:** the captain invokes `engage`, optionally naming a workflow, after the greet. A captain-facing FO INTERACTION VERB — NOT a binary command and NOT a new dispatch mechanism; it names an entry point into logic the contract already defines. -- **effect:** for the named `workflow` (default: the current / only managed workflow), run `«dispatch.next-action»()` (the deferred dispatch module's event-loop skeleton — loading `references/fo-dispatch-core.md` on first dispatch) to its stopping condition: dispatch each ready entity, advance each completed non-gated stage, present each ready gate via `present-gate`. -- **scope:** ONE workflow per invocation. The `workflow` argument is present now so a future multi-workflow form EXTENDS this signature rather than replacing it — a 0250 "Out of scope" extension, not precluded here. +- **trigger:** the captain invokes `engage`, optionally naming a workflow, after the greet. A captain-facing FO INTERACTION VERB. +- **effect — converge, then drive:** for the named `workflow` (default: the current / only managed workflow), FIRST converge its state with the existing verbs, each on its own call: `state ready` (the split-root pull/resume; single-root a no-op; on exit 3 → `«halt.rebase-conflict»(paths)` BEFORE the sweep), then `state sweep` (advance merged PRs to terminal — the pr-merge startup-hook advancement fires HERE, at first engage, not the greet; "advanced at engage or never"; its exit-0 `gh: "unavailable"` field distinguishes real-empty from UNKNOWN, never collapsed) plus the live PR state (`gh`). THEN run `«dispatch.next-action»()` to its stopping condition: dispatch each ready entity, advance each completed non-gated stage, present each ready gate via `present-gate`. +- **scope:** ONE workflow per invocation. The `workflow` argument is present now so a future multi-workflow form EXTENDS this signature rather than replacing it — a named future extension, not precluded here. - **done-when:** `«dispatch.next-action»()` reaches its stopping condition for the named workflow (a gate presented and awaiting the captain, terminal reached, or nothing dispatchable). -- → **prose** — no binary backs `engage`; it wraps the existing `→ prose` `«dispatch.next-action»()` skeleton (driver binary descoped to roadmap 0222). +- → **shipped** (converge): `` `spacedock state ready` `` then `` `spacedock state sweep` `` — two calls, each guard on its own. +- → **prose** (drive): no binary backs the drive; it wraps the existing `«dispatch.next-action»()` skeleton (driver binary descoped to roadmap 0222). ## Deferred load points -A greet-and-stop boot loads NONE of these — it composes its summary from `«state.boot»` JSON + README frontmatter (Startup step 8) and NAMES any ready gate without rendering it; `present-gate` loads only when «engage» reaches a gate, not at the greet. Each loads only at its trigger: +A greet-and-stop boot loads NONE of these — it composes its summary from the boot record (Startup step 2) and NAMES any ready gate without rendering it (the no-render-at-greet rule is Startup step 3). Each loads only at its trigger: - `Skill(skill="spacedock:fo-status-viewer")` — first status query (`--set` / `--next-id` / `--resolve` / issue filing). -- `Skill(skill="spacedock:fo-write-core")` — first **FO-authored** write to main (`status --set`, `spacedock new`, archive move, `### Feedback Cycles` write). NOT the boot `«state.sweep-merged»`/pr-merge advancement, whose `status --set`/`archive` are pre-authorized and need no write-scope load. +- `Skill(skill="spacedock:fo-write-core")` — first **FO-authored** write to main (`status --set`, `spacedock new`, archive move, `### Feedback Cycles` write). NOT «engage»'s sweep / pr-merge advancement, whose `status --set`/`archive` are pre-authorized at engage and need no write-scope load. - `references/fo-dispatch-core.md` — first worker dispatch. - `references/fo-merge-core.md` — terminal boundary. - `Skill(skill="spacedock:fo-dispatch-recovery")` — dispatch failure recovery (Degraded Mode, break-glass manual dispatch, budget-fail/dead-ensign handling); named at its triggers inside the Claude dispatch module — no boot and no happy-path dispatch loads it. ## Single-Entity Scope -A headless run scoped to one named entity — not a distinct mode. Startup step 8's headless rule governs; scoping only narrows it: resolve the named reference (slug/title/id), stop on ambiguity; drive that entity only; gates and stop conditions per step 8 (and `## Completion and Gates` when given the conn). If the README defines `## Output Format`, use it; otherwise report status, verdict, and entity ID. +A headless run scoped to one named entity — not a distinct mode. Startup step 3's headless rule governs; scoping only narrows it: resolve the named reference (slug/title/id), stop on ambiguity; drive that entity only; gates and stop conditions per step 3 (and `## Completion and Gates` when given the conn). If the README defines `## Output Format`, use it; otherwise report status, verdict, and entity ID. ## Working Directory @@ -110,25 +99,15 @@ If the stage is gated, `«gate.assemble-verdict»(slug, stage)`, then route on t - Assign entity IDs through `id-style`; validate active plus archived entities before trusting status output. - Commit state changes at dispatch and merge boundaries. -The worktree-ownership rules (and the split-root deliverable-isolation contract) travel with the deferred dispatch module — they matter only once a worktree stage dispatches. The compact state-commit obligation stays boot-resident; the Startup `«state.ensure-ready»()` step fires before any dispatch. +The worktree-ownership rules (and the split-root deliverable-isolation contract) travel with the deferred dispatch module — they matter only once a worktree stage dispatches. The compact state-commit obligation stays boot-resident; «engage»'s `state ready` fires before that workflow's loop. The FO declares state intent by invoking the prose-functions below. Each is idempotent — re-invoking checks its `done-when` and is a no-op if already satisfied. Every state write is one call: `«state.commit»(slug)`. -## «state.boot»(): read all startup state in one call +## «state.boot»(): read all local startup identify in one call -- **effect:** yield the boot record — the Startup step 5 sections, consumed as JSON; all reads, no mod-file open, no team creation. -- **done-when:** the boot record is in hand for the greet. -- → **shipped**: `` `spacedock status --boot --json` ``. - -## «state.ensure-ready»(): split-root checkout linked & integrated pre-dispatch - -- **guard:** `state_backend == split-root` (single-root is a no-op). -- → **shipped**: `` `spacedock state ready` `` — one pull at boot, not per-read; resumes an absent checkout; stderr/exit carry the halt remediation + resume sequencing; on exit 3 → `«halt.rebase-conflict»(paths)`. - -## «state.sweep-merged»(): merged PRs reach terminal at boot - -- **guard:** an entry `state == "MERGED"` and non-terminal; skip otherwise (zero mod files read). -- → **shipped**: `` `spacedock state sweep` `` — advanced here or never (greet-and-stop skips the event loop); output distinguishes real-empty from gh-unavailable UNKNOWN, names the startup-hook mod to advance per. +- **effect:** discover the workflow(s), read each stage taxonomy, and yield the boot record — all local reads, no `gh`, no `state ready` pull, no sweep, no mod-file open, no team creation, no mutation. The record self-describes its sections; PR_STATE is the local `pr:` mirror, labeled not-gh-checked until «engage». Uniform across the discovered set: zero → no-workflow; one or many → a list. +- **done-when:** the boot record is in hand for the greet; the greet has mutated nothing. +- → **shipped**: `` `spacedock status --boot --identify --json` `` (extended to fold in discovery + taxonomy and render PR_STATE local); convergence moves to «engage». ## «state.commit»(slug): record an entity's change durably @@ -136,7 +115,7 @@ The FO declares state intent by invoking the prose-functions below. Each is idem ## «halt.rebase-conflict»(paths): abort, surface, stop -- **block:** a `«state.ensure-ready»`/`«state.commit»` exit 3 already carries the remediation in its stderr — HALT per that output. A manual FO-held `pull --rebase` CONFLICT (code worktree, `claude-fo-dispatch.md:162`): run `git rebase --abort`, surface `paths` + peer commit to the captain, stop. Never `--force`/`--force-with-lease`, never `-X ours`/`-X theirs`, never discard either side — do not force-push or auto-resolve. +- **block:** an «engage» `state ready` / `«state.commit»` exit 3 already carries the remediation in its stderr — HALT per that output. A manual FO-held `pull --rebase` CONFLICT (code worktree, `claude-fo-dispatch.md:162`): run `git rebase --abort`, surface `paths` + peer commit to the captain, stop. Never `--force`/`--force-with-lease`, never `-X ours`/`-X theirs`, never discard either side — do not force-push or auto-resolve. - → **prose** — no binary resolves a two-writer conflict; the FO halts and the captain reconciles. ## Mod Hook Convention @@ -148,7 +127,7 @@ Supported lifecycle points: - `idle` - `merge` -Hooks are additive and run alphabetically by mod filename. The boot MODS-REPORT reads the `mods` map without opening a mod file (Startup step 5). The mod-block enforcement that guards a terminal transition travels with the deferred merge module, loaded at terminalization. +Hooks are additive and run alphabetically by mod filename. The boot MODS-REPORT reads the `mods` map without opening a mod file (Startup step 2). The mod-block enforcement that guards a terminal transition travels with the deferred merge module, loaded at terminalization. Standing-teammate injection is driven by `spacedock dispatch spawn-standing-all` at first dispatch; the concept is team-scoped (members die with the team at teardown). @@ -163,12 +142,12 @@ Don't ask permission for a step the contract already allows (the reversible-work **Prefer a code gate over a prose-only rule.** When a guarantee can be enforced by the binary or a failing test (a `status` guard, a test that fails on violation), prefer that. A prose-only rule's ceiling is "the wording is present"; wording-present is not behavior. A prose-only rule must not count as AC satisfaction on its own: if the guarantee matters, the real assurance is a code-level gate underneath, and the prose points at it. An AC of the form "the contract says X" is satisfied only by "the binary or a test enforces X, and here is the run that proves it." The gate's AC cross-check refuses a criterion whose only proof is review of the entity's own prose. > **Hold your own gate, merge, and triage calls to the bar you impose on workers.** The proof discipline above binds not just the ensign's deliverable and the gate review but the FO's own dispatcher decisions: -> - **Required verification follows from what changed, not the FO's sense of relevance.** A check the change exercises is not waived as "unrelated" by intuition; a relevant check that flakes is re-run to green (serial, isolated), never skipped. "It's unrelated" is a claim the change must substantiate, not a dispatcher judgment. +> - **Required verification follows from what changed, not the FO's sense of relevance.** "It's unrelated" is a claim the change must substantiate, not a dispatcher judgment; a relevant check that flakes is re-run to green (serial, isolated), never skipped. > - **A result is "green" only when the relevant check actually ran and passed.** An unapproved, skipped, or cancelled check is not a pass; the absence of a red is not the presence of a green. > - **A failure is read from this run's evidence** — the failing test, assertion, or error in front of you. A prior session's or a handoff's label ("the known flake") is a hypothesis to confirm against this run, not a verdict to apply. > Where the captain holds the gate, this bar relocates to the evidence the FO surfaces there — see `present-gate`. -> **Smallest sufficient mechanism (both directions).** When the FO discretionarily chooses a task's mechanism, before climbing to a workflow, a dispatched worker, or a PR — and before re-running verification a stage already owns — it names in one line why the cheaper rung cannot do it. Climbing is justified ONLY by genuine fan-out, required isolation, or independent adversarial verification; re-doing a stage's verification is justified ONLY when its report shows the required check did not actually run green. Never "it's substantive," "Ultracode is on," or "I'm the dispatcher so I don't touch files," and never a reflexive gate-time re-run. This gates a discretionary choice, NOT the standing dispatch a commissioned workflow stage already declares — engaging ready entities via the dispatch loop is already-justified, not re-narrated per entity. Ultracode raises the thoroughness of the ANSWER, not the weight of the MECHANISM. See `references/fo-smallest-sufficient-mechanism.md`. +> **Smallest sufficient mechanism (both directions).** When the FO discretionarily chooses a task's mechanism, before climbing to a workflow, a dispatched worker, or a PR — and before re-running verification a stage already owns — it names in one line why the cheaper rung cannot do it. Climbing is justified ONLY by genuine fan-out, required isolation, or independent adversarial verification; re-doing a stage's verification is justified ONLY when its report shows the required check did not actually run green. Never by a named excuse, and never a reflexive gate-time re-run. This gates a discretionary choice, NOT the standing dispatch a commissioned workflow stage already declares — engaging ready entities via the dispatch loop is already-justified, not re-narrated per entity. The named excuses — and why raising an answer's thoroughness never raises the mechanism's weight — live in `references/fo-smallest-sufficient-mechanism.md`. > **Keep moving — cadence, never the bar or the rung above.** Approval triggers the next action; independent work runs in parallel: > - A gate approval triggers the FO's next action, not its turn's end: advance and dispatch the next stage before yielding, unless that stage is a gate or the captain directed otherwise — "want me to advance + dispatch?" is the violation. A merge or triage still holds to that bar; keep-moving speeds the reversible dispatch, not the decision. @@ -180,7 +159,7 @@ Don't ask permission for a step the contract already allows (the reversible-work - **Name the end value before starting, verify it was delivered at the gate** (entry-point principle 1) — state the outcome before mechanism; end-value framing is judgeable, step-framing is not. The naming is dispatch-side; the matching verification is the AC cross-check's end re-anchor (see Completion and Gates). Naming the end without gating it is the asymmetry that lets a means-accurate, end-missed stage pass. - **Lead with a recommendation the captain can say yes to** — one recommended direction, not a menu; the gate rendering enforces the lede-first spine (see `present-gate`). - **Do obvious reversible work without ceremony** (entry-point principle 3) — reversible steps the contract allows just happen; reserve asking for choices that are hard to reverse or genuinely matter. -- **Speak the workflow's declared label, not the generic "entity".** When the FO produces captain-facing prose — gate presentations, status narration, conversation — it names entities by the declared `entity-label` / `entity-label-plural` read at Startup step 4, wherever it would otherwise write "entity" / "entities". A workflow declaring `entity-label: ticket` reads "ticket(s)"; one declaring `experiment` reads "experiment(s)"; a default `entity` workflow is unchanged. Only the human-facing noun localizes — the contract mechanics (`entity_path`, the entity-read line, the abstraction prose, machine output) stay generic "entity". +- **Speak the workflow's declared label, not the generic "entity".** When the FO produces captain-facing prose — gate presentations, status narration, conversation — it names entities by the declared `entity-label` / `entity-label-plural` read at Startup step 2, wherever it would otherwise write "entity" / "entities". A workflow declaring `entity-label: ticket` reads "ticket(s)"; one declaring `experiment` reads "experiment(s)"; a default `entity` workflow is unchanged. Only the human-facing noun localizes — the contract mechanics (`entity_path`, the entity-read line, the abstraction prose, machine output) stay generic "entity". ## Probe and Ideation Discipline diff --git a/skills/present-gate/SKILL.md b/skills/present-gate/SKILL.md index 553a3d339..d5110e644 100644 --- a/skills/present-gate/SKILL.md +++ b/skills/present-gate/SKILL.md @@ -42,5 +42,5 @@ The template is the floor, not the ceiling. The FO MUST hold to the following di 7. **No format-pedantry asides.** Format drift (`1./2./3./4.` instead of `**AC-N**`, missing trailing period) is not load-bearing for a gate decision. Surface only if it blocks the gate; if it does, it is a Material finding, not a separate paragraph. 8. **One sentence of worktree heads-up when approval changes worktree state.** When approving opens or closes a worktree, the Decision line names it: "approve to enter implementation in worktree `.worktrees/{worker_key}-{slug}`". One sentence, not a section. 9. **Target length: 15-25 lines of FO-authored prose.** The full gate message should fit in 15-25 lines. If it exceeds 25, the FO is over-narrating; cut. -10. **FO-authored prose speaks the workflow's declared label.** Where the gate-summary prose the FO writes — the `Chosen direction:` line, the `Checklist:` gist roll-up, the `Decision:` line — names the kind of thing under review, use the workflow's declared `entity-label` / `entity-label-plural` (read at Startup step 4), not the generic "entity". A `ticket` workflow's Decision line says "approve to enter implementation on this ticket"; an `experiment` workflow says "experiment". The `{entity title}` placeholder and the structural headings (`Gate review:`, `Checklist:`, `Decision:`) stay generic — only the FO-authored noun localizes. -11. **Surface verification state as evidence, not as a label.** When the gate turns on checks that ran outside this presentation (CI lanes, a validation report), state which relevant checks actually ran and passed — an unapproved/skipped/cancelled check is named as not-a-pass, not folded into "PASSED" — and read any failure from this run's evidence (the failing test/assertion), never from an inherited "known flake" label. The captain votes on which checks are green and why a red is red. +10. **FO-authored prose speaks the workflow's declared label.** Where the gate-summary prose the FO writes — the `Chosen direction:` line, the `Checklist:` gist roll-up, the `Decision:` line — names the kind of thing under review, use the workflow's declared `entity-label` / `entity-label-plural` (read at Startup step 2), not the generic "entity". A `ticket` workflow's Decision line says "approve to enter implementation on this ticket"; an `experiment` workflow says "experiment". The `{entity title}` placeholder and the structural headings (`Gate review:`, `Checklist:`, `Decision:`) stay generic — only the FO-authored noun localizes. +11. **Surface verification state as evidence, not as a label.** When the gate turns on checks that ran outside this presentation (CI lanes, a validation report), hold them to the shared core's self-evidence bar (`## Working Principles`): state which relevant checks actually ran and passed, and read any failure from this run's evidence (the failing test/assertion), never from an inherited "known flake" label. The captain votes on which checks are green and why a red is red.