Skip to content

feat(claude-ops): lane-launcher captures and persists the launch commit - #1383

Merged
kyle-sexton merged 5 commits into
mainfrom
fix/792-lane-launcher-capture-launch-commit
Jul 26, 2026
Merged

feat(claude-ops): lane-launcher captures and persists the launch commit#1383
kyle-sexton merged 5 commits into
mainfrom
fix/792-lane-launcher-capture-launch-commit

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

context/refresh.md's git staleness probe (added in #514 / PR #791) referenced a <lane-launch-commit> placeholder described as "the repo HEAD when lanes start/restart last ran" — but lane-launcher.sh never captured this value anywhere. An operator following the probe literally had no automated way to fill in <lane-launch-commit>; it was advisory-only.

Fix

  • lane-launcher.sh captures git rev-parse HEAD right after the pre-launch git pull (a pure read, so it also previews correctly under --dry-run) and writes it, for every lane actually (re)started that run, to <data-dir>/lanes/<lane>-launch-commit (bare hex SHA + newline). A lane start skips as already-running keeps its existing marker untouched — only lanes that actually launch this run get recorded. Best-effort: a write failure or an unresolvable HEAD warns on stderr but never fails an already-launched session.
  • New --data-dir DIR option (default: $CLAUDE_PLUGIN_DATA env var if set, else ~/.claude/plugins/data/claude-ops, matching check-all.sh's existing fallback convention in the same plugin).
  • SKILL.md's invocation now passes --data-dir "${CLAUDE_PLUGIN_DATA}" explicitly. Per current plugins-reference (fetched and verified this session, per the repo's fresh-docs mandate): CLAUDE_PLUGIN_DATA is exported as a real environment variable only to hook processes and MCP/LSP subprocesses — for skill content it instead resolves by inline text substitution "anywhere the placeholder appears" in the rendered skill body, exactly like the existing ${CLAUDE_PLUGIN_ROOT} usage on the same line. A script a skill shells out to via the Bash tool does not inherit CLAUDE_PLUGIN_DATA as an env var, so leaving --data-dir off would silently fall through to the script's own ~/.claude/plugins/data/claude-ops guess instead of the real marketplace-qualified directory Claude Code resolves. $ARGUMENTS comes after the injected --data-dir, so an operator-supplied --data-dir still wins (last flag wins in the parser).
  • context/refresh.md now points the probe at the real marker file (cat "$data_dir/lanes/<lane>-launch-commit" | tr -d '\r' — the repo's standing CRLF-hazard convention for any captured Windows value, e.g. the 0.19.1 CHANGELOG's claude-ops: fix plugins-skill default-marketplace resolver on version skew; generalize CRLF gotcha; correct install_new render doc (F1-F3) #1176/F2 note) instead of the unfillable placeholder, with an explicit note that a hex-only git rev-parse value carries no injection risk but any future non-git-rev-parse source must be validated before reaching git log.
  • README.md / SKILL.md — documented the new persisted artifact and gotcha (per-machine, best-effort; a missing marker means "never started here via lane-launcher.sh", not "launcher broken").
  • Version 0.19.20.20.0 (minor — new backward-compatible CLI option + new persisted artifact) + matching CHANGELOG.md entry.

Verification

  • lane-launcher.test.sh: 100/100 PASS (12 new cases covering capture/write, skip-if-already-running, --dry-run preview-only, unresolvable-HEAD best-effort, and the $CLAUDE_PLUGIN_DATA fallback).
  • shellcheck clean on both lane-launcher.sh and lane-launcher.test.sh.
  • shfmt -d — no formatting diff.
  • markdownlint-cli2 clean on all touched markdown (SKILL.md, context/refresh.md, README.md, CHANGELOG.md).
  • skill-quality:check lanesPASS, 0 errors, 1 soft warning (SKILL.md 216/200 soft line target — pre-existing category, not introduced by this change beyond the doc additions).
  • skill-quality:check validate-evals lanes — conforms (check-jsonschema, ok).

Closes #792

Related

This was generated by AI during work-loop execution.

context/refresh.md's git staleness probe referenced a <lane-launch-commit>
placeholder with no producer: lane-launcher.sh never recorded the repo HEAD
when a lane last (re)started, so the probe was advisory-only with no
automated way to fill it in.

lane-launcher.sh now captures `git rev-parse HEAD` right after the pre-launch
pull and writes it, for every lane actually (re)started that run, to
<data-dir>/lanes/<lane>-launch-commit (a lane `start` skips as
already-running keeps its existing marker). New --data-dir option; SKILL.md
threads ${CLAUDE_PLUGIN_DATA} through explicitly since a skill-invoked
script does not inherit it as an env var the way hook/MCP/LSP subprocesses do
(verified against current plugins-reference). The write is best-effort and
never fails an already-launched lane.

Closes #792

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 23s —— View job


PR #1383 Review

  • Read changed files
  • Analyze lane-launcher.sh changes
  • Analyze test coverage in lane-launcher.test.sh
  • Review documentation updates
  • Check plugin.json version bump
  • Compile and post review findings

Summary

This is a well-scoped, high-quality fix. The core problem was real: context/refresh.md's staleness probe referenced <lane-launch-commit> with no producer, making it advisory-only. The implementation is clean, the best-effort contract is properly applied, and test coverage is excellent. No blocking issues found.


lane-launcher.sh

Correctness of write_launch_commit_markerlane-launcher.sh#L295–L305

The combined mkdir -p ... || printf ... idiom correctly uses || so both errors are reported as a single "write failed" message. The return 0 is unconditional as the best-effort contract requires. One minor point: when mkdir -p fails and short-circuits, the error message says "marker write failed" without indicating it was the directory creation that failed. Not a bug since this is best-effort, but a slightly more specific error (e.g. "marker dir creation or write failed") would aid debugging. Minor/optional.

Capture timinglane-launcher.sh#L455–L461

capture_launch_commit is correctly positioned after the pull step but unconditional of --no-pull. The inline comment explains the reasoning ("a skipped pull still leaves a well-defined HEAD to record"), which is correct. Under --dry-run, this is a read-only operation so it correctly populates LAUNCH_COMMIT for preview output without side effects.

Global state orderinglane-launcher.sh#L577–L596

LAUNCH_COMMIT is set in refresh_repo_and_plugins() which is called at the top of both action_start() and action_restart() before for_each_lane. The implicit ordering contract (capture before any launch_lane) holds. action_stop() and action_status() never call launch_lane, so the unset LAUNCH_COMMIT="" state is never reached on those paths.

--data-dir "" edge caseSKILL.md#L67

The skill always passes --data-dir "${CLAUDE_PLUGIN_DATA}". In a real Claude Code session, ${CLAUDE_PLUGIN_DATA} is resolved by inline text substitution and will always be a non-empty path. However, in edge cases where the substitution yields an empty string (e.g. the skill is rendered outside a plugin-marketplace context), check_optarg would reject the empty argument and exit 3. The PR description explicitly documents that this env-var path only applies to direct/manual invocations, so this is a theoretical risk only. A belt-and-suspenders mitigation would be to guard the flag conditionally in SKILL.md:

bash "${CLAUDE_PLUGIN_ROOT}/skills/lanes/scripts/lane-launcher.sh" \
  ${CLAUDE_PLUGIN_DATA:+--data-dir "${CLAUDE_PLUGIN_DATA}"} $ARGUMENTS

This would let the script fall through to its own default when the substitution is empty. Flagging as a suggestion, not a blocker — the current behavior is documented and only affects non-standard rendering.

Security — The value written by write_launch_commit_marker comes from git rev-parse HEAD and is a bare hex SHA. The context/refresh.md probe explicitly documents the no-injection-risk property and includes a forward guard ([[ "$lane_launch_commit" =~ ^[0-9a-f]{7,64}$ ]]) for any future non-git-rev-parse sources. Good defensive documentation. context/refresh.md#L71–L78


lane-launcher.test.sh

Coverage for the new feature is thorough across all 12 new cases:

  • Real write (--data-dir) with SHA verification ✓
  • Skip-if-already-running leaves existing marker untouched ✓
  • Restart re-records the marker ✓
  • --dry-run preview output + no-write assertion ✓
  • Unresolvable HEAD: best-effort exit 0, warning on stderr, no file written ✓
  • $CLAUDE_PLUGIN_DATA env-var fallback ✓

Minor naming notelane-launcher.test.sh#L458

if [[ -e "$DATA_DIR4/lanes/work-launch-commit" ]]; then notwritten=1; else notwritten=0; fi
assert_eq "marker: dry-run writes no file" 0 "$notwritten"

The variable is named notwritten but is set to 1 when the file exists (i.e., when it was written). The logic is correct (assert_eq 0 "$notwritten" passes only when the file is absent), but the inversion is slightly confusing. was_written would make the intent clearer. Nit.


Documentation

context/refresh.md — The probe now reads from the real marker path with cat "$data_dir/lanes/<lane>-launch-commit" 2>/dev/null | tr -d '\r' and includes explicit [[ -n "$lane_launch_commit" ]] guard before using it in a git range. The tr -d '\r' follows the repo's standing CRLF-hazard convention. The security note is appropriately scoped: documents current no-risk state and the validation pattern for future changes.

SKILL.md — The explanation of why --data-dir is passed explicitly (inline substitution vs. env var inheritance for skill-invoked scripts) is clearly documented inline, not just in the PR body. This prevents future maintainers from removing it as "redundant". SKILL.md#L74–L87

Gotchas section — The "per-machine and best-effort" gotcha is a necessary addition: a missing marker means "never started here via lane-launcher.sh" not "launcher broken" — this distinction matters for operators debugging stale probes. SKILL.md#L164–L168


Version bump

0.19.20.20.0 (minor) is correct per semver: new backward-compatible --data-dir CLI option + new persisted artifact.


Verdict

Approved with one suggestion: consider the ${CLAUDE_PLUGIN_DATA:+--data-dir ...} guard in SKILL.md for robustness when CLAUDE_PLUGIN_DATA is empty. Not a blocker. The notwritten variable naming is a nit. Everything else — implementation, tests, documentation, and version bump — is correct and well-reasoned.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 15s —— View job


Security Review

  • Load tools
  • Fetch PR diff
  • Read changed files (lane-launcher.sh, SKILL.md, context/refresh.md, lane-launcher.test.sh)
  • Analyze for security vulnerabilities
  • Post findings

Scope: Files changed in this PR — lane-launcher.sh, SKILL.md, context/refresh.md, lane-launcher.test.sh, plugin.json, CHANGELOG.md, README.md.


SUGGESTION — Lane name used as file path component without character validation

Confidence: CONFIRMED

launch_commit_marker_path() — L278-280

Lane names are read from the config with jq -r '.lanes[$i].name // ""' and validated only for non-emptiness (line 504). They are then passed directly to launch_commit_marker_path "$name", which builds the marker path via:

printf '%s/%s-launch-commit' "$(resolve_data_dir)" "$1"

No character set restriction is enforced on the name component. A lane name containing ../ — e.g. ../../tmp/evil — would produce a marker path like <data-dir>/lanes/../../tmp/evil-launch-commit. The subsequent mkdir -p "$(dirname "$path")" in write_launch_commit_marker (line 301) would then create directories outside the intended data directory, and the SHA file would land there.

The written content is a hex SHA from git rev-parse HEAD, so the file itself is harmless — but the mkdir -p traversal creates arbitrary directory trees relative to wherever --data-dir resolves. Lane configs are operator-controlled local files, which bounds practical risk, but defense in depth is appropriate here.

Recommendation: validate lane names against a safe character set (e.g., [[ "$name" =~ ^[A-Za-z0-9_-]+$ ]]) at config parse time in resolve_config, alongside the existing duplicate-name check. This is the only place where free-form config text becomes a filesystem path component.


SUGGESTION — Staleness probe omits the format guard the document itself recommends

Confidence: PLAUSIBLE

context/refresh.md — L55-57

The probe snippet reads the marker file and interpolates its content into a git command:

lane_launch_commit="$(cat "$data_dir/lanes/<lane>-launch-commit" 2>/dev/null | tr -d '\r')"
[[ -n "$lane_launch_commit" ]] && git log --oneline "${lane_launch_commit}..${default}" -- plugins/claude-ops/

The document at lines 71-79 explicitly identifies this surface and prescribes a validation guard — [[ "$lane_launch_commit" =~ ^[0-9a-f]{7,64}$ ]] — but then omits it from the actual probe code, relying instead on the prose caveat. This means a reader who copies the code block literally gets the unguarded version.

Shell injection is not possible here: "${lane_launch_commit}..${default}" is double-quoted, so the entire value (including any embedded whitespace or special characters) is passed to git as a single positional argument, not split into separate tokens. Git therefore cannot mistake it for a flag like --work-tree=.... The realistic risk is a tampered marker file causing a confusing git error rather than code execution.

Nonetheless, the document already has the right answer and the probe should model it. Operator-executed probe snippets are more likely to be copied verbatim than followed with careful annotation-reading.

Recommendation: add the guard the document already cites, directly in the code block — between the lane_launch_commit=... assignment and the git log line:

# Validate: must be a bare hex SHA (the only value lane-launcher.sh ever writes).
[[ "$lane_launch_commit" =~ ^[0-9a-f]{7,64}$ ]] || { echo "marker invalid — skipping probe"; lane_launch_commit=""; }

No findings on remaining changed files

  • SKILL.md invocation (bash "...lane-launcher.sh" --data-dir "${CLAUDE_PLUGIN_DATA}" $ARGUMENTS): ${CLAUDE_PLUGIN_DATA} is double-quoted in the template, so a path with spaces is correctly handled after Claude Code's inline substitution. The unquoted $ARGUMENTS is the standard skill argument-passing pattern (pre-existing, not introduced here) and is appropriate for this operator-invocable skill.
  • lane-launcher.sh argument parser: flag-swallow guard (check_optarg), -- passthrough, and DATA_DIR_OVERRIDE handling are all correct.
  • capture_launch_commit: git rev-parse HEAD produces only hex characters; no injection surface.
  • write_launch_commit_marker: writes via printf '%s\n' "$LAUNCH_COMMIT" >"$path" — not shell-executed. Best-effort wrapper correctly returns 0 on failure.
  • lane-launcher.test.sh stubs: heredoc escaping is correct; $CLAUDE_LOG is intentionally expanded into the stub body at write time; no TOCTOU risk in the test harness.
  • plugin.json, CHANGELOG.md, README.md: documentation and version bump only; no new code surface.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 912d25cb8d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/skills/lanes/context/refresh.md Outdated
Comment thread plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh
Comment thread plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh
…-capture-launch-commit

# Conflicts:
#	plugins/claude-ops/CHANGELOG.md
Three review findings on the launch-commit marker.

The probe in context/refresh.md resolved its data dir from a
$CLAUDE_PLUGIN_DATA env fallback, but that file is read raw (no placeholder
substitution) and the Bash tool never receives the variable — only hook and
MCP/LSP subprocesses do. The probe therefore fell through to the unqualified
~/.claude/plugins/data/claude-ops guess, read no marker, and skipped the
staleness check silently. SKILL.md, which is skill content and does
substitute, now carries the data_dir assignment; refresh.md points at it.

A lane name is the marker's filename, so config preflight now rejects a name
that is not a single path component: `work` and `group/../work` otherwise
shared one marker and a targeted restart of either corrupted the other's
probe.

A (re)start that cannot record its commit now removes the previous launch's
marker instead of leaving it to be read as this session's launch point.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 2s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 2s —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

Warning

Automated security review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

Warning

Automated review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dfe17a7cce

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh Outdated
The data directory is plugin-wide, but a lane name is only unique within one
repo — `work` is a conventional name in every checkout the launcher manages.
Starting `work` in repo B overwrote repo A's marker, so A's staleness probe
diffed against a SHA from an unrelated history: an invalid-revision error at
best, a silently wrong answer at worst.

The marker path gains a repo component:
<data-dir>/lanes/<repo-key>/<lane>-launch-commit, where <repo-key> is the
resolved absolute repo path folded to [A-Za-z0-9_-] — the same shape Claude
Code uses for its own per-project directories.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 2s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 2s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3eeb474701

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh
Comment thread plugins/claude-ops/skills/lanes/scripts/lane-launcher.sh
Comment thread plugins/claude-ops/README.md Outdated
The character fold introduced with the repo namespace was not injective:
/repos/foo-bar and /repos/foo/bar collapse onto one key, reinstating the very
collision the namespace exists to prevent. And it keyed on the --repo argument
verbatim, while the documented probe asks git for the toplevel — so a --repo
naming a symlink wrote under one key and the probe read another.

Both sides now derive the key the same way: git hash-object over
`git rev-parse --show-toplevel`. README's persistent-state section, which still
advertised the pre-namespace path, carries the new layout and the one-liner
that prints a checkout's key.
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 2s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 2s —— View job


I'll analyze this and get back to you.

@kyle-sexton
kyle-sexton merged commit 2ce26a5 into main Jul 26, 2026
27 checks passed
@kyle-sexton
kyle-sexton deleted the fix/792-lane-launcher-capture-launch-commit branch July 26, 2026 01:12

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b3bfa5d8b9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

local top
top="$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null)" || top=""
[[ -n "$top" ]] || top="$REPO"
REPO_MARKER_KEY="$(printf '%s' "$top" | git hash-object --stdin 2>/dev/null)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hash repository keys in the target repository

When --repo targets a SHA-256 repository but the launcher is invoked outside that checkout or from a SHA-1 repository, this unscoped git hash-object uses the caller's repository format. With Git 2.43, the launcher consequently produces a 40-character SHA-1 key while the documented refresh probe, run inside the target checkout, produces a 64-character SHA-256 key for the same canonical path. The marker is then written under a directory the probe never reads, silently disabling staleness detection; run this hash with git -C "$REPO" so both sides use the target repository's object format.

Useful? React with 👍 / 👎.

Comment on lines +276 to +279
traversal="$(jq -r '
[ .lanes[].name
| select(. != null)
| select(test("[/\\\\]") or . == "." or . == "..") ] | join(", ")' "$CONFIG")"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-string names before testing path components

When any earlier lane has a non-string name, jq raises number cannot be matched (or the equivalent type error) at test(), but the script does not use set -e or check this command substitution's status. traversal therefore remains empty and preflight continues without examining later names, so a subsequent ../escape name reaches marker-path construction despite the new containment check. Validate every name's type before calling test, and fail the config when the validation query itself fails.

Useful? React with 👍 / 👎.

kyle-sexton added a commit that referenced this pull request Aug 9, 2026
…gate arming to the installs that asked (#2050)

Three bot-filed P2 defects in the `claude-ops` lanes launcher, all
verified to reproduce at `origin/main` and to stop reproducing here.
Every fix is covered by a new assertion that FAILS against the pre-fix
launcher and passes against this one.

## 1. The launch-commit marker key was digested in the wrong repository
(#1383)

`git hash-object` uses the object format of whatever repository it
resolves. The launcher called it **unscoped**, so it keyed on the
*caller's* format while taking the toplevel from the repository `--repo`
names. Reached from a SHA-1 working directory, a SHA-256 target produced
a 40-character key, while `skills/lanes/context/refresh.md`'s probe runs
inside that checkout and computed the 64-character one — the launcher
wrote its marker to a directory the probe never reads and staleness
detection was silently off.

The comment above the key asserted the two sides agree because both call
`git rev-parse --show-toplevel`. That settles the *path* and says
nothing about the *digest*, so the invariant it claimed did not hold.

Both digests are now taken with `-C "$REPO"`. The anchor is `$REPO`
(guaranteed by `resolve_repo` to be an existing directory) rather than
the hashed `$top` (a string git handed back) — anchoring on a path that
may not exist would fail the digest into the `unkeyed` fallback and
collapse every such repo onto one key.

**Scope is broader than the filed report:** `restart-consumer.sh`
derived its ledger key the same unscoped way and is fixed with it. The
hand-recompute snippets in the README, the changelog, and `refresh.md`
already run inside the target repository and were correct as written;
they are untouched.

## 2. An explicitly empty stop-gate marker was read as an absent one
(#1865)

`gate_option_from_settings` ended `select(type == "string") ] | last //
empty`, which prints nothing for an explicit `""` and nothing for an
absent key. `[[ -n "$marker" ]]` then dropped `--marker` for both, the
arm record carried no marker key at all, and `lane-stop-gate.sh`'s
precedence (managed ▷ arm record ▷ user settings ▷ default) walked past
it to the user-level marker — where a marker file left over from another
lane can authorize a stop this lane never signaled.

A `v:` prefix now carries "the lane set this" through the shell, so an
explicit empty value reaches the helper as `--marker ""`,
`lane-stop-gate-arm.sh` records `{"marker": ""}`, and the gate's `[[ -n
"$MARKER" ]]` guard leaves the marker channel off instead of falling
through.

**The sentinel is deliberately not symmetric.** `lane-stop-gate.sh`
substitutes the default token for an empty sentinel, so emptiness is not
a configured value there; recording one would buy no behavior change
while shadowing the user-level sentinel. An empty sentinel is therefore
still treated as absent, and a fixture pins that asymmetry.

## 3. The stop-gate arm id reached installs that never asked for it
(#1865)

Arming keyed off an any-quantifier over the `autonomy` / `autonomy@*`
namespace, then injected `lane_stop_gate_arm_id` into **every** entry in
it, and option extraction took its last match from any entry rather than
a requesting one.

The gate never treats this channel as a trusted verdict in either
direction, so an id landing on an entry set to `false` was not
overriding that `false`. What it did do is mark installs the lane never
asked to arm — leaving the settings handed to `claude` an inaccurate
record of what was requested, and letting a non-requesting entry's
marker reach the arm call. One shared filter now defines "an entry that
requested the gate", and detection, option extraction, and injection all
use it.

Arming every discovered helper script is unchanged and deliberate.

## Tests

`lane-launcher.test.sh` grows a SHA-256 cross-format marker fixture
(skipped where git cannot create a SHA-256 repository) and four
gate-arming fixtures. Against the pre-fix launcher with this test file,
six assertions fail:

- `marker: written under the TARGET repo's object-format key`
- `marker: nothing is written under the caller-format key`
- `arm: an explicitly empty marker still reaches the helper`
- `arm: options come from the requesting entry`
- `arm: a disabled sibling's marker never reaches the helper`
- `arm: the explicitly-disabled entry receives no arm id`

All six pass here; the suite is 193 assertions, 0 failures.

## Verification (independent re-run)

The pre-fix control was reproduced by copying the scripts directory to a
scratch path, replacing
`lane-launcher.sh` with `origin/main`'s, and running this branch's
**unchanged** test file against
it: `lane-launcher.test: FAIL — 6 case(s) failed` there, `PASS — 193
cases` here. The six failures
are exactly the list above, so no fixture is passing on both trees.

The SHA-256 block **executed** rather than skipping — `git version
2.54.0.windows.1` creates
`--object-format=sha256` repositories, and cases 158-160 report PASS.
The skip guard remains because
the format is not universally compiled in.

Beyond the arm stub: `lane-stop-gate-arm.sh` invoked directly with
`--marker ""` writes
`"marker": ""` into the arm record, while omitting the flag writes no
`marker` key at all. The
launcher's explicit-empty distinction therefore survives to the gate,
whose `gate_option` returns the
empty string (via the same `v:` idiom) rather than falling through to
user settings.

Folding the key test and the value test into one `select` adds no type
fragility: jq's `and`
short-circuits, so a non-autonomy scalar entry is never indexed, and an
*autonomy* entry whose value
is a scalar errors identically under the old and new filters.

Gates from the worktree root, all green: `check-changelog-parity.sh`
`--check` / `--check-bump
origin/main` / `--check-order`, `check-shell-portability.sh`,
`check-skill-portability.sh`,
`check-silent-skips.sh`, `check-plugin-manifest-presence.sh`,
`check-changed-skills.sh origin/main`
(0 errors; one pre-existing SKILL.md-length warning),
`validate-plugins.sh`, `markdownlint-cli2` on
the changelog, and `shellcheck -x` on all three changed scripts.

Version renumbered to `0.27.6` — `main` published `0.27.4` and then
`0.27.5` while this branch was in
flight.

## Related

No linked issue

The three findings were filed as review threads on merged PRs #1383 and
#1865, not as issues. Those PRs are referenced for provenance only —
this PR closes nothing.

A fourth thread on #1383, `PRRT_kwDOTCGFQM6TzlNw`, needs no change here:
the vacuous-traversal escape
it describes was already closed by `353baf64` (#1851), and a control at
`353baf64^` reproduces it.
Its adjacent defence-in-depth observation — the three preflight `jq`
substitutions in
`lane-launcher.sh` that ignore exit status — is deliberately left for a
separate change. Lines 1-406
of that file are byte-identical to `main` and all seven `$(jq …)`
command substitutions in it are
unchanged; the only edit above the marker-key block is line 407, where
the property list's own count
went from "Two" to "Three".

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

claude-ops: lane-launcher never captures launch commit, leaving refresh.md's staleness probe unfillable

1 participant