Skip to content

Commit 6ea56f9

Browse files
clkaoclaude
andauthored
FO binary launcher invariant — pin one resolved launcher + CI-enforce it (#433)
* AC-4: accept var-capture launcher idiom in filing assertions The filing assertions keyed `new` recognition on a literal spacedock/ SPACEDOCK_BIN token on the same line as the create verb, so the contract-blessed `B=${SPACEDOCK_BIN:-spacedock}; $B new <slug>` idiom — where the $B call segment carries no launcher token — was missed. Recognize the var-capture launcher: when a command captures the launcher into a var from ${SPACEDOCK_BIN:-spacedock}, accept $VAR new / $VAR --new with that exact var. Scoped to the captured var name so an unrelated $X new never false-passes. FO behavior is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * AC-1/AC-2: pin one launcher, lint bare-spacedock helper examples AC-1: tighten the FO launcher-invariant in first-officer-shared-core so startup resolves ONE launcher from SPACEDOCK_BIN/PATH, reports its path/version, and uses THAT launcher for every later helper call — removing the bare-`spacedock` shorthand allowance that taught the drift habit (and the contradictory bare-shorthand parenthetical on the out-of-range abort). shared-core stays exactly at its 28586 baseline. AC-2: add a structural lint in internal/contractlint that fails when a FO reference file teaches a bare `spacedock <helper> --<flag>` runnable invocation outside an allowlisted fallback/diagnostic/binding context, with a paired discriminator control proving it is not vacuous. Brought the 7 flagged invocation examples (status --discover/--set, dispatch trunk/reconcile/spawn-standing-all, reuse advance) into compliance with ${SPACEDOCK_BIN:-spacedock}. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * AC-3: behavioral no-PATH-drift test across helper subcommands The existing launcher tests set SPACEDOCK_BIN OR put spacedock on PATH, never both — so they did not prove the exact regression: SPACEDOCK_BIN and a DIFFERENT `spacedock` on PATH both present, the launcher silently using PATH for later helper calls (false subcommand-missing on state ready/sweep). Add a behavioral test that stands up both binaries with distinct identities and asserts LauncherCommand() resolves the SPACEDOCK_BIN binary for every helper subcommand (status/state/dispatch/merge), plus an absolute-path identity assertion. A driftingLauncher control reproduces the bug shape so the assertion is proven non-vacuous (RED-first: the real assertion fails `path-bin:state` when pointed at the drift shape). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * AC-1: reconcile invariant prose with the lint allowlist The invariant's allowed-bare-`spacedock` carve-out now matches the AC-2 lint's exemption classes exactly: bare `spacedock` is fine for naming a command (prose, `→` binding lines), the version-gate fallback probe, and diagnostic/install hints — never a post-gate helper invocation. Contract and gate agree, so an adversarial audit finds no contradiction. shared-core stays at 28583/28586. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Cycle-1 fix: close line-132 escape + --name lint hole Validation caught a live counterexample: claude-fo-dispatch.md:132 shipped a bare imperative post-gate helper invocation (`spacedock dispatch context-budget --name {ensign-name}`) that the invariant forbids, and the AC-2 lint could not catch it because `--name` was absent from launcherHelperInvocation's invocation-flag allowlist. RED-first: added `name` to the allowlist, confirmed the lint reds on the still-bare line 132 (and only that line — the `→`-binding twin at fo-dispatch-core.md:98 stays exempt), then converted line 132 to ${SPACEDOCK_BIN:-spacedock}. Added a discriminator control asserting the `--name` bare form IS flagged and the line-98 binding twin is NOT, so the class cannot regress silently. Full re-scan of FO/ensign refs surfaces no other escape; `go test ./...` green. Conversion lands in untracked claude-fo-dispatch.md (+18 B); every tracked file stays at/under baseline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 40aae9b commit 6ea56f9

8 files changed

Lines changed: 333 additions & 13 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// ABOUTME: Structural lint — a FO reference file must not teach a bare `spacedock`
2+
// ABOUTME: helper INVOCATION by example; post-gate helper calls use ${SPACEDOCK_BIN:-spacedock}.
3+
package contractlint
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"strings"
10+
"testing"
11+
)
12+
13+
// The FO launcher invariant pins ONE launcher at the version gate and uses it for
14+
// every later Spacedock helper call. A bare `spacedock <helper> …` INVOCATION in a
15+
// FO reference doc teaches the drift the invariant forbids: a reader copies it and
16+
// runs a different `$PATH` binary mid-session. This is a doc-AUTHORING rule — a
17+
// defect a machine can see (a bare launcher token in a runnable example), not a
18+
// prose-grep of a behavior claim and not a code-bound consistency check. It is the
19+
// AC-2 structural arm; the behavior is proven by the SPACEDOCK_BIN-vs-PATH live
20+
// drive in internal/ensigncycle (AC-3).
21+
//
22+
// The rule discriminates a runnable INVOCATION from the legitimate bare forms:
23+
// - it names a helper verb (status/state/dispatch/merge/new) AND carries an
24+
// invocation flag (`--workflow-dir`, `--discover`, `--set`, …), so a bare
25+
// command NAME mentioned in prose ("`spacedock new <slug>` mints the id") is
26+
// not flagged — naming a command is not invoking it;
27+
// - it is NOT a `→` capability-binding line, which names the SHIPPED command
28+
// surface, not a call the FO emits;
29+
// - it does NOT already resolve the launcher (`${SPACEDOCK_BIN:-spacedock}`);
30+
// - it is NOT in a fallback/diagnostic/install context (`on $PATH`, `doctor`,
31+
// `brew install`, `go build`, `--help`), where bare `spacedock` is correct
32+
// (the version-gate fallback probe and operator hints).
33+
34+
// foReferenceDir is the first-officer reference surface this lint walks.
35+
func foReferenceDir(t *testing.T) string {
36+
t.Helper()
37+
return filepath.Join(skillsRoot(t), "first-officer", "references")
38+
}
39+
40+
// launcherHelperInvocation matches a bare `spacedock <helper> … --<flag>` runnable
41+
// example inside a backtick span: a helper verb followed (anywhere before the next
42+
// backtick) by an invocation flag. The leading boundary keeps `${SPACEDOCK_BIN:-spacedock}`
43+
// from matching — that form has `:-` before `spacedock`, not a span/space boundary.
44+
var launcherHelperInvocation = regexp.MustCompile(
45+
"`spacedock (?:status|state|dispatch|merge|new)\\b[^`]*?--(?:workflow-dir|boot|discover|set|next-id|validate|json|resolve|where|next|archived|name)\\b")
46+
47+
// launcherDiagnosticContext marks a line where a bare `spacedock` is legitimate: the
48+
// version-gate PATH fallback probe, the `doctor` remedy, an install hint, or a
49+
// `--help` reference.
50+
var launcherDiagnosticContext = regexp.MustCompile("on `?\\$?PATH|\\bdoctor\\b|brew install|go build|--help")
51+
52+
// lineHasBareLauncherHelperCall reports whether a doc line teaches a bare
53+
// `spacedock` helper INVOCATION the launcher invariant forbids.
54+
func lineHasBareLauncherHelperCall(line string) bool {
55+
if !launcherHelperInvocation.MatchString(line) {
56+
return false
57+
}
58+
if strings.Contains(line, "${SPACEDOCK_BIN:-spacedock}") {
59+
return false
60+
}
61+
if strings.HasPrefix(strings.TrimSpace(line), "- → ") {
62+
return false
63+
}
64+
if launcherDiagnosticContext.MatchString(line) {
65+
return false
66+
}
67+
return true
68+
}
69+
70+
// TestFOReferencesUseResolvedLauncher is the AC-2 lint: no FO reference file teaches
71+
// a bare `spacedock` helper invocation by example. A flagged line must resolve the
72+
// launcher (`${SPACEDOCK_BIN:-spacedock}`) instead.
73+
func TestFOReferencesUseResolvedLauncher(t *testing.T) {
74+
dir := foReferenceDir(t)
75+
entries, err := os.ReadDir(dir)
76+
if err != nil {
77+
t.Fatalf("read FO reference dir %s: %v", dir, err)
78+
}
79+
scanned := 0
80+
for _, ent := range entries {
81+
if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".md") {
82+
continue
83+
}
84+
path := filepath.Join(dir, ent.Name())
85+
data, err := os.ReadFile(path)
86+
if err != nil {
87+
t.Fatalf("read %s: %v", path, err)
88+
}
89+
scanned++
90+
for i, line := range strings.Split(string(data), "\n") {
91+
if lineHasBareLauncherHelperCall(line) {
92+
t.Errorf("%s:%d teaches a bare `spacedock` helper invocation; post-gate helper calls must resolve the pinned launcher as `${SPACEDOCK_BIN:-spacedock}` (launcher invariant): %q", path, i+1, strings.TrimSpace(line))
93+
}
94+
}
95+
}
96+
if scanned == 0 {
97+
t.Fatal("scanned zero FO reference files — extractor bug; the lint would pass vacuously")
98+
}
99+
}
100+
101+
// TestBareLauncherHelperScannerDiscriminates is the DISCRIMINATOR control: it proves
102+
// the scanner flags a genuine bare-invocation and PASSES every legitimate form, so
103+
// TestFOReferencesUseResolvedLauncher can never pass vacuously (e.g. a typo'd verb
104+
// that never matches, or an exemption swallowing the real leak).
105+
func TestBareLauncherHelperScannerDiscriminates(t *testing.T) {
106+
// The real leak shape — a bare runnable helper invocation. MUST flag.
107+
leak := "otherwise `spacedock status --discover`: one path → use it"
108+
if !lineHasBareLauncherHelperCall(leak) {
109+
t.Errorf("discriminator: a bare `spacedock status --discover` invocation was NOT flagged (scanner would pass vacuously): %q", leak)
110+
}
111+
112+
// A bare state-mutation invocation. MUST flag.
113+
leakSet := "Entity frontmatter — via `spacedock status --set` for all field updates"
114+
if !lineHasBareLauncherHelperCall(leakSet) {
115+
t.Errorf("discriminator: a bare `spacedock status --set` invocation was NOT flagged: %q", leakSet)
116+
}
117+
118+
// A bare `--name` helper invocation — the imperative context-budget probe. MUST
119+
// flag: this is the class the lint missed at claude-fo-dispatch.md:132 because
120+
// `--name` was absent from the invocation-flag allowlist.
121+
leakName := "**Context budget check:** Run `spacedock dispatch context-budget --name {ensign-name}`. Parse the JSON output."
122+
if !lineHasBareLauncherHelperCall(leakName) {
123+
t.Errorf("discriminator: a bare `spacedock dispatch context-budget --name` invocation was NOT flagged (the line-132 escape class): %q", leakName)
124+
}
125+
126+
// The `→`-binding TWIN of the same `dispatch context-budget --name` text at
127+
// fo-dispatch-core.md:98 — it names the SHIPPED command surface per host, not an
128+
// FO-emitted call. MUST pass, so adding `--name` does not over-flag the binding line.
129+
bindingTwin := "- → **Claude:** PRESENT — `spacedock dispatch context-budget --name {name}`. · **Codex:** ABSENT. · **Pi:** ABSENT."
130+
if lineHasBareLauncherHelperCall(bindingTwin) {
131+
t.Errorf("discriminator: the `→`-binding twin of the `--name` invocation was wrongly flagged: %q", bindingTwin)
132+
}
133+
134+
// The resolved-launcher form. MUST pass — this is the contract-blessed invocation.
135+
resolved := "run `${SPACEDOCK_BIN:-spacedock} status --workflow-dir {workflow_dir} --discover`"
136+
if lineHasBareLauncherHelperCall(resolved) {
137+
t.Errorf("discriminator: the resolved `${SPACEDOCK_BIN:-spacedock}` form was wrongly flagged: %q", resolved)
138+
}
139+
140+
// A bare command NAME with no invocation flag — naming, not invoking. MUST pass.
141+
nameOnly := "`spacedock new <slug>` mints the id and writes the stamped entity"
142+
if lineHasBareLauncherHelperCall(nameOnly) {
143+
t.Errorf("discriminator: a bare command-name mention (no invocation flag) was wrongly flagged: %q", nameOnly)
144+
}
145+
146+
// A `→` capability-binding line names the SHIPPED command surface. MUST pass.
147+
shippedLine := "- → **shipped**: `` `spacedock status --boot --json` ``."
148+
if lineHasBareLauncherHelperCall(shippedLine) {
149+
t.Errorf("discriminator: a `→ shipped:` capability-binding line was wrongly flagged: %q", shippedLine)
150+
}
151+
152+
// The version-gate PATH fallback probe — bare `spacedock` is correct here. MUST pass.
153+
fallback := "If `SPACEDOCK_BIN` is unusable, retry once with bare `spacedock status --discover` on `$PATH`"
154+
if lineHasBareLauncherHelperCall(fallback) {
155+
t.Errorf("discriminator: the version-gate PATH fallback probe was wrongly flagged: %q", fallback)
156+
}
157+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// ABOUTME: Behavioral AC-3 — with a SPACEDOCK_BIN binary AND a DIFFERENT spacedock
2+
// ABOUTME: on PATH both present, every helper invocation resolves the SPACEDOCK_BIN one.
3+
package dispatch
4+
5+
import (
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// The launcher drift bug: a session launched with SPACEDOCK_BIN set runs its
13+
// version gate on that binary, then later resolves a helper call (status, state
14+
// ready, …) against a DIFFERENT `spacedock` on $PATH — a stale brew install whose
15+
// command surface differs, yielding false subcommand-missing results. This test
16+
// reproduces the exact condition the bug needs (both binaries present, different
17+
// identities) and asserts the resolved launcher is the SPACEDOCK_BIN one for every
18+
// helper subcommand, not just for a status probe.
19+
20+
// resolveHelperBinary runs `launcherExpr <helper-args>` with the given SPACEDOCK_BIN
21+
// and a PATH whose `spacedock` is a distinct binary, returning what the resolved
22+
// binary printed (each stub echoes its own identity tag plus its first arg).
23+
func resolveHelperBinary(t *testing.T, launcherExpr, spacedockBin, pathDir string, helperArgs ...string) string {
24+
t.Helper()
25+
cmd := exec.Command("sh", "-c", launcherExpr+" "+strings.Join(helperArgs, " "))
26+
cmd.Env = append(environWithoutSpacedockBin(), "SPACEDOCK_BIN="+spacedockBin)
27+
cmd.Env = append(cmd.Env, "PATH="+pathDir)
28+
out, err := cmd.CombinedOutput()
29+
if err != nil {
30+
t.Fatalf("resolve helper %q failed: %v\n%s", helperArgs, err, out)
31+
}
32+
return strings.TrimSpace(string(out))
33+
}
34+
35+
// twoBinaryFixture writes an executable SPACEDOCK_BIN binary tagged `env-bin` and a
36+
// DIFFERENT `spacedock` on a fresh PATH dir tagged `path-bin`, so a resolved call's
37+
// output reveals which binary ran. Returns (spacedockBin, pathDir).
38+
func twoBinaryFixture(t *testing.T) (string, string) {
39+
t.Helper()
40+
envBin := writeExecutable(t, t.TempDir(), "spacedock-launch", "#!/bin/sh\necho env-bin:$1\n")
41+
pathDir := t.TempDir()
42+
writeExecutable(t, pathDir, "spacedock", "#!/bin/sh\necho path-bin:$1\n")
43+
return envBin, pathDir
44+
}
45+
46+
// driftingLauncher is the BUG shape used as the RED control: it ignores SPACEDOCK_BIN
47+
// and always runs bare `spacedock` from PATH — exactly the drift the invariant bans.
48+
const driftingLauncher = `spacedock`
49+
50+
// helperSubcommands are the post-gate helper calls the regression touched (the false
51+
// subcommand-missing surfaced on `state ready`/`state sweep`).
52+
var helperSubcommands = [][]string{
53+
{"status", "--boot"},
54+
{"state", "ready"},
55+
{"state", "sweep"},
56+
{"dispatch", "build"},
57+
{"merge", "guard"},
58+
}
59+
60+
// TestLauncherInvariantNoPathDriftAcrossHelpers (AC-3, behavioral) proves the real
61+
// LauncherCommand() resolves the SPACEDOCK_BIN binary for EVERY helper subcommand
62+
// when a different `spacedock` is also on PATH. The driftingLauncher control proves
63+
// the assertion actually catches the bug (it RUNS the wrong binary), so a green real
64+
// result is not vacuous.
65+
func TestLauncherInvariantNoPathDriftAcrossHelpers(t *testing.T) {
66+
envBin, pathDir := twoBinaryFixture(t)
67+
68+
for _, helper := range helperSubcommands {
69+
name := strings.Join(helper, "_")
70+
71+
// RED control: the drift shape resolves the PATH binary — the bug. The
72+
// assertion MUST observe `path-bin` here, proving it can tell the binaries
73+
// apart and would fail a real drift.
74+
t.Run("drift_control_"+name, func(t *testing.T) {
75+
got := resolveHelperBinary(t, driftingLauncher, envBin, pathDir, helper...)
76+
if !strings.HasPrefix(got, "path-bin:") {
77+
t.Fatalf("drift control for %v resolved %q, expected the PATH binary (`path-bin:`) — the assertion cannot distinguish the binaries", helper, got)
78+
}
79+
})
80+
81+
// GREEN: the real launcher resolves the SPACEDOCK_BIN binary despite the
82+
// different PATH `spacedock` — no mid-session drift.
83+
t.Run(name, func(t *testing.T) {
84+
got := resolveHelperBinary(t, LauncherCommand(), envBin, pathDir, helper...)
85+
if !strings.HasPrefix(got, "env-bin:") {
86+
t.Fatalf("helper %v resolved %q — the launcher drifted to the PATH `spacedock` instead of the pinned SPACEDOCK_BIN binary (`env-bin:`)", helper, got)
87+
}
88+
})
89+
}
90+
}
91+
92+
// TestLauncherInvariantResolvesAbsoluteSpacedockBinPath asserts the resolved binary
93+
// is the EXACT SPACEDOCK_BIN path, not merely "some env-bin" — the strongest form of
94+
// AC-3's "keeps resolving the SPACEDOCK_BIN path" claim.
95+
func TestLauncherInvariantResolvesAbsoluteSpacedockBinPath(t *testing.T) {
96+
pathDir := t.TempDir()
97+
writeExecutable(t, pathDir, "spacedock", "#!/bin/sh\necho path-bin\n")
98+
// The env binary prints its own resolved path ($0) so we can assert identity.
99+
envBin := writeExecutable(t, t.TempDir(), "spacedock-launch", "#!/bin/sh\necho \"$0\"\n")
100+
101+
got := resolveHelperBinary(t, LauncherCommand(), envBin, pathDir, "state", "ready")
102+
if got != envBin {
103+
t.Fatalf("launcher resolved %q, want the SPACEDOCK_BIN path %q", got, filepath.Clean(envBin))
104+
}
105+
}

internal/ensigncycle/shared_filing_negative_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ func TestAssertClaudeFilingViaNew(t *testing.T) {
3434
t.Fatalf("expected the `--new` alias to count as atomic filing: %v", err)
3535
}
3636

37+
// Positive: the contract-blessed var-capture launcher idiom — capture the
38+
// resolved launcher once (`B=${SPACEDOCK_BIN:-spacedock}`) then invoke it as
39+
// `$B new <slug>`. The `$B new` line carries NEITHER `spacedock` nor
40+
// `SPACEDOCK_BIN` literally, so a regex keyed only on those tokens misses it.
41+
// This is correct FO behavior (the launcher invariant), so it must pass.
42+
filedVarCapture := claudeToolUse("Bash", `{"command":"B=${SPACEDOCK_BIN:-spacedock}\n$B new `+slug+` --workflow-dir ."}`)
43+
if err := assertClaudeFilingViaNew(filedVarCapture, slug); err != nil {
44+
t.Fatalf("expected the `$B new` var-capture idiom to count as atomic filing: %v", err)
45+
}
46+
47+
// Negative: a `$VAR new <slug>` call whose var was NEVER captured from
48+
// `${SPACEDOCK_BIN:-spacedock}` is not a launcher filing — the var-capture path
49+
// must stay tied to a real launcher resolution, not any `$X new`.
50+
bareVar := claudeToolUse("Bash", `{"command":"$EDITOR new `+slug+`.md"}`)
51+
if err := assertClaudeFilingViaNew(bareVar, slug); err == nil {
52+
t.Fatal("expected a `$VAR new` call with no launcher capture to fail")
53+
}
54+
3755
// Negative: no atomic filing at all — the FO only previewed the id and never
3856
// committed to a create path. Must fail on the missing-`new` half.
3957
previewOnly := claudeToolUse("Bash", `{"command":"spacedock status --next-id --workflow-dir ."}`)
@@ -75,6 +93,15 @@ func TestAssertCodexFilingViaNew(t *testing.T) {
7593
t.Fatalf("expected a `new`-filed Codex stream to pass: %v", err)
7694
}
7795

96+
// Positive: the contract-blessed var-capture launcher idiom on Codex — the
97+
// capture and the `$B new <slug>` call land in one command_execution split by
98+
// a newline, so the call segment carries no literal `spacedock`/`SPACEDOCK_BIN`.
99+
// Correct FO behavior; must pass.
100+
filedVarCapture := codexCommand("B=${SPACEDOCK_BIN:-spacedock}\\n$B new " + slug + " --workflow-dir .")
101+
if err := assertCodexFilingViaNew(filedVarCapture, slug); err != nil {
102+
t.Fatalf("expected the `$B new` var-capture idiom to count as atomic filing on Codex: %v", err)
103+
}
104+
78105
// Negative: no atomic filing — must fail on the missing-`new` half.
79106
none := codexCommand("spacedock status --workflow-dir .")
80107
if err := assertCodexFilingViaNew(none, slug); err == nil {

internal/ensigncycle/shared_filing_test.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,48 @@ import (
2222
// newInvocation matches a spacedock atomic-create invocation in a command string:
2323
// either the `new` subcommand or the `--new` flag (its alias), in a `spacedock`
2424
// or `${SPACEDOCK_BIN…}` launcher call. The slug is matched separately so the
25-
// command must carry BOTH the create verb and the requested slug.
25+
// command must carry BOTH the create verb and the requested slug. Single-line by
26+
// design (`[^\n]*?` never crosses a newline) so it does not pair a launcher token
27+
// on one line with a `new` verb on an unrelated later line.
2628
var newInvocation = regexp.MustCompile(`(?:spacedock|SPACEDOCK_BIN)[^\n]*?(?:\bnew\b|--new)`)
2729

30+
// launcherCapture matches the contract-blessed var-capture of the resolved
31+
// launcher — `B=${SPACEDOCK_BIN:-spacedock}` — anywhere in a command string. The
32+
// captured var name is recorded so the create call below can require THAT var.
33+
var launcherCapture = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)=\$\{SPACEDOCK_BIN:-spacedock\}`)
34+
2835
// nextIDInvocation matches a `status --next-id` candidate-preview command — the
2936
// first half of the manual filing pair the atomic path replaces.
3037
var nextIDInvocation = regexp.MustCompile(`--next-id\b`)
3138

3239
// commandFilesViaNew reports whether a command string is the atomic-create call
33-
// for the requested slug: a `new`/`--new` invocation that names the slug.
40+
// for the requested slug: a `new`/`--new` invocation that names the slug. It
41+
// accepts two launcher shapes: a direct `spacedock … new` / `${SPACEDOCK_BIN…} new`
42+
// call, and the var-capture idiom `B=${SPACEDOCK_BIN:-spacedock}; $B new` where the
43+
// create call invokes the captured var (so the `$B new` segment carries no literal
44+
// launcher token). The slug must always appear.
3445
func commandFilesViaNew(command, slug string) bool {
35-
return newInvocation.MatchString(command) && strings.Contains(command, slug)
46+
if !strings.Contains(command, slug) {
47+
return false
48+
}
49+
if newInvocation.MatchString(command) {
50+
return true
51+
}
52+
return capturedLauncherFilesViaNew(command)
53+
}
54+
55+
// capturedLauncherFilesViaNew reports whether the command captured the resolved
56+
// launcher into a var (`B=${SPACEDOCK_BIN:-spacedock}`) and then ran `$B new` /
57+
// `$B --new` with that exact var. Tying recognition to the captured var name keeps
58+
// it from matching an unrelated `$X new` that never resolved a spacedock launcher.
59+
func capturedLauncherFilesViaNew(command string) bool {
60+
m := launcherCapture.FindStringSubmatch(command)
61+
if m == nil {
62+
return false
63+
}
64+
varName := regexp.QuoteMeta(m[1])
65+
call := regexp.MustCompile(`"?\$\{?` + varName + `\}?"?[^\n]*?(?:\bnew\b|--new)`)
66+
return call.MatchString(command)
3667
}
3768

3869
// assertClaudeFilingViaNew scans the stream-json transcript for the FO filing the

0 commit comments

Comments
 (0)