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
34 changes: 34 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: pages

on:
push:
branches: [main]
paths:
- "site/**"
- ".github/workflows/pages.yml"
workflow_dispatch: {}

permissions:
contents: read
pages: write
id-token: write

# Allow one concurrent deployment; newer runs cancel in-progress ones.
concurrency:
group: pages
cancel-in-progress: true

jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: site
- id: deployment
uses: actions/deploy-pages@v4
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@ guardrail violation) it produces no output and never partially applies changes.
| [`test-coverage`](loops/test-coverage/README.md) | Backfills tests for uncovered changed lines (self-validating) | AI | PR |
| [`security-remediation`](loops/security-remediation/README.md) | Human-gated fixes for security findings above a threshold | hybrid | PR |
| [`kb-gap`](loops/kb-gap/README.md) | Drafts KB articles for recurring support topics (self-heal docs) | AI | PR |
| [`metric-anomaly`](loops/metric-anomaly/README.md) | Z-score anomaly briefs over your metrics | deterministic | PR |
| [`incident-followup`](loops/incident-followup/README.md) | Overdue postmortem action items + recurring root causes | deterministic | PR |

The "top 5 after auto-docs" shortlist is complete. See the
Plus a **long-horizon** loop — the
[Experiment Lifecycle Orchestrator](loops/experiment/README.md) — built on the
`longrun` primitives (durable state, approval gates, resumable plans).

🌐 **[Project site](https://dcca.github.io/loopy/)** · See the
[roadmap](openspec/roadmap.md) for the backlog and the
[research catalog](openspec/research/loop-use-cases.md) for the full survey.

Expand Down
44 changes: 44 additions & 0 deletions loops/incident-followup/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# incident-followup loop

Keeps postmortems honest. Sweeps incident **action items** for overdue ones and
clusters incidents by **root cause** to surface recurring failure patterns, then
publishes a single markdown follow-up digest as a pull request.

## How it works

| Phase | What happens |
|-------|--------------|
| **trigger** | Weekly schedule or manual dispatch |
| **detect** | Compute overdue action items and recurring root causes; work needed if either is non-empty |
| **act** | Render the digest and emit it as one change set under `reports/**` |
| **output** | One PR refreshing `reports/incident-followup.md` |
| **guardrails** | Allowlist `reports/**`, `maxFiles: 5`, `skipIfOpenPr` |

Fully deterministic (no AI). The incident data is the only external boundary,
injected as `services.incidents`.

## Injected boundaries (`services`)

- `incidents` — an `IncidentSource` exposing `incidents()` and `actionItems()`;
the loop performs no network I/O of its own
- `now` — optional `() => Date` clock for reproducible runs (default `new Date`)

## What counts

- **Overdue** — an *open* action item whose `dueIso` is strictly before "now".
`daysOverdue` is whole days, clamped at 0; done and dateless items are excluded.
- **Recurrence** — a normalized root cause shared by `>= minRecurrence`
incidents. Incidents without a root cause are skipped.

## Configuration (`loop.yaml`)

- `reportPath` — where the digest is written (default `reports/incident-followup.md`)
- `minRecurrence` — incidents sharing a cause before it counts as recurring
(default `2`)

## Notes

The digest is one weekly artifact rather than a stream of per-item nags — the
deliberate antidote to alert fatigue. The loop produces no PR when nothing is
overdue and no cause recurs, and every change is gated by the `reports/**`
allowlist.
93 changes: 93 additions & 0 deletions loops/incident-followup/hooks/incidents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
export interface ActionItem {
id: string;
title: string;
incidentId: string;
status: "open" | "done";
dueIso?: string;
}

export interface Incident {
id: string;
title: string;
rootCause?: string;
closedIso?: string;
}

export interface IncidentSource {
incidents(): Promise<Incident[]>;
actionItems(): Promise<ActionItem[]>;
}

export interface OverdueItem {
item: ActionItem;
daysOverdue: number;
}

export interface Recurrence {
rootCause: string;
count: number;
incidentIds: string[];
}

const DAY_MS = 86_400_000;

/** Normalize a root-cause string: lowercased, trimmed, inner whitespace collapsed. */
export function normalizeCause(s: string): string {
return s.toLowerCase().trim().replace(/\s+/g, " ");
}

/**
* Find open action items whose `dueIso` is strictly before `nowIso`.
*
* Items without a due date, items already `done`, and items not yet due are
* excluded. `daysOverdue` is `floor((now - due) / 86400000)` clamped to a
* minimum of 0. Result is sorted by `daysOverdue` descending.
*/
export function findOverdue(items: ActionItem[], nowIso: string): OverdueItem[] {
const now = Date.parse(nowIso);

const overdue: OverdueItem[] = [];
for (const item of items) {
if (item.status !== "open") continue;
if (item.dueIso === undefined) continue;
const due = Date.parse(item.dueIso);
if (Number.isNaN(due)) continue;
if (!(due < now)) continue;
const daysOverdue = Math.max(0, Math.floor((now - due) / DAY_MS));
overdue.push({ item, daysOverdue });
}

overdue.sort((a, b) => b.daysOverdue - a.daysOverdue);
return overdue;
}

/**
* Cluster incidents by normalized root cause and surface recurring failures.
*
* Incidents without a `rootCause` are skipped. A recurrence is a cause whose
* incident count is `>= minCount`. Result is sorted by count descending, then
* cause ascending.
*/
export function findRecurrences(incidents: Incident[], minCount: number): Recurrence[] {
const groups = new Map<string, Recurrence>();
for (const incident of incidents) {
if (incident.rootCause === undefined) continue;
const cause = normalizeCause(incident.rootCause);
if (cause.length === 0) continue;
const existing = groups.get(cause);
if (existing) {
existing.count += 1;
existing.incidentIds.push(incident.id);
} else {
groups.set(cause, { rootCause: cause, count: 1, incidentIds: [incident.id] });
}
}

const recurrences = [...groups.values()].filter((r) => r.count >= minCount);
recurrences.sort(
(a, b) =>
b.count - a.count ||
(a.rootCause < b.rootCause ? -1 : a.rootCause > b.rootCause ? 1 : 0),
);
return recurrences;
}
148 changes: 148 additions & 0 deletions loops/incident-followup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type {
ActResult,
DetectResult,
FileChange,
Guardrails,
Loop,
LoopManifest,
Trigger,
} from "../../src/core/index.js";
import {
findOverdue,
findRecurrences,
type IncidentSource,
type OverdueItem,
type Recurrence,
} from "./hooks/incidents.js";

export interface IncidentFollowupConfig {
/** path the follow-up digest is written to (default "reports/incident-followup.md") */
reportPath: string;
/** incidents sharing a root cause before it counts as a recurrence (default 2) */
minRecurrence: number;
}

export interface IncidentFollowupServices {
incidents: IncidentSource;
/** injectable clock for deterministic runs (default `new Date`) */
now?: () => Date;
}

interface Findings {
nowIso: string;
overdue: OverdueItem[];
recurrences: Recurrence[];
}

/** Build the incident-followup loop from explicit config + the injected services. */
export function createIncidentFollowupLoop(
config: IncidentFollowupConfig,
guardrails: Guardrails,
trigger: Trigger,
services: IncidentFollowupServices,
): Loop {
async function computeFindings(): Promise<Findings> {
const nowIso = (services.now?.() ?? new Date()).toISOString();
const overdue = findOverdue(await services.incidents.actionItems(), nowIso);
const recurrences = findRecurrences(await services.incidents.incidents(), config.minRecurrence);
return { nowIso, overdue, recurrences };
}

return {
id: "incident-followup",
trigger,
guardrails,

async detect(): Promise<DetectResult> {
const { overdue, recurrences } = await computeFindings();
if (overdue.length === 0 && recurrences.length === 0) {
return { workNeeded: false, reason: "no overdue action items or recurring root causes" };
}
return {
workNeeded: true,
reason: `${overdue.length} overdue item(s), ${recurrences.length} recurring cause(s)`,
affected: [...overdue.map((o) => o.item.id), ...recurrences.map((r) => r.rootCause)],
};
},

async act(): Promise<ActResult> {
const { nowIso, overdue, recurrences } = await computeFindings();
const dateIso = nowIso.slice(0, 10);
const change: FileChange = {
path: config.reportPath,
op: "write",
contents: renderDigest(overdue, recurrences, dateIso),
};
return { changes: [change], summary: summarize(overdue, recurrences, config.reportPath) };
},
};
}

/** Build the incident-followup loop from a parsed `loop.yaml` manifest. */
export function createIncidentFollowupLoopFromManifest(
manifest: LoopManifest,
services: IncidentFollowupServices,
): Loop {
const c = manifest.config;
const config: IncidentFollowupConfig = {
reportPath:
typeof c["reportPath"] === "string" ? c["reportPath"] : "reports/incident-followup.md",
minRecurrence: typeof c["minRecurrence"] === "number" ? c["minRecurrence"] : 2,
};
return createIncidentFollowupLoop(config, manifest.guardrails, manifest.trigger, services);
}

/** Render the markdown follow-up digest. */
export function renderDigest(
overdue: OverdueItem[],
recurrences: Recurrence[],
dateIso: string,
): string {
const lines: string[] = [`# Incident follow-up — ${dateIso}`, ""];

lines.push("## Overdue action items", "");
if (overdue.length === 0) {
lines.push("None — all open action items are on track.", "");
} else {
lines.push("| Days overdue | Item | Incident |", "|---|---|---|");
for (const o of overdue) {
lines.push(`| ${o.daysOverdue} | ${o.item.title} | ${o.item.incidentId} |`);
}
lines.push("");
}

lines.push("## Recurring root causes", "");
if (recurrences.length === 0) {
lines.push("None — no root cause met the recurrence threshold.", "");
} else {
lines.push("| Count | Root cause | Incidents |", "|---|---|---|");
for (const r of recurrences) {
lines.push(`| ${r.count} | ${r.rootCause} | ${r.incidentIds.join(", ")} |`);
}
lines.push("");
}

lines.push("_Generated by loopy incident-followup. Review and assign owners._");
return lines.join("\n");
}

function summarize(overdue: OverdueItem[], recurrences: Recurrence[], reportPath: string): string {
return [
"## Incident follow-up",
"",
`- ${overdue.length} overdue action item(s)`,
`- ${recurrences.length} recurring root cause(s)`,
`- digest: \`${reportPath}\``,
"",
"_Generated by loopy incident-followup. Review and assign owners._",
].join("\n");
}

export type {
ActionItem,
Incident,
IncidentSource,
OverdueItem,
Recurrence,
} from "./hooks/incidents.js";
export { findOverdue, findRecurrences, normalizeCause } from "./hooks/incidents.js";
15 changes: 15 additions & 0 deletions loops/incident-followup/loop.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
id: incident-followup

trigger:
type: schedule
cron: "0 7 * * 1" # weekly, Mondays 07:00 UTC

guardrails:
pathAllowlist:
- "reports/**"
maxFiles: 5
skipIfOpenPr: true

config:
reportPath: "reports/incident-followup.md"
minRecurrence: 2
43 changes: 43 additions & 0 deletions loops/incident-followup/playbook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Incident Follow-up Playbook

This loop is **deterministic** — it needs no AI step. It sweeps incident action
items for overdue ones, clusters incidents by root cause to surface recurring
failure patterns, and writes a single markdown follow-up digest as a PR.

## Behavior

1. **detect** — read action items and incidents from the injected
`IncidentSource`. Compute the overdue action items (`findOverdue`) and the
recurring root causes (`findRecurrences`). Work is needed when at least one
overdue item or one recurrence exists.
2. **act** — render the digest (`renderDigest`) and emit a single change set
writing it to `reportPath`.
3. **output** — one PR containing the refreshed digest.

## What counts

- **Overdue** — an *open* action item whose `dueIso` is strictly before "now".
`daysOverdue = floor((now - due) / 86400000)`, never negative. Done items and
items without a due date are excluded. Sorted most-overdue first.
- **Recurrence** — incidents grouped by normalized root cause (lowercased,
trimmed, whitespace collapsed). A cause with `>= minRecurrence` incidents is a
recurring failure pattern. Incidents without a root cause are skipped. Sorted
by count, then cause name.

## Guardrails

- Allowlist is `reports/**`; `maxFiles: 5`.
- `skipIfOpenPr` avoids stacking duplicate digest PRs.
- The clock is injectable (`services.now`) so runs are fully reproducible.

## Anti-pattern: nag fatigue

The digest is a single weekly artifact, not a stream of per-item pings. Surfacing
overdue work and recurring causes together — and only when something is actually
overdue or recurring — keeps the signal high. Re-alerting every item every day
trains people to ignore the loop; this design deliberately avoids that.

## Optional AI enhancement

An AI step could later draft suggested owners or remediation themes for the
recurring causes, but it is intentionally not part of the core deterministic loop.
Loading
Loading