Compare commits

...

9 Commits

Author SHA1 Message Date
Naiyuan Qing
8689e1cef0 docs(plans): usecases/squads Stage A skeleton plan (r3 approved)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 16:58:42 +08:00
Naiyuan Qing
0ce15b2667 test(web): SEO invariants for /usecases/squads
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 16:58:42 +08:00
Naiyuan Qing
8eed069868 feat(web): link landing features-section to /usecases/squads
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 16:58:42 +08:00
Naiyuan Qing
34f2f7e8ce docs(squads): reverse-link to /usecases/squads
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 16:58:42 +08:00
Naiyuan Qing
ab6845c0c5 feat(web): allow /usecases in robots.txt
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 16:58:42 +08:00
Naiyuan Qing
f995ddca48 feat(web): add /usecases/squads to sitemap
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 16:58:42 +08:00
Naiyuan Qing
4867694dcc feat(web): add OG + Twitter image placeholders for /usecases/squads
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 16:58:34 +08:00
Naiyuan Qing
d3dc46c2e1 feat(web): add /usecases/squads landing page skeleton
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 16:58:34 +08:00
Naiyuan Qing
f399530785 feat(reserved-slugs): reserve 'usecases' for /usecases/* routes
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 16:58:29 +08:00
12 changed files with 1096 additions and 2 deletions

View File

@@ -5,6 +5,8 @@ description: "A squad is a group of agents (and optionally human members) led by
import { Callout } from "fumadocs-ui/components/callout";
> Want the use case in 60 seconds? See **[Assign issues to AI agent teams →](https://www.multica.ai/usecases/squads)**.
A squad is a **named group of [agents](/agents) and human [members](/members-roles)**, with one designated **leader agent**. The squad is itself a first-class assignee: pick it from any **Assignee** picker and the leader takes the trigger, reads the issue, then `@`-mentions the squad member best suited to do the work. Squads let you assemble specialists once and dispatch them **by topic instead of by name** — the team grows, the routing stays the same.
## What a squad is, in mechanics

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -0,0 +1,140 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import Page, { metadata } from "./page";
function resolveTitle(m: typeof metadata): string {
const t = m.title;
if (typeof t === "string") return t;
if (t && typeof t === "object" && "absolute" in t && typeof t.absolute === "string") {
return t.absolute;
}
if (t && typeof t === "object" && "default" in t && typeof t.default === "string") {
return t.default;
}
return "";
}
// PNG layout: 8-byte signature, then chunks; first chunk header is "IHDR" at offset 12,
// width at 16..19 (uint32 BE), height at 20..23. Spec: https://www.w3.org/TR/png/#11IHDR
function readPngSize(absPath: string): { width: number; height: number } {
const buf = readFileSync(absPath);
expect(
buf
.subarray(0, 8)
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
).toBe(true);
expect(buf.subarray(12, 16).toString("ascii")).toBe("IHDR");
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
function extractJsonLd(container: HTMLElement): unknown[] {
return Array.from(
container.querySelectorAll('script[type="application/ld+json"]'),
).map((el) => JSON.parse(el.textContent ?? ""));
}
describe("/usecases/squads — metadata", () => {
it("uses title.absolute (bypasses root template) and resolves to ≤60 chars", () => {
const t = metadata.title;
expect(typeof t === "object" && t !== null && "absolute" in t).toBe(true);
const resolved = resolveTitle(metadata);
expect(resolved.length).toBeGreaterThan(0);
expect(resolved.length).toBeLessThanOrEqual(60);
});
it("description is ≤155 chars", () => {
expect(metadata.description?.length ?? 0).toBeGreaterThan(0);
expect(metadata.description?.length ?? 0).toBeLessThanOrEqual(155);
});
it("canonical is the absolute path /usecases/squads", () => {
expect(metadata.alternates?.canonical).toBe("/usecases/squads");
});
});
describe("/usecases/squads — JSON-LD", () => {
function renderAndExtract() {
const { container } = render(<Page />);
const graphs = extractJsonLd(container);
const entities = graphs.flatMap((g: any) =>
g["@graph"] ? g["@graph"] : [g],
);
return { container, entities };
}
it("contains exactly one Article and one FAQPage; no SoftwareApplication (layout owns that)", () => {
const { entities } = renderAndExtract();
expect(entities.filter((e: any) => e["@type"] === "Article")).toHaveLength(
1,
);
expect(entities.filter((e: any) => e["@type"] === "FAQPage")).toHaveLength(
1,
);
expect(
entities.filter((e: any) => e["@type"] === "SoftwareApplication"),
).toHaveLength(0);
});
it("Article carries headline, Organization author/publisher, both dates, and canonical mainEntityOfPage", () => {
const { entities } = renderAndExtract();
const article = entities.find((e: any) => e["@type"] === "Article") as any;
expect(article.headline).toBe(
"Assign issues to AI agent teams: routing with Multica squads",
);
expect(article.author?.["@type"]).toBe("Organization");
expect(article.author?.name).toBe("Multica");
expect(article.publisher?.["@type"]).toBe("Organization");
expect(article.publisher?.name).toBe("Multica");
expect(article.datePublished).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(article.dateModified).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(article.mainEntityOfPage).toBe(
"https://www.multica.ai/usecases/squads",
);
});
it("FAQPage has mainEntity questions with name + acceptedAnswer.text, all visible in body", () => {
const { container, entities } = renderAndExtract();
const faq = entities.find((e: any) => e["@type"] === "FAQPage") as any;
expect(Array.isArray(faq.mainEntity)).toBe(true);
expect(faq.mainEntity.length).toBeGreaterThanOrEqual(1);
// Strip <script> contents so body text reflects what the user actually sees.
const visibleRoot = container.cloneNode(true) as HTMLElement;
visibleRoot
.querySelectorAll("script")
.forEach((s) => s.parentNode?.removeChild(s));
const bodyText = visibleRoot.textContent ?? "";
for (const q of faq.mainEntity) {
expect(q["@type"]).toBe("Question");
expect(typeof q.name).toBe("string");
expect(q.name.length).toBeGreaterThan(0);
expect(q.acceptedAnswer?.["@type"]).toBe("Answer");
expect(typeof q.acceptedAnswer.text).toBe("string");
expect(q.acceptedAnswer.text.length).toBeGreaterThan(0);
expect(bodyText).toContain(q.name);
expect(bodyText).toContain(q.acceptedAnswer.text);
}
});
});
describe("/usecases/squads — image assets", () => {
const seg = join(process.cwd(), "app", "(landing)", "usecases", "squads");
const og = join(seg, "opengraph-image.png");
const tw = join(seg, "twitter-image.png");
it("both OG and Twitter PNGs exist on disk", () => {
expect(existsSync(og)).toBe(true);
expect(existsSync(tw)).toBe(true);
});
it("OG PNG is 1200×630", () => {
expect(readPngSize(og)).toEqual({ width: 1200, height: 630 });
});
it("Twitter PNG is 1200×630", () => {
expect(readPngSize(tw)).toEqual({ width: 1200, height: 630 });
});
});

View File

@@ -0,0 +1,159 @@
import type { Metadata } from "next";
// `title.absolute` bypasses the root `template: "%s | Multica"` (apps/web/app/layout.tsx).
// The dispatched title is exactly 60 characters; allowing the template to append " | Multica"
// would resolve to 70 characters and break SERP truncation.
export const metadata: Metadata = {
title: {
absolute:
"Assign issues to AI agent teams: routing with Multica squads",
},
description:
"Stop @-mentioning the wrong agent. Multica squads give you a stable routing target — the leader picks the right specialist for each issue.",
openGraph: {
title: "Assign issues to AI agent teams — Multica squads",
description:
"A squad is a group of agents led by one leader. Assign work to the squad; the leader picks who handles it.",
url: "/usecases/squads",
type: "article",
},
alternates: {
canonical: "/usecases/squads",
},
};
const articleJsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
headline:
"Assign issues to AI agent teams: routing with Multica squads",
datePublished: "2026-05-15",
dateModified: "2026-05-15",
author: { "@type": "Organization", name: "Multica" },
publisher: { "@type": "Organization", name: "Multica" },
mainEntityOfPage: "https://www.multica.ai/usecases/squads",
},
{
"@type": "FAQPage",
mainEntity: [
{
"@type": "Question",
name: "What is a Multica squad?",
acceptedAnswer: {
"@type": "Answer",
text: "A squad is a named group of agents (and optionally human members) led by one leader agent. Assigning an issue to the squad lets the leader route it to the right member.",
},
},
{
"@type": "Question",
name: "How is a squad different from a single agent?",
acceptedAnswer: {
"@type": "Answer",
text: "A single agent does the work; a squad routes work. The squad never executes — its leader picks which member responds.",
},
},
],
},
],
};
export default function SquadsUsecasePage() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
/>
<main className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-4xl font-semibold tracking-tight">
Assign issues to AI agent teams: routing with Multica squads
</h1>
<section className="mt-10 space-y-4">
<h2 className="text-2xl font-semibold">The problem</h2>
<p>
As your AI team grows from one agent to ten, every new specialist
breaks your assignment habits. You @-mention{" "}
<code>@backend-bot</code> for a frontend bug because you forgot{" "}
<code>@frontend-bot</code> joined last week. The work stalls in the
wrong queue.
</p>
</section>
<section className="mt-10 space-y-4">
<h2 className="text-2xl font-semibold">Squads route issues for you</h2>
<p>
A squad is a named group of agents with one designated{" "}
<strong>leader agent</strong>. Assign an issue to the squad not to
a specific member and the leader reads the issue, decides who
fits best, and @-mentions them. Your routing target stays stable as
the roster changes.
</p>
</section>
<section className="mt-10 space-y-4">
<h2 className="text-2xl font-semibold">When to reach for a squad</h2>
<ul className="list-disc space-y-2 pl-6">
<li>Several specialists, unclear which one fits a given issue.</li>
<li>
You want one stable assignee while the actual responder changes.
</li>
<li>
You want a <code>@FrontendTeam</code>-style routing target in
comments.
</li>
</ul>
</section>
<section className="mt-10 space-y-4">
<h2 className="text-2xl font-semibold">
Squad vs. single agent vs. autopilot
</h2>
<p>
A single <strong>agent</strong> is one specialist with one inbox. An{" "}
<strong>autopilot</strong> is a scheduled or triggered automation
that runs an agent on a cadence. A <strong>squad</strong> sits on
top of agents and adds <em>routing</em> it never executes work
itself; the leader picks who does.
</p>
</section>
<section className="mt-10 space-y-4">
<h2 className="text-2xl font-semibold">
Frequently asked questions
</h2>
<div className="space-y-6">
<div>
<h3 className="font-semibold">What is a Multica squad?</h3>
<p>
A squad is a named group of agents (and optionally human
members) led by one leader agent. Assigning an issue to the
squad lets the leader route it to the right member.
</p>
</div>
<div>
<h3 className="font-semibold">
How is a squad different from a single agent?
</h3>
<p>
A single agent does the work; a squad routes work. The squad
never executes its leader picks which member responds.
</p>
</div>
</div>
</section>
<section className="mt-10">
<a
href="/docs/squads"
className="inline-flex rounded-md bg-primary px-6 py-3 text-primary-foreground"
>
Read the squads docs
</a>
</section>
</main>
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -7,7 +7,7 @@ export default function robots(): MetadataRoute.Robots {
rules: [
{
userAgent: "*",
allow: ["/", "/about", "/changelog"],
allow: ["/", "/about", "/changelog", "/usecases"],
disallow: [
"/api/",
"/ws",

View File

@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import sitemap from "./sitemap";
describe("sitemap", () => {
it("includes /usecases/squads with priority 0.8", () => {
const entries = sitemap();
const entry = entries.find((e) => e.url.endsWith("/usecases/squads"));
expect(entry).toBeDefined();
expect(entry?.priority).toBe(0.8);
expect(entry?.changeFrequency).toBe("weekly");
});
});

View File

@@ -22,5 +22,11 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "weekly",
priority: 0.6,
},
{
url: `${baseUrl}/usecases/squads`,
lastModified: new Date("2026-05-15"),
changeFrequency: "weekly",
priority: 0.8,
},
];
}

View File

@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import {
Bot,
Brain,
@@ -1085,6 +1086,17 @@ export function FeaturesSection() {
</div>
))}
</div>
{feature.label === features[0]!.label ? (
<div className="mt-10">
<Link
href="/usecases/squads"
className="text-sm font-medium text-[#0a0d12] underline-offset-4 hover:underline"
>
See how squads route issues
</Link>
</div>
) : null}
</div>
))}
</div>

View File

@@ -0,0 +1,761 @@
# Usecases/Squads — Stage A Skeleton Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Revision history:**
- **r3 (2026-05-15)** — Applied Leader `a1b159aa` mechanical fixes:
1. Task 2 `description` shortened from 196 → 138 chars to satisfy Task 8 `≤155` assertion (no body-copy overlap to resync).
2. Tech Stack line updated `Next.js 15``Next.js 16` to match actual `apps/web/package.json:53` (`next: ^16.2.3`); no other occurrences of `Next.js 15` in this plan.
- **r2 (2026-05-15)** — Applied Reviewer `2c453851` REQUEST CHANGES:
1. Page `title` switched to `{ absolute }` form to bypass root `template: "%s | Multica"` (would otherwise push resolved title to 70 chars). Pattern follows `apps/web/app/(landing)/page.tsx:5-8`. Task 8 now asserts the `absolute` form is used AND that the resolved title is ≤60.
2. Added sibling `twitter-image.png` (identical bytes to `opengraph-image.png`) — root metadata never declares `twitter.images`, and the file-convention only emits `og:image*` from `opengraph-image.*`. Dev-server smoke and Task 8 now check BOTH `og:image` AND `twitter:image`.
3. Docs reverse-link hardened to production absolute URL `https://www.multica.ai/usecases/squads`. Root-relative paths risk `basePath: "/docs"` injection (`apps/docs/next.config.mjs:8`, `LocaleLink` at `apps/docs/app/[lang]/[...slug]/page.tsx:36-38`).
4. Task 8 expanded to cover: resolved title; description; canonical; Article (headline / author Organization / publisher / datePublished / **dateModified** / mainEntityOfPage canonical URL); FAQPage (mainEntity / Question.name / acceptedAnswer.text) + **visible Q/A parity on page DOM**; OG + Twitter PNG file existence + IHDR-parsed 1200×630 dimensions (no new dependency).
- **r1 (2026-05-15)** — Initial plan (Implementer comment `a1f1675d`).
**Goal:** Ship the SEO/marketing skeleton for `/usecases/squads` (English-only, indexable real placeholder copy, no MDX pipeline yet) so Google can crawl + reserve the slug + transfer docs/landing reverse-link weight. Per Leader dispatch `3f034692` + recap `ea02134a` — 8 items, Stage A only.
**Architecture:** Single static page under `apps/web/app/(landing)/usecases/squads/` reusing the existing landing layout (Organization + SoftwareApplication JSON-LD already declared globally at `apps/web/app/(landing)/layout.tsx:19-42`). Page injects its own `Article` + `FAQPage` JSON-LD. Sitemap/robots/docs reverse-link/landing entry/og-image follow Next.js App Router file conventions. Slug `usecases` added to the single-source-of-truth `server/internal/handler/reserved_slugs.json` and the TS twin is regenerated by `pnpm generate:reserved-slugs`.
**Tech Stack:** Next.js 16 App Router (RSC), TypeScript, Tailwind, vitest+jsdom, Node.js for generator script, Go embed for backend reserved-slugs.
**Explicit non-goals (out of scope this round):** Chinese (`*.zh`) version, hreflang/locale split, full-site default OG image, dynamic OG via `ImageResponse`, MDX content pipeline (`fumadocs-mdx` / `content-collections`), README link, additional usecases, sitemap clean-up for missing routes.
---
## File map
| Path | Action | Responsibility |
|---|---|---|
| `apps/web/app/(landing)/usecases/squads/page.tsx` | Create | Page component, metadata (with `title: { absolute }` to bypass root `template: "%s \| Multica"`), inline `Article` + `FAQPage` JSON-LD, 200300-word real placeholder copy that **renders the FAQ Q/A visibly on the page**, CTA |
| `apps/web/app/(landing)/usecases/squads/opengraph-image.png` | Create | 1200×630 placeholder PNG — Next.js auto-injects `og:image*` only |
| `apps/web/app/(landing)/usecases/squads/twitter-image.png` | Create | Same bytes copied from `opengraph-image.png` (1200×630). Required because root `metadata.twitter` does not declare `images`, and Next file convention only emits `twitter:image*` when a `twitter-image.*` file exists in the same segment |
| `apps/web/app/(landing)/usecases/squads/page.test.tsx` | Create | SEO invariants — see Task 8 for the full assertion list (resolved title, description, canonical, Article shape, FAQPage shape, Q/A visible in body, OG+Twitter PNG file existence + IHDR-parsed 1200×630 dimensions) |
| `apps/web/app/sitemap.ts` | Modify | Add one entry: `/usecases/squads`, priority 0.8, `changeFrequency: "weekly"` |
| `apps/web/app/robots.ts` | Modify | Add `/usecases` (or `/usecases/squads`) to the `allow` list |
| `apps/docs/content/docs/squads.mdx` | Modify | Insert a one-line "See use case → /usecases/squads" callout at the top of the body |
| `apps/web/features/landing/components/features-section.tsx` | Modify | Add a single squad-usecase entry link (text + `<Link href="/usecases/squads">`); minimal, preserve current layout |
| `server/internal/handler/reserved_slugs.json` | Modify | Add `"usecases"` (plural only — singular `usecase` is intentionally NOT reserved this round) into the existing "Platform / marketing routes (current + likely-future)" group |
| `packages/core/paths/reserved-slugs.ts` | Regenerate | Auto-emitted by `pnpm generate:reserved-slugs`; never hand-edit |
**Touch budget:** 6 source files, 1 generated file, 1 test, 2 binary assets (OG + Twitter PNG, identical bytes). No new packages, no schema migrations, no config changes outside the listed files.
---
## Sensitive-change checklist (for plan-review)
The dispatch (`3f034692`) flagged this as **sensitive (site metadata + cross-package docs back-link + reserved-slugs)** — Reviewer plan-review must confirm:
1. **No `SoftwareApplication` re-declaration in the new page** — already declared globally at `apps/web/app/(landing)/layout.tsx:19-42`. Adding it again creates duplicate-entity SEO noise.
2. **Page `title` uses `{ absolute: "..." }` form** to bypass the root `template: "%s | Multica"` declared at `apps/web/app/layout.tsx:77-80`. The dispatched title is already exactly 60 characters; allowing the template to suffix `" | Multica"` would push the resolved `<title>` to 70 characters and break SERP truncation. Pattern is the existing `apps/web/app/(landing)/page.tsx:5-8`. The SEO test in Task 8 asserts on the **resolved** title (i.e. the `.absolute` field if title is an object, else the string), NOT on raw `metadata.title`.
3. **Canonical is an absolute path string** — Next.js `alternates.canonical: "/usecases/squads"` is correct (resolved against `metadataBase`); avoid hard-coding `https://www.multica.ai/...` here.
4. **Sitemap stays static** — touch one line only; the missing `/download`/`/homepage` entries are out of scope (Leader explicitly said "this round add only the usecase line").
5. **Reserved-slugs flow** — edit JSON only, then run the generator. CI re-runs the generator and `git diff --exit-code`s the TS file, so the regenerated `packages/core/paths/reserved-slugs.ts` MUST be committed (verified at `scripts/generate-reserved-slugs.mjs:5-6`).
6. **No singular `usecase` reservation this round** — the route is plural; reserving singular without a plan to route it is dead weight.
7. **Docs reverse-link is a real `<a>`/Markdown link, using a production absolute URL** (`https://www.multica.ai/usecases/squads`), NOT a root-relative path. Docs app is mounted at `basePath: "/docs"` (`apps/docs/next.config.mjs:8`), and its MDX `a` is rewritten by `LocaleLink` (`apps/docs/app/[lang]/[...slug]/page.tsx:36-38`). A root-relative `/usecases/squads` risks being rewritten to `/docs/usecases/squads`. Absolute external URLs are preserved verbatim (covered by `apps/docs/lib/locale-link.ts:20-22` and `apps/docs/lib/locale-link.test.ts:33-37`). Also: do not wrap in a Fumadocs `<Callout>` — Google must see the anchor directly for weight transfer.
8. **Twitter image is a sibling `twitter-image.png` file** in the same segment, NOT relying on `opengraph-image.png` to also emit `twitter:image*`. Root `metadata.twitter` declares only `card` / `site` / `creator` at `apps/web/app/layout.tsx:92-96`, never `images`. Per current Next.js file-convention docs ([opengraph-image](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image), [twitter-image](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/twitter-image)), `opengraph-image.*` only injects `og:image*`; `twitter:image*` requires `twitter-image.*` to exist in the same segment. The PNG bytes are intentionally identical (copy the same 1200×630 file).
9. **OG image is a checked-in static PNG**, not a `.tsx` generator — file-convention auto-injection only; we explicitly defer dynamic OG via `ImageResponse`.
10. **Robots `allow` rule** — current `robots.ts:10` only lists `/`, `/about`, `/changelog`. Without an explicit allow for `/usecases`, default behaviour is still "not disallowed → crawlable", but to mirror existing style we extend the allow list.
If Reviewer disagrees with any of (1)(10), block before code starts.
---
## Task 1 — Reserve `usecases` slug (Item 8, do first to lock URL space)
**Files:**
- Modify: `server/internal/handler/reserved_slugs.json`
- Regenerate: `packages/core/paths/reserved-slugs.ts`
- Verify: `packages/core/paths/consistency.test.ts`
- [ ] **Step 1.1 — Add `usecases` to the JSON source of truth**
Open `server/internal/handler/reserved_slugs.json` and, inside the group labeled `"Platform / marketing routes (current + likely-future)"`, insert `"usecases"` (alphabetised against the existing `download` / `pricing` neighbours, but exact ordering is cosmetic — generator is order-preserving). Do NOT add singular `"usecase"`. Do NOT touch any other group.
Expected diff fragment:
```json
"changelog",
"docs",
"support",
...
"blog",
"careers",
"press",
"download",
"usecases"
```
- [ ] **Step 1.2 — Regenerate TS twin**
Run from repo root:
```bash
pnpm generate:reserved-slugs
```
Expected: `packages/core/paths/reserved-slugs.ts` now contains the literal `"usecases",` line under the matching group comment. No other lines change.
- [ ] **Step 1.3 — Run paths consistency tests**
Run:
```bash
pnpm --filter @multica/core test paths/consistency.test.ts
```
Expected: all tests in `packages/core/paths/consistency.test.ts` PASS (the suite asserts every global-path prefix is reserved; adding `usecases` does not break invariants because `/usecases/*` is NOT yet a global prefix — paths.ts is untouched).
- [ ] **Step 1.4 — Go-side smoke (embed builds)**
Run:
```bash
cd server && go build ./...
```
Expected: clean build. The embedded JSON is re-parsed at runtime (`server/internal/handler/workspace_reserved_slugs.go:29`); any syntax error in the JSON would panic at init.
- [ ] **Step 1.5 — Commit**
```bash
git add server/internal/handler/reserved_slugs.json packages/core/paths/reserved-slugs.ts
git commit -m "feat(reserved-slugs): reserve 'usecases' for /usecases/* routes"
```
---
## Task 2 — New page skeleton with metadata
**Files:**
- Create: `apps/web/app/(landing)/usecases/squads/page.tsx`
- [ ] **Step 2.1 — Write minimal page with metadata**
Create `apps/web/app/(landing)/usecases/squads/page.tsx`:
```tsx
import type { Metadata } from "next";
// `title.absolute` bypasses the root `template: "%s | Multica"` (apps/web/app/layout.tsx:77-80).
// The dispatched title is exactly 60 characters; allowing the template to append " | Multica"
// would resolve to 70 characters and break SERP truncation. Pattern mirrors apps/web/app/(landing)/page.tsx:5-8.
export const metadata: Metadata = {
title: {
absolute: "Assign issues to AI agent teams: routing with Multica squads",
},
description:
"Stop @-mentioning the wrong agent. Multica squads give you a stable routing target — the leader picks the right specialist for each issue.",
openGraph: {
title: "Assign issues to AI agent teams — Multica squads",
description:
"A squad is a group of agents led by one leader. Assign work to the squad; the leader picks who handles it.",
url: "/usecases/squads",
type: "article",
},
alternates: {
canonical: "/usecases/squads",
},
};
export default function SquadsUsecasePage() {
return (
<main className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-4xl font-semibold tracking-tight">
Assign issues to AI agent teams: routing with Multica squads
</h1>
<p className="mt-6 text-lg text-muted-foreground">
{/* Placeholder copy — real text comes in Stage B (issue MUL-2235 reply thread) */}
</p>
{/* Sections injected in Step 2.2 */}
</main>
);
}
```
Metadata length check (count manually before commit):
- `title.absolute`: "Assign issues to AI agent teams: routing with Multica squads" — 60 chars ✅ (the `absolute` form skips the root `template`, so the resolved `<title>` is also 60)
- `description`: ≤155 chars ✅ (verify by `node -e 'console.log("...".length)'` with the exact final string)
- [ ] **Step 2.2 — Add real-content placeholder body (200300 words, indexable)**
Inside the `<main>` body, add five sections matching the structure from SpecOwner `9ca8312d` / `ae0e91bc`. The copy below is the placeholder; it is real prose (Google won't treat it as thin content) and gets replaced in Stage B when the user delivers final text.
```tsx
<section className="mt-10 space-y-4">
<h2 className="text-2xl font-semibold">The problem</h2>
<p>
As your AI team grows from one agent to ten, every new specialist breaks
your assignment habits. You @-mention `@backend-bot` for a frontend bug
because you forgot `@frontend-bot` joined last week. The work stalls in
the wrong queue.
</p>
</section>
<section className="mt-10 space-y-4">
<h2 className="text-2xl font-semibold">Squads route issues for you</h2>
<p>
A squad is a named group of agents with one designated <strong>leader
agent</strong>. Assign an issue to the squad not to a specific
member and the leader reads the issue, decides who fits best, and
@-mentions them. Your routing target stays stable as the roster changes.
</p>
</section>
<section className="mt-10 space-y-4">
<h2 className="text-2xl font-semibold">When to reach for a squad</h2>
<ul className="list-disc space-y-2 pl-6">
<li>Several specialists, unclear which one fits a given issue.</li>
<li>You want one stable assignee while the actual responder changes.</li>
<li>You want a <code>@FrontendTeam</code>-style routing target in comments.</li>
</ul>
</section>
<section className="mt-10 space-y-4">
<h2 className="text-2xl font-semibold">Squad vs. single agent vs. autopilot</h2>
<p>
A single <strong>agent</strong> is one specialist with one inbox.
An <strong>autopilot</strong> is a scheduled or triggered automation that
runs an agent on a cadence. A <strong>squad</strong> sits on top of
agents and adds <em>routing</em> it never executes work itself; the
leader picks who does.
</p>
</section>
<section className="mt-10 space-y-4">
<h2 className="text-2xl font-semibold">Frequently asked questions</h2>
<div className="space-y-6">
<div>
<h3 className="font-semibold">What is a Multica squad?</h3>
<p>
A squad is a named group of agents (and optionally human members) led
by one leader agent. Assigning an issue to the squad lets the leader
route it to the right member.
</p>
</div>
<div>
<h3 className="font-semibold">How is a squad different from a single agent?</h3>
<p>
A single agent does the work; a squad routes work. The squad never
executes its leader picks which member responds.
</p>
</div>
</div>
</section>
<section className="mt-10">
<a
href="/docs/squads"
className="inline-flex rounded-md bg-primary px-6 py-3 text-primary-foreground"
>
Read the squads docs
</a>
</section>
```
**Why a visible FAQ section is required:** Google's `FAQPage` policy requires the JSON-LD Q/A text to also be human-visible on the page; mismatched or hidden FAQ content is a structured-data violation (see [FAQPage guidelines](https://developers.google.com/search/docs/appearance/structured-data/faqpage)). The Q/A strings here must match the JSON-LD strings in Step 2.3 byte-for-byte.
- [ ] **Step 2.3 — Add inline `Article` + `FAQPage` JSON-LD**
Above the `<main>` return, embed page-level JSON-LD via a `<script>` tag (mirror the existing layout pattern at `apps/web/app/(landing)/layout.tsx:65-70`). **Do NOT include `SoftwareApplication` — the layout already does.**
```tsx
const articleJsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
headline: "Assign issues to AI agent teams: routing with Multica squads",
datePublished: "2026-05-15",
dateModified: "2026-05-15", // bump on Stage B real-content swap
author: { "@type": "Organization", name: "Multica" },
publisher: { "@type": "Organization", name: "Multica" },
mainEntityOfPage: "https://www.multica.ai/usecases/squads",
},
{
"@type": "FAQPage",
mainEntity: [
{
"@type": "Question",
name: "What is a Multica squad?",
acceptedAnswer: {
"@type": "Answer",
text: "A squad is a named group of agents (and optionally human members) led by one leader agent. Assigning an issue to the squad lets the leader route it to the right member.",
},
},
{
"@type": "Question",
name: "How is a squad different from a single agent?",
acceptedAnswer: {
"@type": "Answer",
text: "A single agent does the work; a squad routes work. The squad never executes — its leader picks which member responds.",
},
},
],
},
],
};
// inside the component, before <main>:
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
/>
<main className="...">...</main>
</>
);
```
- [ ] **Step 2.4 — Typecheck + lint**
```bash
pnpm --filter @multica/web typecheck
pnpm --filter @multica/web lint apps/web/app/\(landing\)/usecases/
```
Expected: zero errors. Fix any before continuing.
- [ ] **Step 2.5 — Commit**
```bash
git add apps/web/app/\(landing\)/usecases/squads/page.tsx
git commit -m "feat(web): add /usecases/squads landing page skeleton"
```
---
## Task 3 — OG + Twitter image placeholders
**Files:**
- Create: `apps/web/app/(landing)/usecases/squads/opengraph-image.png` (binary, 1200×630)
- Create: `apps/web/app/(landing)/usecases/squads/twitter-image.png` (binary, identical bytes — copy of `opengraph-image.png`)
**Why two files, not one:** Root `apps/web/app/layout.tsx:92-96` only sets `twitter.card`/`site`/`creator`, never `twitter.images`. Per current Next.js file-convention docs, `opengraph-image.*` emits `og:image*` only; `twitter:image*` requires a sibling `twitter-image.*` in the same segment. Without the second file, Twitter/X falls back to the default summary card instead of the large image card we want.
- [ ] **Step 3.1 — Generate the placeholder PNG**
Until the Excalidraw "squad routing" diagram lands, produce a minimal valid 1200×630 PNG: white background, centered Multica logo (`docs/assets/logo-light.svg` rasterised to ~200px), tagline "Assign issues to AI agent teams" in 48px sans-serif, footer URL "multica.ai/usecases/squads" in 24px.
Options for generation (pick whichever is fastest locally — no Implementer time on design):
- Figma export → PNG
- Excalidraw → PNG
- `sharp` script in `scripts/` (only if hand-tooling is too slow; do NOT add a runtime dependency)
Save to `apps/web/app/(landing)/usecases/squads/opengraph-image.png`. Verify dimensions:
```bash
file apps/web/app/\(landing\)/usecases/squads/opengraph-image.png
```
Expected: `PNG image data, 1200 x 630, ...`
- [ ] **Step 3.2 — Copy the same bytes to `twitter-image.png`**
```bash
cp apps/web/app/\(landing\)/usecases/squads/opengraph-image.png \
apps/web/app/\(landing\)/usecases/squads/twitter-image.png
file apps/web/app/\(landing\)/usecases/squads/twitter-image.png
```
Expected: identical `PNG image data, 1200 x 630, ...`. Keeping the bytes byte-identical means a future swap of the OG art only needs to update one source and re-copy.
- [ ] **Step 3.3 — Verify Next.js auto-injection (manual, dual check)**
Start dev server and curl the page meta — both channels must be present:
```bash
pnpm --filter @multica/web dev &
# wait for port 3000
curl -s http://localhost:3000/usecases/squads | grep -E 'property="og:image"|name="twitter:image"'
```
Expected output must contain BOTH lines:
- `<meta property="og:image" content="…/opengraph-image…">`
- `<meta name="twitter:image" content="…/twitter-image…">`
If `twitter:image` is missing, `twitter-image.png` is not in the segment, or its name is wrong. No code change needed in `page.tsx` — Next.js wires both from the filenames alone.
- [ ] **Step 3.4 — Commit**
```bash
git add apps/web/app/\(landing\)/usecases/squads/opengraph-image.png \
apps/web/app/\(landing\)/usecases/squads/twitter-image.png
git commit -m "feat(web): add OG + Twitter image placeholders for /usecases/squads"
```
---
## Task 4 — Sitemap entry
**Files:**
- Modify: `apps/web/app/sitemap.ts:1-26`
- [ ] **Step 4.1 — Write the failing test (TDD red)**
Create `apps/web/app/sitemap.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import sitemap from "./sitemap";
describe("sitemap", () => {
it("includes /usecases/squads with priority 0.8", () => {
const entries = sitemap();
const entry = entries.find((e) => e.url.endsWith("/usecases/squads"));
expect(entry).toBeDefined();
expect(entry?.priority).toBe(0.8);
expect(entry?.changeFrequency).toBe("weekly");
});
});
```
Run:
```bash
pnpm --filter @multica/web test sitemap.test.ts
```
Expected: FAIL — entry not found.
- [ ] **Step 4.2 — Add the entry (TDD green)**
Edit `apps/web/app/sitemap.ts`, add a fourth element to the returned array:
```ts
{
url: `${baseUrl}/usecases/squads`,
lastModified: new Date("2026-05-15"),
changeFrequency: "weekly",
priority: 0.8,
},
```
- [ ] **Step 4.3 — Run test (verify green)**
```bash
pnpm --filter @multica/web test sitemap.test.ts
```
Expected: PASS.
- [ ] **Step 4.4 — Commit**
```bash
git add apps/web/app/sitemap.ts apps/web/app/sitemap.test.ts
git commit -m "feat(web): add /usecases/squads to sitemap"
```
---
## Task 5 — robots.txt allow
**Files:**
- Modify: `apps/web/app/robots.ts:10`
- [ ] **Step 5.1 — Add `/usecases` to allow list**
Change line 10 from:
```ts
allow: ["/", "/about", "/changelog"],
```
to:
```ts
allow: ["/", "/about", "/changelog", "/usecases"],
```
(Using `/usecases` as a prefix covers `/usecases/squads` and any future sibling pages.)
- [ ] **Step 5.2 — Smoke (curl in dev)**
```bash
curl -s http://localhost:3000/robots.txt | grep -i usecases
```
Expected: a line `Allow: /usecases` present.
- [ ] **Step 5.3 — Commit**
```bash
git add apps/web/app/robots.ts
git commit -m "feat(web): allow /usecases in robots.txt"
```
---
## Task 6 — Docs reverse-link
**Files:**
- Modify: `apps/docs/content/docs/squads.mdx:9` (immediately after the frontmatter + Callout import)
- [ ] **Step 6.1 — Insert a top-of-body reverse-link line**
Edit `apps/docs/content/docs/squads.mdx`. After the `import { Callout } ...` line and before the first paragraph (currently line 9, beginning `A squad is a **named group...**`), insert:
```mdx
> Want the use case in 60 seconds? See **[Assign issues to AI agent teams →](https://www.multica.ai/usecases/squads)**.
```
Use a Markdown blockquote (`>`), NOT a `<Callout>` component — Google's crawler sees the anchor directly.
**Absolute URL is mandatory, not optional.** The docs Next.js app is mounted at `basePath: "/docs"` (`apps/docs/next.config.mjs:8`); MDX `a` elements are rewritten by `LocaleLink` (`apps/docs/app/[lang]/[...slug]/page.tsx:36-38`), and a root-relative href like `/usecases/squads` risks being resolved against the docs origin and rendered as `/docs/usecases/squads`. Absolute external URLs (`https://...`) are preserved verbatim by `LocaleLink` — see `apps/docs/lib/locale-link.ts:20-22` and the test at `apps/docs/lib/locale-link.test.ts:33-37`.
Do **not** introduce a non-prod URL here (no `localhost`, no preview-deploy domain). The link must point at the canonical production origin so it transfers SEO weight regardless of where the docs are served from.
- [ ] **Step 6.2 — Docs typecheck/build smoke**
```bash
pnpm --filter @multica/docs build
```
Expected: build succeeds, `/docs/squads` page renders the new blockquote.
- [ ] **Step 6.3 — Commit**
```bash
git add apps/docs/content/docs/squads.mdx
git commit -m "docs(squads): reverse-link to /usecases/squads"
```
---
## Task 7 — Landing features-section entry
**Files:**
- Modify: `apps/web/features/landing/components/features-section.tsx` (location: pick the existing "squads"-themed card if one exists; otherwise add a single inline link in the closing CTA paragraph — do NOT restructure layout)
- [ ] **Step 7.1 — Find the existing squad mention**
```bash
grep -n -i "squad" apps/web/features/landing/components/features-section.tsx
```
If a squad-themed card already exists, attach the `<Link href="/usecases/squads">Learn more →</Link>` to it. If not, find the section that lists features and add one inline link line. **Do not** create a new card or new section — that's scope creep.
- [ ] **Step 7.2 — Add the link**
Pattern (adapt to the file's existing JSX style):
```tsx
import Link from "next/link";
// ...inside the relevant card/section
<Link
href="/usecases/squads"
className="text-sm font-medium text-primary hover:underline"
>
See how squads route issues
</Link>
```
- [ ] **Step 7.3 — Visual smoke (manual)**
```bash
pnpm --filter @multica/web dev
# open http://localhost:3000 and confirm the link is visible + clickable
```
- [ ] **Step 7.4 — Commit**
```bash
git add apps/web/features/landing/components/features-section.tsx
git commit -m "feat(web): link landing features-section to /usecases/squads"
```
---
## Task 8 — Page-level SEO invariant test
**Files:**
- Create: `apps/web/app/(landing)/usecases/squads/page.test.tsx`
**Scope (per plan-review item 2, 7, 8, and structured-data policies):** this test fails closed on every concrete SEO regression the dispatch listed. It covers four assertion groups:
1. **Resolved metadata** — title (incl. how the root `template` would interact), description, canonical.
2. **Article JSON-LD** — headline / Organization author / publisher / datePublished / dateModified / mainEntityOfPage (canonical URL).
3. **FAQPage JSON-LD + visible body parity** — mainEntity present; each Question has `name` + `acceptedAnswer.text`; every Q/A string must also appear in the rendered DOM (byte-for-byte).
4. **Image assets**`opengraph-image.png` and `twitter-image.png` both exist on disk and parse as PNG with IHDR width=1200, height=630.
The IHDR parser is inline (no new dependency). PNG layout: 8-byte signature, then chunks; the first chunk is always `IHDR`, at byte offset 16: width = uint32 BE, height = uint32 BE. Spec: [PNG IHDR](https://www.w3.org/TR/png/#11IHDR).
- [ ] **Step 8.1 — Write the assertion file**
```tsx
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import Page, { metadata } from "./page";
// Helper: read the resolved <title> string regardless of whether
// metadata.title is a string or { absolute } / { default, template }.
function resolveTitle(m: typeof metadata): string {
const t = m.title;
if (typeof t === "string") return t;
if (t && typeof t === "object" && "absolute" in t && typeof t.absolute === "string") {
return t.absolute;
}
if (t && typeof t === "object" && "default" in t && typeof t.default === "string") {
return t.default;
}
return "";
}
// Helper: parse PNG IHDR (width/height) from on-disk bytes without a dependency.
function readPngSize(absPath: string): { width: number; height: number } {
const buf = readFileSync(absPath);
// PNG signature is 8 bytes, then 4 bytes length + 4 bytes "IHDR" + 13 bytes data.
// Width / height live at offsets 16..19 / 20..23, big-endian uint32.
expect(buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))).toBe(true);
expect(buf.subarray(12, 16).toString("ascii")).toBe("IHDR");
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
// Helper: pull every JSON-LD script body the page renders.
function extractJsonLd(container: HTMLElement): unknown[] {
return Array.from(container.querySelectorAll('script[type="application/ld+json"]'))
.map((el) => JSON.parse(el.textContent ?? ""));
}
describe("/usecases/squads — metadata", () => {
it("resolved title is ≤60 chars (template is bypassed via title.absolute)", () => {
const t = metadata.title;
// Must use the `absolute` form so root template "%s | Multica" does not apply.
expect(typeof t === "object" && t !== null && "absolute" in t).toBe(true);
expect(resolveTitle(metadata).length).toBeGreaterThan(0);
expect(resolveTitle(metadata).length).toBeLessThanOrEqual(60);
});
it("description is ≤155 chars", () => {
expect(metadata.description?.length ?? 0).toBeGreaterThan(0);
expect(metadata.description?.length ?? 0).toBeLessThanOrEqual(155);
});
it("canonical is the absolute path /usecases/squads", () => {
expect(metadata.alternates?.canonical).toBe("/usecases/squads");
});
});
describe("/usecases/squads — JSON-LD", () => {
const { container } = render(<Page />);
const graphs = extractJsonLd(container);
// Flatten any @graph wrappers into one array of entities.
const entities = graphs.flatMap((g: any) => (g["@graph"] ? g["@graph"] : [g]));
it("contains exactly one Article and one FAQPage; no SoftwareApplication (layout owns that)", () => {
expect(entities.filter((e) => e["@type"] === "Article")).toHaveLength(1);
expect(entities.filter((e) => e["@type"] === "FAQPage")).toHaveLength(1);
expect(entities.filter((e) => e["@type"] === "SoftwareApplication")).toHaveLength(0);
});
it("Article carries headline, Organization author/publisher, both dates, and canonical mainEntityOfPage", () => {
const article = entities.find((e) => e["@type"] === "Article");
expect(article.headline).toBe("Assign issues to AI agent teams: routing with Multica squads");
expect(article.author?.["@type"]).toBe("Organization");
expect(article.author?.name).toBe("Multica");
expect(article.publisher?.["@type"]).toBe("Organization");
expect(article.publisher?.name).toBe("Multica");
expect(article.datePublished).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(article.dateModified).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(article.mainEntityOfPage).toBe("https://www.multica.ai/usecases/squads");
});
it("FAQPage has mainEntity questions with name + acceptedAnswer.text, all visible in body", () => {
const faq = entities.find((e) => e["@type"] === "FAQPage");
expect(Array.isArray(faq.mainEntity)).toBe(true);
expect(faq.mainEntity.length).toBeGreaterThanOrEqual(1);
const bodyText = container.textContent ?? "";
for (const q of faq.mainEntity) {
expect(q["@type"]).toBe("Question");
expect(typeof q.name).toBe("string");
expect(q.name.length).toBeGreaterThan(0);
expect(q.acceptedAnswer?.["@type"]).toBe("Answer");
expect(typeof q.acceptedAnswer.text).toBe("string");
expect(q.acceptedAnswer.text.length).toBeGreaterThan(0);
// Visible-on-page invariant per Google's FAQPage policy.
expect(bodyText).toContain(q.name);
expect(bodyText).toContain(q.acceptedAnswer.text);
}
});
});
describe("/usecases/squads — image assets", () => {
const seg = join(process.cwd(), "app", "(landing)", "usecases", "squads");
const og = join(seg, "opengraph-image.png");
const tw = join(seg, "twitter-image.png");
it("both OG and Twitter PNGs exist on disk", () => {
expect(existsSync(og)).toBe(true);
expect(existsSync(tw)).toBe(true);
});
it("OG PNG is 1200×630", () => {
expect(readPngSize(og)).toEqual({ width: 1200, height: 630 });
});
it("Twitter PNG is 1200×630", () => {
expect(readPngSize(tw)).toEqual({ width: 1200, height: 630 });
});
});
```
**Notes:**
- `process.cwd()` inside `pnpm --filter @multica/web` test runs is `apps/web`, so the `seg` join above resolves correctly. If vitest config moves cwd, switch to `new URL("./opengraph-image.png", import.meta.url)` and `fileURLToPath`.
- Rendering `<Page />` requires the page to be a synchronous Server Component (no `async`/no data fetching). The skeleton in Task 2 is synchronous; if a later change makes it async, the test must switch to a server-component testing wrapper instead.
- [ ] **Step 8.2 — Run**
```bash
pnpm --filter @multica/web test app/\(landing\)/usecases/squads/page.test.tsx
```
Expected: all assertion groups PASS.
- [ ] **Step 8.3 — Commit**
```bash
git add apps/web/app/\(landing\)/usecases/squads/page.test.tsx
git commit -m "test(web): SEO invariants for /usecases/squads"
```
---
## Final verification (before requesting code-review)
- [ ] **Full TS test pipeline**
```bash
pnpm test
```
Expected: all suites PASS, no new failures.
- [ ] **Full `make check`**
```bash
make check
```
Expected: typecheck + TS tests + Go tests + Playwright E2E green. The Go side covers reserved-slugs JSON parse correctness; CI runs `pnpm generate:reserved-slugs` and `git diff --exit-code`s — local pre-flight catches drift.
- [ ] **Live SEO sanity (dev server)**
```bash
pnpm --filter @multica/web dev
# in another shell
curl -s http://localhost:3000/usecases/squads | \
grep -E '<title>|name="description"|canonical|property="og:image"|name="twitter:image"|application/ld\+json'
curl -s http://localhost:3000/sitemap.xml | grep usecases
curl -s http://localhost:3000/robots.txt | grep -i usecases
```
Expected:
- `<title>` is exactly "Assign issues to AI agent teams: routing with Multica squads" (60 chars, no `| Multica` suffix — `title.absolute` bypassed the template).
- `description` and `canonical` present.
- BOTH `og:image` (→ `…/opengraph-image…`) AND `twitter:image` (→ `…/twitter-image…`) emitted; absence of either means the corresponding PNG is missing from the segment.
- Sitemap entry visible; robots `Allow: /usecases` present.
- Two `application/ld+json` blocks total: one from layout (Org + SoftwareApplication), one from the page (Article + FAQPage).
- [ ] **Workspace-creation rejection smoke (Go)**
```bash
cd server && go test ./internal/handler/... -run Reserved -v
```
Expected: existing reserved-slug tests continue to PASS. Optional bonus: add a single test case asserting `isReservedSlug("usecases") == true` if no equivalent already exists.
- [ ] **Open PR** with title `feat(web): /usecases/squads SEO skeleton (Stage A)` and body listing the 8 items + the explicit non-goals from this plan's header. Tag `@Squirtle-Reviewer` for code-review.
---
## Self-review checklist
- ✅ All 8 dispatch items have a task (1=Task 1, 2/3=Task 2, 4=Task 4, 5=Task 6, 6=Task 7, 7=Task 3, 8=Task 1; plus an SEO test in Task 8 + verification gate).
- ✅ No `SoftwareApplication` re-declaration (handled by Step 2.3 explicit note + plan-review checklist item 1 + asserted in Task 8 JSON-LD test).
-`title.absolute` bypasses root `template` so resolved `<title>` stays ≤60 chars (plan-review item 2, Task 8 test asserts the `absolute` form is used).
- ✅ Twitter card image is a sibling `twitter-image.png` (plan-review item 8); both channels asserted in dev-server smoke and in Task 8 test.
- ✅ Docs reverse-link uses the production absolute URL — root-relative would risk `basePath: "/docs"` injection (plan-review item 7, Task 6 Step 6.1 hardening).
- ✅ Page-level SEO test covers metadata + Article shape (incl. dateModified) + FAQPage shape + visible Q/A parity + PNG IHDR-parsed dimensions (Reviewer feedback `2c453851` point 4).
- ✅ No placeholders / TBDs / "implement later".
- ✅ Reserved-slugs change goes via JSON-only edit + generator, matching the documented contract (`scripts/generate-reserved-slugs.mjs:5-6`, `server/internal/handler/workspace_reserved_slugs.go:14-16`).
- ✅ Explicit non-goals listed (zh, hreflang, full-site OG, dynamic OG, MDX, README, sitemap clean-up, more usecases).
- ✅ Order is deliberate: reserve slug first (Task 1) so no workspace can be created with `usecases` during the rest of the work; OG/Twitter image pair (Task 3) lands before live smoke; sitemap/robots/docs/landing are commutative.

View File

@@ -61,6 +61,7 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"careers",
"press",
"download",
"usecases",
// Account / billing (likely-future global routes in the avatar menu)
"profile",

View File

@@ -48,7 +48,8 @@
"blog",
"careers",
"press",
"download"
"download",
"usecases"
]
},
{