SLOPSHOPPER

lens

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

newpanebandguardcommandtoast
★ 64v?MITupdated 2026-10-25mgratzer/forge/lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lens
│ ┃ forge ✕ › fix the failing auth test and add an audit log call │ ┃ No forge run yet │ ┃ Start one with /forge-… or try /forge demo ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ PIPELINE ⏺ Update(src/auth.ts) │ ┃ ○ shape optional ⎿ Added 2 lines, removed 1 line │ ┃ ○ create-issue ⏺ Bash(bun test) │ ┃ ○ implement ⎿ 3 pass, 1 fail │ ┃ ○ reflect │ ┃ ○ address-pr-feedback ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ○ guard │ ┃ ○ land ✻ Worked for 42s · done 4:20 PM │ │ › /forge │ ⎿ lens: No forge run in this session yet. │ ⎿ lens: Next: /forge-shape <idea>, /forge-create-issue <idea>, or │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · forge
No forge run yet Start one with /forge-… or try /forge demo PIPELINE ○ shape optional ○ create-issue ○ implement ○ reflect ○ address-pr-feedback ○ guard ○ land
Pane · forge-config
Reading AGENTS.md…
Pane · forge-waves
No batch yet: run /forge-batch, or /forge demo batch
README

<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> &middot; <a href="#how-it-works">How it works</a> &middot; <a href="#skills">Skills</a> &middot; <a href="docs/lens.md">Lens</a> &middot; <a href="docs/architecture.md">Architecture</a> &middot; <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">

Quick Start

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.

How It Works

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.

Skills

SkillCommandPurpose
Setup Project/forge-setup-projectSet up or audit a project's context for agentic engineering
Shape/forge-shapeShape a vague idea into a clear plan, one question at a time
Create Issue/forge-create-issuePlan 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-reflectSelf-review changes: a PR, a branch, or uncommitted work
Address PR Feedback/forge-address-pr-feedbackAddress 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 Lens

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.

Install Details

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.

Contributing

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).

DocumentPurpose
ContextShared vocabulary used across the skills
ArchitectureSkill workflow, operating constraints, design decisions
DevelopmentCreating and modifying skills
Coding GuidelinesSkill and role file format, authoring conventions
TestingAutomated checks and validating skills by hand
PR WorkflowCommits, PRs, branch naming, review
Unattended ShippingGuard loop, Waves, launchers, project setup
LensThe Claude Code plugin and the lens

License

MIT

Source 6 files
hooks/register.tsx 321 lines
1import 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};
321
hooks/demo.ts 131 lines
1// 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}
131
hooks/model.ts 284 lines
1// 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}
284
hooks/parse.ts 259 lines
1// 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}
259
hooks/views.tsx 325 lines
1import 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`;
325
types/index.d.ts 98 lines
1export 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