mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
9 Commits
v0.3.23
...
agent/squi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8689e1cef0 | ||
|
|
0ce15b2667 | ||
|
|
8eed069868 | ||
|
|
34f2f7e8ce | ||
|
|
ab6845c0c5 | ||
|
|
f995ddca48 | ||
|
|
4867694dcc | ||
|
|
d3dc46c2e1 | ||
|
|
f399530785 |
@@ -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
|
||||
|
||||
BIN
apps/web/app/(landing)/usecases/squads/opengraph-image.png
Normal file
BIN
apps/web/app/(landing)/usecases/squads/opengraph-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
140
apps/web/app/(landing)/usecases/squads/page.test.tsx
Normal file
140
apps/web/app/(landing)/usecases/squads/page.test.tsx
Normal 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 });
|
||||
});
|
||||
});
|
||||
159
apps/web/app/(landing)/usecases/squads/page.tsx
Normal file
159
apps/web/app/(landing)/usecases/squads/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
apps/web/app/(landing)/usecases/squads/twitter-image.png
Normal file
BIN
apps/web/app/(landing)/usecases/squads/twitter-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -7,7 +7,7 @@ export default function robots(): MetadataRoute.Robots {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: ["/", "/about", "/changelog"],
|
||||
allow: ["/", "/about", "/changelog", "/usecases"],
|
||||
disallow: [
|
||||
"/api/",
|
||||
"/ws",
|
||||
|
||||
12
apps/web/app/sitemap.test.ts
Normal file
12
apps/web/app/sitemap.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
761
docs/superpowers/plans/2026-05-15-usecases-squads-skeleton.md
Normal file
761
docs/superpowers/plans/2026-05-15-usecases-squads-skeleton.md
Normal 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, 200–300-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 (200–300 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
"blog",
|
||||
"careers",
|
||||
"press",
|
||||
"download"
|
||||
"download",
|
||||
"usecases"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user