Skip to content

Commit 39f4aa3

Browse files
gckoclaude
andcommitted
Merge main into bridge-seam-inbox-events (PR 435)
Bring PR 435 up to date with main (65 commits), reconciling the bridge-seam work with two paradigm shifts main introduced: - Contract gate: main replaced the integer contract with minor-version coupling (#468). plugin.json now carries the frozen D4 tombstone requires-contract ">=3,<4" alongside the branch's "hooks" registration. - FO Startup collapse: main collapsed the FO Startup recipe to <=4 steps ("boot identifies, engage converges") and caps first-officer-shared-core.md at 26755 bytes. Rewove PR 435's boot additions onto that spine: * launch-branch pin -> a sub-bullet of the boot-identify step * before-greet Bridge liveness heartbeat -> step 2b (non-counting), an explicit carve-out from main's "boot writes nothing" rule * gate drain-before-present kept in «gate.assemble-verdict» To fit the byte cap while preserving the opt-in fleet feature, relocated the Fleet Mode section to a deferred references/fo-fleet.md (idiomatic to main's defer-non-common-prose philosophy), rewritten against the new boot-identify + engage paradigm, and repointed fo-dispatch-core + SKILL.md. .gitignore: kept the _bridge/ block, dropped main's duplicate /spacedock. All contractlint + bridge/fleet integration tests pass; full suite green except the pre-existing agentsview TestSurveyCodexPresenceThroughSync (fails identically on clean main — local binary quirk). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents b1064f9 + 570de04 commit 39f4aa3

254 files changed

Lines changed: 16665 additions & 1767 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude-plugin/plugin.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "spacedock",
3-
"version": "0.23.0",
3+
"version": "0.24.0",
44
"description": "Turn directories of markdown files into structured workflows operated by AI agents",
55
"author": {
66
"name": "CL Kao"
@@ -17,7 +17,7 @@
1717
],
1818
"skills": "./skills/",
1919
"hooks": "./hooks/hooks.json",
20-
"requires-contract": ">=2,<3",
20+
"requires-contract": ">=3,<4",
2121
"interface": {
2222
"displayName": "Spacedock",
2323
"shortDescription": "Plain text workflows for AI agents",

.codex-plugin/plugin.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "spacedock",
3-
"version": "0.23.0",
3+
"version": "0.24.0",
44
"description": "Turn directories of markdown files into structured workflows operated by AI agents",
55
"author": {
66
"name": "CL Kao"
@@ -16,7 +16,7 @@
1616
"automation"
1717
],
1818
"skills": "./skills/",
19-
"requires-contract": ">=2,<3",
19+
"requires-contract": ">=3,<4",
2020
"interface": {
2121
"displayName": "Spacedock",
2222
"shortDescription": "Plain text workflows for AI agents",

.github/workflows/release.yml

Lines changed: 198 additions & 28 deletions
Large diffs are not rendered by default.

.github/workflows/runtime-live-e2e.yml

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,3 +646,84 @@ jobs:
646646
live-artifacts/pi/**
647647
live-artifacts/journey-metrics/**
648648
if-no-files-found: warn
649+
650+
# AC-3: posts a per-PR delta of this run's journey-cost observations against
651+
# the latest published release ledger, as a single sticky-updating PR comment
652+
# (`gh pr comment --edit-last`, so repeated pushes update ONE comment instead
653+
# of appending a new one). PR-only — a workflow_dispatch run has no PR to
654+
# comment on. needs: claude-live with no explicit `if:` on that edge means
655+
# this job runs only once claude-live's matrix completed successfully;
656+
# shallow-boot-window (like every scenario this comments on) is Claude-only.
657+
# A job-level permissions block grants pull-requests: write beyond the
658+
# workflow-level contents: read default; specifying any job-level permissions
659+
# replaces (not merges with) the workflow default, so contents/actions read
660+
# are restated here for checkout and cross-job artifact download.
661+
journey-delta-comment:
662+
needs: claude-live
663+
if: github.event_name == 'pull_request'
664+
runs-on: ubuntu-latest
665+
permissions:
666+
contents: read
667+
actions: read
668+
pull-requests: write
669+
steps:
670+
- uses: actions/checkout@v5
671+
672+
- uses: actions/setup-go@v6
673+
with:
674+
go-version: "1.22"
675+
676+
- name: Download this run's Claude journey metrics
677+
uses: actions/download-artifact@v5
678+
with:
679+
pattern: runtime-live-e2e-claude-live-*
680+
path: ${{ runner.temp }}/current-run-artifacts
681+
merge-multiple: true
682+
683+
# Best-effort: no previously published release (or one with no
684+
# journey-costs asset) means no baseline to diff against. `|| true` /
685+
# explicit exit-0 branches degrade either gap to a non-fatal skip so a
686+
# missing baseline never REDs the PR.
687+
- name: Download the latest published release's journey-cost ledger
688+
id: download_previous_ledger
689+
env:
690+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
691+
run: |
692+
set -euo pipefail
693+
previous_tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)"
694+
if [ -z "$previous_tag" ] || [ "$previous_tag" = "null" ]; then
695+
echo "::warning::no published release found; skipping journey-cost delta comment" >&2
696+
echo "found=false" >> "$GITHUB_OUTPUT"
697+
exit 0
698+
fi
699+
if ! gh release download "$previous_tag" -p 'journey-costs-*.json' -O "$RUNNER_TEMP/previous-journey-costs.json"; then
700+
echo "::warning::published release $previous_tag has no journey-costs asset; skipping journey-cost delta comment" >&2
701+
echo "found=false" >> "$GITHUB_OUTPUT"
702+
exit 0
703+
fi
704+
echo "found=true" >> "$GITHUB_OUTPUT"
705+
706+
# download-artifact's merge-multiple flat-copies every matched artifact's
707+
# files into current-run-artifacts, but PRESERVES each artifact's own
708+
# internal path prefix (verified against a real run's downloaded zip:
709+
# the journey-metrics JSON lands several directories deep, not directly
710+
# under "live-artifacts/journey-metrics/"). A hardcoded exact subpath is
711+
# brittle to that prefix; find + cp (release.yml's own pattern for the
712+
# same class of problem) is layout-agnostic — it locates the metrics
713+
# files wherever they actually landed.
714+
- name: Locate this run's journey metrics
715+
if: steps.download_previous_ledger.outputs.found == 'true'
716+
run: |
717+
set -euo pipefail
718+
mkdir -p "$RUNNER_TEMP/current-run-metrics"
719+
find "$RUNNER_TEMP/current-run-artifacts" -path '*/journey-metrics/*.json' -type f -exec cp {} "$RUNNER_TEMP/current-run-metrics/" \;
720+
721+
- name: Post the journey-cost delta PR comment
722+
if: steps.download_previous_ledger.outputs.found == 'true'
723+
env:
724+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
725+
run: |
726+
set -euo pipefail
727+
go run ./cmd/spacedock-release journey-delta "$RUNNER_TEMP/previous-journey-costs.json" \
728+
--metrics-dir "$RUNNER_TEMP/current-run-metrics" \
729+
--pr "${{ github.event.pull_request.number }}"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package main
2+
3+
import (
4+
"io"
5+
"os"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// TestDevPreversionCommandPrintsComputedVersion — the subcommand prints exactly
11+
// the post-release dev pre-version (X.(Y+1).0-pre1) to stdout, so release.yml's
12+
// `DEV_VERSION="$(... dev-preversion "$RELEASE_VERSION")"` capture stamps `next`
13+
// PAST the just-released stable version rather than re-stamping it.
14+
func TestDevPreversionCommandPrintsComputedVersion(t *testing.T) {
15+
out, code := captureStdout(t, func() int { return devPreversion([]string{"0.24.0"}) })
16+
if code != 0 {
17+
t.Fatalf("dev-preversion exit = %d, want 0 on a bare stable semver", code)
18+
}
19+
if got := strings.TrimSpace(out); got != "0.25.0-pre1" {
20+
t.Fatalf("dev-preversion stdout = %q, want 0.25.0-pre1", got)
21+
}
22+
}
23+
24+
// TestDevPreversionCommandRejectsMissingArg — with no version argument the
25+
// subcommand exits with a usage error and prints nothing consumable.
26+
func TestDevPreversionCommandRejectsMissingArg(t *testing.T) {
27+
if code := devPreversion(nil); code == 0 {
28+
t.Fatalf("dev-preversion exit = 0 with no argument; want non-zero")
29+
}
30+
}
31+
32+
// TestDevPreversionCommandRejectsExtraArg — the subcommand takes exactly one
33+
// version; a second argument is a usage error, so a release.yml miswiring fails
34+
// loud rather than silently ignoring the extra token.
35+
func TestDevPreversionCommandRejectsExtraArg(t *testing.T) {
36+
if code := devPreversion([]string{"0.24.0", "0.25.0"}); code == 0 {
37+
t.Fatalf("dev-preversion exit = 0 with two arguments; want non-zero")
38+
}
39+
}
40+
41+
// TestDevPreversionCommandRejectsHyphenatedInput — a pre-release (hyphenated)
42+
// input is malformed for this subcommand (it runs only on the stable branch),
43+
// so it exits non-zero instead of emitting a doubly-suffixed version.
44+
func TestDevPreversionCommandRejectsHyphenatedInput(t *testing.T) {
45+
if code := devPreversion([]string{"0.24.0-pre1"}); code == 0 {
46+
t.Fatalf("dev-preversion exit = 0 on a hyphenated input; want non-zero")
47+
}
48+
}
49+
50+
// captureStdout runs fn with os.Stdout redirected to a pipe and returns what fn
51+
// printed plus fn's return code, so a subcommand whose contract IS its stdout
52+
// (release.yml captures it via $()) is verified on the real bytes it emits.
53+
func captureStdout(t *testing.T, fn func() int) (string, int) {
54+
t.Helper()
55+
r, w, err := os.Pipe()
56+
if err != nil {
57+
t.Fatalf("os.Pipe: %v", err)
58+
}
59+
orig := os.Stdout
60+
os.Stdout = w
61+
defer func() { os.Stdout = orig }()
62+
63+
code := fn()
64+
w.Close()
65+
data, err := io.ReadAll(r)
66+
if err != nil {
67+
t.Fatalf("read captured stdout: %v", err)
68+
}
69+
return string(data), code
70+
}

cmd/spacedock-release/e2e_gate.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,19 @@ import (
1515
// without a live gh.
1616
type runListFunc func(commit string) ([]byte, error)
1717

18-
// ghRunListForCommit queries the green Runtime Live E2E runs whose head commit is
19-
// the release commit. `--status success` excludes parked/waiting runs (the spike
20-
// proved a parked run is never success), and `-c <commit>` binds the query to the
21-
// exact tagged commit so a green run on some other line cannot satisfy the gate.
18+
// ghRunListForCommit queries the Runtime Live E2E runs whose head commit is the
19+
// release commit; `-c <commit>` binds the query to the exact tagged commit so a
20+
// green run on some other line cannot satisfy the gate. The query deliberately
21+
// omits `--status success`: gh's `--status` filter can lag a run's `conclusion`
22+
// right after a re-run flips it to green (observed at the v0.23.0 cut — the
23+
// filtered query returned `[]` while the same query without `--status` already
24+
// saw the success), so the pre-filter costs genuine re-run-to-green cuts and
25+
// buys nothing. Excluding parked/waiting runs is EvaluateE2EGate's job: its
26+
// predicate requires conclusion == "success", so an empty-conclusion parked run
27+
// never qualifies regardless of what this query returns.
2228
func ghRunListForCommit(commit string) ([]byte, error) {
2329
cmd := exec.Command("gh", "run", "list",
2430
"--workflow", "Runtime Live E2E",
25-
"--status", "success",
2631
"-c", commit,
2732
"--json", "databaseId,headSha,conclusion,status",
2833
)

cmd/spacedock-release/e2e_gate_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"os"
55
"path/filepath"
6+
"runtime"
67
"strings"
78
"testing"
89
)
@@ -82,6 +83,109 @@ func TestE2EGateCommandRejectsMissingCommit(t *testing.T) {
8283
}
8384
}
8485

86+
// writeGhStatusLagShim writes a `gh` shim that reproduces the v0.23.0 gate-time
87+
// filter-consistency lag: when argv carries a bare `--status` flag it prints
88+
// `[]` (the observed behavior of `--status success` right after a re-run flips
89+
// a run to green), and otherwise it prints fixtureJSON (the unfiltered query,
90+
// which already saw the success). This drives the REAL ghRunListForCommit
91+
// end-to-end — the fakeRunLister seam above bypasses the args entirely and
92+
// cannot see the bug.
93+
//
94+
// It also pins the query's ONLY binding to the live matrix: `--workflow
95+
// "Runtime Live E2E"` is not among the fetched `--json` fields, so nothing else
96+
// in the predicate distinguishes a Runtime Live E2E run from an unrelated green
97+
// run on the release commit (e.g. ordinary push CI). The shim prints `[]`
98+
// unless argv carries that exact `--workflow "Runtime Live E2E"` pair, so
99+
// dropping the token from ghRunListForCommit fails the gate closed here — a
100+
// regression that dropped it would otherwise fail OPEN (any green run on the
101+
// commit would satisfy the gate).
102+
func writeGhStatusLagShim(t *testing.T, fixtureJSON string) string {
103+
t.Helper()
104+
if runtime.GOOS == "windows" {
105+
t.Skip("gh stub shim is a POSIX shell script")
106+
}
107+
dir := t.TempDir()
108+
// echo, not cat: the stub PATH contains only this shim, so an external `cat`
109+
// would itself fail to resolve. echo is a shell builtin.
110+
script := `#!/bin/sh
111+
have_workflow=0
112+
prev=""
113+
for arg in "$@"; do
114+
if [ "$prev" = "--workflow" ] && [ "$arg" = "Runtime Live E2E" ]; then
115+
have_workflow=1
116+
fi
117+
if [ "$arg" = "--status" ]; then
118+
echo '[]'
119+
exit 0
120+
fi
121+
prev="$arg"
122+
done
123+
if [ "$have_workflow" != "1" ]; then
124+
echo '[]'
125+
exit 0
126+
fi
127+
echo '` + fixtureJSON + `'
128+
`
129+
path := filepath.Join(dir, "gh")
130+
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
131+
t.Fatal(err)
132+
}
133+
return dir
134+
}
135+
136+
// rerunToGreenJSON pins the 2026-06-30 gate-time observation: the only success
137+
// entry for the release commit is a run that was re-run to green (attempt 2).
138+
const rerunToGreenJSON = `[
139+
{"databaseId": 28429490220, "headSha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "conclusion": "success", "status": "completed", "attempt": 2}
140+
]`
141+
142+
// parkedOnlyJSON has no success entry at all — only a parked/waiting run.
143+
const parkedOnlyJSON = `[
144+
{"databaseId": 28400000000, "headSha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "conclusion": "", "status": "waiting"}
145+
]`
146+
147+
// TestE2EGateCommandPassesOnRerunToGreenLiveRun is T1 (AC-1): drives the REAL
148+
// ghRunListForCommit against the gh shim. RED under today's query shape (argv
149+
// carries `--status success`, so the shim replays the filter-consistency lag
150+
// and returns `[]`, blocking a genuinely green cut); GREEN once
151+
// ghRunListForCommit drops `--status success` (the shim then sees no bare
152+
// `--status` arg and returns the fixture, so the gate matches the run).
153+
func TestE2EGateCommandPassesOnRerunToGreenLiveRun(t *testing.T) {
154+
stubDir := writeGhStatusLagShim(t, rerunToGreenJSON)
155+
t.Setenv("PATH", stubDir)
156+
157+
summary := newSummaryFile(t)
158+
t.Setenv("GITHUB_STEP_SUMMARY", summary)
159+
t.Setenv("SPACEDOCK_E2E_GATE_WAIVER", "")
160+
161+
code := runE2EGate([]string{gateCommit}, ghRunListForCommit)
162+
if code != 0 {
163+
t.Fatalf("e2e-gate exit = %d, want 0 for a re-run-to-green Runtime Live E2E run (AC-1)", code)
164+
}
165+
if got := readFile(t, summary); !strings.Contains(got, "28429490220") {
166+
t.Errorf("step summary did not cite the matched re-run-to-green run:\n%s", got)
167+
}
168+
}
169+
170+
// TestE2EGateCommandBlocksParkedOnlyRunViaLiveQuery is T2 (AC-2): a
171+
// parked-only fixture still blocks the cut under the new (unfiltered) query
172+
// shape. The parked-run defense lives in EvaluateE2EGate's predicate (empty
173+
// conclusion never matches), not in the dropped `--status` flag, so removing
174+
// the flag must not regress this.
175+
func TestE2EGateCommandBlocksParkedOnlyRunViaLiveQuery(t *testing.T) {
176+
stubDir := writeGhStatusLagShim(t, parkedOnlyJSON)
177+
t.Setenv("PATH", stubDir)
178+
179+
summary := newSummaryFile(t)
180+
t.Setenv("GITHUB_STEP_SUMMARY", summary)
181+
t.Setenv("SPACEDOCK_E2E_GATE_WAIVER", "")
182+
183+
code := runE2EGate([]string{gateCommit}, ghRunListForCommit)
184+
if code == 0 {
185+
t.Fatalf("e2e-gate exit = 0 on a parked-only run list; want non-zero (cut blocked, AC-2)")
186+
}
187+
}
188+
85189
func newSummaryFile(t *testing.T) string {
86190
t.Helper()
87191
path := filepath.Join(t.TempDir(), "step_summary")

0 commit comments

Comments
 (0)