diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..bdde509 --- /dev/null +++ b/.github/workflows/pages.yml @@ -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 diff --git a/README.md b/README.md index 03dbcff..b073d54 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/loops/incident-followup/README.md b/loops/incident-followup/README.md new file mode 100644 index 0000000..8d2b1e7 --- /dev/null +++ b/loops/incident-followup/README.md @@ -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. diff --git a/loops/incident-followup/hooks/incidents.ts b/loops/incident-followup/hooks/incidents.ts new file mode 100644 index 0000000..0459339 --- /dev/null +++ b/loops/incident-followup/hooks/incidents.ts @@ -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; + actionItems(): Promise; +} + +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(); + 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; +} diff --git a/loops/incident-followup/index.ts b/loops/incident-followup/index.ts new file mode 100644 index 0000000..b4a0024 --- /dev/null +++ b/loops/incident-followup/index.ts @@ -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 { + 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 { + 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 { + 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"; diff --git a/loops/incident-followup/loop.yaml b/loops/incident-followup/loop.yaml new file mode 100644 index 0000000..9432f61 --- /dev/null +++ b/loops/incident-followup/loop.yaml @@ -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 diff --git a/loops/incident-followup/playbook.md b/loops/incident-followup/playbook.md new file mode 100644 index 0000000..b913c66 --- /dev/null +++ b/loops/incident-followup/playbook.md @@ -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. diff --git a/loops/metric-anomaly/README.md b/loops/metric-anomaly/README.md new file mode 100644 index 0000000..3f06d41 --- /dev/null +++ b/loops/metric-anomaly/README.md @@ -0,0 +1,54 @@ +# metric-anomaly loop + +Turns metric drift into a **readable brief** instead of a pager storm. Watches a +set of metric series, flags the ones whose latest point has drifted from the +series' own baseline (by z-score), and writes a markdown anomaly brief as a +single reviewable pull request โ€” a calm narrator, not an alarm. + +## How it works + +| Phase | What happens | +|-------|--------------| +| **trigger** | Daily schedule or manual dispatch | +| **detect** | For each series, z-score the latest point against its baseline; work needed if any series crosses `zThreshold` | +| **act** | Render an anomaly brief and emit a single change writing it to `reportPath` | +| **output** | One PR containing the brief; no anomalies โ‡’ no work | +| **guardrails** | Allowlist `reports/**`, `maxFiles: 5`, `skipIfOpenPr` | + +Fully deterministic (no AI). The metric data is the only external boundary, +injected as `services.metrics` (a `MetricSource`); the clock is injected as +`services.now` for reproducible output. + +## Detection (z-score) + +For each series with at least 3 points: + +- The **baseline** is every point except the last. +- Compute the baseline's **mean** and **population standard deviation**. +- The latest point's `z = (value - mean) / std` when `std > 0`, else `0`. +- It is an anomaly when `std > 0` and `|z| >= zThreshold`; direction is `up` + when the value is at/above the mean, otherwise `down`. + +Series with fewer than 3 points or a flat baseline (`std == 0`) are skipped, so +constant or sparse series never produce false positives. Results are sorted by +`|z|` descending. + +## Injected boundaries (`services`) + +- `metrics` โ€” a `MetricSource` whose `series()` returns the series to inspect. + There is no network in the loop; wiring a real data source is the caller's job. +- `now` โ€” `() => Date`, the clock used to date the brief (default `new Date`). + +## Configuration (`loop.yaml`) + +- `reportPath` โ€” path the anomaly brief is written to (default + `reports/anomalies.md`) +- `zThreshold` โ€” z-score magnitude at/above which the latest point is anomalous + (default `3`) + +## Anti-pattern warning + +Metrics are noisy. A z-score crossing is a **signal to look**, not a verdict โ€” a +single threshold cannot know about deploys, seasonality, or missing data. This +loop deliberately stops at a brief for human review; never wire it to act on its +own findings automatically. diff --git a/loops/metric-anomaly/hooks/anomaly.ts b/loops/metric-anomaly/hooks/anomaly.ts new file mode 100644 index 0000000..748cf0d --- /dev/null +++ b/loops/metric-anomaly/hooks/anomaly.ts @@ -0,0 +1,73 @@ +export interface MetricPoint { + t: string; + value: number; +} + +export interface MetricSeries { + name: string; + points: MetricPoint[]; +} + +/** The metric data boundary: returns the series to inspect (no network here). */ +export interface MetricSource { + series(): Promise; +} + +export interface Anomaly { + metric: string; + value: number; + mean: number; + std: number; + z: number; + direction: "up" | "down"; +} + +/** + * Detect anomalies in each series by comparing its final point to the + * z-score baseline formed by all preceding points. + * + * For each series with `>= 3` points: + * - baseline = every point except the last + * - mean and population standard deviation are computed over the baseline + * - the last point's z-score is `(last.value - mean) / std` when `std > 0`, + * otherwise `0` + * - it is an anomaly when `std > 0` and `|z| >= threshold` + * - direction is `"up"` when `last.value >= mean`, otherwise `"down"` + * + * Series with fewer than 3 points or a flat baseline (`std == 0`) are skipped. + * Results are sorted by `|z|` descending. + */ +export function detectAnomalies(series: MetricSeries[], threshold: number): Anomaly[] { + const anomalies: Anomaly[] = []; + + for (const s of series) { + if (s.points.length < 3) continue; + + const last = s.points[s.points.length - 1]; + if (last === undefined) continue; + + const baseline = s.points.slice(0, -1); + const n = baseline.length; + if (n === 0) continue; + + const mean = baseline.reduce((sum, p) => sum + p.value, 0) / n; + const variance = baseline.reduce((sum, p) => sum + (p.value - mean) ** 2, 0) / n; + const std = Math.sqrt(variance); + if (std === 0) continue; + + const z = (last.value - mean) / std; + if (Math.abs(z) < threshold) continue; + + anomalies.push({ + metric: s.name, + value: last.value, + mean, + std, + z, + direction: last.value >= mean ? "up" : "down", + }); + } + + anomalies.sort((a, b) => Math.abs(b.z) - Math.abs(a.z)); + return anomalies; +} diff --git a/loops/metric-anomaly/index.ts b/loops/metric-anomaly/index.ts new file mode 100644 index 0000000..0fff8c0 --- /dev/null +++ b/loops/metric-anomaly/index.ts @@ -0,0 +1,134 @@ +import type { + ActResult, + DetectResult, + FileChange, + Guardrails, + Loop, + LoopManifest, + RunContext, + Trigger, +} from "../../src/core/index.js"; +import { detectAnomalies, type Anomaly, type MetricSource } from "./hooks/anomaly.js"; + +export interface MetricAnomalyConfig { + /** path the anomaly brief is written to (default "reports/anomalies.md") */ + reportPath: string; + /** z-score magnitude at/above which a final point counts as anomalous (default 3) */ + zThreshold: number; +} + +export interface MetricAnomalyServices { + metrics: MetricSource; + /** clock boundary, injectable for deterministic output (default `new Date`) */ + now?: () => Date; +} + +/** Build the metric-anomaly loop from explicit config + the injected services. */ +export function createMetricAnomalyLoop( + config: MetricAnomalyConfig, + guardrails: Guardrails, + trigger: Trigger, + services: MetricAnomalyServices, +): Loop { + async function computeAnomalies(): Promise { + const series = await services.metrics.series(); + return detectAnomalies(series, config.zThreshold); + } + + return { + id: "metric-anomaly", + trigger, + guardrails, + + async detect(_ctx: RunContext): Promise { + const anomalies = await computeAnomalies(); + if (anomalies.length === 0) { + return { workNeeded: false, reason: "no metric anomalies" }; + } + const n = anomalies.length; + return { + workNeeded: true, + reason: `${n} ${n === 1 ? "anomaly" : "anomalies"}`, + affected: anomalies.map((a) => a.metric), + }; + }, + + async act(_ctx: RunContext): Promise { + const anomalies = await computeAnomalies(); + const date = isoDate((services.now ?? (() => new Date()))()); + + const change: FileChange = { + path: config.reportPath, + op: "write", + contents: renderAnomalyBrief(anomalies, date), + }; + + return { changes: [change], summary: summarize(anomalies) }; + }, + }; +} + +/** Build the metric-anomaly loop from a parsed `loop.yaml` manifest. */ +export function createMetricAnomalyLoopFromManifest( + manifest: LoopManifest, + services: MetricAnomalyServices, +): Loop { + const c = manifest.config; + const config: MetricAnomalyConfig = { + reportPath: typeof c["reportPath"] === "string" ? c["reportPath"] : "reports/anomalies.md", + zThreshold: typeof c["zThreshold"] === "number" ? c["zThreshold"] : 3, + }; + return createMetricAnomalyLoop(config, manifest.guardrails, manifest.trigger, services); +} + +/** Render the anomaly brief as markdown. Numbers are rounded for display only. */ +export function renderAnomalyBrief(anomalies: Anomaly[], dateIso: string): string { + const lines: string[] = [`# Metric anomaly brief โ€” ${dateIso}`, ""]; + + if (anomalies.length === 0) { + lines.push("No anomalies detected against each series' own baseline."); + lines.push(""); + return lines.join("\n"); + } + + const n = anomalies.length; + lines.push(`${n} ${n === 1 ? "metric" : "metrics"} drifted from baseline:`); + lines.push(""); + lines.push("| Metric | Direction | Latest | Baseline mean | Baseline std | z-score |"); + lines.push("|--------|-----------|--------|---------------|--------------|---------|"); + for (const a of anomalies) { + const arrow = a.direction === "up" ? "up" : "down"; + lines.push( + `| ${a.metric} | ${arrow} | ${round(a.value)} | ${round(a.mean)} | ${round(a.std)} | ${round(a.z)} |`, + ); + } + lines.push(""); + + return lines.join("\n"); +} + +function summarize(anomalies: Anomaly[]): string { + const items = anomalies.map( + (a) => `- \`${a.metric}\` ${a.direction} (z ${round(a.z)})`, + ); + return [ + "## Metric anomaly brief", + "", + "Detected anomalies against each metric series' own baseline:", + ...items, + "", + "_Generated by loopy metric-anomaly. Review before acting; metrics can be noisy._", + ].join("\n"); +} + +/** Round to a few decimals for display, never in the returned anomaly numbers. */ +function round(n: number): number { + return Math.round(n * 1000) / 1000; +} + +function isoDate(d: Date): string { + return d.toISOString().slice(0, 10); +} + +export type { Anomaly, MetricPoint, MetricSeries, MetricSource } from "./hooks/anomaly.js"; +export { detectAnomalies } from "./hooks/anomaly.js"; diff --git a/loops/metric-anomaly/loop.yaml b/loops/metric-anomaly/loop.yaml new file mode 100644 index 0000000..2118871 --- /dev/null +++ b/loops/metric-anomaly/loop.yaml @@ -0,0 +1,16 @@ +id: metric-anomaly + +trigger: + type: schedule + cron: "0 7 * * *" # daily, 07:00 UTC + +guardrails: + pathAllowlist: + - "reports/**" + maxFiles: 5 + skipIfOpenPr: true + +config: + reportPath: "reports/anomalies.md" + # z-score magnitude at/above which a series' latest point is anomalous. + zThreshold: 3 diff --git a/loops/metric-anomaly/playbook.md b/loops/metric-anomaly/playbook.md new file mode 100644 index 0000000..3db5085 --- /dev/null +++ b/loops/metric-anomaly/playbook.md @@ -0,0 +1,41 @@ +# Metric Anomaly โ†’ Narrator Playbook + +This loop is **deterministic** โ€” it needs no AI step. It reads metric series from +an injected `MetricSource`, z-scores each series' latest point against its own +baseline, and writes a markdown anomaly brief as a single reviewable pull +request. + +## Behavior + +1. **detect** โ€” call `services.metrics.series()` and run `detectAnomalies` with + `zThreshold`. Work is needed only if at least one series crosses the + threshold. For each series with `>= 3` points, the baseline is all points + except the last; the latest point's z-score is `(value - mean) / std` over + the baseline (population std). It is an anomaly when `std > 0` and + `|z| >= zThreshold`. +2. **act** โ€” render the anomaly brief (`renderAnomalyBrief`) dated from + `services.now`, and emit a single change writing it to `reportPath`. +3. **output** โ€” one PR containing the brief; no anomalies โ‡’ no work. + +## The MetricSource boundary + +The loop performs no I/O of its own. All metric data arrives through +`services.metrics`, a `MetricSource` with a single `series()` method. This keeps +detection pure and testable: a fake source plus a fixed `now` makes every run +reproducible. Wiring `series()` to a real metrics backend is the caller's +responsibility and lives outside the loop. + +## Guardrails + +- Allowlist is `reports/**` only; `maxFiles: 5`. +- `skipIfOpenPr` avoids stacking duplicate anomaly briefs. +- Series with fewer than 3 points or a flat baseline (`std == 0`) are skipped, + never aborting the run โ€” constant or sparse series cannot raise false alarms. + +## Anti-pattern warning + +A z-score crossing means **look here**, not **this is broken**. Metrics are +noisy: deploys, seasonality, and missing data all move a series without anything +being wrong. The brief exists for a human to read and judge. Do **not** layer on +an AI or automation step that *acts* on these findings; the deliberate end state +of this loop is a reviewable narrative, nothing more. diff --git a/openspec/archive/add-incident-followup-loop/proposal.md b/openspec/archive/add-incident-followup-loop/proposal.md new file mode 100644 index 0000000..f3aa06b --- /dev/null +++ b/openspec/archive/add-incident-followup-loop/proposal.md @@ -0,0 +1,57 @@ +# Proposal: Incident Follow-up Loop + +**Change ID:** `add-incident-followup-loop` +**Created:** 2026-06-22 +**Status:** Implementation Complete +**Completed:** 2026-06-22 + +--- + +## Problem Statement + +Postmortem action items quietly fall off, and the same root causes recur across +incidents without anyone noticing the pattern. A deterministic sweep that surfaces +overdue items and recurring root causes keeps reliability honest. + +## Proposed Solution + +Add `loops/incident-followup/`: deterministically sweep an injected incident +source for overdue action items and cluster incidents by root cause to flag +recurrences, then write a follow-up digest as a PR. No AI; data is an injected +boundary. Reuses core runner/guardrails/GitHub adapter; turnkey via a file source. + +- **detect** โ€” overdue open action items + root-cause clusters โ‰ฅ `minRecurrence`. +- **act** โ€” write a deterministic digest to `reports/incident-followup.md`. +- **output** โ€” one PR; **guardrails** allowlist `reports/**`. + +## Scope + +### In Scope +- `loops/incident-followup/` (hooks/incidents.ts, index.ts, loop.yaml, playbook, README) +- Catalog entry + `loopy run incident-followup` (incidents from `LOOPY_INCIDENTS_FILE`) +- Unit tests + +### Out of Scope +- Live incident-tool connectors (incident.io/Rootly) โ€” injected boundary +- Auto-nudging owners / auto-filing tickets (future, gated) + +## Success Criteria + +- [ ] Surfaces overdue open items and recurring root causes. +- [ ] Nothing overdue/recurring โ†’ no PR (fail-safe). +- [ ] Writes a digest within the `reports/**` allowlist. + +## Risks & Mitigations + +| Risk | Prob | Impact | Mitigation | +|------|------|--------|------------| +| Nag fatigue | Med | Low | Single overwritten digest; skipIfOpenPr | +| Bad clustering | Med | Low | Deterministic cause normalization + min threshold | + +--- + +## Archive Information + +**Archived:** 2026-06-22 +**Outcome:** Successfully implemented +**Verification:** typecheck + lint + 126 tests + build all passing diff --git a/openspec/archive/add-incident-followup-loop/specs/incident-followup-loop_delta.md b/openspec/archive/add-incident-followup-loop/specs/incident-followup-loop_delta.md new file mode 100644 index 0000000..c16a87a --- /dev/null +++ b/openspec/archive/add-incident-followup-loop/specs/incident-followup-loop_delta.md @@ -0,0 +1,43 @@ +# Delta: Incident Follow-up Loop + +**Change ID:** `add-incident-followup-loop` +**Affects:** `loops/incident-followup/`, CLI catalog/run + +--- + +## ADDED + +### Requirement: Overdue & Recurrence Detection + +The incident-followup loop surfaces overdue open action items and root-cause +clusters that recur at least `minRecurrence` times. + +#### Scenario: Overdue or recurrence found +- GIVEN an open action item past its due date, or a root cause across multiple incidents +- WHEN detect runs +- THEN it reports work needed + +#### Scenario: Clean state +- GIVEN no overdue items and no recurring causes +- WHEN detect runs +- THEN it reports no work needed and produces no PR + +--- + +### Requirement: Follow-up Digest Output + +The loop writes a deterministic follow-up digest as a reviewable PR within the +reports allowlist. + +#### Scenario: Digest produced +- GIVEN overdue items and/or recurrences +- WHEN the loop acts +- THEN it writes a follow-up digest for review + +## MODIFIED + +(None) + +## REMOVED + +(None) diff --git a/openspec/archive/add-incident-followup-loop/tasks.md b/openspec/archive/add-incident-followup-loop/tasks.md new file mode 100644 index 0000000..263deb1 --- /dev/null +++ b/openspec/archive/add-incident-followup-loop/tasks.md @@ -0,0 +1,23 @@ +# Implementation Tasks: Incident Follow-up Loop + +**Change ID:** `add-incident-followup-loop` + +--- + +## Phase 1: Loop +- [x] 1.1 `hooks/incidents.ts` (IncidentSource, findOverdue, findRecurrences, normalizeCause) +- [x] 1.2 `index.ts` (+ renderDigest, fromManifest) +- [x] 1.3 loop.yaml, playbook, README +- [x] 1.4 Unit tests + +## Phase 2: CLI +- [x] 2.1 Catalog entry + `loopy run incident-followup` (LOOPY_INCIDENTS_FILE) +- [x] 2.2 Package export + +**Quality Gate:** typecheck + lint + tests + build + +--- + +## Completion Checklist +- [x] All phases complete and validated +- [x] Ready for `/openspec-archive` diff --git a/openspec/archive/add-landing-page/proposal.md b/openspec/archive/add-landing-page/proposal.md new file mode 100644 index 0000000..d06620f --- /dev/null +++ b/openspec/archive/add-landing-page/proposal.md @@ -0,0 +1,59 @@ +# Proposal: Landing Page (GitHub Pages) + +**Change ID:** `add-landing-page` +**Created:** 2026-06-22 +**Status:** Implementation Complete +**Completed:** 2026-06-22 + +--- + +## Problem Statement + +loopy has no public face. A polished landing page communicates the value +(one importable, guardrailed framework spanning many automation loops) and, with +an honest "compared to best-in-class" table, positions it against point tools. + +## Proposed Solution + +Add a self-contained static site under `site/` (hand-written HTML + CSS, no build, +no CDNs) and a GitHub Actions workflow (`.github/workflows/pages.yml`) that +deploys it to GitHub Pages on push to `main`. + +- Sections: hero + CTA, the loop contract, loops grid, 1-click install, a + fair comparison table (vs Dependabot/Renovate, CodeRabbit, Vanta/Drata, + Statsig/Eppo), long-horizon primitives, footer. +- Premium, responsive, accessible design; no external dependencies. + +## Scope + +### In Scope +- `site/index.html`, `site/styles.css`, inline SVG logo +- Pages deploy workflow (Actions source) + +### Out of Scope +- Full docs site / versioned docs +- Custom domain + +## Success Criteria + +- [ ] `site/` renders a complete, responsive landing page with the comparison table. +- [ ] A Pages workflow deploys `site/` on push to `main`. +- [ ] No external runtime dependencies. + +> Note: the repo's Pages source must be set to "GitHub Actions" in Settings for +> the deploy to publish; the site + workflow are delivered regardless. + +## Risks & Mitigations + +| Risk | Prob | Impact | Mitigation | +|------|------|--------|------------| +| Unfair/overclaiming comparison | Med | Med | Factual rows + honest caption that specialists go deeper | +| Pages not enabled | Med | Low | Documented one-time repo setting; non-blocking workflow | + +--- + +## Archive Information + +**Archived:** 2026-06-22 +**Outcome:** Successfully implemented +**Verification:** typecheck + lint + 126 tests + build all passing diff --git a/openspec/archive/add-landing-page/specs/landing-page_delta.md b/openspec/archive/add-landing-page/specs/landing-page_delta.md new file mode 100644 index 0000000..889a41a --- /dev/null +++ b/openspec/archive/add-landing-page/specs/landing-page_delta.md @@ -0,0 +1,37 @@ +# Delta: Landing Page + +**Change ID:** `add-landing-page` +**Affects:** `site/`, `.github/workflows/pages.yml` + +--- + +## ADDED + +### Requirement: Public Landing Page + +The project ships a self-contained static landing page that explains loopy and +positions it against best-in-class point tools. + +#### Scenario: Page content +- GIVEN the `site/` directory +- WHEN it is served +- THEN it presents the loop contract, the loops, 1-click install, and an honest comparison table, with no external runtime dependencies + +--- + +### Requirement: Pages Deployment + +The landing page deploys to GitHub Pages via a workflow. + +#### Scenario: Deploy on push +- GIVEN a push to `main` touching `site/` +- WHEN the pages workflow runs +- THEN it publishes the site to GitHub Pages (when Pages source is set to GitHub Actions) + +## MODIFIED + +(None) + +## REMOVED + +(None) diff --git a/openspec/archive/add-landing-page/tasks.md b/openspec/archive/add-landing-page/tasks.md new file mode 100644 index 0000000..5f64dba --- /dev/null +++ b/openspec/archive/add-landing-page/tasks.md @@ -0,0 +1,21 @@ +# Implementation Tasks: Landing Page + +**Change ID:** `add-landing-page` + +--- + +## Phase 1: Site +- [x] 1.1 `site/index.html` (hero, contract, loops grid, install, comparison, long-horizon, footer) +- [x] 1.2 `site/styles.css` + inline SVG logo (self-contained, responsive) + +## Phase 2: Deploy +- [x] 2.1 `.github/workflows/pages.yml` (Actions โ†’ GitHub Pages on push to main) +- [x] 2.2 README links to the page + +**Quality Gate:** site renders; workflow valid + +--- + +## Completion Checklist +- [x] Site + workflow present +- [x] Ready for `/openspec-archive` diff --git a/openspec/archive/add-metric-anomaly-loop/proposal.md b/openspec/archive/add-metric-anomaly-loop/proposal.md new file mode 100644 index 0000000..30b9a0d --- /dev/null +++ b/openspec/archive/add-metric-anomaly-loop/proposal.md @@ -0,0 +1,57 @@ +# Proposal: Metric Anomaly Loop + +**Change ID:** `add-metric-anomaly-loop` +**Created:** 2026-06-22 +**Status:** Implementation Complete +**Completed:** 2026-06-22 + +--- + +## Problem Statement + +Metric regressions hide until someone notices. A cheap, deterministic detector +that watches key metrics and writes a reviewable anomaly brief is a reusable +engine (feeds weekly briefs, churn, data-quality). + +## Proposed Solution + +Add `loops/metric-anomaly/`: deterministic z-score anomaly detection over an +injected metric source, writing a markdown anomaly brief as a PR. No AI required; +the metric data is an injected boundary. Reuses the core runner, guardrails, and +GitHub adapter; turnkey via a file-based metric source. + +- **detect** โ€” z-score of each series' latest point vs. its baseline; work needed if any |z| โ‰ฅ threshold. +- **act** โ€” write a deterministic anomaly brief to `reports/anomalies.md`. +- **output** โ€” one PR; **guardrails** allowlist `reports/**`. + +## Scope + +### In Scope +- `loops/metric-anomaly/` (hooks/anomaly.ts, index.ts, loop.yaml, playbook, README) +- Catalog entry + `loopy run metric-anomaly` (metrics from `LOOPY_METRICS_FILE`) +- Unit tests + +### Out of Scope +- AI narration / root-cause correlation (future enhancement) +- Live metric-warehouse connectors (injected boundary) + +## Success Criteria + +- [ ] Flags a clear spike; ignores flat/low-variance/short series. +- [ ] No anomaly โ†’ no PR (fail-safe). +- [ ] Writes a brief within the `reports/**` allowlist. + +## Risks & Mitigations + +| Risk | Prob | Impact | Mitigation | +|------|------|--------|------------| +| Acting on noisy metrics | Med | Med | Output is a brief for review, not an action; z threshold; idempotent file | +| Alert fatigue | Med | Low | Single overwritten report; skipIfOpenPr | + +--- + +## Archive Information + +**Archived:** 2026-06-22 +**Outcome:** Successfully implemented +**Verification:** typecheck + lint + 126 tests + build all passing diff --git a/openspec/archive/add-metric-anomaly-loop/specs/metric-anomaly-loop_delta.md b/openspec/archive/add-metric-anomaly-loop/specs/metric-anomaly-loop_delta.md new file mode 100644 index 0000000..f8c7d63 --- /dev/null +++ b/openspec/archive/add-metric-anomaly-loop/specs/metric-anomaly-loop_delta.md @@ -0,0 +1,43 @@ +# Delta: Metric Anomaly Loop + +**Change ID:** `add-metric-anomaly-loop` +**Affects:** `loops/metric-anomaly/`, CLI catalog/run + +--- + +## ADDED + +### Requirement: Z-Score Anomaly Detection + +The metric-anomaly loop flags a series whose latest point deviates from its +baseline by at least the z-score threshold. + +#### Scenario: Spike flagged +- GIVEN a metric series whose latest point is a clear outlier vs. its baseline +- WHEN detect runs +- THEN it reports work needed and identifies the metric + +#### Scenario: Quiet series ignored +- GIVEN flat, low-variance, or too-short series +- WHEN detect runs +- THEN it reports no work needed and produces no PR + +--- + +### Requirement: Anomaly Brief Output + +The loop writes a deterministic markdown anomaly brief as a reviewable PR within +the reports allowlist. + +#### Scenario: Brief produced +- GIVEN one or more anomalies +- WHEN the loop acts +- THEN it writes an anomaly brief for review + +## MODIFIED + +(None) + +## REMOVED + +(None) diff --git a/openspec/archive/add-metric-anomaly-loop/tasks.md b/openspec/archive/add-metric-anomaly-loop/tasks.md new file mode 100644 index 0000000..e2ac2f5 --- /dev/null +++ b/openspec/archive/add-metric-anomaly-loop/tasks.md @@ -0,0 +1,23 @@ +# Implementation Tasks: Metric Anomaly Loop + +**Change ID:** `add-metric-anomaly-loop` + +--- + +## Phase 1: Loop +- [x] 1.1 `hooks/anomaly.ts` (MetricSource, detectAnomalies z-score) +- [x] 1.2 `index.ts` (+ renderAnomalyBrief, fromManifest) +- [x] 1.3 loop.yaml, playbook, README +- [x] 1.4 Unit tests + +## Phase 2: CLI +- [x] 2.1 Catalog entry + `loopy run metric-anomaly` (LOOPY_METRICS_FILE) +- [x] 2.2 Package export + +**Quality Gate:** typecheck + lint + tests + build + +--- + +## Completion Checklist +- [x] All phases complete and validated +- [x] Ready for `/openspec-archive` diff --git a/openspec/roadmap.md b/openspec/roadmap.md index 70d1b9a..dce90d1 100644 --- a/openspec/roadmap.md +++ b/openspec/roadmap.md @@ -51,10 +51,14 @@ gates, resumable plans) in `src/core/longrun/`; the **KB-Gap Self-Heal** loop (first product-level wedge, turnkey); and the **Experiment Lifecycle Orchestrator** ([`specs/experiment-orchestrator.md`](specs/experiment-orchestrator.md)) โ€” the flagship long-horizon loop on `runPlan` + gates + state (design โ†’ approve โ†’ -launch โ†’ bake โ†’ readout โ†’ decide). Remaining candidates on the same primitives: -Metric Anomaly โ†’ Root-Cause Narrator, Incident Action-Item/Recurrence, Churn โ†’ -Intervention. Cross-cutting follow-ups: non-PR output adapters (issue tracker, -experimentation platform, dashboards) and a `loopy advance ` CLI verb. +launch โ†’ bake โ†’ readout โ†’ decide). The **Metric Anomaly** and **Incident Follow-up** loops are now shipped +(deterministic, turnkey). Remaining candidates on the same primitives: Churn โ†’ +Intervention, Voice-of-Customer miner. Cross-cutting follow-ups: non-PR output +adapters (issue tracker, experimentation platform, dashboards) and a +`loopy advance ` CLI verb for long-horizon loops. + +A **project landing page** ships under `site/` (deployed to GitHub Pages via +`.github/workflows/pages.yml`). ## Backlog (catalogued, not yet proposed) diff --git a/openspec/specs/incident-followup-loop.md b/openspec/specs/incident-followup-loop.md new file mode 100644 index 0000000..942802a --- /dev/null +++ b/openspec/specs/incident-followup-loop.md @@ -0,0 +1,42 @@ +# Incident Follow-up Loop Specification + +> Source of truth. Established by change `add-incident-followup-loop` (2026-06-22). + +--- + +## ADDED + +### Requirement: Overdue & Recurrence Detection + +The incident-followup loop surfaces overdue open action items and root-cause +clusters that recur at least `minRecurrence` times. + +#### Scenario: Overdue or recurrence found +- GIVEN an open action item past its due date, or a root cause across multiple incidents +- WHEN detect runs +- THEN it reports work needed + +#### Scenario: Clean state +- GIVEN no overdue items and no recurring causes +- WHEN detect runs +- THEN it reports no work needed and produces no PR + +--- + +### Requirement: Follow-up Digest Output + +The loop writes a deterministic follow-up digest as a reviewable PR within the +reports allowlist. + +#### Scenario: Digest produced +- GIVEN overdue items and/or recurrences +- WHEN the loop acts +- THEN it writes a follow-up digest for review + +## MODIFIED + +(None) + +## REMOVED + +(None) diff --git a/openspec/specs/landing-page.md b/openspec/specs/landing-page.md new file mode 100644 index 0000000..e1a2939 --- /dev/null +++ b/openspec/specs/landing-page.md @@ -0,0 +1,36 @@ +# Landing Page Specification + +> Source of truth. Established by change `add-landing-page` (2026-06-22). + +--- + +## ADDED + +### Requirement: Public Landing Page + +The project ships a self-contained static landing page that explains loopy and +positions it against best-in-class point tools. + +#### Scenario: Page content +- GIVEN the `site/` directory +- WHEN it is served +- THEN it presents the loop contract, the loops, 1-click install, and an honest comparison table, with no external runtime dependencies + +--- + +### Requirement: Pages Deployment + +The landing page deploys to GitHub Pages via a workflow. + +#### Scenario: Deploy on push +- GIVEN a push to `main` touching `site/` +- WHEN the pages workflow runs +- THEN it publishes the site to GitHub Pages (when Pages source is set to GitHub Actions) + +## MODIFIED + +(None) + +## REMOVED + +(None) diff --git a/openspec/specs/metric-anomaly-loop.md b/openspec/specs/metric-anomaly-loop.md new file mode 100644 index 0000000..8f95896 --- /dev/null +++ b/openspec/specs/metric-anomaly-loop.md @@ -0,0 +1,42 @@ +# Metric Anomaly Loop Specification + +> Source of truth. Established by change `add-metric-anomaly-loop` (2026-06-22). + +--- + +## ADDED + +### Requirement: Z-Score Anomaly Detection + +The metric-anomaly loop flags a series whose latest point deviates from its +baseline by at least the z-score threshold. + +#### Scenario: Spike flagged +- GIVEN a metric series whose latest point is a clear outlier vs. its baseline +- WHEN detect runs +- THEN it reports work needed and identifies the metric + +#### Scenario: Quiet series ignored +- GIVEN flat, low-variance, or too-short series +- WHEN detect runs +- THEN it reports no work needed and produces no PR + +--- + +### Requirement: Anomaly Brief Output + +The loop writes a deterministic markdown anomaly brief as a reviewable PR within +the reports allowlist. + +#### Scenario: Brief produced +- GIVEN one or more anomalies +- WHEN the loop acts +- THEN it writes an anomaly brief for review + +## MODIFIED + +(None) + +## REMOVED + +(None) diff --git a/package.json b/package.json index c69ef94..629fb27 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,14 @@ "./loops/experiment": { "types": "./dist/loops/experiment/index.d.ts", "default": "./dist/loops/experiment/index.js" + }, + "./loops/metric-anomaly": { + "types": "./dist/loops/metric-anomaly/index.d.ts", + "default": "./dist/loops/metric-anomaly/index.js" + }, + "./loops/incident-followup": { + "types": "./dist/loops/incident-followup/index.d.ts", + "default": "./dist/loops/incident-followup/index.js" } }, "files": [ diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..10d8dea --- /dev/null +++ b/site/index.html @@ -0,0 +1,445 @@ + + + + + +loopy โ€” reusable automation loops for your repo + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ 8 loops shipped ยท PR-first ยท provider-agnostic +

Reusable automation loops for your repo.

+

+ loopy is a library of guardrailed loops software teams import into their repositories. + One contract, a fail-safe runner, and a reviewable pull request out the other end โ€” + import one in a single command. +

+ +
+ + + Star on GitHub + +
+ $ + npx loopy add auto-docs + +
+
+ +
+
8
Production loops
+
1
Portable contract
+
PR
Reviewable output, always
+
0
Partial / on-error writes
+
+
+
+ + +
+
+
+ The loop contract +

One contract, every loop.

+

+ However smart its internals, every loop follows the same five-stage shape and runs through + a single loop-agnostic runner. That uniformity is what makes loops importable, reviewable, and safe. +

+
+ +
+
+ 01 + trigger + A cron schedule, a repo event, or a manual run kicks the loop off. +
+
+ 02 + detect + Cheap, deterministic check for whether there is actually work to do. +
+
+ 03 + act + Do the work โ€” an AI step, deterministic code, or a hybrid of both. +
+
+ 04 + output + A reviewable pull request โ€” safe and reversible โ€” or an advisory comment. +
+
+ 05 + guardrails + Path allowlist, max-files cap, idempotency, and human approval gates. +
+
+ +
+ + Fail-safe by design. On any error โ€” including a guardrail violation โ€” the runner produces no output and never partially applies changes. +
+
+
+ + +
+
+
+ The catalog +

Loops that keep your repo healthy.

+

+ Each loop does one job and ships its own reviewable output. Add any of them with + npx loopy add <loop>. +

+
+ +
+
+
auto-docsAI
+

Updates documentation when the code surface drifts away from what the docs describe.

+
+
+
dep-updatesDeterministic
+

One grouped pull request bumping your non-major dependency updates together.

+
+
+
changelogDeterministic
+

Drafts a changelog entry from unreleased commits using conventional-commit history.

+
+
+
pr-reviewAI
+

Posts an advisory automated review comment on a pull request โ€” never blocking.

+
+
+
test-coverageAI
+

Backfills tests for uncovered changed lines, self-validating before it opens a PR.

+
+
+
security-remediationHybrid
+

Human-gated fixes for security findings above a configured severity threshold.

+
+
+
kb-gapAI
+

Self-heals docs by drafting KB articles for recurring support-ticket topics.

+
+
+
metric-anomalyDeterministic
+

Z-score anomaly detection that ships a concise brief when a metric goes sideways.

+
+
+
incident-followupLong-horizon
+

Tracks overdue action items and flags recurrence across past incidents.

+
+
+ + +
+
+ + +
+
+
+ 1-click install +

Import a loop in a single command.

+

+ loopy vendors the loop into your repo โ€” workflow, config, and playbook โ€” so it lives in your + version control and runs on your CI. No platform to adopt, no lock-in. +

+
+ +
+
+
bash โ€” your-repo
+
+
$ npx loopy add dep-updates
+
โœ“ added dep-updates โ†’ .github/workflows/loopy-dep-updates.yml
+
โœ“ wrote loopy/dep-updates/loop.yaml
+
โœ“ guardrails: allowlist ยท max-files ยท skip-if-open-PR
+
 
+
$ npx loopy list
+
auto-docs dep-updates changelog pr-review
+
test-coverage security-remediation kb-gap โ€ฆ
+
 
+
$ npx loopy run dep-updates
+
detect โ†’ 6 updates ยท act โ†’ grouped bump
+
output โ†’ PR #142 opened for review โœ“
+
+
+ +
+
    +
  • + + +
    +
    Add a loop
    +
    npx loopy add dep-updates โ€” vendors the loop and its guardrails into your repo.
    +
    +
  • +
  • + โ‰ก +
    +
    List the catalog
    +
    npx loopy list โ€” see every loop available to import.
    +
    +
  • +
  • + โ–ท +
    +
    Run it
    +
    npx loopy run <loop> โ€” trigger a loop locally or wire it to CI / cron.
    +
    +
  • +
+
+
+
+
+ + +
+
+
+ Compared to best-in-class +

One framework, many loops.

+

+ Specialist tools are excellent in their niche. loopy's angle is different: a single + guardrailed, importable framework that spans many loops with one contract and one CLI. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Dependabot / RenovateCodeRabbitVanta / DrataStatsig / Eppoloopy
ScopeDependency updatesPR reviewComplianceExperimentsMany loops in one framework
InstallPer-tool setupPer-tool setupPer-tool setupPer-tool setupOne CLI โ€” npx loopy add
OutputPRsPR commentsDashboards / reportsDashboards / readoutsReviewable PR or comment, uniformly
GuardrailsUpdate rulesReview configPolicy controlsExperiment gatesFirst-class: allowlist ยท caps ยท idempotency ยท human gates
ExtensibilityClosed productClosed productClosed productClosed productโœ“ Importable, open loops
AI providern/aVendor-managedVendor-managedn/aProvider-agnostic (defaults to OpenRouter, configurable)
+
+ +

+ Honest take: the specialists go deeper in their niche โ€” Renovate's update graph, CodeRabbit's + review depth, Vanta's audit coverage, and Statsig's stats engine each outclass a single loopy loop on + their home turf. loopy isn't trying to beat them at X; it gives you one guardrailed, importable + framework spanning many loops, so you adopt the contract once instead of stitching together point tools. +

+
+
+ + +
+
+
+ Long-horizon primitives +

Loops that remember, wait, and resume.

+

+ Beyond single-shot loops, loopy ships the substrate for stateful, multi-step work that + unfolds over days โ€” the same contract, extended across time. +

+
+ +
+
+
+ +

Durable state

A persistent store gives loops memory across runs โ€” they pick up where they left off.

+
+
+ +

Human-approval gates

First-class checkpoints that block a plan until a person signs off โ€” built into the contract.

+
+
+ +

Resumable plans

Multi-step plans with wait-states and gate-blocking that survive restarts and resume cleanly.

+
+
+ +
+
Powered by the primitives
+

Experiment Lifecycle Orchestrator

+
+
1Design
+
2Approvehuman gate
+
3Launch
+
4Bakewait-state
+
5Readout
+
6Decidehuman gate
+
+
+
+
+
+ + +
+
+

Adopt the contract once.

+

Import your first loop, review the PR it opens, and let it run on your CI. That's the whole onboarding.

+
+ Get started on GitHub +
+ $ + npx loopy add auto-docs + +
+
+
+
+ +
+ + + + + + + diff --git a/site/loopy.svg b/site/loopy.svg new file mode 100644 index 0000000..01666d0 --- /dev/null +++ b/site/loopy.svg @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..a02f60a --- /dev/null +++ b/site/styles.css @@ -0,0 +1,658 @@ +/* loopy โ€” landing page styles + Self-contained, no external dependencies. System font stack only. */ + +:root { + --bg: #07080d; + --bg-soft: #0c0e16; + --panel: #11131d; + --panel-2: #151826; + --border: #20243a; + --border-2: #2a2f4a; + --text: #e9ecf6; + --text-dim: #a3a9c2; + --text-mute: #6f7593; + --accent: #8b9bff; + --accent-2: #22d3ee; + --accent-soft: rgba(139, 155, 255, 0.14); + --good: #4ade80; + --warn: #fbbf24; + --radius: 16px; + --radius-sm: 10px; + --maxw: 1120px; + --mono: ui-monospace, "SF Mono", "SFMono-Regular", "Cascadia Code", + "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, + Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif; +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + margin: 0; + font-family: var(--sans); + background: var(--bg); + color: var(--text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + overflow-x: hidden; +} + +a { color: inherit; text-decoration: none; } + +::selection { background: rgba(139, 155, 255, 0.35); color: #fff; } + +.wrap { + width: 100%; + max-width: var(--maxw); + margin-inline: auto; + padding-inline: 24px; +} + +/* ---------- shared section scaffolding ---------- */ +section { position: relative; padding: 88px 0; } + +.eyebrow { + display: inline-block; + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--accent-2); + margin: 0 0 14px; +} + +.section-title { + font-size: clamp(26px, 3.4vw, 40px); + line-height: 1.12; + letter-spacing: -0.02em; + margin: 0 0 14px; + font-weight: 700; +} + +.section-sub { + color: var(--text-dim); + font-size: clamp(15px, 1.6vw, 18px); + max-width: 62ch; + margin: 0 0 44px; +} + +.section-head { margin-bottom: 8px; } + +/* ---------- nav ---------- */ +header.nav { + position: sticky; + top: 0; + z-index: 50; + backdrop-filter: saturate(160%) blur(12px); + background: rgba(7, 8, 13, 0.72); + border-bottom: 1px solid var(--border); +} +.nav-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 64px; +} +.brand { + display: flex; + align-items: center; + gap: 11px; + font-weight: 700; + letter-spacing: -0.01em; + font-size: 18px; +} +.brand svg { width: 38px; height: auto; display: block; } +.nav-links { + display: flex; + align-items: center; + gap: 28px; + font-size: 14.5px; + color: var(--text-dim); +} +.nav-links a:hover { color: var(--text); } +.nav-links .nav-cta { + color: var(--text); + border: 1px solid var(--border-2); + padding: 8px 16px; + border-radius: 999px; + background: var(--panel); + transition: border-color .2s, background .2s; +} +.nav-links .nav-cta:hover { border-color: var(--accent); background: var(--panel-2); } +@media (max-width: 720px) { + .nav-links a:not(.nav-cta) { display: none; } +} + +/* ---------- buttons ---------- */ +.btn { + display: inline-flex; + align-items: center; + gap: 9px; + font-weight: 600; + font-size: 15px; + padding: 13px 22px; + border-radius: 999px; + border: 1px solid transparent; + cursor: pointer; + transition: transform .15s ease, box-shadow .2s ease, border-color .2s, background .2s; + white-space: nowrap; +} +.btn-primary { + color: #0a0b12; + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%); + box-shadow: 0 8px 30px rgba(80, 110, 255, 0.35); +} +.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 12px 38px rgba(80, 110, 255, 0.5); } +.btn-ghost { + color: var(--text); + background: var(--panel); + border-color: var(--border-2); +} +.btn-ghost:hover { border-color: var(--accent); background: var(--panel-2); } +.btn svg { width: 18px; height: 18px; } + +/* ---------- hero ---------- */ +.hero { + position: relative; + padding-top: 96px; + padding-bottom: 96px; + overflow: hidden; +} +.hero-bg { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; +} +.hero-bg::before { + content: ""; + position: absolute; + top: -260px; left: 50%; + transform: translateX(-50%); + width: 1100px; height: 760px; + background: + radial-gradient(closest-side, rgba(139,155,255,0.28), transparent 70%), + radial-gradient(closest-side, rgba(34,211,238,0.18), transparent 70%); + background-position: 30% 0, 70% 30%; + filter: blur(8px); +} +.hero-bg::after { + content: ""; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px); + background-size: 46px 46px; + mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, #000 30%, transparent 75%); + -webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, #000 30%, transparent 75%); +} +.hero .wrap { position: relative; z-index: 1; } + +.pill { + display: inline-flex; + align-items: center; + gap: 9px; + padding: 6px 14px 6px 8px; + border: 1px solid var(--border-2); + border-radius: 999px; + background: var(--panel); + font-size: 13px; + color: var(--text-dim); + margin-bottom: 26px; +} +.pill b { color: var(--text); font-weight: 600; } +.pill .dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--good); + box-shadow: 0 0 0 4px rgba(74,222,128,0.18); +} + +.hero h1 { + font-size: clamp(38px, 6.4vw, 72px); + line-height: 1.04; + letter-spacing: -0.035em; + margin: 0 0 22px; + font-weight: 800; + max-width: 16ch; +} +.hero h1 .grad { + background: linear-gradient(110deg, var(--accent) 10%, var(--accent-2) 90%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.hero p.lead { + font-size: clamp(17px, 2vw, 21px); + color: var(--text-dim); + max-width: 56ch; + margin: 0 0 34px; +} +.hero-cta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; +} + +/* copyable command */ +.cmd { + display: inline-flex; + align-items: center; + gap: 14px; + font-family: var(--mono); + font-size: 14.5px; + background: var(--panel); + border: 1px solid var(--border-2); + border-radius: 999px; + padding: 11px 11px 11px 18px; + color: var(--text); +} +.cmd .prompt { color: var(--accent-2); user-select: none; } +.cmd code { color: var(--text); } +.cmd .copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; height: 32px; + border-radius: 999px; + border: 1px solid var(--border-2); + background: var(--panel-2); + color: var(--text-dim); + cursor: pointer; + transition: color .2s, border-color .2s, background .2s; +} +.cmd .copy:hover { color: var(--text); border-color: var(--accent); } +.cmd .copy svg { width: 15px; height: 15px; } +.cmd .copy.copied { color: var(--good); border-color: var(--good); } + +.hero-stats { + display: flex; + flex-wrap: wrap; + gap: 38px; + margin-top: 56px; + padding-top: 34px; + border-top: 1px solid var(--border); +} +.hero-stats .stat .n { + font-size: 28px; font-weight: 800; letter-spacing: -0.02em; + font-family: var(--mono); +} +.hero-stats .stat .l { color: var(--text-mute); font-size: 13.5px; } + +/* ---------- contract pipeline ---------- */ +.pipeline { + display: flex; + align-items: stretch; + gap: 0; + flex-wrap: wrap; +} +.stage { + flex: 1 1 0; + min-width: 150px; + position: relative; + display: flex; + flex-direction: column; + gap: 8px; + padding: 22px 20px; + background: linear-gradient(180deg, var(--panel) 0%, var(--bg-soft) 100%); + border: 1px solid var(--border); + border-right: none; +} +.stage:first-child { border-radius: var(--radius) 0 0 var(--radius); } +.stage:last-child { border-radius: 0 var(--radius) var(--radius) 0; border-right: 1px solid var(--border); } +.stage::after { + content: ""; + position: absolute; + right: -1px; top: 50%; + transform: translate(50%, -50%) rotate(45deg); + width: 13px; height: 13px; + background: var(--bg-soft); + border-top: 1px solid var(--border); + border-right: 1px solid var(--border); + z-index: 2; +} +.stage:last-child::after { display: none; } +.stage .num { + font-family: var(--mono); + font-size: 12px; + color: var(--accent-2); +} +.stage .name { + font-family: var(--mono); + font-weight: 700; + font-size: 16px; + letter-spacing: -0.01em; +} +.stage .desc { font-size: 13.5px; color: var(--text-dim); line-height: 1.5; } +.stage.is-guard { background: linear-gradient(180deg, rgba(139,155,255,0.10), var(--bg-soft)); } +.stage.is-guard .name { color: var(--accent); } + +.failsafe { + margin-top: 24px; + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-soft); + color: var(--text-dim); + font-size: 14.5px; +} +.failsafe svg { width: 20px; height: 20px; color: var(--good); flex: none; } +.failsafe b { color: var(--text); } + +@media (max-width: 760px) { + .pipeline { flex-direction: column; } + .stage { border-right: 1px solid var(--border); border-bottom: none; } + .stage:first-child { border-radius: var(--radius) var(--radius) 0 0; } + .stage:last-child { border-radius: 0 0 var(--radius) var(--radius); border-bottom: 1px solid var(--border); } + .stage::after { + right: 50%; top: auto; bottom: -1px; + transform: translate(50%, 50%) rotate(135deg); + } +} + +/* ---------- loops grid ---------- */ +.grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; +} +@media (max-width: 900px) { .grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 580px) { .grid { grid-template-columns: 1fr; } } + +.card { + position: relative; + padding: 22px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: linear-gradient(180deg, var(--panel) 0%, var(--bg-soft) 100%); + transition: transform .18s ease, border-color .2s ease, box-shadow .2s ease; + overflow: hidden; +} +.card::before { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(420px circle at 0% 0%, var(--accent-soft), transparent 60%); + opacity: 0; + transition: opacity .25s ease; + pointer-events: none; +} +.card:hover { transform: translateY(-3px); border-color: var(--border-2); box-shadow: 0 14px 40px rgba(0,0,0,0.35); } +.card:hover::before { opacity: 1; } +.card .top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} +.card .lname { + font-family: var(--mono); + font-weight: 700; + font-size: 16px; + letter-spacing: -0.01em; +} +.card .lname::before { content: "$ "; color: var(--text-mute); font-weight: 400; } +.card p { margin: 0; font-size: 14px; color: var(--text-dim); line-height: 1.55; } + +.tag { + font-family: var(--mono); + font-size: 10.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 4px 9px; + border-radius: 999px; + border: 1px solid var(--border-2); + white-space: nowrap; + flex: none; +} +.tag-det { color: #7dd3fc; border-color: rgba(125,211,252,0.35); background: rgba(125,211,252,0.08); } +.tag-ai { color: #c4b5fd; border-color: rgba(196,181,253,0.35); background: rgba(196,181,253,0.08); } +.tag-lh { color: #fcd34d; border-color: rgba(252,211,77,0.35); background: rgba(252,211,77,0.08); } +.tag-hy { color: #6ee7b7; border-color: rgba(110,231,183,0.35); background: rgba(110,231,183,0.08); } + +.legend { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 26px; + font-size: 13px; + color: var(--text-mute); +} +.legend span { display: inline-flex; align-items: center; gap: 7px; } +.legend i { width: 9px; height: 9px; border-radius: 3px; display: inline-block; } + +/* ---------- install ---------- */ +.install-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 26px; + align-items: start; +} +@media (max-width: 820px) { .install-grid { grid-template-columns: 1fr; } } + +.terminal { + border: 1px solid var(--border); + border-radius: var(--radius); + background: #0a0c14; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0,0,0,0.4); +} +.terminal .bar { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: var(--panel); +} +.terminal .bar i { width: 11px; height: 11px; border-radius: 50%; display: inline-block; } +.terminal .bar i:nth-child(1){ background:#ff5f57; } +.terminal .bar i:nth-child(2){ background:#febc2e; } +.terminal .bar i:nth-child(3){ background:#28c840; } +.terminal .bar .ttl { margin-left: 10px; font-family: var(--mono); font-size: 12.5px; color: var(--text-mute); } +.terminal .body { + padding: 22px 20px; + font-family: var(--mono); + font-size: 13.5px; + line-height: 1.9; +} +.terminal .line { white-space: pre-wrap; } +.terminal .line .p { color: var(--accent-2); } +.terminal .line .c { color: var(--text); } +.terminal .out { color: var(--text-mute); } +.terminal .out .ok { color: var(--good); } +.terminal .out .hl { color: var(--accent); } + +.install-copy h3 { font-size: 20px; margin: 0 0 8px; letter-spacing: -0.01em; } +.install-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; } +.install-list li { + display: flex; gap: 14px; align-items: flex-start; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-soft); +} +.install-list .ic { + flex: none; width: 34px; height: 34px; border-radius: 9px; + display: grid; place-items: center; + background: var(--accent-soft); color: var(--accent); + font-family: var(--mono); font-weight: 700; font-size: 14px; +} +.install-list code { + font-family: var(--mono); + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 2px 7px; + font-size: 13px; + color: var(--text); +} +.install-list .t { font-weight: 600; font-size: 15px; margin-bottom: 2px; } +.install-list .d { font-size: 13.5px; color: var(--text-dim); } + +/* ---------- comparison table ---------- */ +.table-scroll { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); } +table.cmp { + width: 100%; + border-collapse: collapse; + font-size: 14px; + min-width: 760px; +} +table.cmp th, table.cmp td { + text-align: left; + padding: 16px 18px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +table.cmp thead th { + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-mute); + font-weight: 600; + background: var(--panel); + position: sticky; top: 0; +} +table.cmp thead th.loopy-col, +table.cmp td.loopy-col { + background: linear-gradient(180deg, rgba(139,155,255,0.10), rgba(34,211,238,0.05)); + border-left: 1px solid var(--border-2); + border-right: 1px solid var(--border-2); + color: var(--text); +} +table.cmp thead th.loopy-col { + color: var(--accent); + font-weight: 700; +} +table.cmp tbody th { + font-weight: 600; + color: var(--text); + white-space: nowrap; +} +table.cmp td { color: var(--text-dim); } +table.cmp td.loopy-col { color: var(--text); font-weight: 500; } +table.cmp tbody tr:last-child th, +table.cmp tbody tr:last-child td { border-bottom: none; } +table.cmp .check { color: var(--good); font-weight: 700; } +.cmp-caption { + margin-top: 18px; + font-size: 13.5px; + color: var(--text-mute); + max-width: 78ch; +} +.cmp-caption b { color: var(--text-dim); } + +/* ---------- long-horizon ---------- */ +.lh-wrap { background: var(--bg-soft); } +.lh-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 40px; + align-items: center; +} +@media (max-width: 880px) { .lh-grid { grid-template-columns: 1fr; } } +.lh-prims { display: flex; flex-direction: column; gap: 14px; } +.lh-prim { + display: flex; gap: 14px; align-items: flex-start; + padding: 18px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel); +} +.lh-prim .ic { + flex: none; width: 38px; height: 38px; border-radius: 10px; + display: grid; place-items: center; + background: var(--accent-soft); color: var(--accent); +} +.lh-prim .ic svg { width: 20px; height: 20px; } +.lh-prim h4 { margin: 0 0 3px; font-size: 15.5px; } +.lh-prim p { margin: 0; font-size: 13.5px; color: var(--text-dim); } + +.orchestrator { + border: 1px solid var(--border-2); + border-radius: var(--radius); + background: linear-gradient(180deg, var(--panel) 0%, var(--bg-soft) 100%); + padding: 26px; +} +.orchestrator .badge { + font-family: var(--mono); font-size: 11px; letter-spacing: 0.1em; + text-transform: uppercase; color: var(--accent-2); margin-bottom: 6px; +} +.orchestrator h3 { margin: 0 0 18px; font-size: 21px; letter-spacing: -0.01em; } +.flow { display: flex; flex-direction: column; gap: 0; } +.flow .step { + display: flex; align-items: center; gap: 14px; + padding: 11px 0; + position: relative; +} +.flow .step .marker { + flex: none; width: 30px; height: 30px; border-radius: 50%; + display: grid; place-items: center; + border: 1px solid var(--border-2); + background: var(--panel-2); + font-family: var(--mono); font-size: 12px; font-weight: 700; color: var(--accent); + z-index: 1; +} +.flow .step:not(:last-child) .marker::after { + content: ""; position: absolute; left: 14px; top: 41px; bottom: -11px; + width: 1px; background: var(--border-2); +} +.flow .step .label { font-weight: 600; font-size: 15px; } +.flow .step .gate { + margin-left: auto; + font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.05em; + text-transform: uppercase; color: var(--warn); + border: 1px solid rgba(251,191,36,0.35); + background: rgba(251,191,36,0.08); + padding: 3px 8px; border-radius: 999px; +} + +/* ---------- cta band ---------- */ +.cta-band { text-align: center; } +.cta-band h2 { font-size: clamp(28px, 4vw, 44px); letter-spacing: -0.025em; margin: 0 0 14px; } +.cta-band p { color: var(--text-dim); max-width: 52ch; margin: 0 auto 30px; font-size: 17px; } +.cta-band .hero-cta { justify-content: center; } + +/* ---------- footer ---------- */ +footer.foot { + border-top: 1px solid var(--border); + padding: 56px 0 64px; + background: var(--bg-soft); +} +.foot-grid { + display: flex; + flex-wrap: wrap; + gap: 32px; + justify-content: space-between; + align-items: flex-start; +} +.foot .brand { margin-bottom: 12px; } +.foot .blurb { color: var(--text-mute); font-size: 14px; max-width: 38ch; } +.foot-links { display: flex; gap: 56px; flex-wrap: wrap; } +.foot-col h5 { + font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--text-mute); margin: 0 0 14px; font-weight: 600; +} +.foot-col a { display: block; color: var(--text-dim); font-size: 14.5px; margin-bottom: 10px; } +.foot-col a:hover { color: var(--text); } +.foot-bottom { + margin-top: 44px; padding-top: 24px; + border-top: 1px solid var(--border); + display: flex; flex-wrap: wrap; gap: 12px; justify-content: space-between; + color: var(--text-mute); font-size: 13.5px; +} +.foot-bottom .built { font-family: var(--mono); } + +/* ---------- a11y ---------- */ +:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 3px; border-radius: 4px; } +@media (prefers-reduced-motion: reduce) { + * { scroll-behavior: auto !important; transition: none !important; } +} diff --git a/src/cli/catalog.ts b/src/cli/catalog.ts index 6fb698f..2ee0398 100644 --- a/src/cli/catalog.ts +++ b/src/cli/catalog.ts @@ -73,6 +73,20 @@ export const CATALOG: CatalogEntry[] = [ output: "pull-request", secrets: [GH], }, + { + id: "metric-anomaly", + description: "Z-score anomaly briefs over your metrics", + trigger: { kind: "schedule", cron: "0 7 * * *" }, + output: "pull-request", + secrets: [GH], + }, + { + id: "incident-followup", + description: "Overdue postmortem action items + recurring root causes", + trigger: { kind: "schedule", cron: "0 7 * * 1" }, + output: "pull-request", + secrets: [GH], + }, ]; export function getEntry(id: string): CatalogEntry | undefined { diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index 6a767d2..87a3b09 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -33,6 +33,17 @@ import { type Ticket, type TicketSource, } from "../../../loops/kb-gap/index.js"; +import { + createMetricAnomalyLoopFromManifest, + type MetricSeries, + type MetricSource, +} from "../../../loops/metric-anomaly/index.js"; +import { + createIncidentFollowupLoopFromManifest, + type ActionItem, + type Incident, + type IncidentSource, +} from "../../../loops/incident-followup/index.js"; import { createDocWriter, createReviewer, @@ -63,6 +74,8 @@ export interface RunOptions { diffProvider?: DiffProvider; ticketSource?: TicketSource; coveredTopics?: () => Promise; + metricSource?: MetricSource; + incidentSource?: IncidentSource; /** environment used for config resolution (defaults to process.env) */ env?: Env; } @@ -180,6 +193,28 @@ async function buildLoop( articleWriter: createArticleWriter(client, kbDir), }); } + case "metric-anomaly": { + const metrics = options.metricSource ?? metricSourceFromEnv(env); + if (!metrics) { + return ( + "`metric-anomaly` needs metrics. Set LOOPY_METRICS_FILE to a JSON file containing an " + + "array of { name, points: [{ t, value }] }." + ); + } + const manifest = await loadManifest(manifestPath); + return createMetricAnomalyLoopFromManifest(manifest, { metrics }); + } + case "incident-followup": { + const incidents = options.incidentSource ?? incidentSourceFromEnv(env); + if (!incidents) { + return ( + "`incident-followup` needs incident data. Set LOOPY_INCIDENTS_FILE to a JSON file " + + "containing { incidents: [...], actionItems: [...] }." + ); + } + const manifest = await loadManifest(manifestPath); + return createIncidentFollowupLoopFromManifest(manifest, { incidents }); + } default: return ( `\`${entry.id}\` is not runnable via the CLI yet (needs additional boundaries โ€” ` + @@ -239,6 +274,32 @@ function ticketSourceFromEnv(env: Env): TicketSource | null { }; } +function metricSourceFromEnv(env: Env): MetricSource | null { + const file = env["LOOPY_METRICS_FILE"]; + if (!file) return null; + return { + series: async (): Promise => { + const parsed = JSON.parse(await readFile(file, "utf8")) as unknown; + return Array.isArray(parsed) ? (parsed as MetricSeries[]) : []; + }, + }; +} + +function incidentSourceFromEnv(env: Env): IncidentSource | null { + const file = env["LOOPY_INCIDENTS_FILE"]; + if (!file) return null; + return { + incidents: async (): Promise => { + const parsed = JSON.parse(await readFile(file, "utf8")) as { incidents?: Incident[] }; + return Array.isArray(parsed.incidents) ? parsed.incidents : []; + }, + actionItems: async (): Promise => { + const parsed = JSON.parse(await readFile(file, "utf8")) as { actionItems?: ActionItem[] }; + return Array.isArray(parsed.actionItems) ? parsed.actionItems : []; + }, + }; +} + /** Treat existing KB markdown filenames as already-covered topics. */ async function coveredFromKbDir(cwd: string, kbDir: string): Promise { try { diff --git a/test/loops/incident-followup.test.ts b/test/loops/incident-followup.test.ts new file mode 100644 index 0000000..66dd61a --- /dev/null +++ b/test/loops/incident-followup.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { runLoop, silentLogger, type RunContext } from "../../src/core/index.js"; +import { + createIncidentFollowupLoop, + findOverdue, + findRecurrences, + normalizeCause, + type IncidentFollowupConfig, +} from "../../loops/incident-followup/index.js"; +import type { + ActionItem, + Incident, + IncidentSource, +} from "../../loops/incident-followup/hooks/incidents.js"; + +const NOW = "2026-06-23T00:00:00.000Z"; + +describe("findOverdue", () => { + const items: ActionItem[] = [ + { id: "a1", title: "Patch auth", incidentId: "i1", status: "open", dueIso: "2026-06-13T00:00:00.000Z" }, + { id: "a2", title: "Not yet due", incidentId: "i1", status: "open", dueIso: "2026-07-01T00:00:00.000Z" }, + { id: "a3", title: "Already done", incidentId: "i2", status: "done", dueIso: "2026-01-01T00:00:00.000Z" }, + { id: "a4", title: "No due date", incidentId: "i2", status: "open" }, + { id: "a5", title: "Long overdue", incidentId: "i3", status: "open", dueIso: "2026-05-24T00:00:00.000Z" }, + ]; + + it("returns only open, past-due items sorted by daysOverdue desc", () => { + const overdue = findOverdue(items, NOW); + expect(overdue.map((o) => o.item.id)).toEqual(["a5", "a1"]); + expect(overdue[0]?.daysOverdue).toBe(30); + expect(overdue[1]?.daysOverdue).toBe(10); + }); + + it("excludes done, not-yet-due, and dateless items", () => { + const overdue = findOverdue(items, NOW); + const ids = overdue.map((o) => o.item.id); + expect(ids).not.toContain("a2"); + expect(ids).not.toContain("a3"); + expect(ids).not.toContain("a4"); + }); +}); + +describe("findRecurrences", () => { + const incidents: Incident[] = [ + { id: "i1", title: "DB down", rootCause: "Connection pool exhausted" }, + { id: "i2", title: "DB down again", rootCause: "connection pool exhausted" }, + { id: "i3", title: "Timeout", rootCause: "Slow query" }, + { id: "i4", title: "Mystery", rootCause: "connection pool exhausted" }, + { id: "i5", title: "Unknown cause" }, + ]; + + it("groups by normalized cause and applies the threshold", () => { + const recurrences = findRecurrences(incidents, 2); + expect(recurrences).toHaveLength(1); + expect(recurrences[0]?.rootCause).toBe("connection pool exhausted"); + expect(recurrences[0]?.count).toBe(3); + expect(recurrences[0]?.incidentIds).toEqual(["i1", "i2", "i4"]); + }); + + it("skips incidents without a root cause", () => { + const recurrences = findRecurrences(incidents, 1); + const causes = recurrences.map((r) => r.rootCause); + expect(causes).toContain("slow query"); + expect(recurrences.reduce((n, r) => n + r.count, 0)).toBe(4); + }); +}); + +describe("normalizeCause", () => { + it("lowercases, trims, and collapses whitespace", () => { + expect(normalizeCause(" Connection Pool\tExhausted ")).toBe("connection pool exhausted"); + }); +}); + +const config: IncidentFollowupConfig = { + reportPath: "reports/incident-followup.md", + minRecurrence: 2, +}; + +function source(incidents: Incident[], actionItems: ActionItem[]): IncidentSource { + return { + incidents: async () => incidents, + actionItems: async () => actionItems, + }; +} + +const ctx = (): RunContext => ({ repoRoot: "/tmp/loopy-incident", logger: silentLogger }); + +describe("incident-followup loop", () => { + it("produces a digest when items are overdue or causes recur", async () => { + const incidents: Incident[] = [ + { id: "i1", title: "DB down", rootCause: "connection pool exhausted" }, + { id: "i2", title: "DB down again", rootCause: "connection pool exhausted" }, + ]; + const items: ActionItem[] = [ + { id: "a1", title: "Patch auth", incidentId: "i1", status: "open", dueIso: "2026-06-13T00:00:00.000Z" }, + ]; + const loop = createIncidentFollowupLoop( + config, + { pathAllowlist: ["reports/**"], maxFiles: 5 }, + { type: "manual" }, + { incidents: source(incidents, items), now: () => new Date(NOW) }, + ); + + const result = await runLoop(loop, ctx()); + expect(result.status).toBe("produced"); + const change = result.changes?.[0]; + expect(change?.path).toBe("reports/incident-followup.md"); + const contents = change && change.op === "write" ? change.contents : ""; + expect(contents).toContain("# Incident follow-up โ€” 2026-06-23"); + expect(contents).toContain("connection pool exhausted"); + expect(contents).toContain("Patch auth"); + }); + + it("reports no work when nothing is overdue and no cause recurs", async () => { + const incidents: Incident[] = [{ id: "i1", title: "One-off", rootCause: "fluke" }]; + const items: ActionItem[] = [ + { id: "a1", title: "Done", incidentId: "i1", status: "done", dueIso: "2026-01-01T00:00:00.000Z" }, + { id: "a2", title: "Future", incidentId: "i1", status: "open", dueIso: "2026-12-01T00:00:00.000Z" }, + ]; + const loop = createIncidentFollowupLoop( + config, + { pathAllowlist: ["reports/**"], maxFiles: 5 }, + { type: "manual" }, + { incidents: source(incidents, items), now: () => new Date(NOW) }, + ); + + const result = await runLoop(loop, ctx()); + expect(result.status).toBe("no-work"); + }); +}); diff --git a/test/loops/metric-anomaly.test.ts b/test/loops/metric-anomaly.test.ts new file mode 100644 index 0000000..9f7289f --- /dev/null +++ b/test/loops/metric-anomaly.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { runLoop, silentLogger, type RunContext } from "../../src/core/index.js"; +import { + createMetricAnomalyLoop, + type MetricAnomalyConfig, +} from "../../loops/metric-anomaly/index.js"; +import { + detectAnomalies, + type MetricSeries, + type MetricSource, +} from "../../loops/metric-anomaly/hooks/anomaly.js"; + +function series(name: string, values: number[]): MetricSeries { + return { + name, + points: values.map((value, i) => ({ t: `2026-06-${String(i + 1).padStart(2, "0")}`, value })), + }; +} + +function source(all: MetricSeries[]): MetricSource { + return { series: async () => all }; +} + +const config: MetricAnomalyConfig = { + reportPath: "reports/anomalies.md", + zThreshold: 3, +}; + +const guardrails = { pathAllowlist: ["reports/**"], maxFiles: 5, skipIfOpenPr: true }; +const trigger = { type: "manual" as const }; +const now = () => new Date("2026-06-23T07:00:00.000Z"); +const ctx = (): RunContext => ({ repoRoot: "/tmp/loopy-metric-anomaly", logger: silentLogger }); + +describe("detectAnomalies", () => { + it("flags a clear spike against a flat-ish baseline", () => { + const anomalies = detectAnomalies([series("cpu", [10, 10, 11, 10, 100])], 3); + expect(anomalies).toHaveLength(1); + const a = anomalies[0]!; + expect(a.metric).toBe("cpu"); + expect(a.direction).toBe("up"); + expect(a.value).toBe(100); + expect(Math.abs(a.z)).toBeGreaterThanOrEqual(3); + }); + + it("flags a downward spike", () => { + const anomalies = detectAnomalies([series("orders", [100, 101, 99, 100, 0])], 3); + expect(anomalies).toHaveLength(1); + expect(anomalies[0]!.direction).toBe("down"); + }); + + it("returns nothing for a flat series (std == 0)", () => { + expect(detectAnomalies([series("flat", [5, 5, 5, 5])], 3)).toEqual([]); + }); + + it("returns nothing for a gentle series under threshold", () => { + expect(detectAnomalies([series("gentle", [10, 11, 12, 13, 14])], 3)).toEqual([]); + }); + + it("skips series with fewer than 3 points", () => { + expect(detectAnomalies([series("short", [1, 100])], 3)).toEqual([]); + }); + + it("sorts results by |z| descending", () => { + const anomalies = detectAnomalies( + [series("small", [10, 10, 11, 10, 40]), series("big", [10, 10, 11, 10, 200])], + 3, + ); + expect(anomalies.map((a) => a.metric)).toEqual(["big", "small"]); + }); +}); + +describe("metric-anomaly loop", () => { + it("produces a PR writing the brief when an anomaly exists", async () => { + const loop = createMetricAnomalyLoop(config, guardrails, trigger, { + metrics: source([series("cpu", [10, 10, 11, 10, 100])]), + now, + }); + + const result = await runLoop(loop, ctx()); + expect(result.status).toBe("produced"); + expect(result.outputKind).toBe("pull-request"); + const change = result.changes?.[0]; + expect(change?.path).toBe("reports/anomalies.md"); + expect(change?.op).toBe("write"); + const contents = change && change.op === "write" ? change.contents : ""; + expect(contents).toContain("2026-06-23"); + expect(contents).toContain("cpu"); + expect(result.summary).toContain("cpu"); + expect(result.summary).toContain("metrics can be noisy"); + }); + + it("reports no work when there are no anomalies", async () => { + const loop = createMetricAnomalyLoop(config, guardrails, trigger, { + metrics: source([series("flat", [5, 5, 5, 5]), series("gentle", [10, 11, 12, 13])]), + now, + }); + + const result = await runLoop(loop, ctx()); + expect(result.status).toBe("no-work"); + expect(result.changes).toBeUndefined(); + }); +});