-
Notifications
You must be signed in to change notification settings - Fork 16
feat(audit): incidents source collector for a measured MTTR (DF-07) #170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -167,14 +167,14 @@ node "${CLAUDE_SKILL_DIR}/dist/cli.js" patch-judgment "context/audits/YYYY-MM-DD | |
| node "${CLAUDE_SKILL_DIR}/dist/cli.js" patch-report "context/audits/YYYY-MM-DD_HH-MM-SS" context/audits/YYYY-MM-DD_HH-MM-SS/report-blocks.json | ||
| ``` | ||
|
|
||
| - `headline` — the executive band. Transcribe values verbatim from the dimension checks (cite the `check_id`); never invent numbers. Row 1 of the headline (capability Points + Coverage cap-score block) is emitted by the renderer directly from `audit_total`/`coverage` — do not add it as a `delivery[]` entry, and the two connector-gated rows (Cycle time In-Progress→Done, MTTR) are computed by the engine from the tracker artifact and appended by the renderer — do not author them either (an authored gated row is ignored). `delivery[]` carries only rows 2–7, each a `DeliveryMetric` object `{label, display_value?, band?, check_id?}`. Author them in this order, reading DORA bands from each check's `hint` field ("DORA-banded (high)"), and transcribing all values verbatim — never invent numbers: | ||
| - `headline` — the executive band. Transcribe values verbatim from the dimension checks (cite the `check_id`); never invent numbers. Row 1 of the headline (capability Points + Coverage cap-score block) is emitted by the renderer directly from `audit_total`/`coverage` — do not add it as a `delivery[]` entry, and the two connector-gated rows (Cycle time In-Progress→Done from the tracker artifact, MTTR from the incidents artifact) are computed by the engine and appended by the renderer — do not author them either (an authored gated row is ignored). `delivery[]` carries only rows 2–7, each a `DeliveryMetric` object `{label, display_value?, band?, check_id?}`. Author them in this order, reading DORA bands from each check's `hint` field ("DORA-banded (high)"), and transcribing all values verbatim — never invent numbers: | ||
| 1. **Merges** — put the unit in the value, not the label: `label: "Merges"`, `display_value` from `report-context` → `window_stats.merges_per_active_per_week` rendered as a per-week rate `"<n> / week (per active contributor)"` (e.g. `"1.5 / week (per active contributor)"`); no `band`; no `check_id`; source: git artifact. If the value is null (zero active contributors), omit `display_value`. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Keep the Merges display format consistent. Line 171 requires As per coding guidelines, “Treat framework and plugin markdown files as prompts: prioritize clarity, structure, and explicit role, task, and process sections.” 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| 2. **LOC** — `label: "LOC"`, `display_value` from `report-context` → `window_stats.loc_per_active_per_week` as a per-week rate `"<n> / week (per active contributor)"`; same rules. | ||
| 3. **Deployment frequency** — check `DF-01`; band from hint; `check_id: "DF-01"`. | ||
| 4. **Rework rate (DORA)** — check `DF-06`; band from hint; `check_id: "DF-06"`. | ||
| 5. **Lead time for change** — check `DF-02`; band from hint; `check_id: "DF-02"`. | ||
| 6. **Change-failure rate** — check `DF-04`; band from hint; `check_id: "DF-04"`. | ||
| (Rows 8–9 — **Cycle time (In-Progress→Done)** and **MTTR** — are engine-derived: `audit-core`/`enrich` compute the median and the honest gated note from `collected/tracker.json` into `audit.derived_delivery`, and the renderer appends them. Your job for cycle time is upstream, in step 1: fetch the tracker's per-ticket status history so `in_progress_at` is populated — the engine does the rest.) | ||
| (Rows 8–9 — **Cycle time (In-Progress→Done)** and **MTTR** — are engine-derived: `audit-core`/`enrich` compute the median and the honest gated note into `audit.derived_delivery` (cycle time from `collected/tracker.json`, MTTR from `collected/incidents.json`), and the renderer appends them. Your job is upstream, in step 1: for cycle time, fetch the tracker's per-ticket status history so `in_progress_at` is populated; for MTTR, write `collected/incidents.json` per `references/connector-shapes.md` — the engine does the rest.) | ||
|
|
||
| `scale[]` = code size & complexity — author exactly these three rows, each a `{label, display_value, check_id}` transcribed from its check (do not put commits, contributors, or merges here — those are activity, not scale/complexity): | ||
| 1. **Source size** — `check_id: "DESC-04"` — LOC + file count (e.g. `"234k LOC · 1,203 files"`). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -66,6 +66,11 @@ import { | |
| import { collect as collectCi } from './collectors/ci.ts'; | ||
| import { collect as collectTracker } from './collectors/tracker.ts'; | ||
| import { collect as collectDocs } from './collectors/docs.ts'; | ||
| import { | ||
| collect as collectIncidents, | ||
| deriveIncidentAggregates, | ||
| hasMeasurableIncidents, | ||
| } from './collectors/incidents.ts'; | ||
|
|
||
| /** | ||
| * Compute the connector-gated headline rows (Cycle time, MTTR) from the | ||
|
|
@@ -94,6 +99,40 @@ export function computeDerivedDelivery( | |
| } | ||
| } | ||
| const out: DerivedDelivery = { cycle_time: {}, mttr: {} }; | ||
|
|
||
| // MTTR headline — derived from the incidents artifact, mirroring the metric, | ||
| // so the headline can never contradict the DF-07 detail below it. A tracker | ||
| // that merely names an incident system is not incident data and plays no part. | ||
| // Computed independently of the tracker, so it fills even with no tracker. | ||
| try { | ||
| const inc = JSON.parse( | ||
| readFileSync(join(collectedDir, 'incidents.json'), 'utf8') | ||
| ); | ||
| if (inc?.available) { | ||
| const incRaw = (inc.raw ?? {}) as { | ||
| incidents?: Parameters<typeof deriveIncidentAggregates>[0]; | ||
| source_label?: string | null; | ||
| }; | ||
| const agg = deriveIncidentAggregates( | ||
| incRaw.incidents, | ||
| inc.period?.lookback_days | ||
| ); | ||
| const incLabel = incRaw.source_label ?? 'incident source'; | ||
| if (agg.resolved_count > 0 && agg.median_duration_hours !== null) { | ||
| out.mttr.median_hours = round1(agg.median_duration_hours); | ||
| out.mttr.incidents_used = agg.resolved_count; | ||
| out.mttr.display_value = `${out.mttr.median_hours} h`; | ||
| } else if (agg.count > 0) { | ||
| out.mttr.note = `${incLabel} connected — no incident with a resolved recovery span`; | ||
| } else { | ||
| out.mttr.note = `${incLabel} connected — no incidents in window`; | ||
| } | ||
| } | ||
| } catch { | ||
| // No incidents artifact — leave out.mttr empty; the renderer shows the | ||
| // "needs incident connector" gated fallback. | ||
| } | ||
|
|
||
| if (!tracker?.available) return out; | ||
|
|
||
| const raw = (tracker.raw ?? {}) as Record<string, unknown>; | ||
|
|
@@ -131,10 +170,6 @@ export function computeDerivedDelivery( | |
| out.cycle_time.note = `${label} connected — no tickets resolved in window`; | ||
| } | ||
|
|
||
| const incident = raw.incident_source; | ||
| if (typeof incident === 'string' && incident) { | ||
| out.mttr.note = `incident source "${incident}" declared — no incident data mapped`; | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
|
|
@@ -510,6 +545,10 @@ export async function auditCore( | |
| collectCi(repoPath, period), | ||
| collectTracker(repoPath, period), | ||
| collectDocs(repoPath, period), | ||
| // Connector-passed: no connector in the base pass → available:false. The | ||
| // orchestrator overwrites collected/incidents.json with real data and | ||
| // `enrich` re-scores. Present so the SKIP artifact exists every run. | ||
| collectIncidents(repoPath, period), | ||
| ]) { | ||
| writeArtifact(art as { source: string }, collectedDir); | ||
| } | ||
|
|
@@ -597,11 +636,25 @@ export async function auditCore( | |
| const trackerArt = readCollected('tracker'); | ||
| const docsArt = readCollected('docs'); | ||
| const codeHostArt = readCollected('code_host'); | ||
| const incidentsArt = readCollected('incidents'); | ||
| const incidentsRaw = incidentsArt?.raw as | ||
| | { incidents?: Parameters<typeof deriveIncidentAggregates>[0] } | ||
| | undefined; | ||
| const topology: TopologyFlags = computeTopology(repoPath, { | ||
| has_tracker: Boolean(trackerArt?.available), | ||
| has_docs_connector: Boolean(docsArt?.available), | ||
| // A real, measurable incident source: the connector-passed incidents | ||
| // artifact with at least one resolved recovery span in the window (derived | ||
| // from raw.incidents[], same as the metric). Merely naming an incident | ||
| // system in the tracker does NOT award the category — that would grant | ||
| // DF-07 (category 1103) full weight on a git proxy with zero incident data. | ||
| // Without a measurable span category 1103 stays SKIP. | ||
| has_incident_source: Boolean( | ||
| trackerArt?.available && trackerArt?.incident_source | ||
| incidentsArt?.available && | ||
| hasMeasurableIncidents( | ||
| incidentsRaw?.incidents, | ||
| incidentsArt?.period?.lookback_days | ||
| ) | ||
| ), | ||
|
Comment on lines
644
to
658
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dropped the tracker disjunct — 1103 is awarded only on a measured recovery span. standards.toml sources are incidents-only now, and I added the pinning tests: tracker-declared-only leaves DF-07 SKIP (e2e), plus hasMeasurableIncidents true/false legs (unit). |
||
| has_code_host: Boolean(codeHostArt?.available), | ||
| }); | ||
|
|
@@ -1034,7 +1087,7 @@ const CONNECTABLE_SOURCES = new Set([ | |
| 'tracker', | ||
| 'docs', | ||
| 'ci', | ||
| 'incident', | ||
| 'incidents', | ||
| 'code_host', | ||
| ]); | ||
|
|
||
|
|
@@ -1045,7 +1098,7 @@ const COLLECTED_ARTIFACT_SOURCES = new Set([ | |
| 'ci', | ||
| 'tracker', | ||
| 'docs', | ||
| 'incident', | ||
| 'incidents', | ||
| 'code_host', | ||
| ]); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| /** | ||
| * incidents — external incident source collector (connector-passed). | ||
| * | ||
| * Mirrors the tracker/ci/docs collectors: it does not reach out to a service | ||
| * itself. The orchestrator fetches incidents from whatever real incident source | ||
| * the project uses — a dedicated tool (PagerDuty / OpsGenie / incident.io), a | ||
| * status page (Statuspage / Atlassian), or code-host issues labelled as | ||
| * incidents (GitHub/GitLab) — normalises them to `IncidentRecord`s, and passes | ||
| * them here (see references/connector-shapes.md). Without a connector the | ||
| * artifact is `available: false`, so the MTTR metric falls back to its git | ||
| * proxy and category 1103 stays SKIP. | ||
| * | ||
| * The measured signal is real recovery time: for each resolved incident, | ||
| * resolved_at − started_at. The engine — not the orchestrator — derives the | ||
| * median: `deriveIncidentAggregates` is the one place that computes it, shared | ||
| * by this collector, the MTTR metric, and the topology gate. That mirrors the | ||
| * tracker collector, whose metrics derive their aggregates from `raw.tickets[]` | ||
| * rather than trusting a hand-written count, so an orchestrator arithmetic slip | ||
| * can never become a "measured" DORA number. | ||
| */ | ||
| import { makeArtifact, type Period } from './_base.ts'; | ||
|
|
||
| /** One incident, normalised from whatever source the orchestrator fetched. */ | ||
| export interface IncidentRecord { | ||
| id: string; | ||
| /** ISO 8601 — when the incident was opened/detected. */ | ||
| started_at: string; | ||
| /** ISO 8601 — when service was restored; omit/null for still-open incidents. */ | ||
| resolved_at?: string | null; | ||
| /** Optional severity label, verbatim from the source (e.g. "SEV1", "critical"). */ | ||
| severity?: string | null; | ||
| /** Which source this came from, e.g. "pagerduty", "github-label:incident". */ | ||
| source?: string | null; | ||
| } | ||
|
|
||
| export interface IncidentsConnector { | ||
| incidents?: IncidentRecord[]; | ||
| /** Human label of the source for the report, e.g. "PagerDuty", "GitHub incident labels". */ | ||
| source_label?: string | null; | ||
| } | ||
|
|
||
| export interface IncidentsRaw { | ||
| incidents: IncidentRecord[]; | ||
| count: number; | ||
| /** Incidents with a valid started→resolved span (the only ones MTTR can measure). */ | ||
| resolved_count: number; | ||
| /** Resolved incidents whose span could not be parsed (bad/zero/reversed timestamps). */ | ||
| invalid_count: number; | ||
| /** Median recovery time in hours over resolved incidents; null when none. */ | ||
| median_duration_hours: number | null; | ||
| source_label: string | null; | ||
| } | ||
|
|
||
| export function median(nums: number[]): number | null { | ||
| if (nums.length === 0) return null; | ||
| const s = [...nums].sort((a, b) => a - b); | ||
| const mid = Math.floor(s.length / 2); | ||
| return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; | ||
| } | ||
|
|
||
| /** | ||
| * Recovery time in hours for a resolved incident, or null when unmeasurable. | ||
| * A record with no `resolved_at` is still open (not invalid). A record WITH a | ||
| * `resolved_at` but an unparseable, reversed, or zero-length span is invalid — | ||
| * `end <= start` is rejected so an auto-resolved alert flap (a 0h span) cannot | ||
| * band "elite". | ||
| */ | ||
| export function durationHours(inc: IncidentRecord): number | null { | ||
| if (inc.resolved_at == null || inc.resolved_at === '') return null; | ||
| const start = Date.parse(inc.started_at ?? ''); | ||
| const end = Date.parse(inc.resolved_at); | ||
| if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return null; | ||
| return (end - start) / 3_600_000; | ||
| } | ||
|
|
||
| /** | ||
| * Clamp incidents to the audit window, anchored to the NEWEST `started_at` — | ||
| * mirroring `clampToWindow` for CI runs and tracker tickets. Connectors | ||
| * routinely over-fetch (all-time incident history); without this clamp that | ||
| * history would shift the "audit-window" median and make it non-reproducible | ||
| * across differently-scoped fetches. Records with no parseable `started_at` are | ||
| * kept (they cannot be judged against the window). No lookback → no clamp. | ||
| */ | ||
| export function clampIncidentsToWindow( | ||
| incidents: IncidentRecord[], | ||
| lookbackDays?: number | null | ||
| ): { kept: IncidentRecord[]; dropped: number } { | ||
| if (!lookbackDays || lookbackDays <= 0) | ||
| return { kept: incidents, dropped: 0 }; | ||
| let anchor = -Infinity; | ||
| const stamps = incidents.map((inc) => { | ||
| const t = Date.parse(inc.started_at ?? ''); | ||
| if (Number.isFinite(t) && t > anchor) anchor = t; | ||
| return t; | ||
| }); | ||
| if (!Number.isFinite(anchor)) return { kept: incidents, dropped: 0 }; | ||
| const since = anchor - lookbackDays * 86_400_000; | ||
| const kept: IncidentRecord[] = []; | ||
| let dropped = 0; | ||
| incidents.forEach((inc, i) => { | ||
| if (Number.isFinite(stamps[i]) && stamps[i] < since) dropped++; | ||
| else kept.push(inc); | ||
| }); | ||
| return { kept, dropped }; | ||
| } | ||
|
|
||
| export interface IncidentAggregates extends IncidentsRaw { | ||
| /** In-window incidents kept after the window clamp. */ | ||
| incidents: IncidentRecord[]; | ||
| /** Incidents dropped for falling outside the audit window. */ | ||
| dropped_out_of_window: number; | ||
| } | ||
|
|
||
| /** | ||
| * The single deterministic derivation of the MTTR aggregates from raw incident | ||
| * records. Clamps to the window, then over the in-window incidents: counts | ||
| * resolved-with-a-measurable-span, counts resolved-but-unmeasurable | ||
| * (`invalid_count`), and takes the median span. Shared by the collector, the | ||
| * MTTR metric, and the topology gate so the awarded category and the reported | ||
| * value can never diverge. | ||
| */ | ||
| export function deriveIncidentAggregates( | ||
| incidents: IncidentRecord[] | undefined | null, | ||
| lookbackDays?: number | null | ||
| ): IncidentAggregates { | ||
| const all = Array.isArray(incidents) ? incidents : []; | ||
| const { kept, dropped } = clampIncidentsToWindow(all, lookbackDays); | ||
| const durations: number[] = []; | ||
| let invalid = 0; | ||
| for (const inc of kept) { | ||
| if (inc.resolved_at == null || inc.resolved_at === '') continue; // still open | ||
| const d = durationHours(inc); | ||
| if (d === null) invalid++; | ||
| else durations.push(d); | ||
| } | ||
| return { | ||
| incidents: kept, | ||
| count: kept.length, | ||
| resolved_count: durations.length, | ||
| invalid_count: invalid, | ||
| median_duration_hours: median(durations), | ||
| source_label: null, | ||
| dropped_out_of_window: dropped, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Whether an incidents artifact carries at least one measurable recovery span | ||
| * within the window — the predicate that gates category 1103. Shared by the | ||
| * topology gate and the MTTR metric so "awarded" and "measured" stay in lock | ||
| * step. | ||
| */ | ||
| export function hasMeasurableIncidents( | ||
| incidents: IncidentRecord[] | undefined | null, | ||
| lookbackDays?: number | null | ||
| ): boolean { | ||
| return deriveIncidentAggregates(incidents, lookbackDays).resolved_count > 0; | ||
| } | ||
|
|
||
| export function collect( | ||
| _repoPath: string, | ||
| period: Period, | ||
| connector?: IncidentsConnector | ||
| ) { | ||
| if (connector === undefined || connector === null) { | ||
| return makeArtifact( | ||
| 'incidents', | ||
| false, | ||
| 'no incident source connected; supply a PagerDuty/OpsGenie/incident.io, Statuspage, or code-host incident-label connector to measure MTTR from real incidents', | ||
| period, | ||
| {} as IncidentsRaw | ||
| ); | ||
| } | ||
|
|
||
| const agg = deriveIncidentAggregates( | ||
| connector.incidents, | ||
| period.lookback_days | ||
| ); | ||
| const raw: IncidentsRaw = { | ||
| incidents: agg.incidents, | ||
| count: agg.count, | ||
| resolved_count: agg.resolved_count, | ||
| invalid_count: agg.invalid_count, | ||
| median_duration_hours: agg.median_duration_hours, | ||
| source_label: connector.source_label ?? null, | ||
| }; | ||
| return makeArtifact('incidents', true, null, period, raw); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Replace the nonexistent
Step 6.4reference.Both changed prompt documents refer to
SKILL.md Step 6.4, but the current workflow places report authoring in Step 5, item 4. This can direct the orchestrator to the wrong section.plugins/awos/skills/ai-readiness-audit/SKILL.md#L170-L170: changeSKILL.md Step 6.4toSKILL.md Step 5.4.plugins/awos/skills/ai-readiness-audit/output-format.md#L77-L77: changeSKILL.md Step 6.4toSKILL.md Step 5.4.As per coding guidelines, “Treat framework and plugin markdown files as prompts: prioritize clarity, structure, and explicit role, task, and process sections.”
📍 Affects 2 files
plugins/awos/skills/ai-readiness-audit/SKILL.md#L170-L170(this comment)plugins/awos/skills/ai-readiness-audit/output-format.md#L77-L77🤖 Prompt for AI Agents
Source: Coding guidelines