Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
955 changes: 955 additions & 0 deletions docs/superpowers/plans/2026-07-03-mywork-restructure-plan.md

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions docs/superpowers/specs/2026-07-03-mywork-restructure-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
# My Work: reorder, structured cards, retroactive summary resync

Date: 2026-07-03

## Problem

The My Work screen (`web/src/render.ts`, `myWorkView`) shows two sections: "Previous
activity" (merged/closed PRs) above "To-do" (open assigned issues). Two things are off:

1. To-do — the actionable list — renders below the read-only activity history. It
should be on top.
2. Both sections are thin on structure. `todoCard` is a single truncated line with no
room for a full title or an updated-at signal. `prActivityCard`'s body is one
undifferentiated AI-generated prose blob — no labeled fields.

Separately, "Sync GitHub" (the admin-only backfill button) only looks at PRs closed in
the last 14 days, and only (re)generates a PR's summary when its event is *newly*
captured — an already-captured PR's summary is never touched again. That means
existing PRs would stay on the old unstructured prose format forever, even after this
change ships.

## Design

### 1. Reorder (`web/src/render.ts`, `myWorkView`)

Swap section composition from `${hero}${activity}${todo}` to `${hero}${todo}${activity}`.
Section labels ("To-do", "Previous activity") stay as-is.

### 2. To-do card restructure (`todoCard`)

From a single-line truncated row to a two-line card, still one `<a class="cnpy-card">`:

- Row 1: priority badge + `#number` + title. Title wraps up to 2 lines (CSS
line-clamp) instead of single-line ellipsis truncation.
- Row 2: up to 3 labels (cap unchanged, keeps the card bounded) + a right-aligned
relative "updated" timestamp from `t.updatedAt` (captured today, currently unused
in the card).

### 3. Structured PR summaries

**New shared module `shared/prSummary.ts`** — the single source of truth for
recognizing the structured-summary convention, imported by both the Worker and the
web build:

```ts
export interface StructuredPrSummary {
what: string;
why: string | null;
}

export function parseStructuredSummary(raw: string): StructuredPrSummary | null;
```

It matches markdown of the shape:

```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Tag the example fence so markdownlint stays green.

The bare fenced block here triggers MD040. Add a language hint (md/markdown) or convert it to a quoted example so the spec stays lint-clean.

✏️ Suggested fix
-```+```md
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 56-56: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/specs/2026-07-03-mywork-restructure-design.md` around lines
56 - 59, The example fence in the spec body is missing a language tag and is
triggering markdownlint MD040. Update the fenced example near the “What changed”
/ “Why” section to use the existing markdown example block with a language hint
(for example, in the spec template text) or convert it to a quoted example, and
keep the surrounding content in the same structure so the markdown stays
lint-clean.

Source: Linters/SAST tools


Returns `null` when the text doesn't match (old-style prose, the deterministic
excerpt fallback, or a malformed AI response) — callers treat `null` as "render/treat
as plain prose," never as an error.

**Backend (`src/tools/summarize.ts`)** — `workersAiSummarizer`'s system prompt
changes to require exactly that two-field shape (omitting the `**Why:**` line when
the PR body states no rationale), instead of "2-3 short sentences." No signature
change. `excerptSummary` (the deterministic no-AI fallback) is untouched — it keeps
producing plain prose, which is intentional: a parse miss just falls back to today's
rendering, never a broken UI. `pr_summaries.summary` stays a single `TEXT` column; no
migration — the structure lives in the markdown convention, not the schema.

**Frontend (`web/src/render.ts`, `prActivityCard`)** — the summary body now branches
on `parseStructuredSummary(pr.summary)`:
- Matched → two labeled rows ("What changed", and "Why" only when present), each a
small uppercase caption (scaled-down `MW_LABEL` idiom) above its markdown body.
- Not matched → today's single prose block, unchanged.

### 4. Retroactive resync (`src/tools/backfill.ts`, `runBackfill`)

Two independent changes to `runBackfill`:

**a. Full PR history.** Remove the `DAYS_BACK`/cutoff logic on the closed-PR fetch —
paginate through every closed PR the repo has, not just the last 14 days. The open-
issues fetch is untouched (it already has no window).

**b. Decouple summary (re)generation from event-capture outcome.** Today,
`storePrSummary` only runs inside `if (res.outcome === "written")` — i.e., only for
brand-new events. Change: for every PR in the fetched list (regardless of whether its
event was newly captured or already existed), look up its existing `pr_summaries` row
and call `storePrSummary` unless `parseStructuredSummary(existing.summary) !== null`
(i.e., skip only when it's already in the new structured format). `ev.raw` (built
before `ingestEvent` runs) always has the PR's title/body available regardless of the
event's write outcome, so no extra DB read is needed to get the summarizer input —
only one extra read (existing `pr_summaries` row) to decide skip-or-regenerate.

**c. Observability.** `BackfillResult` gains `summarized: number` — incremented each
time `storePrSummary` actually runs (not skipped). Threaded through:
- `src/routes.ts` `/admin/backfill` response (already returns the whole result object,
no route change beyond the type flowing through).
- `web/src/api.ts` `adminBackfill()` return type.
- `web/src/main.ts`'s flash message: `Synced: ${r.captured} captured, ${r.unchanged}
unchanged, ${r.summarized} summaries updated`.
- `web/src/render.ts`'s button title: "Fetch recent GitHub PRs + issues" →
"Fetch all GitHub PRs + issues" (no longer just recent).

**Accepted limitation:** unbounded full-history pagination plus one AI call per
un-migrated PR has no hard cap or resumable cursor. Fine at this project's current
size (a first Sync after this ships does a one-time migration of every existing PR to
the structured format; later clicks are cheap since already-structured PRs are
skipped). Would need a real bound/cursor if the repo's PR history grows a lot —
explicitly not building that now (YAGNI).

## Testing

- `test/render.mywork.test.ts` (pure, no DOM): `todoCard` shows `updatedAt` and
doesn't collapse a long title to one truncated line; `prActivityCard` renders two
labeled rows for a structured summary (with and without a `why`), and falls back to
the existing prose rendering for a non-conforming summary.
- New test file (or colocated in `test/summarize.test.ts`) for
`shared/prSummary.ts`'s `parseStructuredSummary`: matches the two-field shape,
matches "What changed" only (no "Why" line), returns `null` for old-style prose and
for empty/malformed input.
- `test/backfill.test.ts`: the existing "oldPr excluded by the 14-day window"
assertion is now wrong on purpose and must be updated to reflect full-history
fetch. New cases: a PR with an existing non-structured summary gets re-summarized
on a second run (`summarized` increments, `storePrSummary`/summarizer called
again); a PR with an existing structured summary is skipped on a second run
(`summarized` stays 0 for it, summarizer not called again — assert via a
call-counting stub summarizer).
- No new migration, so `test/apply-migrations.ts` is untouched.

## Out of scope

- No change to the live webhook capture path (`src/webhook.ts`) — a real-time merge
event only ever fires once, so the "already captured, skip" question doesn't arise
there.
- No change to issue capture/backfill scope (already unbounded).
- No resumable/bounded backfill cursor for large repos (see accepted limitation
above).
28 changes: 28 additions & 0 deletions shared/prSummary.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
// Recognizes the "What changed / Why" markdown convention emitted by the PR
// summarizer (src/tools/summarize.ts) so both the Worker (the backfill's
// already-structured check, src/tools/backfill.ts) and the web build (card
// rendering, web/src/render.ts) agree on what counts as a structured summary.
// No schema change backs this — pr_summaries.summary stays a single markdown
// TEXT column; the structure lives in this convention, not a stored shape, so
// old prose summaries and the deterministic excerpt fallback degrade
// gracefully to a `null` parse instead of erroring.

export interface StructuredPrSummary {
what: string;
why: string | null;
}

const STRUCTURED_RE = /\*\*What changed:\*\*\s*([\s\S]*?)(?:\s*\*\*Why:\*\*\s*([\s\S]*))?$/i;

/** Parses "**What changed:** ... **Why:** ..." out of a PR summary's markdown.
* Returns null when the text doesn't match (old-style prose, the excerpt
* fallback, or a malformed AI response) — callers treat null as "render/treat
* as plain prose," never as an error. */
export function parseStructuredSummary(raw: string): StructuredPrSummary | null {
const m = raw.match(STRUCTURED_RE);
if (!m) return null;
const what = m[1].trim();
if (!what) return null;
const why = m[2]?.trim() || null;
return { what, why };
}
50 changes: 28 additions & 22 deletions src/tools/backfill.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import type { Env } from "../env";
import { nowIso } from "../db";
import type { PrSummaryRow } from "@shared/rows";
import { first } from "../db";
import { ingestEvent } from "../consumer";
import { eventsFromDelivery } from "../webhook";
import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize";
import { applyEventProgress } from "./progress";
import { parseStructuredSummary } from "@shared/prSummary";

// Admin-triggered server-side GitHub backfill. Unlike scripts/backfill-events.mjs
// (which signs synthetic webhook deliveries with the webhook secret), this runs
Expand All@@ -19,13 +21,13 @@ import { applyEventProgress } from "./progress";

const GH_API = "application/vnd.github+json";
const USER_AGENT = "canopy";
const DAYS_BACK = 14;

export interface BackfillResult {
ok: boolean;
error?: string;
captured: number;
unchanged: number;
summarized: number;
prs: number;
issues: number;
}
Expand DownExpand Up@@ -120,12 +122,12 @@ function issueDelivery(issue: GhIssueListItem) {
export async function runBackfill(
env: Env,
principalLogin: string,
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null; now?: string }
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null }
): Promise<BackfillResult> {
const token = env.GITHUB_SERVICE_TOKEN;
const repo = env.GITHUB_REPO;
if (!token || !repo) {
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, prs: 0, issues: 0 };
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, summarized: 0, prs: 0, issues: 0 };
}

const doFetch = opts?.fetchImpl ?? fetch;
Expand All@@ -136,26 +138,18 @@ export async function runBackfill(
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
};
const cutoffMs = new Date(opts?.now ?? nowIso()).getTime() - DAYS_BACK * 24 * 60 * 60 * 1000;

// (a) Closed PRs updated in the last 14 days. Sorted updated-desc, so we stop
// paginating at the first item whose updated_at predates the cutoff.
// (a) All closed PRs, fully paginated — full history, not just recent
// activity, so a Sync also surfaces PRs merged before this route existed.
const prList: GhPrListItem[] = [];
{
let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`;
let done = false;
while (url && !done) {
while (url) {
const res: Response = await doFetch(url, { headers });
if (!res.ok) break;
const page = (await res.json()) as GhPrListItem[];
for (const pr of page) {
if (pr.updated_at && new Date(pr.updated_at).getTime() < cutoffMs) {
done = true;
break;
}
prList.push(pr);
}
url = done ? null : nextLink(res);
prList.push(...page);
url = nextLink(res);
}
}

Expand All@@ -178,6 +172,7 @@ export async function runBackfill(

let captured = 0;
let unchanged = 0;
let summarized = 0;

for (const pr of prList) {
const payload = prClosedDelivery(pr);
Expand All@@ -186,17 +181,28 @@ export async function runBackfill(
const res = await ingestEvent(env.DB, ev, principalLogin);
if (res.outcome === "written") {
captured++;
// Mirror handleGithubWebhook's summary seam: parse THIS PR's own raw and
// store a capture-time summary (storePrSummary never throws).
} else {
unchanged++;
}

// (Re)summarize unless it's already in the structured format — decoupled
// from the event-capture outcome so a Sync also migrates PRs captured
// before the structured format existed, not just brand-new ones.
const existing = await first<PrSummaryRow>(
env.DB,
`SELECT summary FROM pr_summaries WHERE semantic_key = ?`,
ev.semantic_key
);
const alreadyStructured = existing !== null && parseStructuredSummary(existing.summary) !== null;
if (!alreadyStructured) {
const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } };
await storePrSummary(env.DB, summarizer, {
semantic_key: ev.semantic_key,
pr_number: parsed.pr.number,
title: parsed.pr.title,
body: parsed.pr.body ?? "",
});
} else {
unchanged++;
summarized++;
}
}
}
Expand All@@ -216,5 +222,5 @@ export async function runBackfill(
}
}

return { ok: true, captured, unchanged, prs: prList.length, issues: issueList.length };
return { ok: true, captured, unchanged, summarized, prs: prList.length, issues: issueList.length };
}
17 changes: 12 additions & 5 deletions src/tools/summarize.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,17 @@ export interface Summarizer {

const WORKERS_AI_MODEL = "@cf/meta/llama-3.1-8b-instruct";

// Exported for a content assertion in test/summarize.test.ts — the two-field
// shape here is exactly what shared/prSummary.ts's parseStructuredSummary
// recognizes; keep them in sync if either changes.
export const SUMMARIZER_SYSTEM_PROMPT =
"Summarize this one pull request's description for a team activity feed. " +
"Respond with ONLY this exact markdown structure, nothing else:\n" +
"**What changed:** <1-2 short factual sentences>\n" +
"**Why:** <1 short sentence stating the description's own stated rationale>\n" +
'If the description states no rationale, omit the "**Why:**" line entirely. ' +
"Do not speculate beyond the text.";

/** Workers AI-backed summarizer. Bounded to THAT PR's own title+body — no other
* context is sent. Never throws: any failure (network, empty output, malformed
* response) resolves to null so the caller falls back to excerptSummary. */
Expand All@@ -25,11 +36,7 @@ export function workersAiSummarizer(ai: Ai): Summarizer {
try {
const result = await ai.run(WORKERS_AI_MODEL, {
messages: [
{
role: "system",
content:
"Summarize this one pull request's description in 2-3 short markdown sentences for a team activity feed. Do not speculate beyond the text.",
},
{ role: "system", content: SUMMARIZER_SYSTEM_PROMPT },
{ role: "user", content: `Title: ${title}\n\nBody: ${body}` },
],
});
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
My Work: To-do first, structured PR-summary cards, retroactive resync by AndresL230 · Pull Request #11 · SaplingLearn/canopy · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
955 changes: 955 additions & 0 deletions docs/superpowers/plans/2026-07-03-mywork-restructure-plan.md

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions docs/superpowers/specs/2026-07-03-mywork-restructure-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
# My Work: reorder, structured cards, retroactive summary resync

Date: 2026-07-03

## Problem

The My Work screen (`web/src/render.ts`, `myWorkView`) shows two sections: "Previous
activity" (merged/closed PRs) above "To-do" (open assigned issues). Two things are off:

1. To-do — the actionable list — renders below the read-only activity history. It
should be on top.
2. Both sections are thin on structure. `todoCard` is a single truncated line with no
room for a full title or an updated-at signal. `prActivityCard`'s body is one
undifferentiated AI-generated prose blob — no labeled fields.

Separately, "Sync GitHub" (the admin-only backfill button) only looks at PRs closed in
the last 14 days, and only (re)generates a PR's summary when its event is *newly*
captured — an already-captured PR's summary is never touched again. That means
existing PRs would stay on the old unstructured prose format forever, even after this
change ships.

## Design

### 1. Reorder (`web/src/render.ts`, `myWorkView`)

Swap section composition from `${hero}${activity}${todo}` to `${hero}${todo}${activity}`.
Section labels ("To-do", "Previous activity") stay as-is.

### 2. To-do card restructure (`todoCard`)

From a single-line truncated row to a two-line card, still one `<a class="cnpy-card">`:

- Row 1: priority badge + `#number` + title. Title wraps up to 2 lines (CSS
line-clamp) instead of single-line ellipsis truncation.
- Row 2: up to 3 labels (cap unchanged, keeps the card bounded) + a right-aligned
relative "updated" timestamp from `t.updatedAt` (captured today, currently unused
in the card).

### 3. Structured PR summaries

**New shared module `shared/prSummary.ts`** — the single source of truth for
recognizing the structured-summary convention, imported by both the Worker and the
web build:

```ts
export interface StructuredPrSummary {
what: string;
why: string | null;
}

export function parseStructuredSummary(raw: string): StructuredPrSummary | null;
```

It matches markdown of the shape:

```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Tag the example fence so markdownlint stays green.

The bare fenced block here triggers MD040. Add a language hint (md/markdown) or convert it to a quoted example so the spec stays lint-clean.

✏️ Suggested fix
-```+```md
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 56-56: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/specs/2026-07-03-mywork-restructure-design.md` around lines
56 - 59, The example fence in the spec body is missing a language tag and is
triggering markdownlint MD040. Update the fenced example near the “What changed”
/ “Why” section to use the existing markdown example block with a language hint
(for example, in the spec template text) or convert it to a quoted example, and
keep the surrounding content in the same structure so the markdown stays
lint-clean.

Source: Linters/SAST tools


Returns `null` when the text doesn't match (old-style prose, the deterministic
excerpt fallback, or a malformed AI response) — callers treat `null` as "render/treat
as plain prose," never as an error.

**Backend (`src/tools/summarize.ts`)** — `workersAiSummarizer`'s system prompt
changes to require exactly that two-field shape (omitting the `**Why:**` line when
the PR body states no rationale), instead of "2-3 short sentences." No signature
change. `excerptSummary` (the deterministic no-AI fallback) is untouched — it keeps
producing plain prose, which is intentional: a parse miss just falls back to today's
rendering, never a broken UI. `pr_summaries.summary` stays a single `TEXT` column; no
migration — the structure lives in the markdown convention, not the schema.

**Frontend (`web/src/render.ts`, `prActivityCard`)** — the summary body now branches
on `parseStructuredSummary(pr.summary)`:
- Matched → two labeled rows ("What changed", and "Why" only when present), each a
small uppercase caption (scaled-down `MW_LABEL` idiom) above its markdown body.
- Not matched → today's single prose block, unchanged.

### 4. Retroactive resync (`src/tools/backfill.ts`, `runBackfill`)

Two independent changes to `runBackfill`:

**a. Full PR history.** Remove the `DAYS_BACK`/cutoff logic on the closed-PR fetch —
paginate through every closed PR the repo has, not just the last 14 days. The open-
issues fetch is untouched (it already has no window).

**b. Decouple summary (re)generation from event-capture outcome.** Today,
`storePrSummary` only runs inside `if (res.outcome === "written")` — i.e., only for
brand-new events. Change: for every PR in the fetched list (regardless of whether its
event was newly captured or already existed), look up its existing `pr_summaries` row
and call `storePrSummary` unless `parseStructuredSummary(existing.summary) !== null`
(i.e., skip only when it's already in the new structured format). `ev.raw` (built
before `ingestEvent` runs) always has the PR's title/body available regardless of the
event's write outcome, so no extra DB read is needed to get the summarizer input —
only one extra read (existing `pr_summaries` row) to decide skip-or-regenerate.

**c. Observability.** `BackfillResult` gains `summarized: number` — incremented each
time `storePrSummary` actually runs (not skipped). Threaded through:
- `src/routes.ts` `/admin/backfill` response (already returns the whole result object,
no route change beyond the type flowing through).
- `web/src/api.ts` `adminBackfill()` return type.
- `web/src/main.ts`'s flash message: `Synced: ${r.captured} captured, ${r.unchanged}
unchanged, ${r.summarized} summaries updated`.
- `web/src/render.ts`'s button title: "Fetch recent GitHub PRs + issues" →
"Fetch all GitHub PRs + issues" (no longer just recent).

**Accepted limitation:** unbounded full-history pagination plus one AI call per
un-migrated PR has no hard cap or resumable cursor. Fine at this project's current
size (a first Sync after this ships does a one-time migration of every existing PR to
the structured format; later clicks are cheap since already-structured PRs are
skipped). Would need a real bound/cursor if the repo's PR history grows a lot —
explicitly not building that now (YAGNI).

## Testing

- `test/render.mywork.test.ts` (pure, no DOM): `todoCard` shows `updatedAt` and
doesn't collapse a long title to one truncated line; `prActivityCard` renders two
labeled rows for a structured summary (with and without a `why`), and falls back to
the existing prose rendering for a non-conforming summary.
- New test file (or colocated in `test/summarize.test.ts`) for
`shared/prSummary.ts`'s `parseStructuredSummary`: matches the two-field shape,
matches "What changed" only (no "Why" line), returns `null` for old-style prose and
for empty/malformed input.
- `test/backfill.test.ts`: the existing "oldPr excluded by the 14-day window"
assertion is now wrong on purpose and must be updated to reflect full-history
fetch. New cases: a PR with an existing non-structured summary gets re-summarized
on a second run (`summarized` increments, `storePrSummary`/summarizer called
again); a PR with an existing structured summary is skipped on a second run
(`summarized` stays 0 for it, summarizer not called again — assert via a
call-counting stub summarizer).
- No new migration, so `test/apply-migrations.ts` is untouched.

## Out of scope

- No change to the live webhook capture path (`src/webhook.ts`) — a real-time merge
event only ever fires once, so the "already captured, skip" question doesn't arise
there.
- No change to issue capture/backfill scope (already unbounded).
- No resumable/bounded backfill cursor for large repos (see accepted limitation
above).
28 changes: 28 additions & 0 deletions shared/prSummary.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
// Recognizes the "What changed / Why" markdown convention emitted by the PR
// summarizer (src/tools/summarize.ts) so both the Worker (the backfill's
// already-structured check, src/tools/backfill.ts) and the web build (card
// rendering, web/src/render.ts) agree on what counts as a structured summary.
// No schema change backs this — pr_summaries.summary stays a single markdown
// TEXT column; the structure lives in this convention, not a stored shape, so
// old prose summaries and the deterministic excerpt fallback degrade
// gracefully to a `null` parse instead of erroring.

export interface StructuredPrSummary {
what: string;
why: string | null;
}

const STRUCTURED_RE = /\*\*What changed:\*\*\s*([\s\S]*?)(?:\s*\*\*Why:\*\*\s*([\s\S]*))?$/i;

/** Parses "**What changed:** ... **Why:** ..." out of a PR summary's markdown.
* Returns null when the text doesn't match (old-style prose, the excerpt
* fallback, or a malformed AI response) — callers treat null as "render/treat
* as plain prose," never as an error. */
export function parseStructuredSummary(raw: string): StructuredPrSummary | null {
const m = raw.match(STRUCTURED_RE);
if (!m) return null;
const what = m[1].trim();
if (!what) return null;
const why = m[2]?.trim() || null;
return { what, why };
}
50 changes: 28 additions & 22 deletions src/tools/backfill.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import type { Env } from "../env";
import { nowIso } from "../db";
import type { PrSummaryRow } from "@shared/rows";
import { first } from "../db";
import { ingestEvent } from "../consumer";
import { eventsFromDelivery } from "../webhook";
import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize";
import { applyEventProgress } from "./progress";
import { parseStructuredSummary } from "@shared/prSummary";

// Admin-triggered server-side GitHub backfill. Unlike scripts/backfill-events.mjs
// (which signs synthetic webhook deliveries with the webhook secret), this runs
Expand All@@ -19,13 +21,13 @@ import { applyEventProgress } from "./progress";

const GH_API = "application/vnd.github+json";
const USER_AGENT = "canopy";
const DAYS_BACK = 14;

export interface BackfillResult {
ok: boolean;
error?: string;
captured: number;
unchanged: number;
summarized: number;
prs: number;
issues: number;
}
Expand DownExpand Up@@ -120,12 +122,12 @@ function issueDelivery(issue: GhIssueListItem) {
export async function runBackfill(
env: Env,
principalLogin: string,
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null; now?: string }
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null }
): Promise<BackfillResult> {
const token = env.GITHUB_SERVICE_TOKEN;
const repo = env.GITHUB_REPO;
if (!token || !repo) {
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, prs: 0, issues: 0 };
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, summarized: 0, prs: 0, issues: 0 };
}

const doFetch = opts?.fetchImpl ?? fetch;
Expand All@@ -136,26 +138,18 @@ export async function runBackfill(
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
};
const cutoffMs = new Date(opts?.now ?? nowIso()).getTime() - DAYS_BACK * 24 * 60 * 60 * 1000;

// (a) Closed PRs updated in the last 14 days. Sorted updated-desc, so we stop
// paginating at the first item whose updated_at predates the cutoff.
// (a) All closed PRs, fully paginated — full history, not just recent
// activity, so a Sync also surfaces PRs merged before this route existed.
const prList: GhPrListItem[] = [];
{
let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`;
let done = false;
while (url && !done) {
while (url) {
const res: Response = await doFetch(url, { headers });
if (!res.ok) break;
const page = (await res.json()) as GhPrListItem[];
for (const pr of page) {
if (pr.updated_at && new Date(pr.updated_at).getTime() < cutoffMs) {
done = true;
break;
}
prList.push(pr);
}
url = done ? null : nextLink(res);
prList.push(...page);
url = nextLink(res);
}
}

Expand All@@ -178,6 +172,7 @@ export async function runBackfill(

let captured = 0;
let unchanged = 0;
let summarized = 0;

for (const pr of prList) {
const payload = prClosedDelivery(pr);
Expand All@@ -186,17 +181,28 @@ export async function runBackfill(
const res = await ingestEvent(env.DB, ev, principalLogin);
if (res.outcome === "written") {
captured++;
// Mirror handleGithubWebhook's summary seam: parse THIS PR's own raw and
// store a capture-time summary (storePrSummary never throws).
} else {
unchanged++;
}

// (Re)summarize unless it's already in the structured format — decoupled
// from the event-capture outcome so a Sync also migrates PRs captured
// before the structured format existed, not just brand-new ones.
const existing = await first<PrSummaryRow>(
env.DB,
`SELECT summary FROM pr_summaries WHERE semantic_key = ?`,
ev.semantic_key
);
const alreadyStructured = existing !== null && parseStructuredSummary(existing.summary) !== null;
if (!alreadyStructured) {
const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } };
await storePrSummary(env.DB, summarizer, {
semantic_key: ev.semantic_key,
pr_number: parsed.pr.number,
title: parsed.pr.title,
body: parsed.pr.body ?? "",
});
} else {
unchanged++;
summarized++;
}
}
}
Expand All@@ -216,5 +222,5 @@ export async function runBackfill(
}
}

return { ok: true, captured, unchanged, prs: prList.length, issues: issueList.length };
return { ok: true, captured, unchanged, summarized, prs: prList.length, issues: issueList.length };
}
17 changes: 12 additions & 5 deletions src/tools/summarize.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,17 @@ export interface Summarizer {

const WORKERS_AI_MODEL = "@cf/meta/llama-3.1-8b-instruct";

// Exported for a content assertion in test/summarize.test.ts — the two-field
// shape here is exactly what shared/prSummary.ts's parseStructuredSummary
// recognizes; keep them in sync if either changes.
export const SUMMARIZER_SYSTEM_PROMPT =
"Summarize this one pull request's description for a team activity feed. " +
"Respond with ONLY this exact markdown structure, nothing else:\n" +
"**What changed:** <1-2 short factual sentences>\n" +
"**Why:** <1 short sentence stating the description's own stated rationale>\n" +
'If the description states no rationale, omit the "**Why:**" line entirely. ' +
"Do not speculate beyond the text.";

/** Workers AI-backed summarizer. Bounded to THAT PR's own title+body — no other
* context is sent. Never throws: any failure (network, empty output, malformed
* response) resolves to null so the caller falls back to excerptSummary. */
Expand All@@ -25,11 +36,7 @@ export function workersAiSummarizer(ai: Ai): Summarizer {
try {
const result = await ai.run(WORKERS_AI_MODEL, {
messages: [
{
role: "system",
content:
"Summarize this one pull request's description in 2-3 short markdown sentences for a team activity feed. Do not speculate beyond the text.",
},
{ role: "system", content: SUMMARIZER_SYSTEM_PROMPT },
{ role: "user", content: `Title: ${title}\n\nBody: ${body}` },
],
});
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' My Work: To-do first, structured PR-summary cards, retroactive resync by AndresL230 · Pull Request #11 · SaplingLearn/canopy · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
955 changes: 955 additions & 0 deletions docs/superpowers/plans/2026-07-03-mywork-restructure-plan.md

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions docs/superpowers/specs/2026-07-03-mywork-restructure-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
# My Work: reorder, structured cards, retroactive summary resync

Date: 2026-07-03

## Problem

The My Work screen (`web/src/render.ts`, `myWorkView`) shows two sections: "Previous
activity" (merged/closed PRs) above "To-do" (open assigned issues). Two things are off:

1. To-do — the actionable list — renders below the read-only activity history. It
should be on top.
2. Both sections are thin on structure. `todoCard` is a single truncated line with no
room for a full title or an updated-at signal. `prActivityCard`'s body is one
undifferentiated AI-generated prose blob — no labeled fields.

Separately, "Sync GitHub" (the admin-only backfill button) only looks at PRs closed in
the last 14 days, and only (re)generates a PR's summary when its event is *newly*
captured — an already-captured PR's summary is never touched again. That means
existing PRs would stay on the old unstructured prose format forever, even after this
change ships.

## Design

### 1. Reorder (`web/src/render.ts`, `myWorkView`)

Swap section composition from `${hero}${activity}${todo}` to `${hero}${todo}${activity}`.
Section labels ("To-do", "Previous activity") stay as-is.

### 2. To-do card restructure (`todoCard`)

From a single-line truncated row to a two-line card, still one `<a class="cnpy-card">`:

- Row 1: priority badge + `#number` + title. Title wraps up to 2 lines (CSS
line-clamp) instead of single-line ellipsis truncation.
- Row 2: up to 3 labels (cap unchanged, keeps the card bounded) + a right-aligned
relative "updated" timestamp from `t.updatedAt` (captured today, currently unused
in the card).

### 3. Structured PR summaries

**New shared module `shared/prSummary.ts`** — the single source of truth for
recognizing the structured-summary convention, imported by both the Worker and the
web build:

```ts
export interface StructuredPrSummary {
what: string;
why: string | null;
}

export function parseStructuredSummary(raw: string): StructuredPrSummary | null;
```

It matches markdown of the shape:

```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Tag the example fence so markdownlint stays green.

The bare fenced block here triggers MD040. Add a language hint (md/markdown) or convert it to a quoted example so the spec stays lint-clean.

✏️ Suggested fix
-```+```md
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 56-56: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/specs/2026-07-03-mywork-restructure-design.md` around lines
56 - 59, The example fence in the spec body is missing a language tag and is
triggering markdownlint MD040. Update the fenced example near the “What changed”
/ “Why” section to use the existing markdown example block with a language hint
(for example, in the spec template text) or convert it to a quoted example, and
keep the surrounding content in the same structure so the markdown stays
lint-clean.

Source: Linters/SAST tools


Returns `null` when the text doesn't match (old-style prose, the deterministic
excerpt fallback, or a malformed AI response) — callers treat `null` as "render/treat
as plain prose," never as an error.

**Backend (`src/tools/summarize.ts`)** — `workersAiSummarizer`'s system prompt
changes to require exactly that two-field shape (omitting the `**Why:**` line when
the PR body states no rationale), instead of "2-3 short sentences." No signature
change. `excerptSummary` (the deterministic no-AI fallback) is untouched — it keeps
producing plain prose, which is intentional: a parse miss just falls back to today's
rendering, never a broken UI. `pr_summaries.summary` stays a single `TEXT` column; no
migration — the structure lives in the markdown convention, not the schema.

**Frontend (`web/src/render.ts`, `prActivityCard`)** — the summary body now branches
on `parseStructuredSummary(pr.summary)`:
- Matched → two labeled rows ("What changed", and "Why" only when present), each a
small uppercase caption (scaled-down `MW_LABEL` idiom) above its markdown body.
- Not matched → today's single prose block, unchanged.

### 4. Retroactive resync (`src/tools/backfill.ts`, `runBackfill`)

Two independent changes to `runBackfill`:

**a. Full PR history.** Remove the `DAYS_BACK`/cutoff logic on the closed-PR fetch —
paginate through every closed PR the repo has, not just the last 14 days. The open-
issues fetch is untouched (it already has no window).

**b. Decouple summary (re)generation from event-capture outcome.** Today,
`storePrSummary` only runs inside `if (res.outcome === "written")` — i.e., only for
brand-new events. Change: for every PR in the fetched list (regardless of whether its
event was newly captured or already existed), look up its existing `pr_summaries` row
and call `storePrSummary` unless `parseStructuredSummary(existing.summary) !== null`
(i.e., skip only when it's already in the new structured format). `ev.raw` (built
before `ingestEvent` runs) always has the PR's title/body available regardless of the
event's write outcome, so no extra DB read is needed to get the summarizer input —
only one extra read (existing `pr_summaries` row) to decide skip-or-regenerate.

**c. Observability.** `BackfillResult` gains `summarized: number` — incremented each
time `storePrSummary` actually runs (not skipped). Threaded through:
- `src/routes.ts` `/admin/backfill` response (already returns the whole result object,
no route change beyond the type flowing through).
- `web/src/api.ts` `adminBackfill()` return type.
- `web/src/main.ts`'s flash message: `Synced: ${r.captured} captured, ${r.unchanged}
unchanged, ${r.summarized} summaries updated`.
- `web/src/render.ts`'s button title: "Fetch recent GitHub PRs + issues" →
"Fetch all GitHub PRs + issues" (no longer just recent).

**Accepted limitation:** unbounded full-history pagination plus one AI call per
un-migrated PR has no hard cap or resumable cursor. Fine at this project's current
size (a first Sync after this ships does a one-time migration of every existing PR to
the structured format; later clicks are cheap since already-structured PRs are
skipped). Would need a real bound/cursor if the repo's PR history grows a lot —
explicitly not building that now (YAGNI).

## Testing

- `test/render.mywork.test.ts` (pure, no DOM): `todoCard` shows `updatedAt` and
doesn't collapse a long title to one truncated line; `prActivityCard` renders two
labeled rows for a structured summary (with and without a `why`), and falls back to
the existing prose rendering for a non-conforming summary.
- New test file (or colocated in `test/summarize.test.ts`) for
`shared/prSummary.ts`'s `parseStructuredSummary`: matches the two-field shape,
matches "What changed" only (no "Why" line), returns `null` for old-style prose and
for empty/malformed input.
- `test/backfill.test.ts`: the existing "oldPr excluded by the 14-day window"
assertion is now wrong on purpose and must be updated to reflect full-history
fetch. New cases: a PR with an existing non-structured summary gets re-summarized
on a second run (`summarized` increments, `storePrSummary`/summarizer called
again); a PR with an existing structured summary is skipped on a second run
(`summarized` stays 0 for it, summarizer not called again — assert via a
call-counting stub summarizer).
- No new migration, so `test/apply-migrations.ts` is untouched.

## Out of scope

- No change to the live webhook capture path (`src/webhook.ts`) — a real-time merge
event only ever fires once, so the "already captured, skip" question doesn't arise
there.
- No change to issue capture/backfill scope (already unbounded).
- No resumable/bounded backfill cursor for large repos (see accepted limitation
above).
28 changes: 28 additions & 0 deletions shared/prSummary.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
// Recognizes the "What changed / Why" markdown convention emitted by the PR
// summarizer (src/tools/summarize.ts) so both the Worker (the backfill's
// already-structured check, src/tools/backfill.ts) and the web build (card
// rendering, web/src/render.ts) agree on what counts as a structured summary.
// No schema change backs this — pr_summaries.summary stays a single markdown
// TEXT column; the structure lives in this convention, not a stored shape, so
// old prose summaries and the deterministic excerpt fallback degrade
// gracefully to a `null` parse instead of erroring.

export interface StructuredPrSummary {
what: string;
why: string | null;
}

const STRUCTURED_RE = /\*\*What changed:\*\*\s*([\s\S]*?)(?:\s*\*\*Why:\*\*\s*([\s\S]*))?$/i;

/** Parses "**What changed:** ... **Why:** ..." out of a PR summary's markdown.
* Returns null when the text doesn't match (old-style prose, the excerpt
* fallback, or a malformed AI response) — callers treat null as "render/treat
* as plain prose," never as an error. */
export function parseStructuredSummary(raw: string): StructuredPrSummary | null {
const m = raw.match(STRUCTURED_RE);
if (!m) return null;
const what = m[1].trim();
if (!what) return null;
const why = m[2]?.trim() || null;
return { what, why };
}
50 changes: 28 additions & 22 deletions src/tools/backfill.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import type { Env } from "../env";
import { nowIso } from "../db";
import type { PrSummaryRow } from "@shared/rows";
import { first } from "../db";
import { ingestEvent } from "../consumer";
import { eventsFromDelivery } from "../webhook";
import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize";
import { applyEventProgress } from "./progress";
import { parseStructuredSummary } from "@shared/prSummary";

// Admin-triggered server-side GitHub backfill. Unlike scripts/backfill-events.mjs
// (which signs synthetic webhook deliveries with the webhook secret), this runs
Expand All@@ -19,13 +21,13 @@ import { applyEventProgress } from "./progress";

const GH_API = "application/vnd.github+json";
const USER_AGENT = "canopy";
const DAYS_BACK = 14;

export interface BackfillResult {
ok: boolean;
error?: string;
captured: number;
unchanged: number;
summarized: number;
prs: number;
issues: number;
}
Expand DownExpand Up@@ -120,12 +122,12 @@ function issueDelivery(issue: GhIssueListItem) {
export async function runBackfill(
env: Env,
principalLogin: string,
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null; now?: string }
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null }
): Promise<BackfillResult> {
const token = env.GITHUB_SERVICE_TOKEN;
const repo = env.GITHUB_REPO;
if (!token || !repo) {
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, prs: 0, issues: 0 };
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, summarized: 0, prs: 0, issues: 0 };
}

const doFetch = opts?.fetchImpl ?? fetch;
Expand All@@ -136,26 +138,18 @@ export async function runBackfill(
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
};
const cutoffMs = new Date(opts?.now ?? nowIso()).getTime() - DAYS_BACK * 24 * 60 * 60 * 1000;

// (a) Closed PRs updated in the last 14 days. Sorted updated-desc, so we stop
// paginating at the first item whose updated_at predates the cutoff.
// (a) All closed PRs, fully paginated — full history, not just recent
// activity, so a Sync also surfaces PRs merged before this route existed.
const prList: GhPrListItem[] = [];
{
let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`;
let done = false;
while (url && !done) {
while (url) {
const res: Response = await doFetch(url, { headers });
if (!res.ok) break;
const page = (await res.json()) as GhPrListItem[];
for (const pr of page) {
if (pr.updated_at && new Date(pr.updated_at).getTime() < cutoffMs) {
done = true;
break;
}
prList.push(pr);
}
url = done ? null : nextLink(res);
prList.push(...page);
url = nextLink(res);
}
}

Expand All@@ -178,6 +172,7 @@ export async function runBackfill(

let captured = 0;
let unchanged = 0;
let summarized = 0;

for (const pr of prList) {
const payload = prClosedDelivery(pr);
Expand All@@ -186,17 +181,28 @@ export async function runBackfill(
const res = await ingestEvent(env.DB, ev, principalLogin);
if (res.outcome === "written") {
captured++;
// Mirror handleGithubWebhook's summary seam: parse THIS PR's own raw and
// store a capture-time summary (storePrSummary never throws).
} else {
unchanged++;
}

// (Re)summarize unless it's already in the structured format — decoupled
// from the event-capture outcome so a Sync also migrates PRs captured
// before the structured format existed, not just brand-new ones.
const existing = await first<PrSummaryRow>(
env.DB,
`SELECT summary FROM pr_summaries WHERE semantic_key = ?`,
ev.semantic_key
);
const alreadyStructured = existing !== null && parseStructuredSummary(existing.summary) !== null;
if (!alreadyStructured) {
const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } };
await storePrSummary(env.DB, summarizer, {
semantic_key: ev.semantic_key,
pr_number: parsed.pr.number,
title: parsed.pr.title,
body: parsed.pr.body ?? "",
});
} else {
unchanged++;
summarized++;
}
}
}
Expand All@@ -216,5 +222,5 @@ export async function runBackfill(
}
}

return { ok: true, captured, unchanged, prs: prList.length, issues: issueList.length };
return { ok: true, captured, unchanged, summarized, prs: prList.length, issues: issueList.length };
}
17 changes: 12 additions & 5 deletions src/tools/summarize.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,17 @@ export interface Summarizer {

const WORKERS_AI_MODEL = "@cf/meta/llama-3.1-8b-instruct";

// Exported for a content assertion in test/summarize.test.ts — the two-field
// shape here is exactly what shared/prSummary.ts's parseStructuredSummary
// recognizes; keep them in sync if either changes.
export const SUMMARIZER_SYSTEM_PROMPT =
"Summarize this one pull request's description for a team activity feed. " +
"Respond with ONLY this exact markdown structure, nothing else:\n" +
"**What changed:** <1-2 short factual sentences>\n" +
"**Why:** <1 short sentence stating the description's own stated rationale>\n" +
'If the description states no rationale, omit the "**Why:**" line entirely. ' +
"Do not speculate beyond the text.";

/** Workers AI-backed summarizer. Bounded to THAT PR's own title+body — no other
* context is sent. Never throws: any failure (network, empty output, malformed
* response) resolves to null so the caller falls back to excerptSummary. */
Expand All@@ -25,11 +36,7 @@ export function workersAiSummarizer(ai: Ai): Summarizer {
try {
const result = await ai.run(WORKERS_AI_MODEL, {
messages: [
{
role: "system",
content:
"Summarize this one pull request's description in 2-3 short markdown sentences for a team activity feed. Do not speculate beyond the text.",
},
{ role: "system", content: SUMMARIZER_SYSTEM_PROMPT },
{ role: "user", content: `Title: ${title}\n\nBody: ${body}` },
],
});
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' My Work: To-do first, structured PR-summary cards, retroactive resync by AndresL230 · Pull Request #11 · SaplingLearn/canopy · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
955 changes: 955 additions & 0 deletions docs/superpowers/plans/2026-07-03-mywork-restructure-plan.md

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions docs/superpowers/specs/2026-07-03-mywork-restructure-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
# My Work: reorder, structured cards, retroactive summary resync

Date: 2026-07-03

## Problem

The My Work screen (`web/src/render.ts`, `myWorkView`) shows two sections: "Previous
activity" (merged/closed PRs) above "To-do" (open assigned issues). Two things are off:

1. To-do — the actionable list — renders below the read-only activity history. It
should be on top.
2. Both sections are thin on structure. `todoCard` is a single truncated line with no
room for a full title or an updated-at signal. `prActivityCard`'s body is one
undifferentiated AI-generated prose blob — no labeled fields.

Separately, "Sync GitHub" (the admin-only backfill button) only looks at PRs closed in
the last 14 days, and only (re)generates a PR's summary when its event is *newly*
captured — an already-captured PR's summary is never touched again. That means
existing PRs would stay on the old unstructured prose format forever, even after this
change ships.

## Design

### 1. Reorder (`web/src/render.ts`, `myWorkView`)

Swap section composition from `${hero}${activity}${todo}` to `${hero}${todo}${activity}`.
Section labels ("To-do", "Previous activity") stay as-is.

### 2. To-do card restructure (`todoCard`)

From a single-line truncated row to a two-line card, still one `<a class="cnpy-card">`:

- Row 1: priority badge + `#number` + title. Title wraps up to 2 lines (CSS
line-clamp) instead of single-line ellipsis truncation.
- Row 2: up to 3 labels (cap unchanged, keeps the card bounded) + a right-aligned
relative "updated" timestamp from `t.updatedAt` (captured today, currently unused
in the card).

### 3. Structured PR summaries

**New shared module `shared/prSummary.ts`** — the single source of truth for
recognizing the structured-summary convention, imported by both the Worker and the
web build:

```ts
export interface StructuredPrSummary {
what: string;
why: string | null;
}

export function parseStructuredSummary(raw: string): StructuredPrSummary | null;
```

It matches markdown of the shape:

```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Tag the example fence so markdownlint stays green.

The bare fenced block here triggers MD040. Add a language hint (md/markdown) or convert it to a quoted example so the spec stays lint-clean.

✏️ Suggested fix
-```+```md
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 56-56: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/specs/2026-07-03-mywork-restructure-design.md` around lines
56 - 59, The example fence in the spec body is missing a language tag and is
triggering markdownlint MD040. Update the fenced example near the “What changed”
/ “Why” section to use the existing markdown example block with a language hint
(for example, in the spec template text) or convert it to a quoted example, and
keep the surrounding content in the same structure so the markdown stays
lint-clean.

Source: Linters/SAST tools


Returns `null` when the text doesn't match (old-style prose, the deterministic
excerpt fallback, or a malformed AI response) — callers treat `null` as "render/treat
as plain prose," never as an error.

**Backend (`src/tools/summarize.ts`)** — `workersAiSummarizer`'s system prompt
changes to require exactly that two-field shape (omitting the `**Why:**` line when
the PR body states no rationale), instead of "2-3 short sentences." No signature
change. `excerptSummary` (the deterministic no-AI fallback) is untouched — it keeps
producing plain prose, which is intentional: a parse miss just falls back to today's
rendering, never a broken UI. `pr_summaries.summary` stays a single `TEXT` column; no
migration — the structure lives in the markdown convention, not the schema.

**Frontend (`web/src/render.ts`, `prActivityCard`)** — the summary body now branches
on `parseStructuredSummary(pr.summary)`:
- Matched → two labeled rows ("What changed", and "Why" only when present), each a
small uppercase caption (scaled-down `MW_LABEL` idiom) above its markdown body.
- Not matched → today's single prose block, unchanged.

### 4. Retroactive resync (`src/tools/backfill.ts`, `runBackfill`)

Two independent changes to `runBackfill`:

**a. Full PR history.** Remove the `DAYS_BACK`/cutoff logic on the closed-PR fetch —
paginate through every closed PR the repo has, not just the last 14 days. The open-
issues fetch is untouched (it already has no window).

**b. Decouple summary (re)generation from event-capture outcome.** Today,
`storePrSummary` only runs inside `if (res.outcome === "written")` — i.e., only for
brand-new events. Change: for every PR in the fetched list (regardless of whether its
event was newly captured or already existed), look up its existing `pr_summaries` row
and call `storePrSummary` unless `parseStructuredSummary(existing.summary) !== null`
(i.e., skip only when it's already in the new structured format). `ev.raw` (built
before `ingestEvent` runs) always has the PR's title/body available regardless of the
event's write outcome, so no extra DB read is needed to get the summarizer input —
only one extra read (existing `pr_summaries` row) to decide skip-or-regenerate.

**c. Observability.** `BackfillResult` gains `summarized: number` — incremented each
time `storePrSummary` actually runs (not skipped). Threaded through:
- `src/routes.ts` `/admin/backfill` response (already returns the whole result object,
no route change beyond the type flowing through).
- `web/src/api.ts` `adminBackfill()` return type.
- `web/src/main.ts`'s flash message: `Synced: ${r.captured} captured, ${r.unchanged}
unchanged, ${r.summarized} summaries updated`.
- `web/src/render.ts`'s button title: "Fetch recent GitHub PRs + issues" →
"Fetch all GitHub PRs + issues" (no longer just recent).

**Accepted limitation:** unbounded full-history pagination plus one AI call per
un-migrated PR has no hard cap or resumable cursor. Fine at this project's current
size (a first Sync after this ships does a one-time migration of every existing PR to
the structured format; later clicks are cheap since already-structured PRs are
skipped). Would need a real bound/cursor if the repo's PR history grows a lot —
explicitly not building that now (YAGNI).

## Testing

- `test/render.mywork.test.ts` (pure, no DOM): `todoCard` shows `updatedAt` and
doesn't collapse a long title to one truncated line; `prActivityCard` renders two
labeled rows for a structured summary (with and without a `why`), and falls back to
the existing prose rendering for a non-conforming summary.
- New test file (or colocated in `test/summarize.test.ts`) for
`shared/prSummary.ts`'s `parseStructuredSummary`: matches the two-field shape,
matches "What changed" only (no "Why" line), returns `null` for old-style prose and
for empty/malformed input.
- `test/backfill.test.ts`: the existing "oldPr excluded by the 14-day window"
assertion is now wrong on purpose and must be updated to reflect full-history
fetch. New cases: a PR with an existing non-structured summary gets re-summarized
on a second run (`summarized` increments, `storePrSummary`/summarizer called
again); a PR with an existing structured summary is skipped on a second run
(`summarized` stays 0 for it, summarizer not called again — assert via a
call-counting stub summarizer).
- No new migration, so `test/apply-migrations.ts` is untouched.

## Out of scope

- No change to the live webhook capture path (`src/webhook.ts`) — a real-time merge
event only ever fires once, so the "already captured, skip" question doesn't arise
there.
- No change to issue capture/backfill scope (already unbounded).
- No resumable/bounded backfill cursor for large repos (see accepted limitation
above).
28 changes: 28 additions & 0 deletions shared/prSummary.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
// Recognizes the "What changed / Why" markdown convention emitted by the PR
// summarizer (src/tools/summarize.ts) so both the Worker (the backfill's
// already-structured check, src/tools/backfill.ts) and the web build (card
// rendering, web/src/render.ts) agree on what counts as a structured summary.
// No schema change backs this — pr_summaries.summary stays a single markdown
// TEXT column; the structure lives in this convention, not a stored shape, so
// old prose summaries and the deterministic excerpt fallback degrade
// gracefully to a `null` parse instead of erroring.

export interface StructuredPrSummary {
what: string;
why: string | null;
}

const STRUCTURED_RE = /\*\*What changed:\*\*\s*([\s\S]*?)(?:\s*\*\*Why:\*\*\s*([\s\S]*))?$/i;

/** Parses "**What changed:** ... **Why:** ..." out of a PR summary's markdown.
* Returns null when the text doesn't match (old-style prose, the excerpt
* fallback, or a malformed AI response) — callers treat null as "render/treat
* as plain prose," never as an error. */
export function parseStructuredSummary(raw: string): StructuredPrSummary | null {
const m = raw.match(STRUCTURED_RE);
if (!m) return null;
const what = m[1].trim();
if (!what) return null;
const why = m[2]?.trim() || null;
return { what, why };
}
50 changes: 28 additions & 22 deletions src/tools/backfill.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import type { Env } from "../env";
import { nowIso } from "../db";
import type { PrSummaryRow } from "@shared/rows";
import { first } from "../db";
import { ingestEvent } from "../consumer";
import { eventsFromDelivery } from "../webhook";
import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize";
import { applyEventProgress } from "./progress";
import { parseStructuredSummary } from "@shared/prSummary";

// Admin-triggered server-side GitHub backfill. Unlike scripts/backfill-events.mjs
// (which signs synthetic webhook deliveries with the webhook secret), this runs
Expand All@@ -19,13 +21,13 @@ import { applyEventProgress } from "./progress";

const GH_API = "application/vnd.github+json";
const USER_AGENT = "canopy";
const DAYS_BACK = 14;

export interface BackfillResult {
ok: boolean;
error?: string;
captured: number;
unchanged: number;
summarized: number;
prs: number;
issues: number;
}
Expand DownExpand Up@@ -120,12 +122,12 @@ function issueDelivery(issue: GhIssueListItem) {
export async function runBackfill(
env: Env,
principalLogin: string,
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null; now?: string }
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null }
): Promise<BackfillResult> {
const token = env.GITHUB_SERVICE_TOKEN;
const repo = env.GITHUB_REPO;
if (!token || !repo) {
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, prs: 0, issues: 0 };
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, summarized: 0, prs: 0, issues: 0 };
}

const doFetch = opts?.fetchImpl ?? fetch;
Expand All@@ -136,26 +138,18 @@ export async function runBackfill(
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
};
const cutoffMs = new Date(opts?.now ?? nowIso()).getTime() - DAYS_BACK * 24 * 60 * 60 * 1000;

// (a) Closed PRs updated in the last 14 days. Sorted updated-desc, so we stop
// paginating at the first item whose updated_at predates the cutoff.
// (a) All closed PRs, fully paginated — full history, not just recent
// activity, so a Sync also surfaces PRs merged before this route existed.
const prList: GhPrListItem[] = [];
{
let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`;
let done = false;
while (url && !done) {
while (url) {
const res: Response = await doFetch(url, { headers });
if (!res.ok) break;
const page = (await res.json()) as GhPrListItem[];
for (const pr of page) {
if (pr.updated_at && new Date(pr.updated_at).getTime() < cutoffMs) {
done = true;
break;
}
prList.push(pr);
}
url = done ? null : nextLink(res);
prList.push(...page);
url = nextLink(res);
}
}

Expand All@@ -178,6 +172,7 @@ export async function runBackfill(

let captured = 0;
let unchanged = 0;
let summarized = 0;

for (const pr of prList) {
const payload = prClosedDelivery(pr);
Expand All@@ -186,17 +181,28 @@ export async function runBackfill(
const res = await ingestEvent(env.DB, ev, principalLogin);
if (res.outcome === "written") {
captured++;
// Mirror handleGithubWebhook's summary seam: parse THIS PR's own raw and
// store a capture-time summary (storePrSummary never throws).
} else {
unchanged++;
}

// (Re)summarize unless it's already in the structured format — decoupled
// from the event-capture outcome so a Sync also migrates PRs captured
// before the structured format existed, not just brand-new ones.
const existing = await first<PrSummaryRow>(
env.DB,
`SELECT summary FROM pr_summaries WHERE semantic_key = ?`,
ev.semantic_key
);
const alreadyStructured = existing !== null && parseStructuredSummary(existing.summary) !== null;
if (!alreadyStructured) {
const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } };
await storePrSummary(env.DB, summarizer, {
semantic_key: ev.semantic_key,
pr_number: parsed.pr.number,
title: parsed.pr.title,
body: parsed.pr.body ?? "",
});
} else {
unchanged++;
summarized++;
}
}
}
Expand All@@ -216,5 +222,5 @@ export async function runBackfill(
}
}

return { ok: true, captured, unchanged, prs: prList.length, issues: issueList.length };
return { ok: true, captured, unchanged, summarized, prs: prList.length, issues: issueList.length };
}
17 changes: 12 additions & 5 deletions src/tools/summarize.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,17 @@ export interface Summarizer {

const WORKERS_AI_MODEL = "@cf/meta/llama-3.1-8b-instruct";

// Exported for a content assertion in test/summarize.test.ts — the two-field
// shape here is exactly what shared/prSummary.ts's parseStructuredSummary
// recognizes; keep them in sync if either changes.
export const SUMMARIZER_SYSTEM_PROMPT =
"Summarize this one pull request's description for a team activity feed. " +
"Respond with ONLY this exact markdown structure, nothing else:\n" +
"**What changed:** <1-2 short factual sentences>\n" +
"**Why:** <1 short sentence stating the description's own stated rationale>\n" +
'If the description states no rationale, omit the "**Why:**" line entirely. ' +
"Do not speculate beyond the text.";

/** Workers AI-backed summarizer. Bounded to THAT PR's own title+body — no other
* context is sent. Never throws: any failure (network, empty output, malformed
* response) resolves to null so the caller falls back to excerptSummary. */
Expand All@@ -25,11 +36,7 @@ export function workersAiSummarizer(ai: Ai): Summarizer {
try {
const result = await ai.run(WORKERS_AI_MODEL, {
messages: [
{
role: "system",
content:
"Summarize this one pull request's description in 2-3 short markdown sentences for a team activity feed. Do not speculate beyond the text.",
},
{ role: "system", content: SUMMARIZER_SYSTEM_PROMPT },
{ role: "user", content: `Title: ${title}\n\nBody: ${body}` },
],
});
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' My Work: To-do first, structured PR-summary cards, retroactive resync by AndresL230 · Pull Request #11 · SaplingLearn/canopy · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
955 changes: 955 additions & 0 deletions docs/superpowers/plans/2026-07-03-mywork-restructure-plan.md

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions docs/superpowers/specs/2026-07-03-mywork-restructure-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
# My Work: reorder, structured cards, retroactive summary resync

Date: 2026-07-03

## Problem

The My Work screen (`web/src/render.ts`, `myWorkView`) shows two sections: "Previous
activity" (merged/closed PRs) above "To-do" (open assigned issues). Two things are off:

1. To-do — the actionable list — renders below the read-only activity history. It
should be on top.
2. Both sections are thin on structure. `todoCard` is a single truncated line with no
room for a full title or an updated-at signal. `prActivityCard`'s body is one
undifferentiated AI-generated prose blob — no labeled fields.

Separately, "Sync GitHub" (the admin-only backfill button) only looks at PRs closed in
the last 14 days, and only (re)generates a PR's summary when its event is *newly*
captured — an already-captured PR's summary is never touched again. That means
existing PRs would stay on the old unstructured prose format forever, even after this
change ships.

## Design

### 1. Reorder (`web/src/render.ts`, `myWorkView`)

Swap section composition from `${hero}${activity}${todo}` to `${hero}${todo}${activity}`.
Section labels ("To-do", "Previous activity") stay as-is.

### 2. To-do card restructure (`todoCard`)

From a single-line truncated row to a two-line card, still one `<a class="cnpy-card">`:

- Row 1: priority badge + `#number` + title. Title wraps up to 2 lines (CSS
line-clamp) instead of single-line ellipsis truncation.
- Row 2: up to 3 labels (cap unchanged, keeps the card bounded) + a right-aligned
relative "updated" timestamp from `t.updatedAt` (captured today, currently unused
in the card).

### 3. Structured PR summaries

**New shared module `shared/prSummary.ts`** — the single source of truth for
recognizing the structured-summary convention, imported by both the Worker and the
web build:

```ts
export interface StructuredPrSummary {
what: string;
why: string | null;
}

export function parseStructuredSummary(raw: string): StructuredPrSummary | null;
```

It matches markdown of the shape:

```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Tag the example fence so markdownlint stays green.

The bare fenced block here triggers MD040. Add a language hint (md/markdown) or convert it to a quoted example so the spec stays lint-clean.

✏️ Suggested fix
-```+```md
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 56-56: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/specs/2026-07-03-mywork-restructure-design.md` around lines
56 - 59, The example fence in the spec body is missing a language tag and is
triggering markdownlint MD040. Update the fenced example near the “What changed”
/ “Why” section to use the existing markdown example block with a language hint
(for example, in the spec template text) or convert it to a quoted example, and
keep the surrounding content in the same structure so the markdown stays
lint-clean.

Source: Linters/SAST tools


Returns `null` when the text doesn't match (old-style prose, the deterministic
excerpt fallback, or a malformed AI response) — callers treat `null` as "render/treat
as plain prose," never as an error.

**Backend (`src/tools/summarize.ts`)** — `workersAiSummarizer`'s system prompt
changes to require exactly that two-field shape (omitting the `**Why:**` line when
the PR body states no rationale), instead of "2-3 short sentences." No signature
change. `excerptSummary` (the deterministic no-AI fallback) is untouched — it keeps
producing plain prose, which is intentional: a parse miss just falls back to today's
rendering, never a broken UI. `pr_summaries.summary` stays a single `TEXT` column; no
migration — the structure lives in the markdown convention, not the schema.

**Frontend (`web/src/render.ts`, `prActivityCard`)** — the summary body now branches
on `parseStructuredSummary(pr.summary)`:
- Matched → two labeled rows ("What changed", and "Why" only when present), each a
small uppercase caption (scaled-down `MW_LABEL` idiom) above its markdown body.
- Not matched → today's single prose block, unchanged.

### 4. Retroactive resync (`src/tools/backfill.ts`, `runBackfill`)

Two independent changes to `runBackfill`:

**a. Full PR history.** Remove the `DAYS_BACK`/cutoff logic on the closed-PR fetch —
paginate through every closed PR the repo has, not just the last 14 days. The open-
issues fetch is untouched (it already has no window).

**b. Decouple summary (re)generation from event-capture outcome.** Today,
`storePrSummary` only runs inside `if (res.outcome === "written")` — i.e., only for
brand-new events. Change: for every PR in the fetched list (regardless of whether its
event was newly captured or already existed), look up its existing `pr_summaries` row
and call `storePrSummary` unless `parseStructuredSummary(existing.summary) !== null`
(i.e., skip only when it's already in the new structured format). `ev.raw` (built
before `ingestEvent` runs) always has the PR's title/body available regardless of the
event's write outcome, so no extra DB read is needed to get the summarizer input —
only one extra read (existing `pr_summaries` row) to decide skip-or-regenerate.

**c. Observability.** `BackfillResult` gains `summarized: number` — incremented each
time `storePrSummary` actually runs (not skipped). Threaded through:
- `src/routes.ts` `/admin/backfill` response (already returns the whole result object,
no route change beyond the type flowing through).
- `web/src/api.ts` `adminBackfill()` return type.
- `web/src/main.ts`'s flash message: `Synced: ${r.captured} captured, ${r.unchanged}
unchanged, ${r.summarized} summaries updated`.
- `web/src/render.ts`'s button title: "Fetch recent GitHub PRs + issues" →
"Fetch all GitHub PRs + issues" (no longer just recent).

**Accepted limitation:** unbounded full-history pagination plus one AI call per
un-migrated PR has no hard cap or resumable cursor. Fine at this project's current
size (a first Sync after this ships does a one-time migration of every existing PR to
the structured format; later clicks are cheap since already-structured PRs are
skipped). Would need a real bound/cursor if the repo's PR history grows a lot —
explicitly not building that now (YAGNI).

## Testing

- `test/render.mywork.test.ts` (pure, no DOM): `todoCard` shows `updatedAt` and
doesn't collapse a long title to one truncated line; `prActivityCard` renders two
labeled rows for a structured summary (with and without a `why`), and falls back to
the existing prose rendering for a non-conforming summary.
- New test file (or colocated in `test/summarize.test.ts`) for
`shared/prSummary.ts`'s `parseStructuredSummary`: matches the two-field shape,
matches "What changed" only (no "Why" line), returns `null` for old-style prose and
for empty/malformed input.
- `test/backfill.test.ts`: the existing "oldPr excluded by the 14-day window"
assertion is now wrong on purpose and must be updated to reflect full-history
fetch. New cases: a PR with an existing non-structured summary gets re-summarized
on a second run (`summarized` increments, `storePrSummary`/summarizer called
again); a PR with an existing structured summary is skipped on a second run
(`summarized` stays 0 for it, summarizer not called again — assert via a
call-counting stub summarizer).
- No new migration, so `test/apply-migrations.ts` is untouched.

## Out of scope

- No change to the live webhook capture path (`src/webhook.ts`) — a real-time merge
event only ever fires once, so the "already captured, skip" question doesn't arise
there.
- No change to issue capture/backfill scope (already unbounded).
- No resumable/bounded backfill cursor for large repos (see accepted limitation
above).
28 changes: 28 additions & 0 deletions shared/prSummary.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
// Recognizes the "What changed / Why" markdown convention emitted by the PR
// summarizer (src/tools/summarize.ts) so both the Worker (the backfill's
// already-structured check, src/tools/backfill.ts) and the web build (card
// rendering, web/src/render.ts) agree on what counts as a structured summary.
// No schema change backs this — pr_summaries.summary stays a single markdown
// TEXT column; the structure lives in this convention, not a stored shape, so
// old prose summaries and the deterministic excerpt fallback degrade
// gracefully to a `null` parse instead of erroring.

export interface StructuredPrSummary {
what: string;
why: string | null;
}

const STRUCTURED_RE = /\*\*What changed:\*\*\s*([\s\S]*?)(?:\s*\*\*Why:\*\*\s*([\s\S]*))?$/i;

/** Parses "**What changed:** ... **Why:** ..." out of a PR summary's markdown.
* Returns null when the text doesn't match (old-style prose, the excerpt
* fallback, or a malformed AI response) — callers treat null as "render/treat
* as plain prose," never as an error. */
export function parseStructuredSummary(raw: string): StructuredPrSummary | null {
const m = raw.match(STRUCTURED_RE);
if (!m) return null;
const what = m[1].trim();
if (!what) return null;
const why = m[2]?.trim() || null;
return { what, why };
}
50 changes: 28 additions & 22 deletions src/tools/backfill.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import type { Env } from "../env";
import { nowIso } from "../db";
import type { PrSummaryRow } from "@shared/rows";
import { first } from "../db";
import { ingestEvent } from "../consumer";
import { eventsFromDelivery } from "../webhook";
import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize";
import { applyEventProgress } from "./progress";
import { parseStructuredSummary } from "@shared/prSummary";

// Admin-triggered server-side GitHub backfill. Unlike scripts/backfill-events.mjs
// (which signs synthetic webhook deliveries with the webhook secret), this runs
Expand All@@ -19,13 +21,13 @@ import { applyEventProgress } from "./progress";

const GH_API = "application/vnd.github+json";
const USER_AGENT = "canopy";
const DAYS_BACK = 14;

export interface BackfillResult {
ok: boolean;
error?: string;
captured: number;
unchanged: number;
summarized: number;
prs: number;
issues: number;
}
Expand DownExpand Up@@ -120,12 +122,12 @@ function issueDelivery(issue: GhIssueListItem) {
export async function runBackfill(
env: Env,
principalLogin: string,
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null; now?: string }
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null }
): Promise<BackfillResult> {
const token = env.GITHUB_SERVICE_TOKEN;
const repo = env.GITHUB_REPO;
if (!token || !repo) {
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, prs: 0, issues: 0 };
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, summarized: 0, prs: 0, issues: 0 };
}

const doFetch = opts?.fetchImpl ?? fetch;
Expand All@@ -136,26 +138,18 @@ export async function runBackfill(
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
};
const cutoffMs = new Date(opts?.now ?? nowIso()).getTime() - DAYS_BACK * 24 * 60 * 60 * 1000;

// (a) Closed PRs updated in the last 14 days. Sorted updated-desc, so we stop
// paginating at the first item whose updated_at predates the cutoff.
// (a) All closed PRs, fully paginated — full history, not just recent
// activity, so a Sync also surfaces PRs merged before this route existed.
const prList: GhPrListItem[] = [];
{
let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`;
let done = false;
while (url && !done) {
while (url) {
const res: Response = await doFetch(url, { headers });
if (!res.ok) break;
const page = (await res.json()) as GhPrListItem[];
for (const pr of page) {
if (pr.updated_at && new Date(pr.updated_at).getTime() < cutoffMs) {
done = true;
break;
}
prList.push(pr);
}
url = done ? null : nextLink(res);
prList.push(...page);
url = nextLink(res);
}
}

Expand All@@ -178,6 +172,7 @@ export async function runBackfill(

let captured = 0;
let unchanged = 0;
let summarized = 0;

for (const pr of prList) {
const payload = prClosedDelivery(pr);
Expand All@@ -186,17 +181,28 @@ export async function runBackfill(
const res = await ingestEvent(env.DB, ev, principalLogin);
if (res.outcome === "written") {
captured++;
// Mirror handleGithubWebhook's summary seam: parse THIS PR's own raw and
// store a capture-time summary (storePrSummary never throws).
} else {
unchanged++;
}

// (Re)summarize unless it's already in the structured format — decoupled
// from the event-capture outcome so a Sync also migrates PRs captured
// before the structured format existed, not just brand-new ones.
const existing = await first<PrSummaryRow>(
env.DB,
`SELECT summary FROM pr_summaries WHERE semantic_key = ?`,
ev.semantic_key
);
const alreadyStructured = existing !== null && parseStructuredSummary(existing.summary) !== null;
if (!alreadyStructured) {
const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } };
await storePrSummary(env.DB, summarizer, {
semantic_key: ev.semantic_key,
pr_number: parsed.pr.number,
title: parsed.pr.title,
body: parsed.pr.body ?? "",
});
} else {
unchanged++;
summarized++;
}
}
}
Expand All@@ -216,5 +222,5 @@ export async function runBackfill(
}
}

return { ok: true, captured, unchanged, prs: prList.length, issues: issueList.length };
return { ok: true, captured, unchanged, summarized, prs: prList.length, issues: issueList.length };
}
17 changes: 12 additions & 5 deletions src/tools/summarize.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,17 @@ export interface Summarizer {

const WORKERS_AI_MODEL = "@cf/meta/llama-3.1-8b-instruct";

// Exported for a content assertion in test/summarize.test.ts — the two-field
// shape here is exactly what shared/prSummary.ts's parseStructuredSummary
// recognizes; keep them in sync if either changes.
export const SUMMARIZER_SYSTEM_PROMPT =
"Summarize this one pull request's description for a team activity feed. " +
"Respond with ONLY this exact markdown structure, nothing else:\n" +
"**What changed:** <1-2 short factual sentences>\n" +
"**Why:** <1 short sentence stating the description's own stated rationale>\n" +
'If the description states no rationale, omit the "**Why:**" line entirely. ' +
"Do not speculate beyond the text.";

/** Workers AI-backed summarizer. Bounded to THAT PR's own title+body — no other
* context is sent. Never throws: any failure (network, empty output, malformed
* response) resolves to null so the caller falls back to excerptSummary. */
Expand All@@ -25,11 +36,7 @@ export function workersAiSummarizer(ai: Ai): Summarizer {
try {
const result = await ai.run(WORKERS_AI_MODEL, {
messages: [
{
role: "system",
content:
"Summarize this one pull request's description in 2-3 short markdown sentences for a team activity feed. Do not speculate beyond the text.",
},
{ role: "system", content: SUMMARIZER_SYSTEM_PROMPT },
{ role: "user", content: `Title: ${title}\n\nBody: ${body}` },
],
});
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' My Work: To-do first, structured PR-summary cards, retroactive resync by AndresL230 · Pull Request #11 · SaplingLearn/canopy · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
955 changes: 955 additions & 0 deletions docs/superpowers/plans/2026-07-03-mywork-restructure-plan.md

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions docs/superpowers/specs/2026-07-03-mywork-restructure-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
# My Work: reorder, structured cards, retroactive summary resync

Date: 2026-07-03

## Problem

The My Work screen (`web/src/render.ts`, `myWorkView`) shows two sections: "Previous
activity" (merged/closed PRs) above "To-do" (open assigned issues). Two things are off:

1. To-do — the actionable list — renders below the read-only activity history. It
should be on top.
2. Both sections are thin on structure. `todoCard` is a single truncated line with no
room for a full title or an updated-at signal. `prActivityCard`'s body is one
undifferentiated AI-generated prose blob — no labeled fields.

Separately, "Sync GitHub" (the admin-only backfill button) only looks at PRs closed in
the last 14 days, and only (re)generates a PR's summary when its event is *newly*
captured — an already-captured PR's summary is never touched again. That means
existing PRs would stay on the old unstructured prose format forever, even after this
change ships.

## Design

### 1. Reorder (`web/src/render.ts`, `myWorkView`)

Swap section composition from `${hero}${activity}${todo}` to `${hero}${todo}${activity}`.
Section labels ("To-do", "Previous activity") stay as-is.

### 2. To-do card restructure (`todoCard`)

From a single-line truncated row to a two-line card, still one `<a class="cnpy-card">`:

- Row 1: priority badge + `#number` + title. Title wraps up to 2 lines (CSS
line-clamp) instead of single-line ellipsis truncation.
- Row 2: up to 3 labels (cap unchanged, keeps the card bounded) + a right-aligned
relative "updated" timestamp from `t.updatedAt` (captured today, currently unused
in the card).

### 3. Structured PR summaries

**New shared module `shared/prSummary.ts`** — the single source of truth for
recognizing the structured-summary convention, imported by both the Worker and the
web build:

```ts
export interface StructuredPrSummary {
what: string;
why: string | null;
}

export function parseStructuredSummary(raw: string): StructuredPrSummary | null;
```

It matches markdown of the shape:

```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Tag the example fence so markdownlint stays green.

The bare fenced block here triggers MD040. Add a language hint (md/markdown) or convert it to a quoted example so the spec stays lint-clean.

✏️ Suggested fix
-```+```md
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 56-56: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/specs/2026-07-03-mywork-restructure-design.md` around lines
56 - 59, The example fence in the spec body is missing a language tag and is
triggering markdownlint MD040. Update the fenced example near the “What changed”
/ “Why” section to use the existing markdown example block with a language hint
(for example, in the spec template text) or convert it to a quoted example, and
keep the surrounding content in the same structure so the markdown stays
lint-clean.

Source: Linters/SAST tools


Returns `null` when the text doesn't match (old-style prose, the deterministic
excerpt fallback, or a malformed AI response) — callers treat `null` as "render/treat
as plain prose," never as an error.

**Backend (`src/tools/summarize.ts`)** — `workersAiSummarizer`'s system prompt
changes to require exactly that two-field shape (omitting the `**Why:**` line when
the PR body states no rationale), instead of "2-3 short sentences." No signature
change. `excerptSummary` (the deterministic no-AI fallback) is untouched — it keeps
producing plain prose, which is intentional: a parse miss just falls back to today's
rendering, never a broken UI. `pr_summaries.summary` stays a single `TEXT` column; no
migration — the structure lives in the markdown convention, not the schema.

**Frontend (`web/src/render.ts`, `prActivityCard`)** — the summary body now branches
on `parseStructuredSummary(pr.summary)`:
- Matched → two labeled rows ("What changed", and "Why" only when present), each a
small uppercase caption (scaled-down `MW_LABEL` idiom) above its markdown body.
- Not matched → today's single prose block, unchanged.

### 4. Retroactive resync (`src/tools/backfill.ts`, `runBackfill`)

Two independent changes to `runBackfill`:

**a. Full PR history.** Remove the `DAYS_BACK`/cutoff logic on the closed-PR fetch —
paginate through every closed PR the repo has, not just the last 14 days. The open-
issues fetch is untouched (it already has no window).

**b. Decouple summary (re)generation from event-capture outcome.** Today,
`storePrSummary` only runs inside `if (res.outcome === "written")` — i.e., only for
brand-new events. Change: for every PR in the fetched list (regardless of whether its
event was newly captured or already existed), look up its existing `pr_summaries` row
and call `storePrSummary` unless `parseStructuredSummary(existing.summary) !== null`
(i.e., skip only when it's already in the new structured format). `ev.raw` (built
before `ingestEvent` runs) always has the PR's title/body available regardless of the
event's write outcome, so no extra DB read is needed to get the summarizer input —
only one extra read (existing `pr_summaries` row) to decide skip-or-regenerate.

**c. Observability.** `BackfillResult` gains `summarized: number` — incremented each
time `storePrSummary` actually runs (not skipped). Threaded through:
- `src/routes.ts` `/admin/backfill` response (already returns the whole result object,
no route change beyond the type flowing through).
- `web/src/api.ts` `adminBackfill()` return type.
- `web/src/main.ts`'s flash message: `Synced: ${r.captured} captured, ${r.unchanged}
unchanged, ${r.summarized} summaries updated`.
- `web/src/render.ts`'s button title: "Fetch recent GitHub PRs + issues" →
"Fetch all GitHub PRs + issues" (no longer just recent).

**Accepted limitation:** unbounded full-history pagination plus one AI call per
un-migrated PR has no hard cap or resumable cursor. Fine at this project's current
size (a first Sync after this ships does a one-time migration of every existing PR to
the structured format; later clicks are cheap since already-structured PRs are
skipped). Would need a real bound/cursor if the repo's PR history grows a lot —
explicitly not building that now (YAGNI).

## Testing

- `test/render.mywork.test.ts` (pure, no DOM): `todoCard` shows `updatedAt` and
doesn't collapse a long title to one truncated line; `prActivityCard` renders two
labeled rows for a structured summary (with and without a `why`), and falls back to
the existing prose rendering for a non-conforming summary.
- New test file (or colocated in `test/summarize.test.ts`) for
`shared/prSummary.ts`'s `parseStructuredSummary`: matches the two-field shape,
matches "What changed" only (no "Why" line), returns `null` for old-style prose and
for empty/malformed input.
- `test/backfill.test.ts`: the existing "oldPr excluded by the 14-day window"
assertion is now wrong on purpose and must be updated to reflect full-history
fetch. New cases: a PR with an existing non-structured summary gets re-summarized
on a second run (`summarized` increments, `storePrSummary`/summarizer called
again); a PR with an existing structured summary is skipped on a second run
(`summarized` stays 0 for it, summarizer not called again — assert via a
call-counting stub summarizer).
- No new migration, so `test/apply-migrations.ts` is untouched.

## Out of scope

- No change to the live webhook capture path (`src/webhook.ts`) — a real-time merge
event only ever fires once, so the "already captured, skip" question doesn't arise
there.
- No change to issue capture/backfill scope (already unbounded).
- No resumable/bounded backfill cursor for large repos (see accepted limitation
above).
28 changes: 28 additions & 0 deletions shared/prSummary.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
// Recognizes the "What changed / Why" markdown convention emitted by the PR
// summarizer (src/tools/summarize.ts) so both the Worker (the backfill's
// already-structured check, src/tools/backfill.ts) and the web build (card
// rendering, web/src/render.ts) agree on what counts as a structured summary.
// No schema change backs this — pr_summaries.summary stays a single markdown
// TEXT column; the structure lives in this convention, not a stored shape, so
// old prose summaries and the deterministic excerpt fallback degrade
// gracefully to a `null` parse instead of erroring.

export interface StructuredPrSummary {
what: string;
why: string | null;
}

const STRUCTURED_RE = /\*\*What changed:\*\*\s*([\s\S]*?)(?:\s*\*\*Why:\*\*\s*([\s\S]*))?$/i;

/** Parses "**What changed:** ... **Why:** ..." out of a PR summary's markdown.
* Returns null when the text doesn't match (old-style prose, the excerpt
* fallback, or a malformed AI response) — callers treat null as "render/treat
* as plain prose," never as an error. */
export function parseStructuredSummary(raw: string): StructuredPrSummary | null {
const m = raw.match(STRUCTURED_RE);
if (!m) return null;
const what = m[1].trim();
if (!what) return null;
const why = m[2]?.trim() || null;
return { what, why };
}
50 changes: 28 additions & 22 deletions src/tools/backfill.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import type { Env } from "../env";
import { nowIso } from "../db";
import type { PrSummaryRow } from "@shared/rows";
import { first } from "../db";
import { ingestEvent } from "../consumer";
import { eventsFromDelivery } from "../webhook";
import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize";
import { applyEventProgress } from "./progress";
import { parseStructuredSummary } from "@shared/prSummary";

// Admin-triggered server-side GitHub backfill. Unlike scripts/backfill-events.mjs
// (which signs synthetic webhook deliveries with the webhook secret), this runs
Expand All@@ -19,13 +21,13 @@ import { applyEventProgress } from "./progress";

const GH_API = "application/vnd.github+json";
const USER_AGENT = "canopy";
const DAYS_BACK = 14;

export interface BackfillResult {
ok: boolean;
error?: string;
captured: number;
unchanged: number;
summarized: number;
prs: number;
issues: number;
}
Expand DownExpand Up@@ -120,12 +122,12 @@ function issueDelivery(issue: GhIssueListItem) {
export async function runBackfill(
env: Env,
principalLogin: string,
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null; now?: string }
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null }
): Promise<BackfillResult> {
const token = env.GITHUB_SERVICE_TOKEN;
const repo = env.GITHUB_REPO;
if (!token || !repo) {
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, prs: 0, issues: 0 };
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, summarized: 0, prs: 0, issues: 0 };
}

const doFetch = opts?.fetchImpl ?? fetch;
Expand All@@ -136,26 +138,18 @@ export async function runBackfill(
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
};
const cutoffMs = new Date(opts?.now ?? nowIso()).getTime() - DAYS_BACK * 24 * 60 * 60 * 1000;

// (a) Closed PRs updated in the last 14 days. Sorted updated-desc, so we stop
// paginating at the first item whose updated_at predates the cutoff.
// (a) All closed PRs, fully paginated — full history, not just recent
// activity, so a Sync also surfaces PRs merged before this route existed.
const prList: GhPrListItem[] = [];
{
let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`;
let done = false;
while (url && !done) {
while (url) {
const res: Response = await doFetch(url, { headers });
if (!res.ok) break;
const page = (await res.json()) as GhPrListItem[];
for (const pr of page) {
if (pr.updated_at && new Date(pr.updated_at).getTime() < cutoffMs) {
done = true;
break;
}
prList.push(pr);
}
url = done ? null : nextLink(res);
prList.push(...page);
url = nextLink(res);
}
}

Expand All@@ -178,6 +172,7 @@ export async function runBackfill(

let captured = 0;
let unchanged = 0;
let summarized = 0;

for (const pr of prList) {
const payload = prClosedDelivery(pr);
Expand All@@ -186,17 +181,28 @@ export async function runBackfill(
const res = await ingestEvent(env.DB, ev, principalLogin);
if (res.outcome === "written") {
captured++;
// Mirror handleGithubWebhook's summary seam: parse THIS PR's own raw and
// store a capture-time summary (storePrSummary never throws).
} else {
unchanged++;
}

// (Re)summarize unless it's already in the structured format — decoupled
// from the event-capture outcome so a Sync also migrates PRs captured
// before the structured format existed, not just brand-new ones.
const existing = await first<PrSummaryRow>(
env.DB,
`SELECT summary FROM pr_summaries WHERE semantic_key = ?`,
ev.semantic_key
);
const alreadyStructured = existing !== null && parseStructuredSummary(existing.summary) !== null;
if (!alreadyStructured) {
const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } };
await storePrSummary(env.DB, summarizer, {
semantic_key: ev.semantic_key,
pr_number: parsed.pr.number,
title: parsed.pr.title,
body: parsed.pr.body ?? "",
});
} else {
unchanged++;
summarized++;
}
}
}
Expand All@@ -216,5 +222,5 @@ export async function runBackfill(
}
}

return { ok: true, captured, unchanged, prs: prList.length, issues: issueList.length };
return { ok: true, captured, unchanged, summarized, prs: prList.length, issues: issueList.length };
}
17 changes: 12 additions & 5 deletions src/tools/summarize.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,17 @@ export interface Summarizer {

const WORKERS_AI_MODEL = "@cf/meta/llama-3.1-8b-instruct";

// Exported for a content assertion in test/summarize.test.ts — the two-field
// shape here is exactly what shared/prSummary.ts's parseStructuredSummary
// recognizes; keep them in sync if either changes.
export const SUMMARIZER_SYSTEM_PROMPT =
"Summarize this one pull request's description for a team activity feed. " +
"Respond with ONLY this exact markdown structure, nothing else:\n" +
"**What changed:** <1-2 short factual sentences>\n" +
"**Why:** <1 short sentence stating the description's own stated rationale>\n" +
'If the description states no rationale, omit the "**Why:**" line entirely. ' +
"Do not speculate beyond the text.";

/** Workers AI-backed summarizer. Bounded to THAT PR's own title+body — no other
* context is sent. Never throws: any failure (network, empty output, malformed
* response) resolves to null so the caller falls back to excerptSummary. */
Expand All@@ -25,11 +36,7 @@ export function workersAiSummarizer(ai: Ai): Summarizer {
try {
const result = await ai.run(WORKERS_AI_MODEL, {
messages: [
{
role: "system",
content:
"Summarize this one pull request's description in 2-3 short markdown sentences for a team activity feed. Do not speculate beyond the text.",
},
{ role: "system", content: SUMMARIZER_SYSTEM_PROMPT },
{ role: "user", content: `Title: ${title}\n\nBody: ${body}` },
],
});
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' My Work: To-do first, structured PR-summary cards, retroactive resync by AndresL230 · Pull Request #11 · SaplingLearn/canopy · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
955 changes: 955 additions & 0 deletions docs/superpowers/plans/2026-07-03-mywork-restructure-plan.md

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions docs/superpowers/specs/2026-07-03-mywork-restructure-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
# My Work: reorder, structured cards, retroactive summary resync

Date: 2026-07-03

## Problem

The My Work screen (`web/src/render.ts`, `myWorkView`) shows two sections: "Previous
activity" (merged/closed PRs) above "To-do" (open assigned issues). Two things are off:

1. To-do — the actionable list — renders below the read-only activity history. It
should be on top.
2. Both sections are thin on structure. `todoCard` is a single truncated line with no
room for a full title or an updated-at signal. `prActivityCard`'s body is one
undifferentiated AI-generated prose blob — no labeled fields.

Separately, "Sync GitHub" (the admin-only backfill button) only looks at PRs closed in
the last 14 days, and only (re)generates a PR's summary when its event is *newly*
captured — an already-captured PR's summary is never touched again. That means
existing PRs would stay on the old unstructured prose format forever, even after this
change ships.

## Design

### 1. Reorder (`web/src/render.ts`, `myWorkView`)

Swap section composition from `${hero}${activity}${todo}` to `${hero}${todo}${activity}`.
Section labels ("To-do", "Previous activity") stay as-is.

### 2. To-do card restructure (`todoCard`)

From a single-line truncated row to a two-line card, still one `<a class="cnpy-card">`:

- Row 1: priority badge + `#number` + title. Title wraps up to 2 lines (CSS
line-clamp) instead of single-line ellipsis truncation.
- Row 2: up to 3 labels (cap unchanged, keeps the card bounded) + a right-aligned
relative "updated" timestamp from `t.updatedAt` (captured today, currently unused
in the card).

### 3. Structured PR summaries

**New shared module `shared/prSummary.ts`** — the single source of truth for
recognizing the structured-summary convention, imported by both the Worker and the
web build:

```ts
export interface StructuredPrSummary {
what: string;
why: string | null;
}

export function parseStructuredSummary(raw: string): StructuredPrSummary | null;
```

It matches markdown of the shape:

```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Tag the example fence so markdownlint stays green.

The bare fenced block here triggers MD040. Add a language hint (md/markdown) or convert it to a quoted example so the spec stays lint-clean.

✏️ Suggested fix
-```+```md
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 56-56: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/specs/2026-07-03-mywork-restructure-design.md` around lines
56 - 59, The example fence in the spec body is missing a language tag and is
triggering markdownlint MD040. Update the fenced example near the “What changed”
/ “Why” section to use the existing markdown example block with a language hint
(for example, in the spec template text) or convert it to a quoted example, and
keep the surrounding content in the same structure so the markdown stays
lint-clean.

Source: Linters/SAST tools


Returns `null` when the text doesn't match (old-style prose, the deterministic
excerpt fallback, or a malformed AI response) — callers treat `null` as "render/treat
as plain prose," never as an error.

**Backend (`src/tools/summarize.ts`)** — `workersAiSummarizer`'s system prompt
changes to require exactly that two-field shape (omitting the `**Why:**` line when
the PR body states no rationale), instead of "2-3 short sentences." No signature
change. `excerptSummary` (the deterministic no-AI fallback) is untouched — it keeps
producing plain prose, which is intentional: a parse miss just falls back to today's
rendering, never a broken UI. `pr_summaries.summary` stays a single `TEXT` column; no
migration — the structure lives in the markdown convention, not the schema.

**Frontend (`web/src/render.ts`, `prActivityCard`)** — the summary body now branches
on `parseStructuredSummary(pr.summary)`:
- Matched → two labeled rows ("What changed", and "Why" only when present), each a
small uppercase caption (scaled-down `MW_LABEL` idiom) above its markdown body.
- Not matched → today's single prose block, unchanged.

### 4. Retroactive resync (`src/tools/backfill.ts`, `runBackfill`)

Two independent changes to `runBackfill`:

**a. Full PR history.** Remove the `DAYS_BACK`/cutoff logic on the closed-PR fetch —
paginate through every closed PR the repo has, not just the last 14 days. The open-
issues fetch is untouched (it already has no window).

**b. Decouple summary (re)generation from event-capture outcome.** Today,
`storePrSummary` only runs inside `if (res.outcome === "written")` — i.e., only for
brand-new events. Change: for every PR in the fetched list (regardless of whether its
event was newly captured or already existed), look up its existing `pr_summaries` row
and call `storePrSummary` unless `parseStructuredSummary(existing.summary) !== null`
(i.e., skip only when it's already in the new structured format). `ev.raw` (built
before `ingestEvent` runs) always has the PR's title/body available regardless of the
event's write outcome, so no extra DB read is needed to get the summarizer input —
only one extra read (existing `pr_summaries` row) to decide skip-or-regenerate.

**c. Observability.** `BackfillResult` gains `summarized: number` — incremented each
time `storePrSummary` actually runs (not skipped). Threaded through:
- `src/routes.ts` `/admin/backfill` response (already returns the whole result object,
no route change beyond the type flowing through).
- `web/src/api.ts` `adminBackfill()` return type.
- `web/src/main.ts`'s flash message: `Synced: ${r.captured} captured, ${r.unchanged}
unchanged, ${r.summarized} summaries updated`.
- `web/src/render.ts`'s button title: "Fetch recent GitHub PRs + issues" →
"Fetch all GitHub PRs + issues" (no longer just recent).

**Accepted limitation:** unbounded full-history pagination plus one AI call per
un-migrated PR has no hard cap or resumable cursor. Fine at this project's current
size (a first Sync after this ships does a one-time migration of every existing PR to
the structured format; later clicks are cheap since already-structured PRs are
skipped). Would need a real bound/cursor if the repo's PR history grows a lot —
explicitly not building that now (YAGNI).

## Testing

- `test/render.mywork.test.ts` (pure, no DOM): `todoCard` shows `updatedAt` and
doesn't collapse a long title to one truncated line; `prActivityCard` renders two
labeled rows for a structured summary (with and without a `why`), and falls back to
the existing prose rendering for a non-conforming summary.
- New test file (or colocated in `test/summarize.test.ts`) for
`shared/prSummary.ts`'s `parseStructuredSummary`: matches the two-field shape,
matches "What changed" only (no "Why" line), returns `null` for old-style prose and
for empty/malformed input.
- `test/backfill.test.ts`: the existing "oldPr excluded by the 14-day window"
assertion is now wrong on purpose and must be updated to reflect full-history
fetch. New cases: a PR with an existing non-structured summary gets re-summarized
on a second run (`summarized` increments, `storePrSummary`/summarizer called
again); a PR with an existing structured summary is skipped on a second run
(`summarized` stays 0 for it, summarizer not called again — assert via a
call-counting stub summarizer).
- No new migration, so `test/apply-migrations.ts` is untouched.

## Out of scope

- No change to the live webhook capture path (`src/webhook.ts`) — a real-time merge
event only ever fires once, so the "already captured, skip" question doesn't arise
there.
- No change to issue capture/backfill scope (already unbounded).
- No resumable/bounded backfill cursor for large repos (see accepted limitation
above).
28 changes: 28 additions & 0 deletions shared/prSummary.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
// Recognizes the "What changed / Why" markdown convention emitted by the PR
// summarizer (src/tools/summarize.ts) so both the Worker (the backfill's
// already-structured check, src/tools/backfill.ts) and the web build (card
// rendering, web/src/render.ts) agree on what counts as a structured summary.
// No schema change backs this — pr_summaries.summary stays a single markdown
// TEXT column; the structure lives in this convention, not a stored shape, so
// old prose summaries and the deterministic excerpt fallback degrade
// gracefully to a `null` parse instead of erroring.

export interface StructuredPrSummary {
what: string;
why: string | null;
}

const STRUCTURED_RE = /\*\*What changed:\*\*\s*([\s\S]*?)(?:\s*\*\*Why:\*\*\s*([\s\S]*))?$/i;

/** Parses "**What changed:** ... **Why:** ..." out of a PR summary's markdown.
* Returns null when the text doesn't match (old-style prose, the excerpt
* fallback, or a malformed AI response) — callers treat null as "render/treat
* as plain prose," never as an error. */
export function parseStructuredSummary(raw: string): StructuredPrSummary | null {
const m = raw.match(STRUCTURED_RE);
if (!m) return null;
const what = m[1].trim();
if (!what) return null;
const why = m[2]?.trim() || null;
return { what, why };
}
50 changes: 28 additions & 22 deletions src/tools/backfill.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import type { Env } from "../env";
import { nowIso } from "../db";
import type { PrSummaryRow } from "@shared/rows";
import { first } from "../db";
import { ingestEvent } from "../consumer";
import { eventsFromDelivery } from "../webhook";
import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize";
import { applyEventProgress } from "./progress";
import { parseStructuredSummary } from "@shared/prSummary";

// Admin-triggered server-side GitHub backfill. Unlike scripts/backfill-events.mjs
// (which signs synthetic webhook deliveries with the webhook secret), this runs
Expand All@@ -19,13 +21,13 @@ import { applyEventProgress } from "./progress";

const GH_API = "application/vnd.github+json";
const USER_AGENT = "canopy";
const DAYS_BACK = 14;

export interface BackfillResult {
ok: boolean;
error?: string;
captured: number;
unchanged: number;
summarized: number;
prs: number;
issues: number;
}
Expand DownExpand Up@@ -120,12 +122,12 @@ function issueDelivery(issue: GhIssueListItem) {
export async function runBackfill(
env: Env,
principalLogin: string,
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null; now?: string }
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null }
): Promise<BackfillResult> {
const token = env.GITHUB_SERVICE_TOKEN;
const repo = env.GITHUB_REPO;
if (!token || !repo) {
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, prs: 0, issues: 0 };
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, summarized: 0, prs: 0, issues: 0 };
}

const doFetch = opts?.fetchImpl ?? fetch;
Expand All@@ -136,26 +138,18 @@ export async function runBackfill(
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
};
const cutoffMs = new Date(opts?.now ?? nowIso()).getTime() - DAYS_BACK * 24 * 60 * 60 * 1000;

// (a) Closed PRs updated in the last 14 days. Sorted updated-desc, so we stop
// paginating at the first item whose updated_at predates the cutoff.
// (a) All closed PRs, fully paginated — full history, not just recent
// activity, so a Sync also surfaces PRs merged before this route existed.
const prList: GhPrListItem[] = [];
{
let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`;
let done = false;
while (url && !done) {
while (url) {
const res: Response = await doFetch(url, { headers });
if (!res.ok) break;
const page = (await res.json()) as GhPrListItem[];
for (const pr of page) {
if (pr.updated_at && new Date(pr.updated_at).getTime() < cutoffMs) {
done = true;
break;
}
prList.push(pr);
}
url = done ? null : nextLink(res);
prList.push(...page);
url = nextLink(res);
}
}

Expand All@@ -178,6 +172,7 @@ export async function runBackfill(

let captured = 0;
let unchanged = 0;
let summarized = 0;

for (const pr of prList) {
const payload = prClosedDelivery(pr);
Expand All@@ -186,17 +181,28 @@ export async function runBackfill(
const res = await ingestEvent(env.DB, ev, principalLogin);
if (res.outcome === "written") {
captured++;
// Mirror handleGithubWebhook's summary seam: parse THIS PR's own raw and
// store a capture-time summary (storePrSummary never throws).
} else {
unchanged++;
}

// (Re)summarize unless it's already in the structured format — decoupled
// from the event-capture outcome so a Sync also migrates PRs captured
// before the structured format existed, not just brand-new ones.
const existing = await first<PrSummaryRow>(
env.DB,
`SELECT summary FROM pr_summaries WHERE semantic_key = ?`,
ev.semantic_key
);
const alreadyStructured = existing !== null && parseStructuredSummary(existing.summary) !== null;
if (!alreadyStructured) {
const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } };
await storePrSummary(env.DB, summarizer, {
semantic_key: ev.semantic_key,
pr_number: parsed.pr.number,
title: parsed.pr.title,
body: parsed.pr.body ?? "",
});
} else {
unchanged++;
summarized++;
}
}
}
Expand All@@ -216,5 +222,5 @@ export async function runBackfill(
}
}

return { ok: true, captured, unchanged, prs: prList.length, issues: issueList.length };
return { ok: true, captured, unchanged, summarized, prs: prList.length, issues: issueList.length };
}
17 changes: 12 additions & 5 deletions src/tools/summarize.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,17 @@ export interface Summarizer {

const WORKERS_AI_MODEL = "@cf/meta/llama-3.1-8b-instruct";

// Exported for a content assertion in test/summarize.test.ts — the two-field
// shape here is exactly what shared/prSummary.ts's parseStructuredSummary
// recognizes; keep them in sync if either changes.
export const SUMMARIZER_SYSTEM_PROMPT =
"Summarize this one pull request's description for a team activity feed. " +
"Respond with ONLY this exact markdown structure, nothing else:\n" +
"**What changed:** <1-2 short factual sentences>\n" +
"**Why:** <1 short sentence stating the description's own stated rationale>\n" +
'If the description states no rationale, omit the "**Why:**" line entirely. ' +
"Do not speculate beyond the text.";

/** Workers AI-backed summarizer. Bounded to THAT PR's own title+body — no other
* context is sent. Never throws: any failure (network, empty output, malformed
* response) resolves to null so the caller falls back to excerptSummary. */
Expand All@@ -25,11 +36,7 @@ export function workersAiSummarizer(ai: Ai): Summarizer {
try {
const result = await ai.run(WORKERS_AI_MODEL, {
messages: [
{
role: "system",
content:
"Summarize this one pull request's description in 2-3 short markdown sentences for a team activity feed. Do not speculate beyond the text.",
},
{ role: "system", content: SUMMARIZER_SYSTEM_PROMPT },
{ role: "user", content: `Title: ${title}\n\nBody: ${body}` },
],
});
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); My Work: To-do first, structured PR-summary cards, retroactive resync by AndresL230 · Pull Request #11 · SaplingLearn/canopy · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
955 changes: 955 additions & 0 deletions docs/superpowers/plans/2026-07-03-mywork-restructure-plan.md

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions docs/superpowers/specs/2026-07-03-mywork-restructure-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
# My Work: reorder, structured cards, retroactive summary resync

Date: 2026-07-03

## Problem

The My Work screen (`web/src/render.ts`, `myWorkView`) shows two sections: "Previous
activity" (merged/closed PRs) above "To-do" (open assigned issues). Two things are off:

1. To-do — the actionable list — renders below the read-only activity history. It
should be on top.
2. Both sections are thin on structure. `todoCard` is a single truncated line with no
room for a full title or an updated-at signal. `prActivityCard`'s body is one
undifferentiated AI-generated prose blob — no labeled fields.

Separately, "Sync GitHub" (the admin-only backfill button) only looks at PRs closed in
the last 14 days, and only (re)generates a PR's summary when its event is *newly*
captured — an already-captured PR's summary is never touched again. That means
existing PRs would stay on the old unstructured prose format forever, even after this
change ships.

## Design

### 1. Reorder (`web/src/render.ts`, `myWorkView`)

Swap section composition from `${hero}${activity}${todo}` to `${hero}${todo}${activity}`.
Section labels ("To-do", "Previous activity") stay as-is.

### 2. To-do card restructure (`todoCard`)

From a single-line truncated row to a two-line card, still one `<a class="cnpy-card">`:

- Row 1: priority badge + `#number` + title. Title wraps up to 2 lines (CSS
line-clamp) instead of single-line ellipsis truncation.
- Row 2: up to 3 labels (cap unchanged, keeps the card bounded) + a right-aligned
relative "updated" timestamp from `t.updatedAt` (captured today, currently unused
in the card).

### 3. Structured PR summaries

**New shared module `shared/prSummary.ts`** — the single source of truth for
recognizing the structured-summary convention, imported by both the Worker and the
web build:

```ts
export interface StructuredPrSummary {
what: string;
why: string | null;
}

export function parseStructuredSummary(raw: string): StructuredPrSummary | null;
```

It matches markdown of the shape:

```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Tag the example fence so markdownlint stays green.

The bare fenced block here triggers MD040. Add a language hint (md/markdown) or convert it to a quoted example so the spec stays lint-clean.

✏️ Suggested fix
-```+```md
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
**What changed:** <1-2 factual sentences>
**Why:** <1 sentence — omitted entirely when no rationale is stated>
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 56-56: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/specs/2026-07-03-mywork-restructure-design.md` around lines
56 - 59, The example fence in the spec body is missing a language tag and is
triggering markdownlint MD040. Update the fenced example near the “What changed”
/ “Why” section to use the existing markdown example block with a language hint
(for example, in the spec template text) or convert it to a quoted example, and
keep the surrounding content in the same structure so the markdown stays
lint-clean.

Source: Linters/SAST tools


Returns `null` when the text doesn't match (old-style prose, the deterministic
excerpt fallback, or a malformed AI response) — callers treat `null` as "render/treat
as plain prose," never as an error.

**Backend (`src/tools/summarize.ts`)** — `workersAiSummarizer`'s system prompt
changes to require exactly that two-field shape (omitting the `**Why:**` line when
the PR body states no rationale), instead of "2-3 short sentences." No signature
change. `excerptSummary` (the deterministic no-AI fallback) is untouched — it keeps
producing plain prose, which is intentional: a parse miss just falls back to today's
rendering, never a broken UI. `pr_summaries.summary` stays a single `TEXT` column; no
migration — the structure lives in the markdown convention, not the schema.

**Frontend (`web/src/render.ts`, `prActivityCard`)** — the summary body now branches
on `parseStructuredSummary(pr.summary)`:
- Matched → two labeled rows ("What changed", and "Why" only when present), each a
small uppercase caption (scaled-down `MW_LABEL` idiom) above its markdown body.
- Not matched → today's single prose block, unchanged.

### 4. Retroactive resync (`src/tools/backfill.ts`, `runBackfill`)

Two independent changes to `runBackfill`:

**a. Full PR history.** Remove the `DAYS_BACK`/cutoff logic on the closed-PR fetch —
paginate through every closed PR the repo has, not just the last 14 days. The open-
issues fetch is untouched (it already has no window).

**b. Decouple summary (re)generation from event-capture outcome.** Today,
`storePrSummary` only runs inside `if (res.outcome === "written")` — i.e., only for
brand-new events. Change: for every PR in the fetched list (regardless of whether its
event was newly captured or already existed), look up its existing `pr_summaries` row
and call `storePrSummary` unless `parseStructuredSummary(existing.summary) !== null`
(i.e., skip only when it's already in the new structured format). `ev.raw` (built
before `ingestEvent` runs) always has the PR's title/body available regardless of the
event's write outcome, so no extra DB read is needed to get the summarizer input —
only one extra read (existing `pr_summaries` row) to decide skip-or-regenerate.

**c. Observability.** `BackfillResult` gains `summarized: number` — incremented each
time `storePrSummary` actually runs (not skipped). Threaded through:
- `src/routes.ts` `/admin/backfill` response (already returns the whole result object,
no route change beyond the type flowing through).
- `web/src/api.ts` `adminBackfill()` return type.
- `web/src/main.ts`'s flash message: `Synced: ${r.captured} captured, ${r.unchanged}
unchanged, ${r.summarized} summaries updated`.
- `web/src/render.ts`'s button title: "Fetch recent GitHub PRs + issues" →
"Fetch all GitHub PRs + issues" (no longer just recent).

**Accepted limitation:** unbounded full-history pagination plus one AI call per
un-migrated PR has no hard cap or resumable cursor. Fine at this project's current
size (a first Sync after this ships does a one-time migration of every existing PR to
the structured format; later clicks are cheap since already-structured PRs are
skipped). Would need a real bound/cursor if the repo's PR history grows a lot —
explicitly not building that now (YAGNI).

## Testing

- `test/render.mywork.test.ts` (pure, no DOM): `todoCard` shows `updatedAt` and
doesn't collapse a long title to one truncated line; `prActivityCard` renders two
labeled rows for a structured summary (with and without a `why`), and falls back to
the existing prose rendering for a non-conforming summary.
- New test file (or colocated in `test/summarize.test.ts`) for
`shared/prSummary.ts`'s `parseStructuredSummary`: matches the two-field shape,
matches "What changed" only (no "Why" line), returns `null` for old-style prose and
for empty/malformed input.
- `test/backfill.test.ts`: the existing "oldPr excluded by the 14-day window"
assertion is now wrong on purpose and must be updated to reflect full-history
fetch. New cases: a PR with an existing non-structured summary gets re-summarized
on a second run (`summarized` increments, `storePrSummary`/summarizer called
again); a PR with an existing structured summary is skipped on a second run
(`summarized` stays 0 for it, summarizer not called again — assert via a
call-counting stub summarizer).
- No new migration, so `test/apply-migrations.ts` is untouched.

## Out of scope

- No change to the live webhook capture path (`src/webhook.ts`) — a real-time merge
event only ever fires once, so the "already captured, skip" question doesn't arise
there.
- No change to issue capture/backfill scope (already unbounded).
- No resumable/bounded backfill cursor for large repos (see accepted limitation
above).
28 changes: 28 additions & 0 deletions shared/prSummary.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
// Recognizes the "What changed / Why" markdown convention emitted by the PR
// summarizer (src/tools/summarize.ts) so both the Worker (the backfill's
// already-structured check, src/tools/backfill.ts) and the web build (card
// rendering, web/src/render.ts) agree on what counts as a structured summary.
// No schema change backs this — pr_summaries.summary stays a single markdown
// TEXT column; the structure lives in this convention, not a stored shape, so
// old prose summaries and the deterministic excerpt fallback degrade
// gracefully to a `null` parse instead of erroring.

export interface StructuredPrSummary {
what: string;
why: string | null;
}

const STRUCTURED_RE = /\*\*What changed:\*\*\s*([\s\S]*?)(?:\s*\*\*Why:\*\*\s*([\s\S]*))?$/i;

/** Parses "**What changed:** ... **Why:** ..." out of a PR summary's markdown.
* Returns null when the text doesn't match (old-style prose, the excerpt
* fallback, or a malformed AI response) — callers treat null as "render/treat
* as plain prose," never as an error. */
export function parseStructuredSummary(raw: string): StructuredPrSummary | null {
const m = raw.match(STRUCTURED_RE);
if (!m) return null;
const what = m[1].trim();
if (!what) return null;
const why = m[2]?.trim() || null;
return { what, why };
}
50 changes: 28 additions & 22 deletions src/tools/backfill.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
import type { Env } from "../env";
import { nowIso } from "../db";
import type { PrSummaryRow } from "@shared/rows";
import { first } from "../db";
import { ingestEvent } from "../consumer";
import { eventsFromDelivery } from "../webhook";
import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize";
import { applyEventProgress } from "./progress";
import { parseStructuredSummary } from "@shared/prSummary";

// Admin-triggered server-side GitHub backfill. Unlike scripts/backfill-events.mjs
// (which signs synthetic webhook deliveries with the webhook secret), this runs
Expand All@@ -19,13 +21,13 @@ import { applyEventProgress } from "./progress";

const GH_API = "application/vnd.github+json";
const USER_AGENT = "canopy";
const DAYS_BACK = 14;

export interface BackfillResult {
ok: boolean;
error?: string;
captured: number;
unchanged: number;
summarized: number;
prs: number;
issues: number;
}
Expand DownExpand Up@@ -120,12 +122,12 @@ function issueDelivery(issue: GhIssueListItem) {
export async function runBackfill(
env: Env,
principalLogin: string,
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null; now?: string }
opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null }
): Promise<BackfillResult> {
const token = env.GITHUB_SERVICE_TOKEN;
const repo = env.GITHUB_REPO;
if (!token || !repo) {
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, prs: 0, issues: 0 };
return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, summarized: 0, prs: 0, issues: 0 };
}

const doFetch = opts?.fetchImpl ?? fetch;
Expand All@@ -136,26 +138,18 @@ export async function runBackfill(
"user-agent": USER_AGENT,
"x-github-api-version": "2022-11-28",
};
const cutoffMs = new Date(opts?.now ?? nowIso()).getTime() - DAYS_BACK * 24 * 60 * 60 * 1000;

// (a) Closed PRs updated in the last 14 days. Sorted updated-desc, so we stop
// paginating at the first item whose updated_at predates the cutoff.
// (a) All closed PRs, fully paginated — full history, not just recent
// activity, so a Sync also surfaces PRs merged before this route existed.
const prList: GhPrListItem[] = [];
{
let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`;
let done = false;
while (url && !done) {
while (url) {
const res: Response = await doFetch(url, { headers });
if (!res.ok) break;
const page = (await res.json()) as GhPrListItem[];
for (const pr of page) {
if (pr.updated_at && new Date(pr.updated_at).getTime() < cutoffMs) {
done = true;
break;
}
prList.push(pr);
}
url = done ? null : nextLink(res);
prList.push(...page);
url = nextLink(res);
}
}

Expand All@@ -178,6 +172,7 @@ export async function runBackfill(

let captured = 0;
let unchanged = 0;
let summarized = 0;

for (const pr of prList) {
const payload = prClosedDelivery(pr);
Expand All@@ -186,17 +181,28 @@ export async function runBackfill(
const res = await ingestEvent(env.DB, ev, principalLogin);
if (res.outcome === "written") {
captured++;
// Mirror handleGithubWebhook's summary seam: parse THIS PR's own raw and
// store a capture-time summary (storePrSummary never throws).
} else {
unchanged++;
}

// (Re)summarize unless it's already in the structured format — decoupled
// from the event-capture outcome so a Sync also migrates PRs captured
// before the structured format existed, not just brand-new ones.
const existing = await first<PrSummaryRow>(
env.DB,
`SELECT summary FROM pr_summaries WHERE semantic_key = ?`,
ev.semantic_key
);
const alreadyStructured = existing !== null && parseStructuredSummary(existing.summary) !== null;
if (!alreadyStructured) {
const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } };
await storePrSummary(env.DB, summarizer, {
semantic_key: ev.semantic_key,
pr_number: parsed.pr.number,
title: parsed.pr.title,
body: parsed.pr.body ?? "",
});
} else {
unchanged++;
summarized++;
}
}
}
Expand All@@ -216,5 +222,5 @@ export async function runBackfill(
}
}

return { ok: true, captured, unchanged, prs: prList.length, issues: issueList.length };
return { ok: true, captured, unchanged, summarized, prs: prList.length, issues: issueList.length };
}
17 changes: 12 additions & 5 deletions src/tools/summarize.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,17 @@ export interface Summarizer {

const WORKERS_AI_MODEL = "@cf/meta/llama-3.1-8b-instruct";

// Exported for a content assertion in test/summarize.test.ts — the two-field
// shape here is exactly what shared/prSummary.ts's parseStructuredSummary
// recognizes; keep them in sync if either changes.
export const SUMMARIZER_SYSTEM_PROMPT =
"Summarize this one pull request's description for a team activity feed. " +
"Respond with ONLY this exact markdown structure, nothing else:\n" +
"**What changed:** <1-2 short factual sentences>\n" +
"**Why:** <1 short sentence stating the description's own stated rationale>\n" +
'If the description states no rationale, omit the "**Why:**" line entirely. ' +
"Do not speculate beyond the text.";

/** Workers AI-backed summarizer. Bounded to THAT PR's own title+body — no other
* context is sent. Never throws: any failure (network, empty output, malformed
* response) resolves to null so the caller falls back to excerptSummary. */
Expand All@@ -25,11 +36,7 @@ export function workersAiSummarizer(ai: Ai): Summarizer {
try {
const result = await ai.run(WORKERS_AI_MODEL, {
messages: [
{
role: "system",
content:
"Summarize this one pull request's description in 2-3 short markdown sentences for a team activity feed. Do not speculate beyond the text.",
},
{ role: "system", content: SUMMARIZER_SYSTEM_PROMPT },
{ role: "user", content: `Title: ${title}\n\nBody: ${body}` },
],
});
Expand Down
Loading