runcastle 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/bin/runcastle.js +5580 -0
- package/drizzle/0000_amused_bruce_banner.sql +74 -0
- package/drizzle/0001_mapped_feature_flag.sql +1 -0
- package/drizzle/0002_waypoints.sql +14 -0
- package/drizzle/0003_project_closed_at.sql +1 -0
- package/drizzle/0004_events_project_id.sql +28 -0
- package/drizzle/0005_project_setting_overrides.sql +2 -0
- package/drizzle/meta/0000_snapshot.json +466 -0
- package/drizzle/meta/0001_snapshot.json +474 -0
- package/drizzle/meta/0002_snapshot.json +568 -0
- package/drizzle/meta/0003_snapshot.json +575 -0
- package/drizzle/meta/0004_snapshot.json +582 -0
- package/drizzle/meta/0005_snapshot.json +596 -0
- package/drizzle/meta/_journal.json +48 -0
- package/hook-client.ts +67 -0
- package/index.js +5424 -0
- package/package.json +24 -30
- package/pty-host.cjs +186 -0
- package/sandcastle-template/Containerfile +33 -0
- package/sandcastle-template/Dockerfile +33 -0
- package/skills/burner/implement-ticket.md +42 -0
- package/skills/burner/research-waypoint.md +45 -0
- package/skills/packs/README.md +50 -0
- package/skills/packs/runcastle/.claude-plugin/plugin.json +6 -0
- package/skills/packs/runcastle/skills/converge/SKILL.md +51 -0
- package/skills/packs/runcastle/skills/ideate/SKILL.md +81 -0
- package/skills/packs/runcastle/skills/qa/SKILL.md +30 -0
- package/skills/packs/runcastle/skills/spec/SKILL.md +43 -0
- package/skills/packs/runcastle/skills/tickets/SKILL.md +52 -0
- package/skills/packs/runcastle/skills/waypoint/SKILL.md +54 -0
- package/web/assets/index-CKZ5Z7vc.css +1 -0
- package/web/assets/index-YBuT7tTz.js +108 -0
- package/web/index.html +13 -0
- package/dist/add-DD7G73HH.js +0 -315
- package/dist/add-DD7G73HH.js.map +0 -1
- package/dist/chunk-44KDPUYJ.js +0 -49
- package/dist/chunk-44KDPUYJ.js.map +0 -1
- package/dist/chunk-5VPKMXSV.js +0 -190
- package/dist/chunk-5VPKMXSV.js.map +0 -1
- package/dist/chunk-6CWCR5SO.js +0 -24
- package/dist/chunk-6CWCR5SO.js.map +0 -1
- package/dist/chunk-7CHGGMP7.js +0 -73
- package/dist/chunk-7CHGGMP7.js.map +0 -1
- package/dist/chunk-AHRTPV2C.js +0 -1082
- package/dist/chunk-AHRTPV2C.js.map +0 -1
- package/dist/chunk-GU4LRYYH.js +0 -41
- package/dist/chunk-GU4LRYYH.js.map +0 -1
- package/dist/chunk-LP5MV76P.js +0 -33
- package/dist/chunk-LP5MV76P.js.map +0 -1
- package/dist/chunk-OJ356IMJ.js +0 -136
- package/dist/chunk-OJ356IMJ.js.map +0 -1
- package/dist/chunk-PRNBOQHW.js +0 -32
- package/dist/chunk-PRNBOQHW.js.map +0 -1
- package/dist/chunk-QTC5Y3LU.js +0 -146
- package/dist/chunk-QTC5Y3LU.js.map +0 -1
- package/dist/chunk-SNUH2UT6.js +0 -95
- package/dist/chunk-SNUH2UT6.js.map +0 -1
- package/dist/chunk-UG25BZUN.js +0 -59
- package/dist/chunk-UG25BZUN.js.map +0 -1
- package/dist/chunk-VWAIXSB6.js +0 -78
- package/dist/chunk-VWAIXSB6.js.map +0 -1
- package/dist/index.js +0 -99
- package/dist/index.js.map +0 -1
- package/dist/info-3PW6A6JA.js +0 -80
- package/dist/info-3PW6A6JA.js.map +0 -1
- package/dist/init-MOJNLWXK.js +0 -212
- package/dist/init-MOJNLWXK.js.map +0 -1
- package/dist/logout-YPGZ6BB6.js +0 -27
- package/dist/logout-YPGZ6BB6.js.map +0 -1
- package/dist/publish-W37Y6TO6.js +0 -211
- package/dist/publish-W37Y6TO6.js.map +0 -1
- package/dist/run-M4XXNPGD.js +0 -291
- package/dist/run-M4XXNPGD.js.map +0 -1
- package/dist/search-2KNAXPM5.js +0 -55
- package/dist/search-2KNAXPM5.js.map +0 -1
- package/dist/update-UCT4K6IQ.js +0 -125
- package/dist/update-UCT4K6IQ.js.map +0 -1
- package/dist/validate-GFDSEVPV.js +0 -12
- package/dist/validate-GFDSEVPV.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,40 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "runcastle",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Install and publish Sandcastle workflow packages",
|
|
5
|
-
"license": "MIT",
|
|
3
|
+
"version": "1.0.0",
|
|
6
4
|
"type": "module",
|
|
7
5
|
"bin": {
|
|
8
|
-
"runcastle": "./
|
|
6
|
+
"runcastle": "./bin/runcastle.js"
|
|
9
7
|
},
|
|
10
|
-
"files": [
|
|
11
|
-
"dist"
|
|
12
|
-
],
|
|
13
8
|
"engines": {
|
|
14
|
-
"
|
|
15
|
-
},
|
|
16
|
-
"scripts": {
|
|
17
|
-
"build": "tsup src/index.ts --format esm --clean",
|
|
18
|
-
"test": "vitest run",
|
|
19
|
-
"typecheck": "tsc --noEmit"
|
|
9
|
+
"bun": ">=1.3.14"
|
|
20
10
|
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"bin",
|
|
14
|
+
"hook-client.ts",
|
|
15
|
+
"pty-host.cjs",
|
|
16
|
+
"drizzle",
|
|
17
|
+
"skills",
|
|
18
|
+
"web",
|
|
19
|
+
"sandcastle-template"
|
|
20
|
+
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"@types/semver": "^7.7.1",
|
|
34
|
-
"execa": "^9.6.1",
|
|
35
|
-
"msw": "^2.14.6",
|
|
36
|
-
"tsup": "^8.5.1",
|
|
37
|
-
"typescript": "^5.9.3",
|
|
38
|
-
"vitest": "^4.1.9"
|
|
22
|
+
"@ai-hero/sandcastle": "0.12.0",
|
|
23
|
+
"@hono/mcp": "0.3.1",
|
|
24
|
+
"@hono/trpc-server": "0.4.2",
|
|
25
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
26
|
+
"@trpc/server": "11.18.0",
|
|
27
|
+
"drizzle-orm": "0.45.2",
|
|
28
|
+
"hono": "4.12.30",
|
|
29
|
+
"nanoid": "6.0.0",
|
|
30
|
+
"node-pty": "^1.1.0",
|
|
31
|
+
"simple-git": "^3.36.0",
|
|
32
|
+
"zod": "4.4.3"
|
|
39
33
|
}
|
|
40
34
|
}
|
package/pty-host.cjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY sidecar host (UI-SPEC §5). Run with SYSTEM `node` (not Bun) — one process
|
|
3
|
+
* per terminal — because node-pty's Windows ConPTY backend writes keystrokes to
|
|
4
|
+
* the child through a Node `net.Socket` input pipe that is unusable under Bun
|
|
5
|
+
* (write() throws `ERR_SOCKET_CLOSED`, so INPUT is silently dropped). Output
|
|
6
|
+
* works under Bun, so only WRITE is broken; running node-pty under real `node`
|
|
7
|
+
* here fixes it. The server (`pty-sidecar.ts`) spawns this and talks a
|
|
8
|
+
* newline-delimited JSON protocol over stdio; binary payloads are base64 so they
|
|
9
|
+
* can never collide with the `\n` frame delimiter.
|
|
10
|
+
*
|
|
11
|
+
* PROTOCOL
|
|
12
|
+
* inbound (server → host, over this process's stdin, one JSON object per line):
|
|
13
|
+
* { t:'spawn', file, args, opts:{ cwd, cols, rows, useConpty, env } } // first
|
|
14
|
+
* { t:'write', d:<base64 utf8 keystrokes> }
|
|
15
|
+
* { t:'resize', cols, rows }
|
|
16
|
+
* { t:'kill' }
|
|
17
|
+
* outbound (host → server, over this process's stdout):
|
|
18
|
+
* { t:'ready', pid }
|
|
19
|
+
* { t:'data', d:<base64> } // raw PTY output bytes
|
|
20
|
+
* { t:'exit', code, signal }
|
|
21
|
+
* { t:'error', message } // spawn/host failure (fatal)
|
|
22
|
+
*
|
|
23
|
+
* argv[2] (optional): absolute path to node-pty's entry, resolved by the server
|
|
24
|
+
* so the child never has to re-resolve it. Falls back to bare require('node-pty').
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
'use strict'
|
|
28
|
+
|
|
29
|
+
/** Emit one framed JSON line on stdout. Swallow EPIPE if the server is gone. */
|
|
30
|
+
function send(obj) {
|
|
31
|
+
try {
|
|
32
|
+
process.stdout.write(JSON.stringify(obj) + '\n')
|
|
33
|
+
} catch {
|
|
34
|
+
// server detached — nothing we can do
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Diagnostics go to stderr so they never corrupt the stdout protocol stream. */
|
|
39
|
+
function log(msg) {
|
|
40
|
+
try {
|
|
41
|
+
process.stderr.write(`[pty-host] ${msg}\n`)
|
|
42
|
+
} catch {
|
|
43
|
+
/* ignore */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function loadNodePty() {
|
|
48
|
+
const explicit = process.argv[2]
|
|
49
|
+
if (explicit) {
|
|
50
|
+
try {
|
|
51
|
+
return require(explicit)
|
|
52
|
+
} catch (e) {
|
|
53
|
+
log(`require(${explicit}) failed: ${e && e.message}; falling back to bare specifier`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return require('node-pty')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let proc = null
|
|
60
|
+
let spawned = false
|
|
61
|
+
|
|
62
|
+
function handleSpawn(msg) {
|
|
63
|
+
if (spawned) {
|
|
64
|
+
log('ignoring second spawn message')
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
spawned = true
|
|
68
|
+
let pty
|
|
69
|
+
try {
|
|
70
|
+
pty = loadNodePty()
|
|
71
|
+
} catch (e) {
|
|
72
|
+
send({ t: 'error', message: `failed to load node-pty: ${e && e.message}` })
|
|
73
|
+
process.exit(1)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const opts = msg.opts || {}
|
|
78
|
+
const env = {}
|
|
79
|
+
const src = opts.env || process.env
|
|
80
|
+
for (const k of Object.keys(src)) {
|
|
81
|
+
if (typeof src[k] === 'string') env[k] = src[k]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
proc = pty.spawn(msg.file, msg.args || [], {
|
|
86
|
+
name: 'xterm-256color',
|
|
87
|
+
cols: Math.max(1, opts.cols || 80),
|
|
88
|
+
rows: Math.max(1, opts.rows || 24),
|
|
89
|
+
cwd: opts.cwd,
|
|
90
|
+
env,
|
|
91
|
+
useConpty: opts.useConpty !== false,
|
|
92
|
+
})
|
|
93
|
+
} catch (e) {
|
|
94
|
+
send({ t: 'error', message: `spawn failed: ${e && e.message}` })
|
|
95
|
+
process.exit(1)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
send({ t: 'ready', pid: proc.pid })
|
|
100
|
+
|
|
101
|
+
proc.onData((d) => {
|
|
102
|
+
const buf = typeof d === 'string' ? Buffer.from(d, 'utf8') : d
|
|
103
|
+
send({ t: 'data', d: buf.toString('base64') })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
proc.onExit((e) => {
|
|
107
|
+
send({ t: 'exit', code: e && typeof e.exitCode === 'number' ? e.exitCode : 0, signal: (e && e.signal) || null })
|
|
108
|
+
// Give stdout a tick to flush the framed exit line before we go.
|
|
109
|
+
setTimeout(() => process.exit(0), 20)
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function handle(msg) {
|
|
114
|
+
switch (msg && msg.t) {
|
|
115
|
+
case 'spawn':
|
|
116
|
+
handleSpawn(msg)
|
|
117
|
+
break
|
|
118
|
+
case 'write':
|
|
119
|
+
if (proc && typeof msg.d === 'string') {
|
|
120
|
+
try {
|
|
121
|
+
proc.write(Buffer.from(msg.d, 'base64').toString('utf8'))
|
|
122
|
+
} catch (e) {
|
|
123
|
+
log(`write failed: ${e && e.message}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
break
|
|
127
|
+
case 'resize':
|
|
128
|
+
if (proc && typeof msg.cols === 'number' && typeof msg.rows === 'number') {
|
|
129
|
+
try {
|
|
130
|
+
proc.resize(Math.max(1, msg.cols), Math.max(1, msg.rows))
|
|
131
|
+
} catch (e) {
|
|
132
|
+
log(`resize failed: ${e && e.message}`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
break
|
|
136
|
+
case 'kill':
|
|
137
|
+
if (proc) {
|
|
138
|
+
try {
|
|
139
|
+
proc.kill()
|
|
140
|
+
} catch {
|
|
141
|
+
/* already gone */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
break
|
|
145
|
+
default:
|
|
146
|
+
log(`unknown message: ${JSON.stringify(msg)}`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- stdin line reader (newline-delimited JSON) ------------------------------
|
|
151
|
+
let acc = ''
|
|
152
|
+
process.stdin.setEncoding('utf8')
|
|
153
|
+
process.stdin.on('data', (chunk) => {
|
|
154
|
+
acc += chunk
|
|
155
|
+
let nl
|
|
156
|
+
while ((nl = acc.indexOf('\n')) !== -1) {
|
|
157
|
+
const line = acc.slice(0, nl)
|
|
158
|
+
acc = acc.slice(nl + 1)
|
|
159
|
+
if (!line) continue
|
|
160
|
+
let msg
|
|
161
|
+
try {
|
|
162
|
+
msg = JSON.parse(line)
|
|
163
|
+
} catch (e) {
|
|
164
|
+
log(`bad JSON line: ${e && e.message}`)
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
handle(msg)
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// When the server closes our stdin (it detached / crashed), tear down the child.
|
|
172
|
+
process.stdin.on('end', () => {
|
|
173
|
+
if (proc) {
|
|
174
|
+
try {
|
|
175
|
+
proc.kill()
|
|
176
|
+
} catch {
|
|
177
|
+
/* ignore */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
process.exit(0)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
process.on('uncaughtException', (e) => {
|
|
184
|
+
send({ t: 'error', message: `uncaught: ${e && e.message}` })
|
|
185
|
+
process.exit(1)
|
|
186
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Generic runcastle AFK burner image — Bun + Node + git toolchain for unattended agents.
|
|
2
|
+
# runcastle scaffolds this into a build context on demand (issue #50); nothing
|
|
3
|
+
# runcastle-specific is baked in — per-project deps install at sandbox start via the
|
|
4
|
+
# project's own .sandcastle config. Generalized from this repo's dogfood .sandcastle
|
|
5
|
+
# Dockerfile. Both `docker build` and `podman build` use the enclosing dir as the
|
|
6
|
+
# build context; the image tag is passed by the caller (--image-name), never derived.
|
|
7
|
+
FROM node:22-bookworm
|
|
8
|
+
|
|
9
|
+
# Toolchain: git (worktrees/commits), curl (installers), jq, plus common unpack deps.
|
|
10
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
11
|
+
git curl ca-certificates jq unzip \
|
|
12
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
13
|
+
|
|
14
|
+
# Bun (the agent toolchain default) — installed system-wide before the user switch.
|
|
15
|
+
ENV BUN_INSTALL=/usr/local
|
|
16
|
+
RUN curl -fsSL https://bun.sh/install | bash
|
|
17
|
+
|
|
18
|
+
# Rootless-podman invariant (research #32): the in-container agent user MUST be at
|
|
19
|
+
# UID/GID 1000 so bind-mounted host files share an owner. Sandcastle passes the host
|
|
20
|
+
# uid/gid as build args (defaulting here to 1000); rename the stock `node` user to match.
|
|
21
|
+
ARG AGENT_UID=1000
|
|
22
|
+
ARG AGENT_GID=1000
|
|
23
|
+
RUN groupmod -o -g "$AGENT_GID" node \
|
|
24
|
+
&& usermod -o -u "$AGENT_UID" -g "$AGENT_GID" -d /home/agent -m -l agent node
|
|
25
|
+
USER ${AGENT_UID}:${AGENT_GID}
|
|
26
|
+
|
|
27
|
+
# Claude Code CLI (the agent runtime) — authenticated at runtime via CLAUDE_CODE_OAUTH_TOKEN.
|
|
28
|
+
RUN curl -fsSL https://claude.ai/install.sh | bash
|
|
29
|
+
ENV PATH="/home/agent/.local/bin:$PATH"
|
|
30
|
+
|
|
31
|
+
# Sandcastle bind-mounts the git worktree here and runs the agent in it.
|
|
32
|
+
WORKDIR /home/agent/workspace
|
|
33
|
+
ENTRYPOINT ["sleep", "infinity"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Generic runcastle AFK burner image — Bun + Node + git toolchain for unattended agents.
|
|
2
|
+
# runcastle scaffolds this into a build context on demand (issue #50); nothing
|
|
3
|
+
# runcastle-specific is baked in — per-project deps install at sandbox start via the
|
|
4
|
+
# project's own .sandcastle config. Generalized from this repo's dogfood .sandcastle
|
|
5
|
+
# Dockerfile. Both `docker build` and `podman build` use the enclosing dir as the
|
|
6
|
+
# build context; the image tag is passed by the caller (--image-name), never derived.
|
|
7
|
+
FROM node:22-bookworm
|
|
8
|
+
|
|
9
|
+
# Toolchain: git (worktrees/commits), curl (installers), jq, plus common unpack deps.
|
|
10
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
11
|
+
git curl ca-certificates jq unzip \
|
|
12
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
13
|
+
|
|
14
|
+
# Bun (the agent toolchain default) — installed system-wide before the user switch.
|
|
15
|
+
ENV BUN_INSTALL=/usr/local
|
|
16
|
+
RUN curl -fsSL https://bun.sh/install | bash
|
|
17
|
+
|
|
18
|
+
# Rootless-podman invariant (research #32): the in-container agent user MUST be at
|
|
19
|
+
# UID/GID 1000 so bind-mounted host files share an owner. Sandcastle passes the host
|
|
20
|
+
# uid/gid as build args (defaulting here to 1000); rename the stock `node` user to match.
|
|
21
|
+
ARG AGENT_UID=1000
|
|
22
|
+
ARG AGENT_GID=1000
|
|
23
|
+
RUN groupmod -o -g "$AGENT_GID" node \
|
|
24
|
+
&& usermod -o -u "$AGENT_UID" -g "$AGENT_GID" -d /home/agent -m -l agent node
|
|
25
|
+
USER ${AGENT_UID}:${AGENT_GID}
|
|
26
|
+
|
|
27
|
+
# Claude Code CLI (the agent runtime) — authenticated at runtime via CLAUDE_CODE_OAUTH_TOKEN.
|
|
28
|
+
RUN curl -fsSL https://claude.ai/install.sh | bash
|
|
29
|
+
ENV PATH="/home/agent/.local/bin:$PATH"
|
|
30
|
+
|
|
31
|
+
# Sandcastle bind-mounts the git worktree here and runs the agent in it.
|
|
32
|
+
WORKDIR /home/agent/workspace
|
|
33
|
+
ENTRYPOINT ["sleep", "infinity"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<!-- Forked from Matt Pocock's implement + tdd + code-review skills, 2026-07-14, adapted for runcastle's unattended burner. Rendered per ticket; the placeholder tokens are filled in by the ticket-burner workflow. -->
|
|
2
|
+
|
|
3
|
+
# Implement this ticket — unattended
|
|
4
|
+
|
|
5
|
+
You are a single agent in a sandbox on branch `feature/<slug>`. You have **one ticket**. There is **no human to ask** — no follow-up questions are possible. Everything you need is in this prompt and in the repo. Work carefully, commit only green work, and stop when the ticket is done.
|
|
6
|
+
|
|
7
|
+
## The ticket
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{{TICKET_JSON}}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Feature context
|
|
14
|
+
|
|
15
|
+
{{FEATURE_BRIEF}}
|
|
16
|
+
|
|
17
|
+
## Feature docs (spec / decisions digest)
|
|
18
|
+
|
|
19
|
+
{{DOCS_DIGEST}}
|
|
20
|
+
|
|
21
|
+
## How to work
|
|
22
|
+
|
|
23
|
+
1. **Orient.** Read the ticket's `goal`, `context`, `acceptanceCriteria`, and `seams`. Read the files its `context` names and the existing patterns it points to. Match the conventions of the surrounding code — you are extending this codebase, not starting a new one.
|
|
24
|
+
|
|
25
|
+
2. **Test at the seams (forked tdd).** The ticket's `seams` are the public interfaces to test at — test *there*, at the boundary where behaviour is observable, never against internals or private helpers. A good test reads like a spec ("user can X"), uses the public API only, and survives refactors. Mock only true system boundaries (network, clock, external services); never mock your own modules.
|
|
26
|
+
|
|
27
|
+
3. **Red → green, one criterion at a time.** Where a test framework exists in the repo: for each acceptance criterion, write the failing test at the seam first, then the minimal code to pass it, then move on. One slice per cycle — do not bulk-write tests for imagined behaviour, and do not recompute the expected value the way the code does (use known-good literals). Where the repo has **no** test framework: verify each criterion by actually running the code / driving the demoable path, and state what you ran.
|
|
28
|
+
|
|
29
|
+
4. **Guard every commit.** Before each commit, run typecheck and the relevant tests (the full suite at least once at the end). **Never commit red.** Commit in small logical steps using the convention:
|
|
30
|
+
|
|
31
|
+
`{{COMMIT_CONVENTION}}`
|
|
32
|
+
|
|
33
|
+
5. **Self-review before you finish (forked code-review — two axes).** When all acceptance criteria pass, review your own diff along both axes, then fix what you find and re-run typecheck + tests:
|
|
34
|
+
- **Standards** — does the diff follow the conventions of the surrounding code? Watch for the smells: duplicated logic (extract it), mysterious names (rename), primitive obsession (give the concept a type), speculative generality (delete anything the ticket did not ask for), feature envy, data clumps.
|
|
35
|
+
- **Spec = this ticket** — is every acceptance criterion actually met, and is there **nothing in the diff the ticket did not ask for**? Missing and extra both count.
|
|
36
|
+
Commit the fixes.
|
|
37
|
+
|
|
38
|
+
## Hard rules
|
|
39
|
+
|
|
40
|
+
- **Never expand scope beyond the ticket.** If you notice adjacent work, worthwhile refactors, or another ticket's territory, leave it. Note it in your final commit body if it matters; do not do it.
|
|
41
|
+
- **No questions, no guessing into the void.** Resolve ambiguity from the ticket context and the code. If two readings both satisfy the acceptance criteria, take the smaller one.
|
|
42
|
+
- **If genuinely blocked** (a dependency ticket's output is not there, the environment fails, or a requirement truly cannot be resolved from context + code): **commit nothing for the blocked part**, write `BLOCKED.md` at the repo root stating precisely what blocked you and what is needed to unblock, and stop. Green, complete parts may still be committed; the blocked part must not be. The orchestrator reads your exit state from the commits — a part with no commits is read as not done.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<!-- Forked from Matt Pocock's research + grilling skills, 2026-07-15, adapted for runcastle's unattended research-waypoint runner (SPEC §13.2). Rendered per research waypoint; the placeholder tokens are filled in by the research workflow. -->
|
|
2
|
+
|
|
3
|
+
# Research this waypoint — unattended
|
|
4
|
+
|
|
5
|
+
You are a single agent in a sandbox on branch `feature/<slug>`. You have **one research question**. There is **no human to ask** — no follow-up questions are possible. Everything you need is in this prompt, on the web, and in the repo. Research carefully, write one focused summary, commit it, and stop.
|
|
6
|
+
|
|
7
|
+
## The waypoint
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{{WAYPOINT_JSON}}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Feature context
|
|
14
|
+
|
|
15
|
+
{{FEATURE_BRIEF}}
|
|
16
|
+
|
|
17
|
+
## Feature docs (spec / decisions digest)
|
|
18
|
+
|
|
19
|
+
{{DOCS_DIGEST}}
|
|
20
|
+
|
|
21
|
+
## How to work
|
|
22
|
+
|
|
23
|
+
1. **Understand the question.** Read the waypoint's `title` and `question`. This is a *research* node: its job is to produce a well-sourced answer that later ideation/spec sessions can build on — not to change product code.
|
|
24
|
+
|
|
25
|
+
2. **Research web + repo.** Search the web for authoritative, current sources (official docs, primary references, well-regarded write-ups) and read the parts of *this* repo the question bears on. Prefer primary sources; note version/date where it matters. Reconcile conflicts rather than papering over them.
|
|
26
|
+
|
|
27
|
+
3. **Write ONE summary doc** at exactly:
|
|
28
|
+
|
|
29
|
+
`{{RESEARCH_DOC_PATH}}`
|
|
30
|
+
|
|
31
|
+
Create the `research/` directory if it does not exist. The doc is the deliverable — structure it so a reader who never saw this prompt can act on it:
|
|
32
|
+
- a one-line **answer** up top,
|
|
33
|
+
- the **findings** with reasoning,
|
|
34
|
+
- a short **sources** list (links/titles), and
|
|
35
|
+
- any **open questions** the research could not close.
|
|
36
|
+
|
|
37
|
+
Keep it tight and honest. If the question cannot be answered from available material, say so plainly in the doc and record what would be needed.
|
|
38
|
+
|
|
39
|
+
4. **Commit the doc to the feature branch.** Stage only your new/changed doc under `docs/features/<slug>/` and commit with a clear message, e.g. `research(<seq>): <summary>`. The run reads your exit state from the commit — **a doc with no commit is read as no research landed.**
|
|
40
|
+
|
|
41
|
+
## Hard rules
|
|
42
|
+
|
|
43
|
+
- **Stay in scope.** Answer this one question. Do not touch product code, other docs, or other waypoints' territory. If you surface adjacent questions worth their own waypoint, note them in the doc's *open questions* — do not chase them.
|
|
44
|
+
- **No questions, no guessing into the void.** Resolve ambiguity from the question, the feature docs, and reputable sources. If two readings both fit, take the narrower one and say which you took.
|
|
45
|
+
- **Cite what you rely on.** Every non-obvious claim in the summary should trace to a source or to code you read.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Skill packs
|
|
2
|
+
|
|
3
|
+
**Packs** are runcastle-owned Claude Code *plugin directories* — vendored, adapted forks of Matt Pocock's methodology skills, wired to runcastle's MCP contracts. They are injected into each launched terminal via `--plugin-dir`, so the human's Claude Code needs nothing preinstalled, and upstream changes to Matt's skills can never break us. (Every fork keeps a provenance header crediting the original.)
|
|
4
|
+
|
|
5
|
+
## The `runcastle` pack
|
|
6
|
+
|
|
7
|
+
Four phase-scoped skills, each namespaced `/runcastle:<skill>`:
|
|
8
|
+
|
|
9
|
+
| Skill | Invoked | Does |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `/runcastle:ideate` | entry for `kind=ideation` | grills the human, locks decisions incrementally, drives spec + tickets out of one unbroken window |
|
|
12
|
+
| `/runcastle:spec` | by ideate (size=full) | synthesizes `spec.md`, completes the `spec` phase |
|
|
13
|
+
| `/runcastle:tickets` | by ideate | emits tracer-bullet tickets via MCP, completes the `tickets` phase |
|
|
14
|
+
| `/runcastle:qa` | entry for `kind=qa` | read-only Q&A over an existing feature; never advances phases |
|
|
15
|
+
|
|
16
|
+
Layout (the verified plugin format — only `plugin.json` lives inside `.claude-plugin/`; `skills/` is a sibling at the plugin root):
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
packs/
|
|
20
|
+
├── README.md
|
|
21
|
+
└── runcastle/
|
|
22
|
+
├── .claude-plugin/
|
|
23
|
+
│ └── plugin.json # name: "runcastle" → sets the /runcastle: namespace
|
|
24
|
+
└── skills/
|
|
25
|
+
├── ideate/SKILL.md
|
|
26
|
+
├── spec/SKILL.md
|
|
27
|
+
├── tickets/SKILL.md
|
|
28
|
+
└── qa/SKILL.md
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## How the launcher consumes a pack
|
|
32
|
+
|
|
33
|
+
The session launcher spawns Claude Code with the pack's **root** directory (the folder that directly contains `.claude-plugin/`) passed to `--plugin-dir`:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
claude ... --plugin-dir "<abs path>/packages/skills/packs/runcastle" ...
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The `name` field in `plugin.json` becomes the invocation namespace, so the skills resolve as `/runcastle:ideate` and friends. The injected system prompt tells each session which entry skill to invoke for its kind (`/runcastle:ideate` for ideation, `/runcastle:qa` for Q&A). All four skills carry `disable-model-invocation: false`, so the model may also reach for them by description.
|
|
40
|
+
|
|
41
|
+
## Adding a pack
|
|
42
|
+
|
|
43
|
+
1. Create `packs/<pack-name>/.claude-plugin/plugin.json` with at least `{ "name": "<pack-name>" }` — `name` sets the `/<pack-name>:` namespace.
|
|
44
|
+
2. Add skills at `packs/<pack-name>/skills/<skill>/SKILL.md` (folder name = skill name; frontmatter needs `description` + `disable-model-invocation`).
|
|
45
|
+
3. If you are forking an upstream skill, keep the provenance header and rewire its steps to runcastle's MCP tools (`get_feature_context`, `emit_tickets`, `record_event`, `complete_phase`).
|
|
46
|
+
4. Pass another `--plugin-dir` for it at launch.
|
|
47
|
+
|
|
48
|
+
## Not a pack: `../burner/`
|
|
49
|
+
|
|
50
|
+
The sibling `packages/skills/burner/` holds **prompt templates**, not skills — e.g. `implement-ticket.md`, rendered per ticket by the AFK ticket-burner workflow (placeholders `{{TICKET_JSON}}`, `{{FEATURE_BRIEF}}`, `{{DOCS_DIGEST}}`, `{{COMMIT_CONVENTION}}`). It is never passed to `--plugin-dir`.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: converge
|
|
3
|
+
description: Close a mapped feature. Read ONLY the compressed knowledge (map.md + decisions.md, never the waypoint transcripts), then drive spec and tickets out of that one unbroken window. Entry skill for kind=converge sessions.
|
|
4
|
+
disable-model-invocation: false
|
|
5
|
+
---
|
|
6
|
+
<!-- Forked from Matt Pocock's grilling/to-spec/to-tickets discipline, via https://github.com/mattpocock/skills, 2026-07-15, adapted for runcastle mapped ideation -->
|
|
7
|
+
|
|
8
|
+
# Converge — close a mapped feature
|
|
9
|
+
|
|
10
|
+
A mapped feature was too big for one window, so it was charted into a map and its waypoints were worked in their own sessions. Every waypoint is now terminal, the human clicked **Converge**, and the feature has already crossed G1 into the `spec` phase (or `tickets` for a `collapsed` feature). Your job: turn the compressed knowledge into a spec and tickets — the exact output an unbroken ideation session produces.
|
|
11
|
+
|
|
12
|
+
## Context hygiene is the whole game
|
|
13
|
+
|
|
14
|
+
Everything happens in this one window. **Never compact. Never `/clear`. Never suggest either.** The map plus the decisions ARE the compression — that is the whole point of charting a map. Read them once, hold them, and let spec and tickets fall out of the same conversation.
|
|
15
|
+
|
|
16
|
+
## 0. Read ONLY the compressed knowledge
|
|
17
|
+
|
|
18
|
+
1. Call `mcp__runcastle__get_feature_context`. It returns the feature (`slug`, `title`, `oneLiner`, `size`), current `phase`, and the docs on disk.
|
|
19
|
+
2. Read **only** these two files under `docs/features/<slug>/`:
|
|
20
|
+
- `map.md` — the destination, notes, and out-of-scope decisions.
|
|
21
|
+
- `decisions.md` — every decision the waypoint sessions locked.
|
|
22
|
+
**Do NOT read the waypoint session transcripts.** The map and decisions are the compression; trust them. If a decision is genuinely missing you may check the codebase for a *fact*, but never re-grill the human and never reopen a resolved waypoint — converge, do not re-ideate.
|
|
23
|
+
3. `mcp__runcastle__record_event({ type: "converge.started", message: "<feature title>" })`.
|
|
24
|
+
|
|
25
|
+
## 1. Size branch — run the pipeline in this window
|
|
26
|
+
|
|
27
|
+
Read `feature.size` from step 0:
|
|
28
|
+
|
|
29
|
+
- **`full`** → invoke `/runcastle:spec` (it writes `spec.md` and completes the `spec` phase); when it returns, invoke `/runcastle:tickets`.
|
|
30
|
+
- **`collapsed`** → skip spec entirely; invoke `/runcastle:tickets` directly (it works from `decisions.md` + `map.md`).
|
|
31
|
+
|
|
32
|
+
**Re-convergence** — you may be a fresh session continuing a converge that crashed
|
|
33
|
+
or was closed mid-way (the feature is past G1 but has no tickets). Pick up from
|
|
34
|
+
whatever already exists on disk:
|
|
35
|
+
|
|
36
|
+
- If `docs/features/<slug>/spec.md` **already exists**: read it, do **not**
|
|
37
|
+
rewrite it, and proceed straight to `/runcastle:tickets`. (If the current
|
|
38
|
+
`phase` is still `spec`, call `complete_phase` for spec first — the spec work
|
|
39
|
+
is already done.)
|
|
40
|
+
- If it does not exist (or the feature is `collapsed`), run the size branch
|
|
41
|
+
above as normal.
|
|
42
|
+
|
|
43
|
+
Do not open a new session for these. They run here, on top of the compressed knowledge you just read.
|
|
44
|
+
|
|
45
|
+
## 2. Close — hand off to the human
|
|
46
|
+
|
|
47
|
+
`/runcastle:tickets` ends by emitting tickets to the runcastle store. When it returns, your job is **done**. Tell the human plainly:
|
|
48
|
+
|
|
49
|
+
> Converged. Tickets are in the runcastle UI — review the ticket cards and click **Burn** to start the AFK agents. I'll stop here.
|
|
50
|
+
|
|
51
|
+
Do **not** implement anything. Do **not** start burning. The two human clicks (Burn, then Merge after test-drive) are the only gates left, and they belong to the human.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ideate
|
|
3
|
+
description: The runcastle ideation session. Grill the human relentlessly about a feature, lock decisions incrementally into decisions.md, then drive spec and tickets out of the same unbroken context. Entry skill for kind=ideation sessions.
|
|
4
|
+
disable-model-invocation: false
|
|
5
|
+
---
|
|
6
|
+
<!-- Forked from Matt Pocock's grilling + grill-with-docs skills, via https://github.com/mattpocock/skills, 2026-07-14, adapted for runcastle -->
|
|
7
|
+
|
|
8
|
+
# Ideate — runcastle's ideation session
|
|
9
|
+
|
|
10
|
+
You are the front of the runcastle pipeline. One feature, one unbroken context window: you grill, decisions land, then spec and tickets fall out of the *same* conversation. After you, the human's only job is to click **Burn**. Do it right.
|
|
11
|
+
|
|
12
|
+
## Context hygiene is the whole game
|
|
13
|
+
|
|
14
|
+
Everything happens in this one window. **Never compact. Never `/clear`. Never suggest either.** Spec and tickets must inherit the full grilling context — that inheritance is why they beat anything a fresh session could write. If you feel context pressure, you grilled too wide: converge, don't compact. (Matt Pocock's rule; it is load-bearing here.)
|
|
15
|
+
|
|
16
|
+
## 0. Orient
|
|
17
|
+
|
|
18
|
+
1. Call `mcp__runcastle__get_feature_context`. It returns the feature (`slug`, `title`, `oneLiner`, `size`), current `phase`, the docs already on disk (`brief.md`, and any `decisions.md` — **this may be a resumed session; read what is already locked and do not re-ask it**), and any tickets.
|
|
19
|
+
2. Decisions live at `docs/features/<slug>/decisions.md` in this worktree. The injected system prompt carries the slug and paths; trust `get_feature_context` for the live state.
|
|
20
|
+
3. `mcp__runcastle__record_event({ type: "ideation.started", message: "<feature title>" })`.
|
|
21
|
+
|
|
22
|
+
## 1. Grill relentlessly
|
|
23
|
+
|
|
24
|
+
Interview the human about every aspect of the feature until you reach a genuinely shared understanding. Walk each branch of the design tree, resolving dependencies between decisions one at a time.
|
|
25
|
+
|
|
26
|
+
- **One question at a time.** Wait for the answer before the next. Asking several at once is bewildering.
|
|
27
|
+
- **Always recommend an answer.** Never ask a bare question — put your recommended answer to it and say why. The human corrects or confirms.
|
|
28
|
+
- **Look up facts; ask only for decisions.** If a fact is discoverable in the codebase — how something currently works, what a type looks like, whether a pattern already exists — read/grep the repo and find it. Never make the human be your grep. *Decisions* are theirs: put each one to them and wait.
|
|
29
|
+
- **Model the domain as you go** (forked from domain-modeling): challenge terms that conflict with existing usage, sharpen fuzzy words to one canonical meaning ("you said 'account' — Customer or User?"), invent concrete edge-case scenarios that force precision, and cross-check claims against the actual code ("your code cancels whole Orders, but you just said partial — which is right?").
|
|
30
|
+
|
|
31
|
+
Do not move on until the human confirms you have reached shared understanding.
|
|
32
|
+
|
|
33
|
+
## 2. Lock decisions incrementally
|
|
34
|
+
|
|
35
|
+
The moment a decision locks, append it to `docs/features/<slug>/decisions.md` — **immediately, one at a time, never batched at the end.** If this session dies mid-grill, the locked decisions must already be on disk. Append entries in this shape:
|
|
36
|
+
|
|
37
|
+
```markdown
|
|
38
|
+
## <n>. <short decision title>
|
|
39
|
+
**Decision:** <the choice, in the project's domain vocabulary>
|
|
40
|
+
**Why:** <the reason / the trade-off chosen over the alternatives>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Keep `decisions.md` free of implementation minutiae — it is the record of *what was decided and why*, the raw material spec and tickets are built from. `complete_phase` checkpoints the file into git for you; you just write it.
|
|
44
|
+
|
|
45
|
+
Every few locked decisions, `mcp__runcastle__record_event({ type: "decision.locked", message: "<n> decisions locked" })`.
|
|
46
|
+
|
|
47
|
+
## 3. Escalation branch — when the feature outgrows this window
|
|
48
|
+
|
|
49
|
+
Some features are too big for one unbroken context. The tells: you are rabbit-holing into one corner while whole branches sit untouched; decisions keep hanging on material nobody has read yet (a prototype to build, a dependency to research, an area to grill on its own); the design tree has grown wider than you can hold and converge in this session. When that happens, **do not compact and do not grind on** — chart a map instead:
|
|
50
|
+
|
|
51
|
+
1. `mcp__runcastle__escalate_to_map({ destination: "<the one-line north star>", notes: "<what's locked / constraints so far>" })`. This flips the feature to *mapped* and scaffolds `docs/features/<slug>/map.md`. If it warns that the feature is already mapped, that is fine — the map already exists; just proceed to chart the first batch.
|
|
52
|
+
2. `mcp__runcastle__emit_waypoints({ waypoints: [...] })` for the **first batch** — the questions worth branching now. Each waypoint: `title`, `type` (`grilling` | `research` | `prototype` | `task`), `question` (what that session must answer), and `blockedBy` (1-based positions within this batch, and/or ids of already-stored waypoints) for ordering. Charting the first frontier is enough; later sessions branch the map further (any mapped session may `emit_waypoints`).
|
|
53
|
+
3. `mcp__runcastle__record_event({ type: "map.charted", message: "<n> waypoints charted" })`.
|
|
54
|
+
4. Tell the human plainly:
|
|
55
|
+
|
|
56
|
+
> This feature is bigger than one session. I've charted it into a map — its first waypoints are in the runcastle UI. Work them from there (each opens its own session); converge when the frontier clears. I'll stop here.
|
|
57
|
+
|
|
58
|
+
5. **End the session.** Charting is one session's work — do **not** claim or work a waypoint yourself (claiming is never agent-driven), and do **not** carry on grilling. Stop.
|
|
59
|
+
|
|
60
|
+
## 4. Converge
|
|
61
|
+
|
|
62
|
+
When the human confirms shared understanding and the open questions are answered or explicitly deferred:
|
|
63
|
+
|
|
64
|
+
`mcp__runcastle__complete_phase({ phase: "ideation" })` → returns `{ ok, nextPhase }`. If `ok: false`, it names what the gate wants (e.g. `decisions.md` missing) — fix it and retry. Then `record_event({ type: "phase.completed", message: "ideation" })`.
|
|
65
|
+
|
|
66
|
+
## 5. Size branch — stay in this window
|
|
67
|
+
|
|
68
|
+
Read `feature.size` from step 0:
|
|
69
|
+
|
|
70
|
+
- **`full`** → invoke `/runcastle:spec` (it writes `spec.md` and completes the `spec` phase); when it returns, invoke `/runcastle:tickets`.
|
|
71
|
+
- **`collapsed`** → skip spec entirely; invoke `/runcastle:tickets` directly (it works from `decisions.md`).
|
|
72
|
+
|
|
73
|
+
Do not open a new session for these. They run here, on top of everything you just learned.
|
|
74
|
+
|
|
75
|
+
## 6. Close — hand off to the human
|
|
76
|
+
|
|
77
|
+
`/runcastle:tickets` ends by emitting tickets to the runcastle store. When it returns, your job is **done**. Tell the human plainly:
|
|
78
|
+
|
|
79
|
+
> Tickets are in the runcastle UI. Review the ticket cards and click **Burn** to start the AFK agents. That is the next step — I will stop here.
|
|
80
|
+
|
|
81
|
+
Do **not** implement anything. Do **not** start burning. The two human clicks (Burn, then Merge after test-drive) are the only gates left, and they belong to the human.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: qa
|
|
3
|
+
description: Answer questions about an existing (possibly shipped) feature from its docs and the codebase. Read-only — never advances phases, never emits tickets. Entry skill for kind=qa sessions.
|
|
4
|
+
disable-model-invocation: false
|
|
5
|
+
---
|
|
6
|
+
<!-- Forked from Matt Pocock's grilling + domain-modeling skills, via https://github.com/mattpocock/skills, 2026-07-14, adapted for runcastle -->
|
|
7
|
+
|
|
8
|
+
# Q&A
|
|
9
|
+
|
|
10
|
+
This is a "come back and ask questions" session about a feature that already exists — often already shipped. Your job is to **answer**, accurately, from the record. Nothing here changes state.
|
|
11
|
+
|
|
12
|
+
## Do
|
|
13
|
+
|
|
14
|
+
- Call `mcp__runcastle__get_feature_context` first: it returns the feature, its phase, and all docs under `docs/features/<slug>/` (brief, decisions, spec). That, plus the codebase, is your source of truth.
|
|
15
|
+
- Answer from the docs and the actual code. When the answer lives in the code, read/grep it and cite what you found — do not guess from the docs alone if the code can confirm.
|
|
16
|
+
- When something notable surfaces that the record should keep (a clarification, a discovered discrepancy between docs and code), you MAY log it: `mcp__runcastle__record_event({ type: "qa.note", message: "..." })`.
|
|
17
|
+
|
|
18
|
+
## Do NOT
|
|
19
|
+
|
|
20
|
+
- **Never advance a phase.** No `complete_phase`. This session has no gates to cross.
|
|
21
|
+
- **Never emit tickets.** No `emit_tickets`.
|
|
22
|
+
- **Never implement changes** to the feature.
|
|
23
|
+
|
|
24
|
+
## When the human wants a change
|
|
25
|
+
|
|
26
|
+
A Q&A session is the wrong place to change the feature. If the conversation turns into "let's change X" or "this is a bug, fix it," name it and point them at the right move:
|
|
27
|
+
|
|
28
|
+
> That is a change, not a question — it needs its own feature. Create a new feature in the runcastle UI (you can seed it from this one) and grill it there.
|
|
29
|
+
|
|
30
|
+
Then, if useful, `record_event` a one-line note capturing the change request so it is not lost.
|