A collection of agent skills for structured, GitHub-centric development.

<img src="docs/images/icon-256.png" width="128" height="128" alt="forge icon: a pixel-art anvil with a glowing bar inside an ember progress ring">
<strong>Agent skills for structured development with pluggable issue tracking.</strong><br> One workflow. Ten skills. From idea to merged code.
<a href="#quick-start">Quick start</a> · <a href="#how-it-works">How it works</a> · <a href="#skills">Skills</a> · <a href="docs/lens.md">Lens</a> · <a href="docs/architecture.md">Architecture</a> · <a href="#contributing">Contributing</a>
A forge is where raw material meets intention. You bring the codebase; these skills shape the workflow from issue to implementation to review.
<img src="docs/images/lens.png" width="480" alt="The forge lens beside a Claude Code session: a guarded ship run with its current state, the next command, the pipeline, the PR's CI, review findings, the risk verdict and a timeline">
Install forge one way per machine:
# Claude Code: the skills plus the lens above
/plugin install forge --marketplace mgratzer/forge
# any other agent that reads Agent Skills
npx skills add mgratzer/forge
Then, in your project:
/forge-setup-project # once per repo: AGENTS.md and the docs agents work from
/forge-ship 42 # implement Issue #42, review it in fresh context, open the PR
/forge-ship --guard 42 # and keep going: CI, review feedback, then a risk verdict
With the plugin, the commands are namespaced (/forge:forge-ship), and /forge demo replays a guarded run in the lens without touching GitHub.
forge-setup-project → [forge-shape →] forge-create-issue → forge-implement → forge-reflect → forge-address-pr-feedback → forge-guard → forge-land
╰──── forge-ship ────╯╰────────── forge-ship --guard ───────────╯
Ship implements a change from an Issue, a plan file or a description: it researches the codebase, works in vertical phases with tests, runs the app on the changed path when the project says how, and opens a PR. A fresh-context reviewer, not the session that wrote the code, reviews the diff before you see it.
Guard drives the open PR to review-ready without anyone watching: a simplification pass, a security pass when the diff calls for one, CI, the peer reviewer, and feedback in bounded rounds. It never merges.
Land judges a green PR's risk on its head commit and writes the verdict into the PR. Low risk means you can merge without reading the diff; anything else comes with a list of where to look. It merges by itself only when the project opts in.
Issues live wherever you keep them: GitHub, a markdown plan/ folder in the repo, or a tracker you declare in AGENTS.md. For many Issues at once, forge-batch groups them into Waves that can ship in parallel; see Unattended Shipping.
| Skill | Command | Purpose |
|---|---|---|
| Setup Project | /forge-setup-project | Set up or audit a project's context for agentic engineering |
| Shape | /forge-shape | Shape a vague idea into a clear plan, one question at a time |
| Create Issue | /forge-create-issue | Plan and create Issues (GitHub, markdown, or another provider) |
| Implement | /forge-implement <input> | Implement from an Issue, plan file, or description, and open the PR |
| Reflect | /forge-reflect | Self-review changes: a PR, a branch, or uncommitted work |
| Address PR Feedback | /forge-address-pr-feedback | Address unresolved review threads |
| Ship | /forge-ship <input> | Implement and review in one go; --guard continues through guard and land |
| Guard | /forge-guard [pr] | Drive an open PR to review-ready; never merges |
| Land | /forge-land [pr] | Judge a green PR's risk: merge-ready, or review-ready with where to look |
| Batch | /forge-batch <scope> | Group Issues into Waves that can ship concurrently |
Every skill takes optional trailing guidance: /forge-ship 42 -- keep the diff minimal.
The Claude Code plugin adds the lens: a pane beside the session that follows a forge run. It shows the current state and the command that most likely comes next, the pipeline step, the PR's CI and review state, review findings as a checklist, and the risk verdict, which it marks stale once the PR moves past the judged commit. A status line, a next-step suggestion you take with Tab, and a notification when a run ends or needs you come with it.
/forge says where a run is and what to do next, and /forge config shows the project's unattended settings with their defaults filled in. The lens only reads what the skills already print, so the skills work the same without it. See Lens.
Claude Code plugin. /plugin install forge --marketplace mgratzer/forge brings the skills and the lens. The plugin tracks commits on main: update with claude plugin update forge@forge, or turn on auto-update for the marketplace under /plugin.
npx skills. npx skills add mgratzer/forge detects your agents and installs the skills to each of them (skills.sh).
Manual. Symlink the skills into your agent's skills directory:
ln -s /path/to/forge/skills/forge-* <your-agent-skills-dir>/
Each skill reaches the shared modules through its own _shared symlink, so nothing else needs installing. Use one channel per machine: with both the plugin and npx, Claude Code lists every skill twice.
Forge's skills are Markdown prompts that follow the Agent Skills standard; the lens is TypeScript that Claude Code runs itself. AGENTS.md holds the principles and commands, and CI checks every pull request:
scripts/check-skills.sh # skill structure
npx @biomejs/biome@2.6.2 check --write . # lint and format the lens
claude plugin validate . && claude plugin test . # plugin manifests and lens tests
Test a skill change by invoking it on a real project, and use conventional commits (feat(skills): add a skill).
| Document | Purpose |
|---|---|
| Context | Shared vocabulary used across the skills |
| Architecture | Skill workflow, operating constraints, design decisions |
| Development | Creating and modifying skills |
| Coding Guidelines | Skill and role file format, authoring conventions |
| Testing | Automated checks and validating skills by hand |
| PR Workflow | Commits, PRs, branch naming, review |
| Unattended Shipping | Guard loop, Waves, launchers, project setup |
| Lens | The Claude Code plugin and the lens |
hooks/register.tsx 321 lines1import type { Register } from "claude-code";
2import { atom, read, update } from "claude-code";
3import type { Model } from "../types";
4
5import * as demo from "./demo";
6import * as model from "./model";
7import * as parse from "./parse";
8import { ConfigView, Lens, MAP, WavesView } from "./views";
9
10const state = atom({ plugin: "forge", key: "model" } as const, model.EMPTY);
11const nudged = atom({ plugin: "forge", key: "nudged" } as const, []);
12
13const { lens: LENS, config: CONFIG, waves: WAVES } = model.PANES;
14const PR_FIELDS = "number,url,title,headRefName,headRefOid,state,mergeable,reviewDecision,statusCheckRollup,body";
15const TICK_MS = 1000;
16
17export const register: Register = (on) => {
18 // The last next step proposed, so each is proposed once
19 let suggested = "";
20 // `/forge-x` fires prompt.submit, which starts the run with its args, then skill.prompt
21 let submitted: string | null = null;
22
23 on("session.start", async ($, e, next) => {
24 await $.command.register({
25 name: "forge",
26 description: "forge lens: where the run is and what to run next",
27 argumentHint: "[status|map|pane|config|waves|demo [stuck|batch]|reset]",
28 });
29 await update($, state, (m) => ({ ...m, demo: null, want: { ...m.want, isConfig: true } }));
30
31 // The effect loop: hooks change the model, this does what it asks for
32 let isBusy = false;
33 let shown: string | undefined;
34 $.clock.every(TICK_MS, () => {
35 if (isBusy) return;
36 isBusy = true;
37 void (async () => {
38 const now = await $.clock.now();
39 // The tick re-reads the model only after it wrote to it
40 let wrote = false;
41 const write = async (fn: (x: Model) => Model) => {
42 await update($, state, fn);
43 wrote = true;
44 };
45 let m = await read($, state);
46 if (m.demo && now >= m.demo.nextAt) {
47 await write((x) => demo.tick(x, now));
48 m = await read($, state);
49 wrote = false;
50 }
51
52 // Clear only what this tick consumed: hooks may queue more while it awaits
53 const { panes, alerts } = m;
54 if (panes.length || alerts.length) {
55 await write((x) => ({
56 ...x,
57 panes: x.panes.slice(panes.length),
58 alerts: x.alerts.slice(alerts.length),
59 }));
60 }
61 for (const id of panes) void $.ui.open({ id, title: model.paneTitle(id) });
62 for (const text of alerts) {
63 const sent = await $.ui.notify(text, { title: "forge" }).catch(() => ({ isSent: false }));
64 if (!sent.isSent) $.ui.toast(text, { timeoutMs: 8000 });
65 }
66
67 const isStale = !!m.run && !m.run.terminal && model.isPrStale(m, now, model.PR_REFRESH_MS);
68 if (m.want.pr !== null || m.want.isBranchPr || isStale) {
69 const asked = m.want;
70 const target = asked.pr ?? m.pr?.number;
71 // A request made while this fetch runs stays for the next tick
72 await write((x) =>
73 x.want.pr === asked.pr && x.want.isBranchPr === asked.isBranchPr
74 ? { ...x, want: { ...x.want, pr: null, isBranchPr: false } }
75 : x,
76 );
77 const argv = ["gh", "pr", "view", ...(target ? [String(target)] : []), "--json", PR_FIELDS];
78 const ran = await $.process.run(argv, { cwd: await $.session.cwd(), timeoutMs: 20_000 }).catch(() => null);
79 const data = model.ghPr(ran);
80 if (data) {
81 await write((x) => model.withPr(x, data, now));
82 } else if (m.pr && !m.pr.isDemo) {
83 await write((x) => (x.pr ? { ...x, pr: { ...x.pr, fetchedAt: now } } : x));
84 }
85 }
86
87 if (m.want.isConfig) {
88 const repo = await $.session.repo();
89 const root = repo?.root ?? (await $.session.cwd());
90 const hasAgents = await $.fs.exists(`${root}/AGENTS.md`);
91 const agents = hasAgents ? String(await $.fs.read(`${root}/AGENTS.md`, { as: "text" })) : "";
92 const tracker = (await $.fs.exists(`${root}/plan/INDEX.md`))
93 ? "plan/ folder (markdown)"
94 : "GitHub Issues (gh)";
95 const env = [
96 { name: "FORGE_STATUS_CMD", value: (await $.env.get("FORGE_STATUS_CMD")) ?? "" },
97 { name: "FORGE_LAUNCHER", value: (await $.env.get("FORGE_LAUNCHER")) ?? "" },
98 ];
99 const config = { root, hasAgents, ...parse.unattended(agents), tracker, env };
100 await write((x) => model.withConfig(x, config));
101
102 const text = model.nudgeText(config);
103 if (repo && text && !(await read($, nudged)).includes(root)) {
104 await update($, nudged, (list) => [...list, root]);
105 $.ui.toast(text, { timeoutMs: 8000 });
106 }
107 }
108
109 if (wrote) m = await read($, state);
110 const status = model.statusText(m);
111 if (status !== shown) {
112 shown = status;
113 $.ui.status(status);
114 }
115 })()
116 .catch(() => undefined)
117 .finally(() => {
118 isBusy = false;
119 });
120 });
121 return next(e);
122 });
123
124 on("command.run", { command: "forge" }, async ($, e) => {
125 const [sub = "status", variant = ""] = e.args.trim().split(/\s+/).filter(Boolean);
126 const now = await $.clock.now();
127 switch (sub) {
128 case "map":
129 return { text: MAP };
130 case "pane": {
131 const isOpen = (await $.ui.panes()).some((p) => p.id === LENS.id);
132 await update($, state, (m) => ({ ...m, isLensClosed: isOpen }));
133 if (isOpen) await $.ui.close({ id: LENS.id });
134 else await $.ui.open(LENS);
135 return { text: isOpen ? "forge pane closed." : "forge pane opened." };
136 }
137 case "config":
138 await update($, state, (m) => ({ ...m, want: { ...m.want, isConfig: true } }));
139 await $.ui.open(CONFIG);
140 return { text: "forge config opened: the Unattended Shipping settings this repo resolves to." };
141 case "waves":
142 await $.ui.open(WAVES);
143 return { text: "forge waves opened." };
144 case "demo":
145 await update($, state, (m) => demo.start(m, variant, now));
146 return { text: `forge demo${variant ? ` (${variant})` : ""} playing: a replay, nothing touches GitHub.` };
147 case "reset":
148 await update($, state, (m) => model.fresh(m));
149 return { text: "forge lens cleared." };
150 default: {
151 await update($, state, (m) => model.askPr({ ...m, isLensClosed: false, want: { ...m.want, isConfig: true } }));
152 void $.ui.open(LENS);
153 return { text: model.whereAmI(await read($, state)) };
154 }
155 }
156 });
157
158 // A pane the person closed stays closed until they open it again
159 on("ui.close", async ($, e, next) => {
160 if (e.id === LENS.id && e.origin.kind === "person") await update($, state, (m) => ({ ...m, isLensClosed: true }));
161 return next(e);
162 }).catch((_, e, next) => next(e));
163
164 on("prompt.submit", async ($, e, next) => {
165 const cmd = parse.forgeCommand(e.text);
166 if (cmd) {
167 const now = await $.clock.now();
168 suggested = "";
169 submitted = cmd.skill;
170 await update($, state, (m) => model.startRun(m, cmd.skill, cmd.args, now));
171 }
172 return next(e);
173 }).catch((_, e, next) => next(e));
174
175 on("skill.prompt", async ($, e, next) => {
176 const skill = parse.skillName(e.skill);
177 if (skill && skill === submitted) submitted = null;
178 else if (skill) {
179 const now = await $.clock.now();
180 await update($, state, (m) => model.startRun(m, skill, "", now));
181 }
182 return next(e);
183 }).catch((_, e, next) => next(e));
184
185 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
186 const ran = await next(e);
187 if (ran.deny !== undefined) return ran;
188 // A failed command's terminal state didn't happen; a stage start counts either way,
189 // since skills prepend it to the stage's own command (`… waiting "CI"; gh pr checks`)
190 const lines = parse.statusCalls(e.command).filter((l) => !ran.isError || !parse.TERMINAL.includes(l.state));
191 const isPrCreate = /(?:^|[;&|(]\s*)gh pr create\b/.test(e.command);
192 const url = isPrCreate
193 ? parse.prUrl(parse.resultText(ran.result), parse.repoSlug((await $.session.repo())?.remote ?? null))
194 : null;
195 const isPrCommand = /\bgh pr (create|checks|ready|edit|merge)\b/.test(e.command);
196 if (lines.length || url || isPrCommand) {
197 const now = await $.clock.now();
198 await update($, state, (m) => {
199 const x = lines.reduce((acc, l) => model.applyStatus(acc, l.state, l.detail, now), m);
200 if (!x.run) return x;
201 if (url && x.pr?.number !== url.number) return model.askPr(x, url.number);
202 return isPrCommand ? model.askPr(x) : x;
203 });
204 }
205 return ran;
206 }).catch((_, e, next) => next(e));
207
208 // forge-ship reads the composed skills' files: guard, land, …
209 on("tool.call", { tool: "Read" }, async ($, e, next) => {
210 const step = /forge-([\w-]+)\/SKILL\.md$/.exec(e.file_path)?.[1];
211 if (step && (parse.PIPELINE as readonly string[]).includes(step)) {
212 await update($, state, (m) => (m.run && m.run.skill !== step ? model.setPhase(m, step) : m));
213 }
214 return next(e);
215 }).catch((_, e, next) => next(e));
216
217 // Reviewer sub-agents answer with `### file` / `- [P1] …` findings
218 on("tool.call", { tool: "Agent" }, async ($, e, next) => {
219 const ran = await next(e);
220 // Most sub-agent results hold no findings: write only when one does
221 const found = ran.deny === undefined ? parse.findings(parse.resultText(ran.result)) : [];
222 if (found.length) await update($, state, (m) => model.withReviewFindings(m, found));
223 return ran;
224 }).catch((_, e, next) => next(e));
225
226 // A background reviewer's report arrives as a delivery
227 on("session.receive", async ($, e, next) => {
228 const found = parse.findings(e.text);
229 if (found.length) await update($, state, (m) => model.withReviewFindings(m, found));
230 return next(e);
231 }).catch((_, e, next) => next(e));
232
233 on("turn.complete", async ($, e, next) => {
234 const done = await next(e);
235 const text = done.text ?? "";
236 const now = await $.clock.now();
237 // Skip the write, and the redraws it starts, when the turn can't change anything
238 if (model.turnMatters(await read($, state), text)) {
239 await update($, state, (m) => model.afterTurn(m, text, now));
240 }
241 const step = (await read($, state)).next;
242 if (step && step.text !== suggested && e.reason === "answer") {
243 suggested = step.text;
244 void $.prompt.suggest({ text: step.text });
245 }
246 return done;
247 }).catch((_, e, next) => next(e));
248
249 // Our next step beats the engine's generic guess while a run is open
250 on("prompt.suggest", async ($, e, next) => {
251 const step = (await read($, state)).next;
252 return e.origin.kind === "suggestion" && step ? next({ ...e, text: step.text }) : next(e);
253 }).catch((_, e, next) => next(e));
254
255 // Matchers name panes literally so `claude plugin validate` can report them (model.PANES holds the same ids)
256 on("ui.render", { component: "Pane", requestId: "forge-lens" }, async ($, e) => {
257 const m = await read($, state);
258 return (
259 <Lens
260 ui={$.ui.resolve(e)}
261 now={await $.clock.now()}
262 run={m.run}
263 timeline={m.timeline}
264 last={model.lastState(m)}
265 visited={m.visited}
266 isSetUp={m.config ? m.config.hasAgents : null}
267 pr={m.pr}
268 findings={m.findings}
269 risk={m.risk}
270 result={m.result}
271 next={m.next}
272 onToggle={(id) => void update($, state, (x) => model.toggleFinding(x, id))}
273 onRefresh={() => void update($, state, (x) => model.askPr(x))}
274 />
275 );
276 });
277
278 on("ui.render", { component: "Pane", requestId: "forge-config" }, async ($, e) => (
279 <ConfigView ui={$.ui.resolve(e)} config={(await read($, state)).config} />
280 ));
281
282 on("ui.render", { component: "Pane", requestId: "forge-waves" }, async ($, e) => (
283 <WavesView ui={$.ui.resolve(e)} batch={(await read($, state)).batch} />
284 ));
285
286 // The next step, one line above the prompt
287 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
288 const m = await read($, state);
289 if (e.props.hasSurvey || !m.next || m.isBandHidden) return next(e);
290 const { Box, Button, Text } = $.ui.resolve(e);
291 return (
292 <Box flexDirection="row" gap={1}>
293 <Box flexShrink={0}>
294 <Text color="claude">forge ▸ next</Text>
295 </Box>
296 <Text color="suggestion" bold wrap="truncate-middle">
297 {m.next.text}
298 </Text>
299 <Text dimColor wrap="truncate">
300 {m.next.why}
301 </Text>
302 <Button
303 key="pane"
304 label="Pane"
305 plain
306 onPress={() => {
307 void update($, state, (x) => ({ ...x, isLensClosed: false }));
308 void $.ui.open(LENS);
309 }}
310 />
311 <Button
312 key="hide"
313 label="Hide"
314 plain
315 onPress={() => void update($, state, (x) => ({ ...x, isBandHidden: true }))}
316 />
317 </Box>
318 );
319 });
320};
321hooks/demo.ts 131 lines1// A scripted forge-ship --guard run fed through the same transitions real
2// events take, so every view can be tried without spending a real run.
3import type { Model } from "../types";
4import * as model from "./model";
5import * as parse from "./parse";
6
7const STEP_MS = 2500;
8
9const REVIEW = `### src/routes/notes.js
10- [P1] Line 42: the rate limiter keys on req.ip, which is the proxy's address behind the load balancer
11- [P2] Line 58: the 429 response omits Retry-After
12### src/limiter.js
13- [P1] Line 12: the bucket map grows without bound; entries are never evicted`;
14
15const RISK_LOW = `## Risk
16
17**Verdict:** low risk, judged on 3f9c2a1e8b7d6c5a4f3e2d1c0b9a8f7e6d5c4b3a
18
19- Size: 118 counted lines (64 excluded: tests) → ok
20- Always-review paths: none
21- Tests: ok
22- Exercise: ok
23- Plan: ok
24- Trust: ok
25- What the checks would miss: limits under real proxy headers are only covered by a unit test.`;
26
27const RISK_REVIEW = `## Risk
28
29**Verdict:** needs review, judged on 3f9c2a1e8b7d6c5a4f3e2d1c0b9a8f7e6d5c4b3a
30
31- Size: 412 counted lines (90 excluded: tests) → over 400
32- Always-review paths: src/auth.js
33- Tests: ok
34- Exercise: ok
35- Plan: src/auth.js changes the session cookie, which the Plan left out
36- Trust: ok
37- What the checks would miss: the session cookie change affects every logged-in client.
38
39**Where to look** (needs review only)
40- \`src/auth.js:31\`: does the new SameSite value break the OAuth callback?
41- \`src/limiter.js:12\`: is eviction safe while a request holds the bucket?`;
42
43const BATCH = `## Batch: milestone v0.4 (demo)
44
45### Wave 1 (3 Issues)
46| # | Title | Touch set | Notes |
47|---|-------|-----------|-------|
48| 42 | feat(api): rate-limit the notes API | src/limiter.js, src/routes/notes.js | |
49| 43 | fix(ui): keep the draft on reload | web/editor.js | adjacent to #45 |
50| 44 | docs: document the export format | docs/export.md | |
51
52launch: forge-launch 42 43 44
53
54### Wave 2 (2 Issues, after Wave 1 merges)
55| # | Title | Touch set | Notes |
56|---|-------|-----------|-------|
57| 45 | feat(ui): tag filter in the sidebar | web/sidebar.js, web/editor.js | |
58| 46 | chore(db): add the tags index | db/migrations/0007_tags.sql | serialized: migrations |
59
60launch: forge-launch 45 46
61
62### Excluded
63- #47: HITL (needs a pricing decision)
64- #48: open PR #91 already`;
65
66type Step = (m: Model, now: number) => Model;
67
68export function start(m: Model, variant: string, now: number): Model {
69 if (variant === "batch") {
70 const run = model.startRun(model.fresh(m), "batch", "milestone v0.4", now, true);
71 const out = model.applyStatus(run, "reviewing", "touch sets for 7 Issues", now);
72 return { ...out, batch: parse.batch(BATCH), panes: [...out.panes, model.PANES.waves.id] };
73 }
74 const run = model.startRun(model.fresh(m), "ship", "--guard 42", now, true);
75 return { ...run, demo: { variant, step: 0, nextAt: now + 1000 } };
76}
77
78// The demo's next step, once its time has come
79export function tick(m: Model, now: number): Model {
80 if (!m.demo || now < m.demo.nextAt) return m;
81 const steps = script(m.demo.variant === "stuck");
82 const step = steps[m.demo.step];
83 if (!step) return { ...m, demo: null };
84 return { ...step(m, now), demo: { ...m.demo, step: m.demo.step + 1, nextAt: now + STEP_MS } };
85}
86
87const checks =
88 (pass: number, fail: number, pending: number, failing: string[] = []): Step =>
89 (m) =>
90 model.withDemoPr(m, { pass, fail, pending, failing });
91
92function script(isStuck: boolean): Step[] {
93 const status =
94 (state: string, detail: string): Step =>
95 (m, now) =>
96 model.applyStatus(m, state, detail, now);
97 const then =
98 (...steps: Step[]): Step =>
99 (m, now) =>
100 steps.reduce((acc, s) => s(acc, now), m);
101 const steps: Step[] = [
102 status("implementing", "Issue #42: rate-limit the notes API"),
103 then(checks(0, 0, 5), status("reviewing", "2 reviewer passes on 6 files")),
104 (m) => model.addFindings(m, parse.findings(REVIEW)),
105 (m) => model.closeFindings(m, 1),
106 (m) => model.closeFindings(m, 1),
107 then(status("waiting", "CI on PR #88 (5 checks)"), checks(3, 0, 2)),
108 then(checks(4, 1, 0, ["deps-audit"]), status("addressing", "round 1/3: deps-audit fails, 1 review thread")),
109 then((m) => model.closeFindings(m, 5), status("waiting", "CI rerun and peer review (copilot)"), checks(4, 0, 1)),
110 checks(5, 0, 0),
111 ];
112 if (isStuck) {
113 steps.push(
114 status("addressing", "round 3/3: copilot asks to change the auth cookie"),
115 then(
116 (m) => ({ ...m, risk: parse.riskSection(RISK_REVIEW) }),
117 status("needs-human", "round limit reached: a cookie decision is yours"),
118 ),
119 );
120 } else {
121 steps.push(
122 status("reviewing", "land: fresh-context risk verdict"),
123 then(
124 (m) => ({ ...m, risk: parse.riskSection(RISK_LOW), result: "merge-ready" }),
125 status("merge-ready", "low risk on 3f9c2a1"),
126 ),
127 );
128 }
129 return steps;
130}
131hooks/model.ts 284 lines1// Pure transitions of the forge lens's one model. Hooks apply them with
2// `update($, model, m => …)`; the effect loop in register.tsx does the rest.
3import type { Config, Finding, Model, Pr, Run, Stage } from "../types";
4import * as parse from "./parse";
5
6export const PANES = {
7 lens: { id: "forge-lens", title: "forge" },
8 config: { id: "forge-config", title: "forge config" },
9 waves: { id: "forge-waves", title: "forge waves" },
10} as const;
11
12export const paneTitle = (id: string): string => Object.values(PANES).find((p) => p.id === id)?.title ?? id;
13
14export const EMPTY: Model = {
15 run: null,
16 timeline: [],
17 visited: [],
18 pr: null,
19 findings: [],
20 risk: null,
21 result: "",
22 batch: null,
23 next: null,
24 isBandHidden: false,
25 isLensClosed: false,
26 config: null,
27 want: { pr: null, isBranchPr: false, isConfig: false },
28 alerts: [],
29 panes: [],
30 demo: null,
31};
32
33const withNext = (m: Model): Model => ({
34 ...m,
35 next: parse.nextStep(m.run, m.pr, m.findings.filter((f) => !f.isDone).length, m.risk),
36});
37
38const visit = (m: Model, step: string | null): Model =>
39 step && !m.visited.includes(step) ? { ...m, visited: [...m.visited, step] } : m;
40
41// A clean model that keeps what describes the project, not the run
42export const fresh = (m: Model): Model => ({ ...EMPTY, config: m.config });
43
44// Ask the effect loop for a PR: the one numbered, else the branch's
45export const askPr = (m: Model, n: number | null = m.pr?.number ?? null): Model => ({
46 ...m,
47 want: { ...m.want, pr: n, isBranchPr: n === null },
48});
49
50export const lastState = (m: Model): Stage | null => {
51 const last = m.timeline.at(-1);
52 return last && last.state !== "start" ? last : null;
53};
54
55export function startRun(m: Model, skill: string, args: string, now: number, isDemo = false): Model {
56 const max = /--max-rounds (\d+)/.exec(args)?.[1];
57 const run: Run = {
58 skill,
59 args,
60 startedAt: now,
61 phase: skill === "ship" ? "implement" : skill,
62 round: 0,
63 maxRounds: max ? Number(max) : 0,
64 terminal: null,
65 isDemo,
66 };
67 // these work on a PR: the one named, else the branch's; the others start fresh
68 const takesPr = ["guard", "land", "address-pr-feedback", "reflect"].includes(skill);
69 const started: Model = {
70 ...m,
71 run,
72 timeline: [{ state: "start", detail: parse.runLabel(skill, args), at: now }],
73 findings: [],
74 risk: null,
75 result: "",
76 next: null,
77 isBandHidden: false,
78 pr: null,
79 demo: null,
80 want: { ...m.want, pr: null, isBranchPr: false },
81 panes: m.isLensClosed ? m.panes : [...m.panes, PANES.lens.id],
82 };
83 const asked = takesPr && !isDemo ? askPr(started, parse.argNumber(args)) : started;
84 return visit(asked, parse.currentStep(run));
85}
86
87export function setPhase(m: Model, phase: string): Model {
88 if (!m.run) return m;
89 const run = { ...m.run, phase };
90 return visit({ ...m, run }, parse.currentStep(run));
91}
92
93// One `[forge] <state>: <detail>` transition
94export function applyStatus(m: Model, state: string, detail: string, now: number): Model {
95 const base = m.run ? m : startRun(m, "run", "", now);
96 const prev = base.run;
97 if (!prev) return m;
98 const r = parse.round(detail);
99 const phase = parse.stepFor(state, prev.phase);
100 const isTerminal = parse.TERMINAL.includes(state);
101 const run: Run = {
102 ...prev,
103 phase,
104 round: r ? r.round : state === "addressing" ? Math.max(prev.round, 1) : prev.round,
105 maxRounds: r?.max ? r.max : prev.maxRounds,
106 terminal: isTerminal ? state : prev.terminal,
107 };
108 let out: Model = {
109 ...base,
110 run,
111 timeline: [...base.timeline, { state, detail, at: now }].slice(-40),
112 alerts: isTerminal ? [...base.alerts, `/forge-${run.skill}: ${state}${detail ? `, ${detail}` : ""}`] : base.alerts,
113 };
114 out = visit(out, parse.currentStep(run));
115 if (state === "addressing") out = visit(out, "address-pr-feedback");
116 return withNext(out);
117}
118
119export function addFindings(m: Model, found: Finding[]): Model {
120 if (found.length === 0) return m;
121 const known = new Set(m.findings.map((f) => f.id));
122 return withNext({ ...m, findings: [...m.findings, ...found.filter((f) => !known.has(f.id))].slice(-60) });
123}
124
125// Reviewer findings from a sub-agent's result, a delivered report or an answer
126export const withReviewFindings = (m: Model, found: Finding[]): Model =>
127 m.run && !m.run.isDemo ? addFindings(m, found) : m;
128
129export function toggleFinding(m: Model, id: string): Model {
130 return withNext({ ...m, findings: m.findings.map((f) => (f.id === id ? { ...f, isDone: !f.isDone } : f)) });
131}
132
133export function closeFindings(m: Model, count: number): Model {
134 let left = count;
135 return withNext({
136 ...m,
137 findings: m.findings.map((f) => (!f.isDone && left-- > 0 ? { ...f, isDone: true } : f)),
138 });
139}
140
141export type GhPr = {
142 number: number;
143 url: string;
144 title: string;
145 headRefName: string;
146 headRefOid: string;
147 state: string;
148 mergeable: string;
149 reviewDecision: string;
150 statusCheckRollup: Parameters<typeof parse.checks>[0] | null;
151 body: string | null;
152};
153
154// `gh pr view --json` output, or null when the call failed or printed something else
155export function ghPr(ran: { exitCode: number; stdout: string } | null): GhPr | null {
156 if (ran?.exitCode !== 0) return null;
157 try {
158 return JSON.parse(ran.stdout) as GhPr;
159 } catch {
160 return null;
161 }
162}
163
164export function withPr(m: Model, data: GhPr, now: number): Model {
165 const pr: Pr = {
166 number: data.number,
167 url: data.url,
168 title: data.title,
169 branch: data.headRefName,
170 headSha: data.headRefOid,
171 state: data.state,
172 mergeable: data.mergeable,
173 reviewDecision: data.reviewDecision,
174 checks: parse.checks(data.statusCheckRollup ?? []),
175 isDemo: false,
176 fetchedAt: now,
177 };
178 return withNext({ ...m, pr, risk: parse.riskSection(data.body ?? "") ?? m.risk });
179}
180
181export function withDemoPr(m: Model, checks: Pr["checks"]): Model {
182 const pr: Pr = {
183 number: 88,
184 url: "https://github.com/example/notes/pull/88",
185 title: "feat(api): rate-limit the notes API",
186 branch: "feat/42-rate-limit",
187 headSha: "3f9c2a1e8b7d6c5a4f3e2d1c0b9a8f7e6d5c4b3a",
188 state: "OPEN",
189 mergeable: "MERGEABLE",
190 reviewDecision: "REVIEW_REQUIRED",
191 checks,
192 isDemo: true,
193 fetchedAt: 0,
194 };
195 return withNext({ ...m, pr });
196}
197
198// What a finished turn's answer says: a batch, a ## Risk section, a Result line
199export function withAnswer(m: Model, text: string): Model {
200 let out = m;
201 const found = parse.batch(text);
202 if (found) out = { ...out, batch: found, panes: [...out.panes, PANES.waves.id] };
203 if (out.run && !out.run.isDemo) {
204 out = withReviewFindings(
205 { ...out, risk: parse.riskSection(text) ?? out.risk, result: parse.resultLine(text) || out.result },
206 parse.findings(text),
207 );
208 }
209 return withNext(out);
210}
211
212// How stale the PR may get: the loop refreshes an open run's PR every minute,
213// and a finished turn, which may have pushed, asks again after 15 seconds
214export const PR_REFRESH_MS = 60_000;
215const TURN_REFRESH_MS = 15_000;
216
217export const isPrStale = (m: Model, now: number, ms: number): boolean =>
218 !!m.pr && !m.pr.isDemo && now - m.pr.fetchedAt > ms;
219
220const isLive = (m: Model): boolean => !!m.run && !m.run.isDemo;
221
222// Whether a finished turn can change the model: a live run, or a batch in the answer
223export const turnMatters = (m: Model, text: string): boolean => isLive(m) || /^## Batch: /m.test(text);
224
225export function afterTurn(m: Model, text: string, now: number): Model {
226 if (!turnMatters(m, text)) return m;
227 const out = withAnswer(m, text);
228 return isLive(out) && isPrStale(out, now, TURN_REFRESH_MS) ? askPr(out) : out;
229}
230
231export function withConfig(m: Model, config: Config): Model {
232 return { ...m, config, want: { ...m.want, isConfig: false } };
233}
234
235// The one toast a repository gets: forge isn't set up, or reads a stale heading
236export function nudgeText(c: Config): string {
237 if (!c.hasAgents) return "forge: no AGENTS.md here. /forge-setup-project sets the project up";
238 if (c.hasStaleHeading) return 'forge: AGENTS.md says "## Shipping"; forge reads "## Unattended Shipping"';
239 return "";
240}
241
242export function statusText(m: Model): string | undefined {
243 const r = m.run;
244 if (!r) return undefined;
245 const last = lastState(m);
246 const parts = [`forge ▸ ${r.skill}${r.args ? ` ${r.args.split(" -- ")[0]}` : ""}`];
247 if (r.skill === "ship") parts.push(r.phase);
248 if (last) parts.push(last.state);
249 if (r.round > 0) parts.push(`round ${r.round}/${r.maxRounds || parse.DEFAULT_ROUNDS}`);
250 if (m.pr) {
251 const c = m.pr.checks;
252 const total = c.pass + c.fail + c.pending;
253 parts.push(`PR #${m.pr.number}${total ? ` CI ${c.pass}/${total}${c.fail ? ` ✗${c.fail}` : ""}` : ""}`);
254 }
255 return parts.join(" · ");
256}
257
258// `/forge` with nothing else: where the run is and what to do
259export function whereAmI(m: Model): string {
260 const lines: string[] = [];
261 const r = m.run;
262 if (r) {
263 const last = lastState(m);
264 const state = last ? `, ${last.state}${last.detail ? ` (${last.detail})` : ""}` : "";
265 lines.push(`Run: ${parse.runLabel(r.skill, r.args)}${state}`);
266 } else lines.push("No forge run in this session yet.");
267 const pr = m.pr;
268 if (pr) {
269 const c = pr.checks;
270 lines.push(
271 `PR #${pr.number} ${pr.title}: CI ${c.pass} passed, ${c.fail} failed, ${c.pending} pending; ${pr.reviewDecision || "no review decision"}`,
272 );
273 }
274 if (m.config && !m.config.hasAgents) lines.push("Next: /forge-setup-project (no AGENTS.md yet)");
275 else if (m.next) lines.push(`Next: ${m.next.text} (${m.next.why})`);
276 else if (pr && pr.checks.fail > 0) lines.push(`Next: /forge-guard ${pr.number} (CI fails: guard addresses it)`);
277 else if (pr) lines.push(`Next: /forge-guard ${pr.number}, then /forge-land ${pr.number}`);
278 else
279 lines.push(
280 "Next: /forge-shape <idea>, /forge-create-issue <idea>, or /forge-ship <issue>. /forge map shows the whole flow.",
281 );
282 return lines.join("\n");
283}
284hooks/parse.ts 259 lines1// Pure readers of forge's text contracts: the status line, reviewer findings,
2// the ## Risk section, forge-batch's output and the Unattended Shipping config.
3import type { Batch, Checks, Finding, Next, Pr, Risk, Run, Setting, Wave } from "../types";
4
5export const PIPELINE = [
6 "setup-project",
7 "shape",
8 "create-issue",
9 "implement",
10 "reflect",
11 "address-pr-feedback",
12 "guard",
13 "land",
14] as const;
15
16export const DEFAULT_ROUNDS = 3;
17
18// The pipeline step a status puts a run in; the others leave it where it is
19const STATE_STEP: Record<string, string> = {
20 implementing: "implement",
21 waiting: "guard",
22 "merge-ready": "land",
23 merged: "land",
24};
25
26export const stepFor = (state: string, step: string): string => STATE_STEP[state] ?? step;
27
28export const runLabel = (skill: string, args: string): string => `/forge-${skill} ${args}`.trim();
29
30// `forge-ship`, also `forge:forge-ship` from a plugin install → `ship`
31export const skillName = (name: string): string | null => /^(?:[\w-]+:)?forge-([\w-]+)$/.exec(name)?.[1] ?? null;
32
33export const TERMINAL = ["pushed", "review-ready", "merge-ready", "merged", "needs-human", "failed"];
34const STATES = ["implementing", "reviewing", "waiting", "addressing", ...TERMINAL];
35
36// The status call a skill writes into its Bash command (status-reporting.md):
37// `"$FORGE_STATUS_CMD" <state> "<detail>"` and its fallback `echo "[forge] <state>: <detail>"`.
38// Read from the command, never from output, which any file or tool can fill.
39export function statusCalls(command: string): { state: string; detail: string }[] {
40 const echoed = [...command.matchAll(/echo "\[forge\] ([a-z-]+): ?([^"\n]*)"/g)];
41 const called = [...command.matchAll(/"\$\{?FORGE_STATUS_CMD\}?" ([a-z-]+) "([^"\n]*)"/g)];
42 return (echoed.length ? echoed : called)
43 .map((m) => ({ state: m[1] ?? "", detail: (m[2] ?? "").trim() }))
44 .filter((c) => STATES.includes(c.state));
45}
46
47// `/forge-ship --guard 42`, also `/forge:forge-ship …` from a plugin install
48export function forgeCommand(text: string): { skill: string; args: string } | null {
49 const m = /^\/(\S+)\s*([\s\S]*)$/.exec(text.trim());
50 const skill = m ? skillName(m[1] ?? "") : null;
51 return m && skill ? { skill, args: (m[2] ?? "").trim() } : null;
52}
53
54// A pull request link into `slug` (owner/repo) only: output may name any repository
55// The last link wins: `gh pr create` prints the new PR's URL last
56export function prUrl(text: string, slug: string): { number: number; url: string } | null {
57 let found: { number: number; url: string } | null = null;
58 for (const m of text.matchAll(/https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/g)) {
59 if (slug && (m[1] ?? "").toLowerCase() === slug) found = { number: Number(m[2]), url: m[0] };
60 }
61 return found;
62}
63
64// A low risk verdict, judged by its first word: "not low" or "below" don't count
65export const isLowRisk = (verdict: string): boolean => /^\W*low\b/i.test(verdict);
66
67// owner/repo from a GitHub remote (https or ssh), lowercased; '' for any other host
68export function repoSlug(remote: string | null): string {
69 const m = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(remote ?? "");
70 return (m?.[1] ?? "").toLowerCase();
71}
72
73// A verdict counts only for the commit it was judged on (forge-land's rule)
74export const isOnHead = (risk: Risk | null, pr: Pr | null): boolean =>
75 !!risk && !!pr && risk.sha !== "" && pr.headSha.startsWith(risk.sha);
76
77// The first bare number in a skill's arguments: an Issue or PR number
78export function argNumber(args: string): number | null {
79 // --max-rounds is the one forge flag that takes a value
80 const bare = (args.split(" -- ")[0] ?? "").replace(/--max-rounds[ =]\S+/g, " ");
81 const m = /(?:^|\s)#?(\d+)(?:\s|$)/.exec(bare);
82 return m ? Number(m[1]) : null;
83}
84
85export function round(detail: string): { round: number; max: number } | null {
86 const m = /round (\d+)(?:\s*(?:\/|of)\s*(\d+))?/i.exec(detail);
87 return m ? { round: Number(m[1]), max: Number(m[2] ?? 0) } : null;
88}
89
90// The steps worth a row: setup-project only until the repo is set up, or while it runs
91export const pipelineFor = (step: string | null, isSetUp: boolean | null): string[] =>
92 PIPELINE.filter((s) => s !== "setup-project" || s === step || isSetUp === false);
93
94// Which pipeline step a run is in
95export const currentStep = (run: Run | null): string | null => run?.phase ?? null;
96
97// Reviewer output: `### <file>` headers over `- [P0] <finding>` lines
98export function findings(text: string): Finding[] {
99 const out: Finding[] = [];
100 let file = "";
101 for (const line of text.split("\n")) {
102 const header = /^#{2,4} (.+)$/.exec(line.trim());
103 if (header) file = (header[1] ?? "").replace(/`/g, "");
104 const m = /^\s*[-*] \[(P[0-2])\] (.+)$/.exec(line);
105 if (m) {
106 const body = (m[2] ?? "").trim();
107 // the same finding arrives from the reviewer, a report and the restating answer
108 out.push({ id: `${m[1]}:${file}:${body.slice(0, 80)}`, severity: m[1] ?? "P2", file, text: body, isDone: false });
109 }
110 }
111 return out;
112}
113
114export function riskSection(text: string): Risk | null {
115 const start = text.search(/^## Risk\s*$/m);
116 if (start < 0) return null;
117 const rest = text.slice(start).split("\n").slice(1);
118 const end = rest.findIndex((l) => /^## /.test(l));
119 const body = (end < 0 ? rest : rest.slice(0, end)).join("\n");
120 const verdict = /\*\*Verdict:\*\*\s*(.+?)(?:,\s*judged on\s*`?([0-9a-f]{7,40})`?)?\s*$/m.exec(body);
121 if (!verdict) return null;
122 const look = body.split(/\*\*Where to look\*\*.*\n/)[1] ?? "";
123 return {
124 verdict: (verdict[1] ?? "").trim(),
125 sha: (verdict[2] ?? "").slice(0, 10),
126 bullets: [...(body.split(/\*\*Where to look\*\*/)[0] ?? "").matchAll(/^- (.+)$/gm)].map((m) => m[1] ?? ""),
127 lookAt: [...(look.split(/\*\*Revert/)[0] ?? "").matchAll(/^- (.+)$/gm)].map((m) => m[1] ?? ""),
128 revert: (/\*\*Revert:\*\*\s*(.+)$/m.exec(body)?.[1] ?? "").trim(),
129 };
130}
131
132// forge-land and forge-ship end with `**Result:** <state> …`
133export function resultLine(text: string): string {
134 return (/\*\*Result:\*\*\s*(.+)$/m.exec(text)?.[1] ?? "").trim();
135}
136
137export function batch(text: string): Batch | null {
138 const head = /^## Batch: (.+)$/m.exec(text);
139 if (!head) return null;
140 const waves: Wave[] = [];
141 let excluded: string[] = [];
142 for (const chunk of text.slice(head.index).split(/^### /m).slice(1)) {
143 const [title = "", ...lines] = chunk.split("\n");
144 if (/^Excluded/.test(title)) {
145 excluded = lines.filter((l) => l.startsWith("- ")).map((l) => l.slice(2));
146 continue;
147 }
148 const rows = lines
149 .filter((l) => /^\|\s*#?\d+/.test(l))
150 .map((l) =>
151 l
152 .split("|")
153 .slice(1, -1)
154 .map((c) => c.trim()),
155 )
156 .map(([num = "", t = "", touch = "", notes = ""]) => ({ num, title: t, touch, notes }));
157 const launch = (/^launch: (.+)$/m.exec(lines.join("\n"))?.[1] ?? "").trim();
158 waves.push({ title: title.trim(), rows, launch });
159 }
160 return { scope: (head[1] ?? "").trim(), waves, excluded };
161}
162
163type Rollup = {
164 conclusion?: string | null;
165 status?: string | null;
166 state?: string | null;
167 name?: string;
168 context?: string;
169};
170
171export function checks(rollup: Rollup[]): Checks {
172 const out: Checks = { pass: 0, fail: 0, pending: 0, failing: [] };
173 for (const c of rollup) {
174 const verdict = (c.conclusion ?? c.state ?? "").toUpperCase();
175 if (c.status && c.status !== "COMPLETED") out.pending += 1;
176 else if (["SUCCESS", "NEUTRAL", "SKIPPED"].includes(verdict)) out.pass += 1;
177 else if (verdict === "PENDING" || verdict === "EXPECTED" || verdict === "") out.pending += 1;
178 else {
179 out.fail += 1;
180 out.failing.push(c.name ?? c.context ?? "?");
181 }
182 }
183 return out;
184}
185
186const SETTINGS: Omit<Setting, "value" | "isSet">[] = [
187 { name: "Peer reviewer", fallback: "none", usedBy: "guard" },
188 { name: "Security-sensitive paths", fallback: "forge's risky-signal heuristics", usedBy: "guard, land" },
189 { name: "Serialized resources", fallback: "migrations, lockfiles, generated files", usedBy: "batch" },
190 { name: "Max guard rounds", fallback: String(DEFAULT_ROUNDS), usedBy: "guard" },
191 { name: "Auto-merge", fallback: "off", usedBy: "land" },
192];
193
194export function unattended(agents: string): { hasSection: boolean; hasStaleHeading: boolean; settings: Setting[] } {
195 const at = agents.search(/^## Unattended Shipping\s*$/m);
196 const section = at < 0 ? "" : (agents.slice(at).split(/\n## /)[0] ?? "");
197 const settings = SETTINGS.map((s) => {
198 const value = new RegExp(`^- ${s.name}:\\s*(.+)$`, "mi").exec(section)?.[1]?.trim() ?? "";
199 return { ...s, value, isSet: value !== "" };
200 });
201 return { hasSection: at >= 0, hasStaleHeading: /^## Shipping\s*$/m.test(agents), settings };
202}
203
204// The step a person most likely wants after what just happened
205export function nextStep(run: Run | null, pr: Pr | null, openFindings: number, risk: Risk | null): Next | null {
206 if (!run) return null;
207 const n = pr ? String(pr.number) : "";
208 const issue = argNumber(run.args);
209 switch (run.terminal) {
210 case "needs-human":
211 return { text: `/forge-address-pr-feedback ${n}`.trim(), why: "the guard stopped and needs a decision from you" };
212 case "review-ready":
213 return { text: `/forge-land ${n}`.trim(), why: "green and addressed: land judges the risk" };
214 case "merge-ready":
215 return pr && isOnHead(risk, pr) && isLowRisk(risk?.verdict ?? "")
216 ? {
217 text: `! gh pr merge ${n} --squash --match-head-commit ${pr.headSha}`,
218 why: `land judged ${pr.headSha.slice(0, 7)} low risk; the merge holds only for that commit`,
219 }
220 : { text: `/forge-land ${n}`.trim(), why: "no low risk verdict on the head commit: land judges it again" };
221 case "pushed":
222 return { text: `/forge-guard ${n}`.trim(), why: "the PR is open: guard drives it to review-ready" };
223 case "merged":
224 return { text: "/forge-reflect", why: "merged: capture what the session taught" };
225 case "failed":
226 return { text: runLabel(run.skill, run.args), why: "the run failed: retry once the cause is fixed" };
227 }
228 switch (run.skill) {
229 case "setup-project":
230 return { text: "/forge-shape ", why: "project set up: shape the first idea" };
231 case "shape":
232 return { text: "/forge-create-issue ", why: "shaped: turn it into an Issue" };
233 case "create-issue":
234 return { text: `/forge-ship ${issue ?? ""}`.trim(), why: "Issue ready: ship implements and reviews it" };
235 case "implement":
236 return pr ? { text: `/forge-guard ${n}`, why: "PR open: guard reviews and drives it" } : null;
237 case "address-pr-feedback":
238 return openFindings > 0
239 ? null
240 : { text: `/forge-guard ${n}`.trim(), why: "feedback addressed: guard checks CI and threads again" };
241 case "batch":
242 return { text: "/forge-ship --guard ", why: "start each Issue of Wave 1 in its own session" };
243 }
244 return null;
245}
246
247// The text in a tool's result, whatever its shape: Bash's stdout and stderr
248// (kept when the command exits non-zero), an Agent's content blocks, a string
249export function resultText(result: unknown): string {
250 if (typeof result === "string") return result;
251 if (!result || typeof result !== "object") return "";
252 const r = result as { stdout?: unknown; stderr?: unknown; content?: unknown };
253 const parts = [r.stdout, r.stderr].filter((x): x is string => typeof x === "string");
254 if (Array.isArray(r.content)) {
255 for (const c of r.content) if (c && typeof c === "object" && typeof c.text === "string") parts.push(c.text);
256 }
257 return parts.join("\n");
258}
259hooks/views.tsx 325 lines1import type { EngineInterface } from "claude-code";
2
3import type { Batch, Config, Finding, Next, Pr, Risk, Run, Stage } from "../types";
4import { currentStep, DEFAULT_ROUNDS, isLowRisk, isOnHead, pipelineFor, runLabel, TERMINAL } from "./parse";
5
6type Ui = ReturnType<EngineInterface["ui"]["resolve"]>;
7
8// The steps forge-ship and forge-ship --guard compose
9const SHIP = ["implement", "guard", "land"];
10
11const STATE_COLOR: Record<string, string> = {
12 implementing: "claude",
13 reviewing: "suggestion",
14 waiting: "warning",
15 addressing: "warning",
16 pushed: "success",
17 "review-ready": "success",
18 "merge-ready": "success",
19 merged: "merged",
20 "needs-human": "error",
21 failed: "error",
22};
23
24const SEVERITY_COLOR: Record<string, string> = { P0: "error", P1: "warning", P2: "suggestion" };
25
26function ago(now: number, at: number): string {
27 const s = Math.max(0, Math.round((now - at) / 1000));
28 return s < 60 ? `${s}s` : s < 3600 ? `${Math.round(s / 60)}m` : `${Math.round(s / 3600)}h`;
29}
30
31type LensProps = {
32 ui: Ui;
33 now: number;
34 run: Run | null;
35 timeline: Stage[];
36 // the latest status, never the run's own start entry
37 last: Stage | null;
38 visited: string[];
39 // whether the repo has AGENTS.md; null until the config is read
40 isSetUp: boolean | null;
41 pr: Pr | null;
42 findings: Finding[];
43 risk: Risk | null;
44 result: string;
45 next: Next | null;
46 onToggle: (id: string) => void;
47 onRefresh: () => void;
48};
49
50export function Lens(p: LensProps) {
51 const { Box, Text, Button, Link } = p.ui;
52 const step = currentStep(p.run);
53 const isShip = p.run?.skill === "ship";
54 const last = p.last;
55 const open = p.findings.filter((f) => !f.isDone);
56
57 return (
58 <Box flexDirection="column" gap={1}>
59 {p.run ? (
60 <Box flexDirection="column">
61 <Text bold>
62 {runLabel(p.run.skill, p.run.args)}
63 {p.run.isDemo ? " (demo)" : ""}
64 </Text>
65 <Text dimColor>
66 started {ago(p.now, p.run.startedAt)} ago
67 {p.run.terminal ? `, ended ${p.run.terminal}` : ""}
68 </Text>
69 </Box>
70 ) : (
71 <Box flexDirection="column">
72 <Text bold>No forge run yet</Text>
73 <Text dimColor>Start one with /forge-… or try /forge demo</Text>
74 </Box>
75 )}
76
77 {last && (
78 <Box flexDirection="column">
79 <Text dimColor>NOW</Text>
80 <Text color={STATE_COLOR[last.state] ?? "text"} bold={TERMINAL.includes(last.state)}>
81 {last.state}
82 <Text dimColor>{last.detail ? ` ${last.detail}` : ""}</Text>
83 </Text>
84 </Box>
85 )}
86
87 {p.next && (
88 <Box flexDirection="column">
89 <Text dimColor>NEXT</Text>
90 <Text color="suggestion" bold>
91 {p.next.text}
92 </Text>
93 <Text dimColor>{p.next.why} (Tab in an empty prompt takes it)</Text>
94 </Box>
95 )}
96
97 <Box flexDirection="column">
98 <Text dimColor>PIPELINE{isShip ? " ┃ = composed by ship" : ""}</Text>
99 {pipelineFor(step, p.isSetUp).map((s) => {
100 const isNow = s === step && !p.run?.terminal;
101 const isDone = p.visited.includes(s) && !isNow;
102 const mark = isNow ? "●" : isDone ? "✓" : "○";
103 const color = isNow ? "claude" : isDone ? "success" : "inactive";
104 const bar = isShip && SHIP.includes(s) ? "┃" : " ";
105 let note = "";
106 if (s === "setup-project" && !isNow && !isDone) note = "start here";
107 if (s === "shape" && !isNow && !isDone) note = "optional";
108 if (isNow && isShip && s === "implement" && last?.state === "reviewing") note = "self-review";
109 if (s === "guard" && p.run && p.run.round > 0)
110 note = `round ${p.run.round}/${p.run.maxRounds || DEFAULT_ROUNDS}`;
111 return (
112 <Text color={color}>
113 {" "}
114 {mark} {bar} {s.padEnd(20)}
115 <Text dimColor>{note}</Text>
116 </Text>
117 );
118 })}
119 </Box>
120 {p.pr && (
121 <Box flexDirection="column">
122 <Box flexDirection="row" justifyContent="space-between">
123 <Text dimColor>PULL REQUEST</Text>
124 {!p.pr.isDemo && <Button key="refresh" label="Refresh" plain onPress={p.onRefresh} />}
125 </Box>
126 {p.pr.isDemo ? (
127 <Text>
128 #{p.pr.number} {p.pr.title}
129 </Text>
130 ) : (
131 <Link href={p.pr.url} label={`#${p.pr.number} ${p.pr.title}`} />
132 )}
133 <Text dimColor wrap="truncate">
134 {p.pr.branch} · {p.pr.state.toLowerCase()} · {p.pr.mergeable.toLowerCase()}
135 {p.pr.reviewDecision ? ` · ${p.pr.reviewDecision.toLowerCase().replace(/_/g, " ")}` : ""}
136 </Text>
137 <Text>
138 CI <Text color="success">✓{p.pr.checks.pass}</Text> <Text color="error">✗{p.pr.checks.fail}</Text>{" "}
139 <Text color="warning">◌{p.pr.checks.pending}</Text>
140 <Text dimColor>{p.pr.checks.failing.length ? ` failing: ${p.pr.checks.failing.join(", ")}` : ""}</Text>
141 </Text>
142 </Box>
143 )}
144
145 {p.findings.length > 0 && (
146 <Box flexDirection="column">
147 <Text dimColor>
148 REVIEW FINDINGS {open.length} open / {p.findings.length}
149 </Text>
150 {p.findings.map((f) => (
151 <Box flexDirection="row" gap={1}>
152 <Button key={`f-${f.id}`} label={f.isDone ? "☑" : "☐"} plain onPress={() => p.onToggle(f.id)} />
153 <Text color={f.isDone ? "inactive" : (SEVERITY_COLOR[f.severity] ?? "text")}>{f.severity}</Text>
154 <Text dimColor={f.isDone} strikethrough={f.isDone} wrap="wrap">
155 {f.file ? `${f.file}: ` : ""}
156 {f.text}
157 </Text>
158 </Box>
159 ))}
160 </Box>
161 )}
162
163 {p.risk && <RiskCard ui={p.ui} risk={p.risk} result={p.result} pr={p.pr} />}
164
165 {p.timeline.length > 1 && (
166 <Box flexDirection="column">
167 <Text dimColor>TIMELINE</Text>
168 {p.timeline.slice(-5).map((s) => (
169 <Text wrap="truncate">
170 <Text dimColor>{ago(p.now, s.at).padStart(4)} </Text>
171 <Text color={STATE_COLOR[s.state] ?? "subtle"}>{s.state.padEnd(13)}</Text>
172 <Text dimColor>{s.detail}</Text>
173 </Text>
174 ))}
175 </Box>
176 )}
177 </Box>
178 );
179}
180
181type RiskCardProps = { ui: Ui; risk: Risk; result: string; pr: Pr | null };
182
183function RiskCard({ ui, risk, result, pr }: RiskCardProps) {
184 const { Box, Text } = ui;
185 const isLowVerdict = isLowRisk(risk.verdict);
186 const isCurrent = isOnHead(risk, pr);
187 const head = pr?.headSha ?? "";
188 const isLow = isLowVerdict && isCurrent;
189 return (
190 <Box flexDirection="column" borderStyle="round" borderColor={isLow ? "success" : "warning"} paddingX={1}>
191 <Text bold color={isLow ? "success" : "warning"}>
192 {isLowVerdict ? "LOW RISK" : "NEEDS REVIEW"}
193 <Text dimColor>{risk.sha ? ` judged on ${risk.sha}` : ""}</Text>
194 </Text>
195 {!isCurrent && (
196 <Text color="warning" wrap="wrap">
197 ⚠ stale or unverified: the head is {head ? head.slice(0, 10) : "unknown"}, run /forge-land again
198 </Text>
199 )}
200 {result !== "" && <Text>Result: {result}</Text>}
201 {risk.bullets.map((b) => (
202 <Text dimColor={/→ ok|: ok$|: none$/.test(b)} wrap="wrap">
203 · {b}
204 </Text>
205 ))}
206 {risk.lookAt.length > 0 && <Text bold>Where to look</Text>}
207 {risk.lookAt.map((l) => (
208 <Text color="warning" wrap="wrap">
209 → {l}
210 </Text>
211 ))}
212 {risk.revert !== "" && <Text color="error">Revert: {risk.revert}</Text>}
213 </Box>
214 );
215}
216
217export function ConfigView({ ui, config }: { ui: Ui; config: Config | null }) {
218 const { Box, Text } = ui;
219 if (!config) return <Text dimColor>Reading AGENTS.md…</Text>;
220 return (
221 <Box flexDirection="column" gap={1}>
222 <Box flexDirection="column">
223 <Text dimColor>PROJECT</Text>
224 <Text>{config.root}</Text>
225 <Text color={config.hasAgents ? "success" : "error"}>
226 {config.hasAgents ? "✓ AGENTS.md" : "✗ no AGENTS.md: run /forge-setup-project"}
227 </Text>
228 <Text>
229 Tracker <Text dimColor>{config.tracker}</Text>
230 </Text>
231 </Box>
232 <Box flexDirection="column">
233 <Text dimColor>UNATTENDED SHIPPING {config.hasSection ? "" : "(no section: every setting is a default)"}</Text>
234 {config.hasStaleHeading && (
235 <Text color="warning" wrap="wrap">
236 ⚠ AGENTS.md has a "## Shipping" heading: forge reads "## Unattended Shipping"
237 </Text>
238 )}
239 {config.settings.map((s) => (
240 <Box flexDirection="column">
241 <Text>
242 <Text bold={s.isSet}>{s.name}</Text>
243 <Text dimColor> ({s.usedBy})</Text>
244 </Text>
245 <Text color={s.isSet ? "text" : "inactive"} wrap="wrap">
246 {" "}
247 {s.isSet ? s.value : `default: ${s.fallback}`}
248 </Text>
249 </Box>
250 ))}
251 </Box>
252 <Box flexDirection="column">
253 <Text dimColor>ENVIRONMENT</Text>
254 {config.env.map((v) => (
255 <Text>
256 {v.name} <Text dimColor>{v.value || "unset (status prints as [forge] lines)"}</Text>
257 </Text>
258 ))}
259 </Box>
260 </Box>
261 );
262}
263
264export function WavesView({ ui, batch }: { ui: Ui; batch: Batch | null }) {
265 const { Box, Text } = ui;
266 if (!batch) return <Text dimColor>No batch yet: run /forge-batch, or /forge demo batch</Text>;
267 return (
268 <Box flexDirection="column" gap={1}>
269 <Text bold>Batch: {batch.scope}</Text>
270 {batch.waves.map((w, i) => (
271 <Box flexDirection="column" borderStyle="round" borderColor={i === 0 ? "claude" : "inactive"} paddingX={1}>
272 <Text bold color={i === 0 ? "claude" : "subtle"}>
273 {w.title}
274 </Text>
275 {w.rows.map((r) => (
276 <Box flexDirection="column">
277 <Text wrap="truncate">
278 <Text bold>{r.num.padEnd(5)}</Text>
279 {r.title}
280 </Text>
281 <Text dimColor wrap="truncate">
282 {" "}
283 {r.touch}
284 </Text>
285 {r.notes !== "" && (
286 <Text color="warning">
287 {" "}
288 {r.notes}
289 </Text>
290 )}
291 </Box>
292 ))}
293 {w.launch !== "" && <Text color="suggestion">launch: {w.launch}</Text>}
294 </Box>
295 ))}
296 {batch.excluded.length > 0 && (
297 <Box flexDirection="column">
298 <Text dimColor>EXCLUDED</Text>
299 {batch.excluded.map((x) => (
300 <Text dimColor>· {x}</Text>
301 ))}
302 </Box>
303 )}
304 </Box>
305 );
306}
307
308export const MAP = `forge pipeline
309
310 setup-project once per repo: AGENTS.md, docs, conventions
311 shape turn a rough idea into a shaped plan (optional)
312 create-issue write the Issue, AFK or HITL
313 implement code it, test it, open the PR
314 reflect capture what the session taught
315 address-pr-feedback answer review threads
316 guard CI, peer review, feedback rounds until review-ready
317 land risk verdict; merge-ready, or merged when opted in
318
319 ship = implement + self-review
320 ship --guard = implement + review + guard + land, unattended
321 batch plan Waves of Issues that can run in parallel
322
323Every skill prints [forge] <state>: <detail> at each stage change.
324The forge lens reads those lines: /forge pane, /forge config, /forge waves, /forge demo`;
325types/index.d.ts 98 lines1export type Stage = { state: string; detail: string; at: number };
2
3export type Run = {
4 skill: string;
5 args: string;
6 startedAt: number;
7 // the composed process forge-ship is in (implement, review, guard, land)
8 phase: string;
9 round: number;
10 maxRounds: number;
11 terminal: string | null;
12 isDemo: boolean;
13};
14
15export type Checks = { pass: number; fail: number; pending: number; failing: string[] };
16
17export type Pr = {
18 number: number;
19 url: string;
20 title: string;
21 branch: string;
22 headSha: string;
23 state: string;
24 mergeable: string;
25 reviewDecision: string;
26 checks: Checks;
27 isDemo: boolean;
28 fetchedAt: number;
29};
30
31export type Finding = {
32 id: string;
33 severity: string;
34 file: string;
35 text: string;
36 isDone: boolean;
37};
38
39export type Risk = {
40 verdict: string;
41 sha: string;
42 bullets: string[];
43 lookAt: string[];
44 revert: string;
45};
46
47export type WaveRow = { num: string; title: string; touch: string; notes: string };
48export type Wave = { title: string; rows: WaveRow[]; launch: string };
49export type Batch = { scope: string; waves: Wave[]; excluded: string[] };
50
51export type Next = { text: string; why: string };
52
53export type Setting = { name: string; value: string; fallback: string; usedBy: string; isSet: boolean };
54
55export type Config = {
56 root: string;
57 hasAgents: boolean;
58 hasSection: boolean;
59 hasStaleHeading: boolean;
60 tracker: string;
61 settings: Setting[];
62 env: { name: string; value: string }[];
63};
64
65export type Demo = { variant: string; step: number; nextAt: number };
66
67// Work the effect loop does outside any hook: hooks only change the model
68export type Want = { pr: number | null; isBranchPr: boolean; isConfig: boolean };
69
70export type Model = {
71 run: Run | null;
72 timeline: Stage[];
73 visited: string[];
74 pr: Pr | null;
75 findings: Finding[];
76 risk: Risk | null;
77 result: string;
78 batch: Batch | null;
79 next: Next | null;
80 isBandHidden: boolean;
81 // the person closed the pane: a new run doesn't open it again
82 isLensClosed: boolean;
83 config: Config | null;
84 want: Want;
85 alerts: string[];
86 panes: string[];
87 demo: Demo | null;
88};
89
90declare module "claude-code" {
91 interface PluginState {
92 forge: {
93 model: Model;
94 nudged: string[];
95 };
96 }
97}
98