diff --git a/packages/extension/src/amicode_service/widgets.ts b/packages/extension/src/amicode_service/widgets.ts
index 650b263e..685c5560 100644
Binary files a/packages/extension/src/amicode_service/widgets.ts and b/packages/extension/src/amicode_service/widgets.ts differ
diff --git a/packages/extension/src/amicode_service/widgets_src/campaign-digest.ts b/packages/extension/src/amicode_service/widgets_src/campaign-digest.ts
new file mode 100644
index 00000000..73b0c349
--- /dev/null
+++ b/packages/extension/src/amicode_service/widgets_src/campaign-digest.ts
@@ -0,0 +1,152 @@
+// AMICODE built-in widget: CAMPAIGN DIGEST — the home-dashboard digest over
+// the campaign routes (#678, data contract #658/#662): GET /amicode/campaigns
+// (newest-first list) + GET /amicode/campaign?slug= (the ledger's parsed
+// sections). The tile renders the active campaign's one-line objective, the
+// newest verdict-table entries as compact chips, and the §4 blocked section
+// as the needs-you line. Mechanical projection only — the tile renders what
+// the routes return, no ledger-markdown parsing beyond display compression
+// of the status cells. Any fetch failure or a campaign-less ledger dir
+// renders nothing (height-0 empty-state, the jump-back-in discipline).
+
+export const manifestToml = `
+id = "campaign-digest"
+name = "Campaign digest"
+version = "1.0.0"
+description = "Live digest of your active research campaign — objective, verdicts, needs-you"
+size = "tile"
+height = 150
+`
+
+export const widgetJs = `
+export default {
+ mount: function (el, amico) {
+ var esc = function (s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ }
+ // Verdict chips: the §2 table's data rows (row 0 is the header per the
+ // route contract). A chip is the first cell (the slice/unit) plus the
+ // status cell's leading token — display compression of the raw cell,
+ // not parsing: ledgers write '**DONE** — PR #17 merged …' and the chip
+ // shows 'DONE'.
+ var MAX_VERDICT_CHIPS = 3
+ var showEmpty = function () {
+ el.innerHTML = ''
+ }
+ var statusToken = function (cell) {
+ return String(cell || '')
+ .split('**').join('')
+ .split('\\u2014')[0]
+ .split('\\u2013')[0]
+ .trim()
+ .split(/\\s+/)
+ .slice(0, 2)
+ .join(' ')
+ }
+ var chipTone = function (cell) {
+ var s = String(cell || '').toUpperCase()
+ if (s.indexOf('DONE') >= 0 || s.indexOf('MERGED') >= 0 || s.indexOf('PASS') >= 0) return 'var(--amc-success)'
+ if (s.indexOf('BLOCK') >= 0 || s.indexOf('FAIL') >= 0 || s.indexOf('STUCK') >= 0) return 'var(--amc-danger)'
+ return 'var(--amc-accent)'
+ }
+ // Pick the newest campaign whose status is ACTIVE (case-insensitive —
+ // ledgers write 'ACTIVE' in the frontmatter), falling back to the newest
+ // overall: a finished campaign still deserves a tile until the next one
+ // starts. The list is newest-first per the route contract, so the first
+ // match wins.
+ var pickCampaign = function (campaigns) {
+ if (!campaigns || campaigns.length === 0) return null
+ for (var i = 0; i < campaigns.length; i++) {
+ var status = String((campaigns[i] && campaigns[i].status) || '').toLowerCase()
+ if (status === 'active') return campaigns[i]
+ }
+ return campaigns[0]
+ }
+ // First non-empty line of a section body, bullet + bold markers
+ // stripped (the same mechanical projection the list route applies to
+ // the objective line).
+ var firstLine = function (text) {
+ var lines = String(text || '').split('\\n')
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i].trim().replace(/^[-*]\\s+/, '').split('**').join('')
+ if (line !== '') return line
+ }
+ return ''
+ }
+ var renderCard = function (entry, detail) {
+ var slug = entry.slug
+ var eyebrow = entry.campaign || 'Campaign digest'
+ var objective = String(entry.objective || '')
+ var blocked = firstLine(detail.blocked)
+ var chips = ''
+ var rows = (detail.verdicts || []).slice(1, 1 + MAX_VERDICT_CHIPS)
+ for (var i = 0; i < rows.length; i++) {
+ var row = rows[i]
+ if (!row || row.length < 2) continue
+ var label = statusToken(row[row.length - 1])
+ if (label === '') continue
+ chips +=
+ '' +
+ esc(row[0]) +
+ ' \\u00b7 ' +
+ esc(label) +
+ ''
+ }
+ el.innerHTML =
+ '
' +
+ '
' +
+ esc(eyebrow) +
+ '
' +
+ (objective !== ''
+ ? '
' +
+ esc(objective) +
+ '
'
+ : '') +
+ (chips !== '' ? '
' + chips + '
' : '') +
+ '
' +
+ (blocked !== '' ? '\\u26a0 needs you \\u2014 ' + esc(blocked) : 'nothing blocked') +
+ '
' +
+ '
'
+ var card = el.querySelector('[data-card]')
+ if (card)
+ card.onclick = function () {
+ amico.prompt('Open the campaign ' + slug)
+ }
+ }
+ var epoch = 0
+ var render = function () {
+ var mine = ++epoch
+ amico
+ .fetch('/amicode/campaigns')
+ .then(function (data) {
+ if (mine !== epoch) return undefined
+ if (!data || data.ok === false) return showEmpty()
+ var picked = pickCampaign(data.campaigns)
+ if (!picked || !picked.slug) return showEmpty()
+ return amico
+ .fetch('/amicode/campaign?slug=' + encodeURIComponent(picked.slug))
+ .then(function (detail) {
+ if (mine !== epoch) return undefined
+ if (!detail || detail.ok === false || !detail.campaign) return showEmpty()
+ renderCard(picked, detail.campaign)
+ })
+ })
+ .catch(function () {
+ if (mine === epoch) showEmpty()
+ })
+ }
+ render()
+ // Re-render on host config/theme pushes (the jump-back-in pattern);
+ // the epoch guard drops a stale fetch's late write.
+ amico.onConfig(render)
+ amico.onTheme(render)
+ },
+}
+`
diff --git a/packages/extension/test/amicode_service_contract.test.ts b/packages/extension/test/amicode_service_contract.test.ts
index 48f010a8..ad92caa9 100644
--- a/packages/extension/test/amicode_service_contract.test.ts
+++ b/packages/extension/test/amicode_service_contract.test.ts
@@ -124,6 +124,30 @@ describe("amicode service — golden-fixture parity with the fork", () => {
return obj;
};
+ /** amicode#678: the port ships a campaign-digest BUILT-IN widget the fork
+ * pin predates (recorded at v1.18.10-amicode.18 with jump-back-in only).
+ * Dropped from BOTH sides so parity stays comparable across the next pin
+ * bump; the widget itself is unit-tested in widgets_registry.test.ts.
+ * Touches the three routes that project the registry: /amicode/widgets
+ * (top-level `widgets`) and the dashboard GET/POST (the merge appends
+ * every built-in as a default tile under `dashboard.widget`). */
+ const normalizeBuiltinWidgetDrift = (obj: any): any => {
+ if (obj && typeof obj === "object") {
+ const out = { ...obj };
+ if (Array.isArray(out.widgets)) {
+ out.widgets = out.widgets.filter((w: any) => !(w && w.id === "campaign-digest"));
+ }
+ if (out.dashboard && typeof out.dashboard === "object" && Array.isArray(out.dashboard.widget)) {
+ out.dashboard = {
+ ...out.dashboard,
+ widget: out.dashboard.widget.filter((w: any) => !(w && w.id === "campaign-digest")),
+ };
+ }
+ return out;
+ }
+ return obj;
+ };
+
const normalizeWallClock = (obj: any): any => {
if (
obj &&
@@ -206,7 +230,9 @@ describe("amicode service — golden-fixture parity with the fork", () => {
// JSON routes: deep-equal on parsed bodies (key order is the port's
// business; structure and values are the contract).
const canon = (o: any, seededAt: number) =>
- normalizePortExtensions(normalizePostPinDrift(normalizeListOrder(normalizeWallClock(normalizeFreshTimestamps(o, seededAt)))));
+ normalizeBuiltinWidgetDrift(
+ normalizePortExtensions(normalizePostPinDrift(normalizeListOrder(normalizeWallClock(normalizeFreshTimestamps(o, seededAt))))),
+ );
expect(canon(JSON.parse(received), testSeededAt)).toEqual(canon(JSON.parse(expected), meta.seededAt));
} else {
// Non-JSON routes (the served widget frame): byte-exact after sandbox
diff --git a/packages/extension/test/widgets_registry.test.ts b/packages/extension/test/widgets_registry.test.ts
new file mode 100644
index 00000000..a7662a49
--- /dev/null
+++ b/packages/extension/test/widgets_registry.test.ts
@@ -0,0 +1,124 @@
+// AMICODE (issue #678): the campaign-digest built-in widget — registration
+// and route-contract pins. The widget's JS is a string-rendered module (the
+// jump-back-in pattern), so behavioral assertions are string-level on the
+// registry entry + the route bodies; the iframe itself is exercised by the
+// runtime's own contract, not here.
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { loadRegistry, widgetsResponse, widgetCodeResponse } from "../src/amicode_service/widgets";
+import { manifestToml, widgetJs } from "../src/amicode_service/widgets_src/campaign-digest";
+
+const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
+
+describe("campaign-digest built-in widget (issue #678)", () => {
+ let savedWidgetsDir: string | undefined;
+ let userDir: string;
+
+ beforeAll(() => {
+ // Hermetic registry: point the user-widgets root at an empty temp dir so
+ // host state (~/.amico/widgets) can't leak entries into the assertions.
+ savedWidgetsDir = process.env.AMICODE_WIDGETS_DIR;
+ userDir = mkdtempSync(join(tmpdir(), "amico-widgets-"));
+ process.env.AMICODE_WIDGETS_DIR = userDir;
+ });
+ afterAll(() => {
+ if (savedWidgetsDir === undefined) delete process.env.AMICODE_WIDGETS_DIR;
+ else process.env.AMICODE_WIDGETS_DIR = savedWidgetsDir;
+ rmSync(userDir, { recursive: true, force: true });
+ });
+
+ // AC1 — registration, manifest validity, default tile order
+ it("registers as a built-in after jump-back-in, with a valid tile manifest", () => {
+ const { widgets, warnings } = loadRegistry();
+ const jump = widgets.findIndex((w) => w.manifest.id === "jump-back-in");
+ const digest = widgets.findIndex((w) => w.manifest.id === "campaign-digest");
+ expect(jump).toBeGreaterThanOrEqual(0);
+ expect(digest).toBeGreaterThan(jump);
+ const entry = widgets[digest];
+ expect(entry.builtin).toBe(true);
+ expect(entry.manifest.size).toBe("tile");
+ expect(KEBAB.test(entry.manifest.id)).toBe(true);
+ expect(warnings.some((w) => w.id === "campaign-digest")).toBe(false);
+ });
+
+ it("manifest TOML carries the digest identity (parses into the registry)", () => {
+ expect(manifestToml).toContain('id = "campaign-digest"');
+ expect(manifestToml).toContain('size = "tile"');
+ expect(manifestToml).toMatch(/height = \d+/);
+ });
+
+ // AC2 — the route contract, string-level on the widget source
+ it("fetches the campaigns list then the campaign detail for the picked slug", () => {
+ expect(widgetJs).toContain("'/amicode/campaigns'");
+ expect(widgetJs).toContain("'/amicode/campaign?slug='");
+ expect(widgetJs).toContain("encodeURIComponent(");
+ });
+
+ it("picks the newest ACTIVE campaign with a newest-overall fallback", () => {
+ expect(widgetJs).toContain("pickCampaign");
+ expect(widgetJs).toContain("'active'"); // the ACTIVE status match
+ });
+
+ it("renders up to 3 verdict chips from the detail's verdict rows", () => {
+ expect(widgetJs).toContain("MAX_VERDICT_CHIPS = 3");
+ expect(widgetJs).toContain("verdicts");
+ });
+
+ it("renders the needs-you line from the blocked section", () => {
+ expect(widgetJs).toContain("needs you");
+ expect(widgetJs).toContain("nothing blocked");
+ });
+
+ it("degrades to the empty state when there are no campaigns", () => {
+ expect(widgetJs).toContain("showEmpty");
+ expect(widgetJs).toContain("el.innerHTML = ''");
+ });
+
+ // AC3 — fetch failure → empty state, never an error dump
+ it("catches fetch failures into the empty state", () => {
+ expect(widgetJs).toContain(".catch(");
+ // the catch path and the no-campaign path share the same empty state
+ expect(widgetJs).toContain("showEmpty");
+ });
+
+ // AC4 — click composes into chat with the slug
+ it("clicks through to amico.prompt with the campaign slug", () => {
+ expect(widgetJs).toContain("amico.prompt(");
+ expect(widgetJs).toContain("'Open the campaign '");
+ });
+
+ // AC5 — esc discipline + theme tokens only
+ it("escapes all interpolated HTML and themes via --amc-* custom properties only", () => {
+ expect(widgetJs).toContain("esc(");
+ expect(widgetJs).toContain("&");
+ expect(widgetJs).toContain("<");
+ expect(widgetJs).toContain(">");
+ expect(widgetJs).toContain(""");
+ expect(widgetJs).toContain("var(--amc-");
+ expect(widgetJs).not.toMatch(/#[0-9a-fA-F]{3,8}\b/); // no raw hex colors
+ expect(widgetJs).not.toContain("rgb(");
+ expect(widgetJs).not.toContain("localStorage"); // opaque origin — it throws
+ });
+
+ // AC6 — registry/content-hash machinery consistency
+ it("hashes the entry stably across registry loads (idempotence)", () => {
+ const a = loadRegistry().widgets.find((w) => w.manifest.id === "campaign-digest");
+ const b = loadRegistry().widgets.find((w) => w.manifest.id === "campaign-digest");
+ expect(a).toBeDefined();
+ expect(b).toBeDefined();
+ expect(a!.hash).toMatch(/^[0-9a-f]{16}$/);
+ expect(a!.hash).toBe(b!.hash);
+ });
+
+ it("serves the widget through the /amicode/widgets + widget-code route bodies", () => {
+ const list = JSON.parse(widgetsResponse());
+ expect(list.ok).toBe(true);
+ expect(list.widgets.map((w: any) => w.id)).toContain("campaign-digest");
+ const code = JSON.parse(widgetCodeResponse("campaign-digest"));
+ expect(code.ok).toBe(true);
+ expect(code.hash).toMatch(/^[0-9a-f]{16}$/);
+ expect(code.code).toBe(widgetJs); // the served code IS the source string
+ });
+});