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
3 changes: 3 additions & 0 deletions internal/contractlint/boot_resident_closure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ var foReferenceCores = map[string][]string{
filepath.Join("skills", "first-officer", "references", "fo-dispatch-core.md"): {
"## Dispatch", "## Reuse and Fresh Dispatch", "## Dispatch Adapter", "## Event Loop",
},
filepath.Join("skills", "first-officer", "references", "fo-status-viewer.md"): {
"## Status Viewer", "### Captain-Facing State Display", "## Issue Filing",
},
}

// bodyReferenceRe matches a sibling reference read-path named in a contract body
Expand Down
14 changes: 11 additions & 3 deletions internal/ensigncycle/claude_live_runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,10 @@ func runClaudeFilingScenario(t *testing.T, runner liveDriver, scenario sharedRun
// reports MERGED) with a per-run isolated team root, and grades the durable
// end-state: the FO greets and presents the gate, S7b advances+archives the merged
// PR before-greet, NO team config lands on disk, and NO worker is dispatched. It
// then asserts the AC-2 behavioral signal (no TeamCreate before the greet) and the
// AC-6 measured signal (greet-turn context below the ~60k ceiling, no pre-greet
// ~89k cache_creation spike) over the captured stream.
// then asserts the AC-2 behavioral signals (no TeamCreate before the greet, and no
// pre-greet Read of the deferred fo-status-viewer.md reference) and the AC-6 measured
// signal (greet-turn context below the ~60k ceiling, no pre-greet ~89k
// cache_creation spike) over the captured stream.
func runClaudeShallowBootScenario(t *testing.T, runner liveDriver, scenario sharedRuntimeScenario) {
t.Helper()
workflowRoot := t.TempDir()
Expand All @@ -338,6 +339,13 @@ func runClaudeShallowBootScenario(t *testing.T, runner liveDriver, scenario shar
if err := assertNoTeamCreateBeforeGreet(result.stream); err != nil {
t.Fatalf("%v\nArtifacts: %s", err, result.artifactDir)
}
// AC-2: the greet reads no deferred Status-Viewer reference. The staged plugin
// ships the real fo-status-viewer.md (livePluginDir copies skills/), so the FO
// COULD read it — this asserts the greet-and-stop boot renders from status --boot
// without loading the deferred display rules.
if err := assertGreetReadsNoDeferredStatusReference(result.stream); err != nil {
t.Fatalf("%v\nArtifacts: %s", err, result.artifactDir)
}
// AC-6: the greet-turn context is below the ceiling and no pre-greet 89k
// cache_creation spike (measured, over the captured token stream).
if err := assertShallowBootMeasured(result.stream); err != nil {
Expand Down
38 changes: 38 additions & 0 deletions internal/ensigncycle/shallow_boot_measure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@ package ensigncycle

import (
"fmt"
"strings"

"github.com/spacedock-dev/spacedock/internal/journeymetrics"
)

// deferredStatusReference is the lazily-loaded FO reference the Status Viewer +
// Issue Filing sections moved into. A greet-and-stop boot composes its summary from
// «state.boot» JSON + README frontmatter and must NOT read this reference — it loads
// only at the FIRST status query, --set mutation, id lookup, or issue filing.
const deferredStatusReference = "fo-status-viewer.md"

// AC-6 boot-window measurement thresholds. The ceiling is the milestone's ~60k
// greet-turn context ceiling; the spike threshold is set below the ~89k team-mode
// prefix re-cache (TeamCreate re-caching the whole conversation prefix to the 1h
Expand Down Expand Up @@ -85,6 +92,37 @@ func assertNoTeamCreateBeforeGreet(stream string) error {
return nil
}

// assertGreetReadsNoDeferredStatusReference is the AC-2 behavioral oracle over the
// captured stream's tool-call sequence: no pre-greet turn reads the deferred
// Status-Viewer reference (a Read/Grep/Bash-cat naming fo-status-viewer.md in the
// turns up to and including the greet turn). It is a behavioral observation over the
// real run's tool ordering, NOT a contract grep — the sibling of
// assertNoTeamCreateBeforeGreet. The greet must compose its summary from
// status --boot + README frontmatter; a regression that re-inlined the Status-Viewer
// display rules into the greet path, or a boot that eagerly loaded the deferred
// reference, surfaces a pre-greet read of fo-status-viewer.md and fails this.
func assertGreetReadsNoDeferredStatusReference(stream string) error {
turns, err := journeymetrics.ParseClaudeTurns([]byte(stream))
if err != nil {
return fmt.Errorf("parse stream for AC-2 deferred-reference check: %w", err)
}
if len(turns) == 0 {
return fmt.Errorf("stream carried no assistant turns — nothing to check")
}
greet := greetTurnIndex(turns)
if greet < 0 {
return fmt.Errorf("every assistant turn dispatched — no greet turn produced")
}
for i := 0; i <= greet; i++ {
for _, target := range turns[i].ReadTargets {
if strings.Contains(target, deferredStatusReference) {
return fmt.Errorf("pre-greet turn %d read the deferred Status-Viewer reference (%s) via %q — the greet must render from status --boot, not the deferred display rules", i, deferredStatusReference, target)
}
}
}
return nil
}

// assertShallowBootMeasured is the AC-6 measured-saving oracle over a captured
// claude-stream.jsonl: it parses the stream per turn, identifies the greet turn and
// the pre-greet window (turns up to and including the greet turn), and asserts
Expand Down
31 changes: 31 additions & 0 deletions internal/ensigncycle/shallow_boot_measure_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,37 @@ func TestAssertNoTeamCreateBeforeGreetCatchesLaterDeltaTeamCreate(t *testing.T)
}
}

// TestAssertGreetReadsNoDeferredStatusReferenceOffline validates the AC-2
// deferred-reference oracle against committed MULTI-DELTA streams: the shallow-boot
// positive (the greet renders from status --boot with zero Read of fo-status-viewer.md)
// passes; the NEW negative (a pre-greet Read of fo-status-viewer.md, on a LATER delta
// of its message) fails. Multi-delta is mandatory — the Read lands on a later delta,
// so a first-delta-only parse would FALSE-PASS (the documented hollow-AC defect).
func TestAssertGreetReadsNoDeferredStatusReferenceOffline(t *testing.T) {
if err := assertGreetReadsNoDeferredStatusReference(readMeasureFixture(t, "shallow-boot-greet.stream.jsonl")); err != nil {
t.Fatalf("shallow-boot positive fixture (renders from status --boot, no deferred-reference Read) must pass AC-2: %v", err)
}
if err := assertGreetReadsNoDeferredStatusReference(readMeasureFixture(t, "greet-reads-status-viewer.stream.jsonl")); err == nil {
t.Fatal("the negative fixture (multi-delta pre-greet Read of fo-status-viewer.md) must FAIL AC-2 — the Read lands on a later delta, so a first-delta-only parse would false-pass")
}
}

// TestAssertGreetReadsNoDeferredStatusReferenceCatchesLaterDeltaRead is the AC-2
// positive control over the parser extension: a stream whose Read of
// fo-status-viewer.md is on a LATER delta of its message (the real runner shape, NOT
// a synthetic single-delta one) must make assertGreetReadsNoDeferredStatusReference
// RED. A first-delta-only parse drops the later-delta Read entirely — the read target
// would be invisible and the greet-independence proof hollow.
func TestAssertGreetReadsNoDeferredStatusReferenceCatchesLaterDeltaRead(t *testing.T) {
// msg_read: thinking on delta[0], Read of fo-status-viewer.md on delta[1]; then a text greet.
stream := `{"type":"assistant","message":{"id":"msg_read","model":"claude-opus-4-8","usage":{"input_tokens":8,"cache_read_input_tokens":21000,"cache_creation_input_tokens":600},"content":[{"type":"thinking","thinking":"load the status viewer rules"}]}}
{"type":"assistant","message":{"id":"msg_read","model":"claude-opus-4-8","usage":{"input_tokens":8,"cache_read_input_tokens":21000,"cache_creation_input_tokens":600},"content":[{"type":"tool_use","id":"toolu_read","name":"Read","input":{"file_path":"skills/first-officer/references/fo-status-viewer.md"}}]}}
{"type":"assistant","message":{"id":"msg_greet","model":"claude-opus-4-8","usage":{"input_tokens":100,"cache_read_input_tokens":5000,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"Workflow overview: ... Gate review: ... Decision: approve or reject?"}]}}`
if err := assertGreetReadsNoDeferredStatusReference(stream); err == nil {
t.Fatal("a pre-greet Read of fo-status-viewer.md on a LATER delta must make assertGreetReadsNoDeferredStatusReference RED — the parser must merge later-delta read targets, not read only the first delta")
}
}

// TestParserExtractsTeamCallsFromRealHangCapture is the validator-named ready
// oracle: the committed real-runner stream `sonnet_teamdelete_hang.stream.jsonl`
// (20/27 message ids multi-delta; its lone TeamCreate and TeamDelete each land on a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{"type":"system","subtype":"init","model":"claude-opus-4-8"}
{"type":"assistant","message":{"id":"msg_boot1","model":"claude-opus-4-8","usage":{"input_tokens":4,"output_tokens":12,"cache_creation_input_tokens":1200,"cache_read_input_tokens":8000},"content":[{"type":"thinking","thinking":"check the version"}]}}
{"type":"assistant","message":{"id":"msg_boot1","model":"claude-opus-4-8","usage":{"input_tokens":4,"output_tokens":12,"cache_creation_input_tokens":1200,"cache_read_input_tokens":8000},"content":[{"type":"tool_use","id":"toolu_ver","name":"Bash","input":{"command":"spacedock --version"}}]}}
{"type":"assistant","message":{"id":"msg_boot2","model":"claude-opus-4-8","usage":{"input_tokens":6,"output_tokens":40,"cache_creation_input_tokens":900,"cache_read_input_tokens":18000},"content":[{"type":"thinking","thinking":"read the boot state"}]}}
{"type":"assistant","message":{"id":"msg_boot2","model":"claude-opus-4-8","usage":{"input_tokens":6,"output_tokens":40,"cache_creation_input_tokens":900,"cache_read_input_tokens":18000},"content":[{"type":"tool_use","id":"toolu_boot","name":"Bash","input":{"command":"spacedock status --boot --json"}}]}}
{"type":"assistant","message":{"id":"msg_read","model":"claude-opus-4-8","usage":{"input_tokens":8,"output_tokens":20,"cache_creation_input_tokens":600,"cache_read_input_tokens":21000},"content":[{"type":"thinking","thinking":"let me load the status viewer display rules before greeting"}]}}
{"type":"assistant","message":{"id":"msg_read","model":"claude-opus-4-8","usage":{"input_tokens":8,"output_tokens":20,"cache_creation_input_tokens":600,"cache_read_input_tokens":21000},"content":[{"type":"tool_use","id":"toolu_read","name":"Read","input":{"file_path":"/staged/skills/first-officer/references/fo-status-viewer.md"}}]}}
{"type":"assistant","message":{"id":"msg_greet","model":"claude-opus-4-8","usage":{"input_tokens":120,"output_tokens":300,"cache_creation_input_tokens":400,"cache_read_input_tokens":42000},"content":[{"type":"text","text":"Workflow overview: 1 task at the review gate. Gate review: ... Decision: approve or reject?"}]}}
{"type":"result","subtype":"success","usage":{"input_tokens":130,"output_tokens":352,"cache_creation_input_tokens":2500,"cache_read_input_tokens":68000},"total_cost_usd":0.04,"result":"Workflow overview: 1 task at the review gate. Gate review: ... Decision: approve or reject?"}
11 changes: 10 additions & 1 deletion internal/journeymetrics/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ type ClaudeTurn struct {
// turn that dispatches a worker carries an "Agent" (or "TeamCreate") name, so a
// caller can split the transcript at the first dispatch turn.
ToolNames []string
// ReadTargets is the file paths / commands of this turn's read-like tool_use
// blocks (Read's file_path, Grep's path, Bash's command), so a caller can detect
// whether a turn read a specific file before some boundary turn.
ReadTargets []string
}

// Context returns this turn's context-window size as the boot analysis defines it:
Expand Down Expand Up @@ -191,6 +195,7 @@ func ParseClaudeTurns(data []byte) ([]ClaudeTurn, error) {
id = fmt.Sprintf("line-%d", lineNo)
}
var names []string
var readTargets []string
for _, block := range msg.Content {
if block.Type != "tool_use" {
continue
Expand All @@ -201,16 +206,20 @@ func ParseClaudeTurns(data []byte) ([]ClaudeTurn, error) {
}
seenTool[toolKey] = true
names = append(names, block.Name)
if target := readToolTarget(block.Name, block.Input); target != "" {
readTargets = append(readTargets, target)
}
}
if pos, ok := index[id]; ok {
// A later delta of a message already seen: merge its NEW tool_use names
// (the per-block dedup above keeps a repeated delta from double-counting).
// Usage is identical across deltas, so the first-delta usage is kept.
turns[pos].ToolNames = append(turns[pos].ToolNames, names...)
turns[pos].ReadTargets = append(turns[pos].ReadTargets, readTargets...)
continue
}
index[id] = len(turns)
turns = append(turns, ClaudeTurn{ID: id, Usage: msg.Usage, ToolNames: names})
turns = append(turns, ClaudeTurn{ID: id, Usage: msg.Usage, ToolNames: names, ReadTargets: readTargets})
}
if err := scanner.Err(); err != nil {
return nil, err
Expand Down
30 changes: 30 additions & 0 deletions internal/journeymetrics/readadoption.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,36 @@ func readInputIsScoped(input json.RawMessage) bool {
return scope.Offset != 0 || scope.Limit != 0
}

// readToolTarget returns the file path or command a read-like tool_use names, so a
// caller can detect whether a turn touched a specific file before some boundary
// turn. Read names file_path, Grep names path, and Bash (a `cat`/`grep` of a file
// included) names command. A non-read tool, or a tool whose input lacks the field,
// returns "".
func readToolTarget(name string, input json.RawMessage) string {
switch name {
case "Read":
return jsonStringField(input, "file_path")
case "Grep":
return jsonStringField(input, "path")
case "Bash":
return bashCommand(input)
}
return ""
}

// jsonStringField decodes one named string field from a tool_use input object,
// returning "" when the input is empty, malformed, or lacks the field.
func jsonStringField(input json.RawMessage, field string) string {
if len(input) == 0 {
return ""
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(input, &obj); err != nil {
return ""
}
return rawString(obj[field])
}

// bashCommand extracts the command string from a Bash tool_use input.
func bashCommand(input json.RawMessage) string {
if len(input) == 0 {
Expand Down
43 changes: 4 additions & 39 deletions skills/first-officer/references/first-officer-shared-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,42 +32,11 @@ Shared first-officer semantics — the boot-resident core. The dispatch and merg
- **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.
- **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.

## Status Viewer
## Status Viewer and Issue Filing (deferred module)

The `${SPACEDOCK_BIN:-spacedock} status` launcher owns path resolution and mutation guards; skill instructions stay declarative and never reference a plugin-private script path.

Invoke it as:
```
${SPACEDOCK_BIN:-spacedock} status --workflow-dir {workflow_dir} [--next-id|--next|--archived|--where ...|--boot|--validate|--resolve REF]
```

- `--boot` — startup roll-up (mods, ID style, next-ID candidate, orphans, PR state, dispatchables). Incompatible with `--next`, `--next-id`, `--archived`, `--where`.
- `--validate` — run before trusting manually edited workflow state.
- `--resolve REF` — deterministic lookup by slug, exact stored ID, or sd-b32 address prefix; `--root` rejects unqualified cross-workflow ambiguity rather than guessing.
- `--next-id` — preview the next-id candidate for `sequential` and `sd-b32` (n/a for `slug`). For `sd-b32`, pass `--id-seed "{slug-or-title}"` and optionally `--id-actor "{actor-or-agent}"` so creation context enters the candidate. To file a new entity, do NOT pair `--next-id` with a hand-written file — use `spacedock new` (see FO Write Scope), which mints the id and atomically writes the stamped entity in one call. `--next-id` is candidate-preview only.
- `--next` / `--where "pr !="` — targeted event-loop queries.

The `--set` flag updates entity frontmatter fields:
- `--set {slug} field=value` sets a field
- `--set {slug} field=` clears a field
- `--set {slug} started` or `completed` auto-fills a UTC ISO 8601 timestamp (skipped if already set)

### Captain-Facing State Display

The commissioned README directs the captain to dispatch the FO to inspect workflow state. Invoke `status` for captain-facing display on questions like:

- "what's the workflow state?" / "show me the workflow" / "what's going on?"
- "what's dispatchable?" / "what's ready?" / "what's next?"
- "what's archived?" / "show me the done entities"
- any ad-hoc question a `status` view answers (a single entity, entities in a stage, PR-pending).

**Canonical invocations** (all start with `${SPACEDOCK_BIN:-spacedock} status --workflow-dir {workflow_dir}`):
- Overview: no extra flags.
- Dispatchables: `--next`.
- Archive view: `--archived`.
- Single-entity: `--resolve {ref}` then `--where slug={resolved-slug}`.

**Output rendering guidance.** Forward `status` stdout verbatim inside a fenced code block, with a one-line preface naming the request ("Workflow overview:", "Dispatchable entities:", "Archived entities:"). On empty results, render a literal note ("No dispatchable entities right now.") instead of an empty fence. Do not paraphrase rows, omit columns, invent fields, summarize counts, or editorialize.
- → **reference**: `references/fo-status-viewer.md` (host-neutral; no per-host adapter), loaded at the FIRST status query, `--set` mutation, `--next-id`/`--resolve` lookup, or GitHub-issue filing.
- **done-when:** the FO answers an ad-hoc status question, mutates frontmatter via `--set`, previews/resolves an id, or files a GitHub issue — the `status` query/mutate command surface (flag docs, the canonical captain-facing invocations, the Captain-Facing State Display rendering) and the issue-filing approval gate are resident.
- **guard:** a greet-and-stop boot reads neither — the greet composes its summary from `«state.boot»` JSON + README frontmatter (Startup step 8) and presents any ready gate via `present-gate`; no boot-resident path reaches the Status-Viewer display rules.

## ID Styles

Expand Down Expand Up @@ -228,7 +197,3 @@ Don't ask permission for a step the contract already allows (the reversible-work
- When checking whether tool X supports Y, read X's schema via ToolSearch before grepping for callers — usage presence is not existence evidence.
- Prefer Grep over Read for targeted entity-body inspection; Read whole only when you need the full text. Anchor on a heading or field name (`## Stage Report`, `### Feedback Cycles`, a frontmatter field): `grep -n` gives the heading line (the section offset) and the next heading bounds its span, so the follow-up `Read(offset, limit)` is section-scoped. `status --read <ref> --json` is the fence-safe fallback when markdown-like fenced content makes grep over-count headings.
- On Claude Code, a `Read` followed by a Bash mutation of the same file (including `status --set`) triggers the file-staleness safety net, echoing the file back as cache-write tokens. Grep does not participate. Trust `status --set` stdout (`field: old -> new`, `field: old -> ` for clear-to-empty, `field: -> {timestamp}` for bare-timestamp auto-fill) to narrate mutations without re-reading.

## Issue Filing

Do not file GitHub issues without explicit human approval.
Loading
Loading