Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b36215a
One list of comments, open or closed, and only the agent closes
DeyangChan Aug 5, 2026
0ea670a
Every change passes a security scan before it lands
DeyangChan Aug 5, 2026
78920fc
SonarQube runs the quality gate, and every job declares its own permi…
DeyangChan Aug 6, 2026
fe29761
A round the agent took is finished or handed back before the turn can…
DeyangChan Aug 6, 2026
5eeb21d
The command that reports an unfinished round is named `unanswered`
DeyangChan Aug 6, 2026
b000dc4
A change to the plugin carries its own version, and merging publishes it
DeyangChan Aug 8, 2026
4b6e0fe
/ship drives a branch to green and merges it
DeyangChan Aug 8, 2026
e76d6ad
The review loop is driven end to end, under both hosts, on every pull…
DeyangChan Aug 8, 2026
3369239
Taking a comment off the list is a removal the server confirms
DeyangChan Aug 8, 2026
e822096
An overlay in the reviewed app opens where the reviewer is looking
DeyangChan Aug 8, 2026
c7469e2
A comment that changes place in the list travels there
DeyangChan Aug 8, 2026
1b160de
Merge ci/security-scans into main
DeyangChan Aug 8, 2026
917df70
A round belongs to the session whose watcher took delivery of it
DeyangChan Aug 8, 2026
67b2dfb
Merge review/session-ownership into main
DeyangChan Aug 8, 2026
1dee97c
The workspace says what a round changed, and asks in options
DeyangChan Aug 9, 2026
f9a5ad7
Hide the badges that are not green, and say it plainly
DeyangChan Aug 9, 2026
8bfcd20
Merge review/workspace-round-feedback into main
DeyangChan Aug 9, 2026
a773806
An option reads at the size of the comment, and Recommended is an eye…
DeyangChan Aug 9, 2026
8e7fd8a
The README demo is recorded by a script anyone can run again
DeyangChan Aug 9, 2026
a579ef3
A Codex install is told when a new version is out
DeyangChan Aug 9, 2026
d363c3a
Design reference, and the rules for tweaking a screen that already ex…
DeyangChan Aug 9, 2026
e36652f
The palette comes from the design guide
DeyangChan Aug 9, 2026
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
13 changes: 13 additions & 0 deletions .claude/commands/ship.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
description: Open a pull request for the current branch, drive it green, and merge it
argument-hint: [what the change is, if the branch name does not say]
---

Ship the work on the current branch by following **Shipping a change when
asked** in `CLAUDE.md`. That section owns the procedure; this command only
invokes it.

$ARGUMENTS

Before you start, report in one line: the branch, whether anything under
`plugins/` changed, and — if it did — the version you are shipping and why.
3 changes: 3 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ drove it on.
- [ ] Edited `lib/shell/` rather than a stamped region, ran `node plugins/vstack/lib/build-shell.mjs stamp`, and committed both.
- [ ] Added or renamed a plugin, and updated `.claude-plugin/marketplace.json` in the same commit.
- [ ] Renamed a tool, and added its former directory name to the `LEGACY` map in `lib/workdir.mjs`.
- [ ] Changed something under `plugins/`, and raised `version` in both host manifests with a matching `CHANGELOG.md` entry. Merging this publishes it.
- [ ] Every security scan passes, and no finding was silenced instead of fixed.
- [ ] Added a step that uses an action, and pinned it by commit SHA with the version in a trailing comment.
16 changes: 16 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
version: 2

# Actions are pinned by commit SHA, which is immutable and therefore never
# picks up an upstream fix on its own. Dependabot moves the pin and rewrites
# the version comment beside it.
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
# A compromised release is usually pulled within days of publication, so
# wait before moving a pin onto it.
cooldown:
default-days: 7
commit-message:
prefix: ci
92 changes: 92 additions & 0 deletions .github/scripts/check-version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env node
/*
* check-version.mjs — "does this pull request ship what it changed?"
*
* A host decides an update exists by comparing the `version` in plugin.json on
* main against the version it installed. Everything under plugins/ reaches a
* user the moment it lands on main, so a change there that leaves the version
* alone ships to nobody: the code is live, and every installed copy still
* believes it is current.
*
* Nothing downstream can catch that. The release is already out by then, and
* the repair is another release. So it is caught here, on the pull request,
* while there is still one commit to add.
*
* Run on a pull request with BASE_SHA set to the base of the branch.
*/
import { execFileSync } from "node:child_process"
import { readFileSync } from "node:fs"

const MANIFEST = "plugins/vstack/.claude-plugin/plugin.json"
const CHANGELOG = "CHANGELOG.md"
const SHIPPED_TO_USERS = "plugins/"
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/

const git = (...args) => execFileSync("git", args, { encoding: "utf8" })

const fail = (...lines) => {
for (const line of lines) console.error(line)
process.exit(1)
}

const parse = (version, where) => {
const match = SEMVER.exec(version ?? "")
if (!match) fail(`${where} declares ${JSON.stringify(version)}, which is not a MAJOR.MINOR.PATCH version.`)
return match.slice(1, 4).map(Number)
}

const isHigher = (candidate, current) => {
for (let part = 0; part < 3; part++) {
if (candidate[part] !== current[part]) return candidate[part] > current[part]
}
return false
}

const base = process.env.BASE_SHA
if (!base) fail("BASE_SHA is not set, so there is nothing to compare this branch against.")

const changed = git("diff", "--name-only", `${base}...HEAD`).split("\n").filter(Boolean)
const shipped = changed.filter(file => file.startsWith(SHIPPED_TO_USERS))

if (shipped.length === 0) {
console.log(`Nothing under ${SHIPPED_TO_USERS} changed, so this ships nothing and needs no version.`)
process.exit(0)
}

const declared = JSON.parse(readFileSync(MANIFEST, "utf8")).version
// A branch that adds the manifest has nothing to be higher than.
let previous = null
try {
previous = JSON.parse(git("show", `${base}:${MANIFEST}`)).version
} catch {
console.log(`${MANIFEST} does not exist at the base of this branch.`)
}

if (previous !== null && !isHigher(parse(declared, MANIFEST), parse(previous, `${MANIFEST} at the base`))) {
fail(
`${shipped.length} file(s) under ${SHIPPED_TO_USERS} changed, and every one of them reaches a user`,
`as soon as this merges. This branch declares ${declared} against ${previous} on the base, so no`,
"host will offer the update and the change ships to nobody.",
"",
"Raise `version` in BOTH host manifests and add the matching CHANGELOG.md entry:",
" plugins/vstack/.claude-plugin/plugin.json",
" plugins/vstack/.codex-plugin/plugin.json",
"",
"MAJOR for a breaking change to a skill name, an on-disk path, or a protocol.",
"MINOR for new behaviour. PATCH for a fix.",
"",
"Changed here:",
...shipped.map(file => ` ${file}`),
)
}

const heading = new RegExp(`^## ${declared.replace(/\./g, "\\.")}\\b`, "m")
if (!heading.test(readFileSync(CHANGELOG, "utf8"))) {
fail(
`${CHANGELOG} has no entry for ${declared}, and that entry is published as the release notes.`,
"",
`Add a section starting "## ${declared}" above the previous release.`,
)
}

console.log(`Ships ${declared}, and ${CHANGELOG} says what is in it.`)
59 changes: 59 additions & 0 deletions .github/scripts/publish-release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env node
/*
* publish-release.mjs — tag main at the version it declares, and publish it.
*
* The plugin is distributed by the repository itself, so merging to main is
* what ships it. The tag and the GitHub release are the record of what shipped,
* written from what is already in the tree: the version in plugin.json and its
* CHANGELOG.md entry.
*
* Keyed on the tag rather than the diff, so it is safe to re-run and does not
* care how the commit reached main. A commit whose version is already tagged
* publishes nothing.
*
* Run on a push to main with GH_TOKEN set.
*/
import { execFileSync } from "node:child_process"
import { readFileSync } from "node:fs"

const MANIFEST = "plugins/vstack/.claude-plugin/plugin.json"
const CHANGELOG = "CHANGELOG.md"

const gh = (...args) => execFileSync("gh", args, { encoding: "utf8" })

const version = JSON.parse(readFileSync(MANIFEST, "utf8")).version
const tag = `v${version}`

try {
gh("release", "view", tag, "--json", "tagName")
console.log(`${tag} is already published. Nothing to do.`)
process.exit(0)
} catch {
// No release under that tag yet, which is the case this runs for.
}

// Everything from this version's heading up to the next one. Written by a
// person, so it is published as-is rather than regenerated from commits.
const changelog = readFileSync(CHANGELOG, "utf8")
const heading = new RegExp(`^## ${version.replace(/\./g, "\\.")}\\b.*$`, "m")
const start = changelog.search(heading)

if (start === -1) {
console.error(`${CHANGELOG} has no entry for ${version}, so there are no notes to publish.`)
console.error("A pull request cannot merge without one, so this commit did not come through one.")
process.exit(1)
}

const rest = changelog.slice(start)
const nextRelease = rest.indexOf("\n## ", 1)
const section = (nextRelease === -1 ? rest : rest.slice(0, nextRelease)).trim()
const notes = section.slice(section.indexOf("\n") + 1).trim()

gh(
"release", "create", tag,
"--target", process.env.GITHUB_SHA,
"--title", tag,
"--notes", notes,
)

console.log(`Published ${tag}.`)
118 changes: 108 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,21 @@ on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
tests:
name: Tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
node: ['18', '22']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}
- name: Review lifecycle
Expand All @@ -28,13 +29,67 @@ jobs:
run: node plugins/vstack/skills/review/tests/host-profiles.mjs
- name: Working-directory resolution
run: node plugins/vstack/skills/review/tests/workdir.mjs
- name: Round gate
run: node plugins/vstack/skills/review/tests/round-gate.mjs
- name: Update check
run: node plugins/vstack/skills/review/tests/update-check.mjs
- name: Design tokens
run: node plugins/vstack/skills/review/tests/design-tokens.mjs

e2e:
name: E2E (${{ matrix.host }})
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
host: [claude, codex]
defaults:
run:
working-directory: e2e
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: npm
cache-dependency-path: e2e/package-lock.json
- name: Install the suite
run: npm ci
# The @browser scenarios drive the workspace in a real Chromium.
- name: Install Chromium
run: npx playwright install --with-deps chromium
# The Gherkin features in e2e/features/ drive the real review server and
# CLI; the host decides which profile the workspace is stamped with.
- name: Review loop end to end
env:
VSTACK_HOST: ${{ matrix.host }}
run: npx cucumber-js --format progress --format summary:cucumber-summary.txt
- name: Publish the result to the run summary
if: always()
env:
HOST: ${{ matrix.host }}
run: |
{
echo "### E2E ($HOST)"
echo '```'
cat cucumber-summary.txt 2>/dev/null || echo 'The suite did not produce a summary.'
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

shell:
name: Stamped shell is current
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
# Fails when a page's stamped region has drifted from lib/shell/.
Expand All @@ -45,19 +100,42 @@ jobs:
manifests:
name: Manifests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
# Unpinned on purpose: this job has to run the validator a marketplace
# reviewer would run today, and there is no lockfile to pin it against.
# --ignore-scripts keeps every transitive dependency's lifecycle script
# from running; the CLI does not work without its own postinstall, so
# that one is run explicitly and is the only script that executes.
- name: Install Claude Code
run: npm install --global @anthropic-ai/claude-code
run: | # zizmor: ignore[adhoc-packages]
npm install --global --ignore-scripts @anthropic-ai/claude-code
node "$(npm root -g)/@anthropic-ai/claude-code/install.cjs"
# The community-marketplace review pipeline runs this same check on every
# submission, so a warning here is a warning a reviewer would see.
- name: Validate the marketplace
run: claude plugin validate . --strict
- name: Validate the plugin
run: claude plugin validate ./plugins/vstack --strict
# The path a user takes. CLAUDE_CONFIG_DIR points it at a throwaway
# directory so the local marketplace entry is never written to a real one,
# where it would shadow the published cavalry-collective. The source must
# be ./ and not .
- name: Rehearse the install
run: |
SANDBOX=$(mktemp -d)
export CLAUDE_CONFIG_DIR="$SANDBOX/.claude"
claude plugin marketplace add ./
claude plugin install vstack@cavalry-collective
claude plugin details vstack
rm -rf "$SANDBOX"
# Both hosts read a version out of their own manifest, so they can drift
# apart silently and ship the same commit under two version numbers.
- name: Host manifests declare the same version
Expand All @@ -73,3 +151,23 @@ jobs:
}
console.log(`Both host manifests declare ${claude}.`)
'

version:
name: Plugin changes ship a version
# Only a pull request has a base to compare against. A push to main has
# already been through this.
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# The check reads the manifest at the base of the branch, which a shallow
# clone does not have.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Compare against the base of the branch
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: node .github/scripts/check-version.mjs
Loading