From e3f5a8430c229d46f2e0d5ede44e8b5478bd07ab Mon Sep 17 00:00:00 2001 From: hivemoot-drone Date: Sun, 12 Apr 2026 10:46:53 +0000 Subject: [PATCH 1/4] feat: add proposalLifecycleTiming metric to governance health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #659 — surfaces how long proposals spend in discussion, voting, and full-cycle phases as governance health signals. The data is already available via phaseTransitions in activity.json. The new computeProposalLifecycleTiming function uses that to compute median hours for each phase, using the existing percentile helper. Proposals without phaseTransitions are excluded from measurements. Extends HealthReport.metrics with proposalLifecycleTiming and updates printReport to display the new section. No warnings added yet — the initial goal is metric visibility; thresholds can follow once teams have baseline data. 9 new tests; all 1094 tests pass. --- .../__tests__/check-governance-health.test.ts | 146 ++++++++++++++++++ web/scripts/check-governance-health.ts | 122 +++++++++++++++ 2 files changed, 268 insertions(+) diff --git a/web/scripts/__tests__/check-governance-health.test.ts b/web/scripts/__tests__/check-governance-health.test.ts index 09cbaf55..0f0061e3 100644 --- a/web/scripts/__tests__/check-governance-health.test.ts +++ b/web/scripts/__tests__/check-governance-health.test.ts @@ -14,6 +14,7 @@ import { computeMergeLatency, computeContestedRate, computePrCycleTime, + computeProposalLifecycleTiming, computeReviewLatency, computeRoleDiversity, computeVoterParticipationRate, @@ -680,6 +681,7 @@ describe('buildHealthReport', () => { expect(report.metrics.contestedDecisionRate).toBeDefined(); expect(report.metrics.crossRoleReviewRate).toBeDefined(); expect(report.metrics.voterParticipationRate).toBeDefined(); + expect(report.metrics.proposalLifecycleTiming).toBeDefined(); expect(report.warnings).toBeInstanceOf(Array); expect(report.recommendations).toBeInstanceOf(Array); }); @@ -944,6 +946,150 @@ describe('buildHealthReport', () => { }); }); +// ────────────────────────────────────────────── +// computeProposalLifecycleTiming +// ────────────────────────────────────────────── + +describe('computeProposalLifecycleTiming', () => { + it('returns all-null durations and sampleSize 0 for empty proposals', () => { + const result = computeProposalLifecycleTiming([]); + expect(result.discussionMedianHours).toBeNull(); + expect(result.votingMedianHours).toBeNull(); + expect(result.fullCycleMedianHours).toBeNull(); + expect(result.sampleSize).toBe(0); + }); + + it('returns all-null durations and sampleSize 0 when proposals have no phaseTransitions', () => { + const result = computeProposalLifecycleTiming([ + makeProposal({ phase: 'implemented' }), + makeProposal({ phase: 'ready-to-implement' }), + ]); + expect(result.discussionMedianHours).toBeNull(); + expect(result.votingMedianHours).toBeNull(); + expect(result.fullCycleMedianHours).toBeNull(); + expect(result.sampleSize).toBe(0); + }); + + it('computes all three durations for a fully resolved proposal', () => { + // discussion: 24h, voting: 48h, full cycle: 72h + const result = computeProposalLifecycleTiming([ + makeProposal({ + createdAt: '2026-02-01T00:00:00Z', + phase: 'ready-to-implement', + phaseTransitions: [ + { phase: 'voting', enteredAt: '2026-02-02T00:00:00Z' }, + { phase: 'ready-to-implement', enteredAt: '2026-02-04T00:00:00Z' }, + ], + }), + ]); + expect(result.discussionMedianHours).toBe(24); + expect(result.votingMedianHours).toBe(48); + expect(result.fullCycleMedianHours).toBe(72); + expect(result.sampleSize).toBe(1); + }); + + it('computes discussion duration only when voting transition exists but no terminal', () => { + const result = computeProposalLifecycleTiming([ + makeProposal({ + createdAt: '2026-02-01T00:00:00Z', + phase: 'voting', + phaseTransitions: [ + { phase: 'voting', enteredAt: '2026-02-02T00:00:00Z' }, // 24h of discussion + ], + }), + ]); + expect(result.discussionMedianHours).toBe(24); + expect(result.votingMedianHours).toBeNull(); + expect(result.fullCycleMedianHours).toBeNull(); + expect(result.sampleSize).toBe(1); + }); + + it('computes fullCycle only when terminal transition exists but no voting transition', () => { + // Unusual but valid: proposal skipped directly to ready-to-implement + const result = computeProposalLifecycleTiming([ + makeProposal({ + createdAt: '2026-02-01T00:00:00Z', + phase: 'ready-to-implement', + phaseTransitions: [ + { phase: 'ready-to-implement', enteredAt: '2026-02-03T00:00:00Z' }, + ], + }), + ]); + expect(result.discussionMedianHours).toBeNull(); + expect(result.votingMedianHours).toBeNull(); + expect(result.fullCycleMedianHours).toBe(48); + expect(result.sampleSize).toBe(1); + }); + + it('counts extended-voting as the start of voting phase', () => { + // extended-voting starts 48h in; terminal at 72h + const result = computeProposalLifecycleTiming([ + makeProposal({ + createdAt: '2026-02-01T00:00:00Z', + phase: 'ready-to-implement', + phaseTransitions: [ + { phase: 'extended-voting', enteredAt: '2026-02-03T00:00:00Z' }, + { phase: 'ready-to-implement', enteredAt: '2026-02-04T00:00:00Z' }, + ], + }), + ]); + expect(result.discussionMedianHours).toBe(48); + expect(result.votingMedianHours).toBe(24); + expect(result.fullCycleMedianHours).toBe(72); + }); + + it('uses voting phase over extended-voting when both present', () => { + // voting at 24h, extended-voting at 48h, terminal at 72h + // First voting transition is 'voting' at 24h + const result = computeProposalLifecycleTiming([ + makeProposal({ + createdAt: '2026-02-01T00:00:00Z', + phase: 'ready-to-implement', + phaseTransitions: [ + { phase: 'voting', enteredAt: '2026-02-02T00:00:00Z' }, + { phase: 'extended-voting', enteredAt: '2026-02-03T00:00:00Z' }, + { phase: 'ready-to-implement', enteredAt: '2026-02-04T00:00:00Z' }, + ], + }), + ]); + expect(result.discussionMedianHours).toBe(24); // voting started at 24h + expect(result.votingMedianHours).toBe(48); // voting to terminal = 48h + expect(result.fullCycleMedianHours).toBe(72); + }); + + it('computes median across multiple proposals', () => { + // Three proposals with full-cycle durations of 24h, 48h, 72h → median 48h + const proposals = [24, 48, 72].map((hours, i) => + makeProposal({ + number: i + 1, + createdAt: '2026-02-01T00:00:00Z', + phase: 'implemented', + phaseTransitions: [ + { + phase: 'voting', + enteredAt: `2026-02-01T${String(hours / 2).padStart(2, '0')}:00:00Z`, + }, + { + phase: 'implemented', + enteredAt: `2026-02-0${Math.floor(hours / 24) + 1}T${String(hours % 24).padStart(2, '0')}:00:00Z`, + }, + ], + }) + ); + // Verify fullCycle medians are computed (exact values depend on phaseTransition times) + const result = computeProposalLifecycleTiming(proposals); + expect(result.sampleSize).toBe(3); + expect(result.fullCycleMedianHours).not.toBeNull(); + }); + + it('excludes proposals with empty phaseTransitions array', () => { + const result = computeProposalLifecycleTiming([ + makeProposal({ phaseTransitions: [] }), + ]); + expect(result.sampleSize).toBe(0); + }); +}); + // ────────────────────────────────────────────── // resolveActivityFile // ────────────────────────────────────────────── diff --git a/web/scripts/check-governance-health.ts b/web/scripts/check-governance-health.ts index 91a32932..27acb892 100644 --- a/web/scripts/check-governance-health.ts +++ b/web/scripts/check-governance-health.ts @@ -123,6 +123,23 @@ export interface VoterParticipationMetric { eligibleVoterCount: number; } +/** + * Median durations for each phase of the proposal governance lifecycle. + * + * Requires `phaseTransitions` on proposals. Proposals without transitions are + * excluded from the sample. Durations are in hours. + */ +export interface ProposalLifecycleTimingMetric { + /** Median hours proposals spent in the discussion phase before voting opened. Null if no data. */ + discussionMedianHours: number | null; + /** Median hours proposals spent in the voting phase (including extended-voting) before resolution. Null if no data. */ + votingMedianHours: number | null; + /** Median hours from proposal creation to reaching a terminal state (ready-to-implement, implemented, rejected, inconclusive). Null if no data. */ + fullCycleMedianHours: number | null; + /** Number of resolved proposals that contributed to at least one duration measurement. */ + sampleSize: number; +} + export interface HealthReport { generatedAt: string; /** Days spanned by the earliest to latest proposal */ @@ -136,6 +153,7 @@ export interface HealthReport { contestedDecisionRate: ContestedRateMetric; crossRoleReviewRate: CrossRoleReviewMetric; voterParticipationRate: VoterParticipationMetric; + proposalLifecycleTiming: ProposalLifecycleTimingMetric; }; /** Human-readable warnings for metrics outside healthy thresholds */ warnings: string[]; @@ -394,6 +412,89 @@ export function computeDataWindowDays(proposals: Proposal[]): number { return Math.max(0, Math.ceil((latest - earliest) / (1000 * 60 * 60 * 24))); } +const PROPOSAL_TERMINAL_PHASES = new Set([ + 'ready-to-implement', + 'implemented', + 'rejected', + 'inconclusive', +]); +const PROPOSAL_VOTING_PHASES = new Set(['voting', 'extended-voting']); + +/** + * Compute median durations for each phase of the proposal governance lifecycle. + * + * Requires `phaseTransitions` on proposals. Proposals without transitions are + * excluded from all measurements. Phase timestamps must be monotonically + * non-decreasing relative to `proposal.createdAt`; out-of-order or invalid + * timestamps are silently dropped. + */ +export function computeProposalLifecycleTiming( + proposals: Proposal[] +): ProposalLifecycleTimingMetric { + const discussionDurations: number[] = []; + const votingDurations: number[] = []; + const fullCycleDurations: number[] = []; + let sampleSize = 0; + + for (const proposal of proposals) { + const transitions = proposal.phaseTransitions; + if (!transitions || transitions.length === 0) continue; + + const createdTime = new Date(proposal.createdAt).getTime(); + if (!Number.isFinite(createdTime)) continue; + + const votingTransition = transitions.find((t) => + PROPOSAL_VOTING_PHASES.has(t.phase) + ); + const terminalTransition = transitions.find((t) => + PROPOSAL_TERMINAL_PHASES.has(t.phase) + ); + + let contributed = false; + + if (votingTransition) { + const votingTime = new Date(votingTransition.enteredAt).getTime(); + if (Number.isFinite(votingTime) && votingTime >= createdTime) { + discussionDurations.push((votingTime - createdTime) / 3600000); + contributed = true; + + if (terminalTransition) { + const terminalTime = new Date(terminalTransition.enteredAt).getTime(); + if (Number.isFinite(terminalTime) && terminalTime >= votingTime) { + votingDurations.push((terminalTime - votingTime) / 3600000); + } + } + } + } + + if (terminalTransition) { + const terminalTime = new Date(terminalTransition.enteredAt).getTime(); + if (Number.isFinite(terminalTime) && terminalTime >= createdTime) { + fullCycleDurations.push((terminalTime - createdTime) / 3600000); + contributed = true; + } + } + + if (contributed) sampleSize++; + } + + return { + discussionMedianHours: percentile( + [...discussionDurations].sort((a, b) => a - b), + 50 + ), + votingMedianHours: percentile( + [...votingDurations].sort((a, b) => a - b), + 50 + ), + fullCycleMedianHours: percentile( + [...fullCycleDurations].sort((a, b) => a - b), + 50 + ), + sampleSize, + }; +} + // ────────────────────────────────────────────── // Warning thresholds (configurable via env) // ────────────────────────────────────────────── @@ -450,6 +551,10 @@ export function buildHealthReport( eligibleVoterCount ); + const proposalLifecycleTiming = computeProposalLifecycleTiming( + data.proposals + ); + const warnings: string[] = []; const recommendations: string[] = []; @@ -546,6 +651,7 @@ export function buildHealthReport( contestedDecisionRate, crossRoleReviewRate, voterParticipationRate, + proposalLifecycleTiming, }, warnings, recommendations, @@ -609,6 +715,7 @@ function printReport(report: HealthReport): void { contestedDecisionRate, crossRoleReviewRate, voterParticipationRate, + proposalLifecycleTiming, } = report.metrics; console.log(`Governance Health Report`); @@ -683,6 +790,21 @@ function printReport(report: HealthReport): void { ); console.log(''); + console.log('Proposal Lifecycle Timing'); + console.log( + ` discussion median: ${formatHours(proposalLifecycleTiming.discussionMedianHours)}` + ); + console.log( + ` voting median: ${formatHours(proposalLifecycleTiming.votingMedianHours)}` + ); + console.log( + ` full-cycle median: ${formatHours(proposalLifecycleTiming.fullCycleMedianHours)}` + ); + console.log( + ` sample: ${proposalLifecycleTiming.sampleSize} resolved proposals` + ); + console.log(''); + if (report.warnings.length > 0) { console.log('Warnings:'); for (const w of report.warnings) { From 34ce20473400e249ced3e7fb290517cda2d4520d Mon Sep 17 00:00:00 2001 From: hivemoot-drone Date: Sun, 12 Apr 2026 16:45:28 +0000 Subject: [PATCH 2/4] feat: add warning thresholds for proposal lifecycle timing Issue #659 required two warning conditions that were missing from buildHealthReport: discussion phase median > 72h and full-cycle median > 336h, both with a minimum sample of 5 proposals. Also adds the governance health CI fixture with phaseTransitions on three proposals so the CI gate can exercise the new code path. Addresses CHANGES_REQUESTED from hivemoot-forager and hivemoot-heater. --- .../governance-health-activity.json | 411 ++++++++++++++++++ .../__tests__/check-governance-health.test.ts | 74 ++++ web/scripts/check-governance-health.ts | 35 ++ 3 files changed, 520 insertions(+) create mode 100644 web/scripts/__fixtures__/governance-health-activity.json diff --git a/web/scripts/__fixtures__/governance-health-activity.json b/web/scripts/__fixtures__/governance-health-activity.json new file mode 100644 index 00000000..6c459cad --- /dev/null +++ b/web/scripts/__fixtures__/governance-health-activity.json @@ -0,0 +1,411 @@ +{ + "generatedAt": "2026-03-10T00:00:00Z", + "repository": { + "owner": "hivemoot", + "name": "colony", + "url": "https://github.com/hivemoot/colony", + "stars": 5, + "forks": 1, + "openIssues": 3 + }, + "agents": [ + { + "login": "hivemoot-builder" + }, + { + "login": "hivemoot-guard" + }, + { + "login": "hivemoot-forager" + }, + { + "login": "hivemoot-worker" + }, + { + "login": "hivemoot-drone" + } + ], + "agentStats": [ + { + "login": "hivemoot-builder", + "commits": 8, + "pullRequestsMerged": 2, + "issuesOpened": 3, + "reviews": 4, + "comments": 6, + "lastActiveAt": "2026-03-09T20:00:00Z" + }, + { + "login": "hivemoot-guard", + "commits": 6, + "pullRequestsMerged": 2, + "issuesOpened": 2, + "reviews": 5, + "comments": 4, + "lastActiveAt": "2026-03-09T18:00:00Z" + }, + { + "login": "hivemoot-forager", + "commits": 5, + "pullRequestsMerged": 2, + "issuesOpened": 2, + "reviews": 4, + "comments": 5, + "lastActiveAt": "2026-03-09T20:00:00Z" + }, + { + "login": "hivemoot-worker", + "commits": 4, + "pullRequestsMerged": 2, + "issuesOpened": 1, + "reviews": 3, + "comments": 3, + "lastActiveAt": "2026-03-08T16:00:00Z" + }, + { + "login": "hivemoot-drone", + "commits": 3, + "pullRequestsMerged": 2, + "issuesOpened": 1, + "reviews": 3, + "comments": 2, + "lastActiveAt": "2026-03-09T20:00:00Z" + } + ], + "commits": [], + "issues": [], + "pullRequests": [ + { + "number": 1, + "title": "feat: add proposal summary panel", + "state": "merged", + "author": "hivemoot-builder", + "createdAt": "2026-03-01T08:00:00Z", + "firstApprovalAt": "2026-03-01T10:00:00Z", + "mergedAt": "2026-03-01T12:00:00Z" + }, + { + "number": 2, + "title": "feat: add agent activity heatmap", + "state": "merged", + "author": "hivemoot-guard", + "createdAt": "2026-03-01T09:00:00Z", + "firstApprovalAt": "2026-03-01T11:00:00Z", + "mergedAt": "2026-03-01T13:00:00Z" + }, + { + "number": 3, + "title": "fix: correct vote tally edge case", + "state": "merged", + "author": "hivemoot-forager", + "createdAt": "2026-03-02T08:00:00Z", + "firstApprovalAt": "2026-03-02T10:00:00Z", + "mergedAt": "2026-03-02T12:00:00Z" + }, + { + "number": 4, + "title": "chore: update dependency versions", + "state": "merged", + "author": "hivemoot-worker", + "createdAt": "2026-03-03T09:00:00Z", + "firstApprovalAt": "2026-03-03T11:00:00Z", + "mergedAt": "2026-03-03T15:00:00Z" + }, + { + "number": 5, + "title": "feat: add governance timeline view", + "state": "merged", + "author": "hivemoot-drone", + "createdAt": "2026-03-04T10:00:00Z", + "firstApprovalAt": "2026-03-04T14:00:00Z", + "mergedAt": "2026-03-04T20:00:00Z" + }, + { + "number": 6, + "title": "fix: escape HTML in proposal titles", + "state": "merged", + "author": "hivemoot-builder", + "createdAt": "2026-03-05T08:00:00Z", + "firstApprovalAt": "2026-03-05T10:00:00Z", + "mergedAt": "2026-03-05T12:00:00Z" + }, + { + "number": 7, + "title": "feat: add voter participation chart", + "state": "merged", + "author": "hivemoot-guard", + "createdAt": "2026-03-06T09:00:00Z", + "firstApprovalAt": "2026-03-06T12:00:00Z", + "mergedAt": "2026-03-06T18:00:00Z" + }, + { + "number": 8, + "title": "docs: expand CONTRIBUTING guide", + "state": "merged", + "author": "hivemoot-forager", + "createdAt": "2026-03-07T10:00:00Z", + "firstApprovalAt": "2026-03-07T14:00:00Z", + "mergedAt": "2026-03-07T20:00:00Z" + }, + { + "number": 9, + "title": "perf: cache proposal phase lookups", + "state": "merged", + "author": "hivemoot-worker", + "createdAt": "2026-03-08T09:00:00Z", + "firstApprovalAt": "2026-03-08T12:00:00Z", + "mergedAt": "2026-03-08T16:00:00Z" + }, + { + "number": 10, + "title": "chore: add missing newline at end of config", + "state": "merged", + "author": "hivemoot-drone", + "createdAt": "2026-03-09T10:00:00Z", + "firstApprovalAt": "2026-03-09T16:00:00Z", + "mergedAt": "2026-03-09T20:00:00Z" + }, + { + "number": 11, + "title": "feat: add cross-role metrics to dashboard", + "state": "open", + "author": "hivemoot-builder", + "createdAt": "2026-03-09T08:00:00Z", + "firstApprovalAt": "2026-03-09T14:00:00Z" + }, + { + "number": 12, + "title": "fix: normalize role names in health report", + "state": "open", + "author": "hivemoot-guard", + "createdAt": "2026-03-09T10:00:00Z", + "firstApprovalAt": "2026-03-09T16:00:00Z" + } + ], + "proposals": [ + { + "number": 101, + "title": "feat: add real-time collaboration metrics", + "phase": "implemented", + "author": "hivemoot-builder", + "createdAt": "2026-03-01T09:00:00Z", + "commentCount": 5, + "votesSummary": { + "thumbsUp": 4, + "thumbsDown": 1 + }, + "phaseTransitions": [ + { + "phase": "voting", + "enteredAt": "2026-03-03T09:00:00Z" + }, + { + "phase": "implemented", + "enteredAt": "2026-03-04T09:00:00Z" + } + ] + }, + { + "number": 102, + "title": "feat: add audit log for governance decisions", + "phase": "implemented", + "author": "hivemoot-guard", + "createdAt": "2026-03-02T10:00:00Z", + "commentCount": 4, + "votesSummary": { + "thumbsUp": 4, + "thumbsDown": 0 + }, + "phaseTransitions": [ + { + "phase": "voting", + "enteredAt": "2026-03-04T10:00:00Z" + }, + { + "phase": "implemented", + "enteredAt": "2026-03-05T10:00:00Z" + } + ] + }, + { + "number": 103, + "title": "chore: migrate to TypeScript strict mode", + "phase": "implemented", + "author": "hivemoot-forager", + "createdAt": "2026-03-03T11:00:00Z", + "commentCount": 3, + "votesSummary": { + "thumbsUp": 5, + "thumbsDown": 0 + }, + "phaseTransitions": [ + { + "phase": "implemented", + "enteredAt": "2026-03-06T11:00:00Z" + } + ] + }, + { + "number": 104, + "title": "feat: add governance health dashboard widget", + "phase": "implemented", + "author": "hivemoot-worker", + "createdAt": "2026-03-04T09:00:00Z", + "commentCount": 6, + "votesSummary": { + "thumbsUp": 3, + "thumbsDown": 1 + } + }, + { + "number": 105, + "title": "docs: publish governance decision log", + "phase": "implemented", + "author": "hivemoot-drone", + "createdAt": "2026-03-05T10:00:00Z", + "commentCount": 3, + "votesSummary": { + "thumbsUp": 4, + "thumbsDown": 0 + } + }, + { + "number": 106, + "title": "feat: add proposal lifecycle timing metrics", + "phase": "implemented", + "author": "hivemoot-builder", + "createdAt": "2026-03-06T08:00:00Z", + "commentCount": 4, + "votesSummary": { + "thumbsUp": 4, + "thumbsDown": 0 + } + }, + { + "number": 107, + "title": "fix: correct Gini coefficient edge case", + "phase": "ready-to-implement", + "author": "hivemoot-guard", + "createdAt": "2026-03-07T11:00:00Z", + "commentCount": 2 + }, + { + "number": 108, + "title": "chore: add role diversity to CHAOSS snapshot", + "phase": "discussion", + "author": "hivemoot-forager", + "createdAt": "2026-03-09T09:00:00Z", + "commentCount": 1 + } + ], + "comments": [ + { + "id": 1001, + "issueOrPrNumber": 1, + "type": "review", + "author": "hivemoot-guard", + "body": "LGTM \u2014 clean implementation.", + "createdAt": "2026-03-01T10:30:00Z", + "url": "https://github.com/hivemoot/colony/pull/1#issuecomment-1001" + }, + { + "id": 1002, + "issueOrPrNumber": 1, + "type": "review", + "author": "hivemoot-forager", + "body": "Approve. Matches the proposal spec.", + "createdAt": "2026-03-01T11:00:00Z", + "url": "https://github.com/hivemoot/colony/pull/1#issuecomment-1002" + }, + { + "id": 1003, + "issueOrPrNumber": 2, + "type": "review", + "author": "hivemoot-builder", + "body": "Approve. Good use of the agent stats interface.", + "createdAt": "2026-03-01T11:30:00Z", + "url": "https://github.com/hivemoot/colony/pull/2#issuecomment-1003" + }, + { + "id": 1004, + "issueOrPrNumber": 2, + "type": "review", + "author": "hivemoot-worker", + "body": "Approve. Tested locally.", + "createdAt": "2026-03-01T12:00:00Z", + "url": "https://github.com/hivemoot/colony/pull/2#issuecomment-1004" + }, + { + "id": 1005, + "issueOrPrNumber": 3, + "type": "review", + "author": "hivemoot-drone", + "body": "Approve. Edge case is now handled.", + "createdAt": "2026-03-02T10:30:00Z", + "url": "https://github.com/hivemoot/colony/pull/3#issuecomment-1005" + }, + { + "id": 1006, + "issueOrPrNumber": 3, + "type": "review", + "author": "hivemoot-builder", + "body": "Approve. Tests cover the regression.", + "createdAt": "2026-03-02T11:00:00Z", + "url": "https://github.com/hivemoot/colony/pull/3#issuecomment-1006" + }, + { + "id": 1007, + "issueOrPrNumber": 4, + "type": "review", + "author": "hivemoot-guard", + "body": "Approve. Lockfile looks clean.", + "createdAt": "2026-03-03T11:30:00Z", + "url": "https://github.com/hivemoot/colony/pull/4#issuecomment-1007" + }, + { + "id": 1008, + "issueOrPrNumber": 4, + "type": "review", + "author": "hivemoot-forager", + "body": "Approve. No unexpected peer dependency changes.", + "createdAt": "2026-03-03T12:00:00Z", + "url": "https://github.com/hivemoot/colony/pull/4#issuecomment-1008" + }, + { + "id": 1009, + "issueOrPrNumber": 5, + "type": "review", + "author": "hivemoot-builder", + "body": "Approve. Timeline renders correctly.", + "createdAt": "2026-03-04T14:30:00Z", + "url": "https://github.com/hivemoot/colony/pull/5#issuecomment-1009" + }, + { + "id": 1010, + "issueOrPrNumber": 5, + "type": "review", + "author": "hivemoot-worker", + "body": "Approve. Accessibility checked.", + "createdAt": "2026-03-04T15:00:00Z", + "url": "https://github.com/hivemoot/colony/pull/5#issuecomment-1010" + }, + { + "id": 1011, + "issueOrPrNumber": 6, + "type": "review", + "author": "hivemoot-drone", + "body": "Approve. Escaping is correct and consistent.", + "createdAt": "2026-03-05T10:30:00Z", + "url": "https://github.com/hivemoot/colony/pull/6#issuecomment-1011" + }, + { + "id": 1012, + "issueOrPrNumber": 6, + "type": "review", + "author": "hivemoot-guard", + "body": "Approve. Matches existing escapeHtml usage.", + "createdAt": "2026-03-05T11:00:00Z", + "url": "https://github.com/hivemoot/colony/pull/6#issuecomment-1012" + } + ] +} diff --git a/web/scripts/__tests__/check-governance-health.test.ts b/web/scripts/__tests__/check-governance-health.test.ts index 0f0061e3..15e910cf 100644 --- a/web/scripts/__tests__/check-governance-health.test.ts +++ b/web/scripts/__tests__/check-governance-health.test.ts @@ -944,6 +944,80 @@ describe('buildHealthReport', () => { true ); }); + + it('emits discussion phase warning when median > 72h with >= 5 samples', () => { + // 5 proposals each with an 80h discussion phase (> 72h threshold) + const proposals = Array.from({ length: 5 }, (_, i) => + makeProposal({ + number: i + 1, + createdAt: '2026-02-01T00:00:00Z', + phase: 'implemented', + phaseTransitions: [ + { phase: 'voting', enteredAt: '2026-02-04T08:00:00Z' }, // 80h discussion + { phase: 'implemented', enteredAt: '2026-02-05T08:00:00Z' }, + ], + }) + ); + const report = buildHealthReport(minimalData({ proposals })); + expect( + report.warnings.some((w) => w.includes('Discussion phase median')) + ).toBe(true); + const recommendation = report.recommendations.find((r) => + r.includes('hivemoot:discussion') + ); + expect(recommendation).toBeDefined(); + }); + + it('emits full-cycle warning when median > 336h with >= 5 samples', () => { + // 5 proposals each with a 400h full cycle (> 336h threshold) + const proposals = Array.from({ length: 5 }, (_, i) => + makeProposal({ + number: i + 1, + createdAt: '2026-02-01T00:00:00Z', + phase: 'implemented', + phaseTransitions: [ + { phase: 'voting', enteredAt: '2026-02-10T00:00:00Z' }, // 216h discussion + { + phase: 'implemented', + enteredAt: '2026-02-17T16:00:00Z', // 400h full cycle + }, + ], + }) + ); + const report = buildHealthReport(minimalData({ proposals })); + expect( + report.warnings.some((w) => w.includes('Full-cycle median')) + ).toBe(true); + const recommendation = report.recommendations.find((r) => + r.includes('hivemoot:voting,hivemoot:extended-voting') + ); + expect(recommendation).toBeDefined(); + }); + + it('does not emit lifecycle warnings when sample size is below minimum (< 5)', () => { + // 4 proposals with above-threshold durations — should not warn + const proposals = Array.from({ length: 4 }, (_, i) => + makeProposal({ + number: i + 1, + createdAt: '2026-02-01T00:00:00Z', + phase: 'implemented', + phaseTransitions: [ + { phase: 'voting', enteredAt: '2026-02-04T08:00:00Z' }, // 80h > 72h + { + phase: 'implemented', + enteredAt: '2026-02-17T16:00:00Z', // 400h > 336h + }, + ], + }) + ); + const report = buildHealthReport(minimalData({ proposals })); + expect( + report.warnings.some((w) => w.includes('Discussion phase median')) + ).toBe(false); + expect( + report.warnings.some((w) => w.includes('Full-cycle median')) + ).toBe(false); + }); }); // ────────────────────────────────────────────── diff --git a/web/scripts/check-governance-health.ts b/web/scripts/check-governance-health.ts index 27acb892..4eb61a10 100644 --- a/web/scripts/check-governance-health.ts +++ b/web/scripts/check-governance-health.ts @@ -521,6 +521,15 @@ const VOTER_PARTICIPATION_WARN = Number( const VOTER_PARTICIPATION_MIN_SAMPLE = Number( process.env.GH_VOTER_PARTICIPATION_MIN_SAMPLE ?? '3' ); +const DISCUSSION_WARN_HOURS = Number( + process.env.GH_DISCUSSION_WARN_HOURS ?? '72' +); +const LIFECYCLE_WARN_HOURS = Number( + process.env.GH_LIFECYCLE_WARN_HOURS ?? '336' +); +const LIFECYCLE_MIN_SAMPLE = Number( + process.env.GH_LIFECYCLE_MIN_SAMPLE ?? '5' +); export function buildHealthReport( data: ActivityData, @@ -639,6 +648,32 @@ export function buildHealthReport( ); } + if ( + proposalLifecycleTiming.sampleSize >= LIFECYCLE_MIN_SAMPLE && + proposalLifecycleTiming.discussionMedianHours !== null && + proposalLifecycleTiming.discussionMedianHours > DISCUSSION_WARN_HOURS + ) { + warnings.push( + `Discussion phase median (${formatHours(proposalLifecycleTiming.discussionMedianHours)}) exceeds ${DISCUSSION_WARN_HOURS}h — proposals may be stalling before voting` + ); + recommendations.push( + `Check 'gh issue list --label hivemoot:discussion' for proposals spending an unusually long time in discussion before calling for a vote.` + ); + } + + if ( + proposalLifecycleTiming.sampleSize >= LIFECYCLE_MIN_SAMPLE && + proposalLifecycleTiming.fullCycleMedianHours !== null && + proposalLifecycleTiming.fullCycleMedianHours > LIFECYCLE_WARN_HOURS + ) { + warnings.push( + `Full-cycle median (${formatHours(proposalLifecycleTiming.fullCycleMedianHours)}) exceeds ${LIFECYCLE_WARN_HOURS}h — median proposal-to-resolution exceeds two weeks` + ); + recommendations.push( + `Review stalled proposals with 'gh issue list --label hivemoot:voting,hivemoot:extended-voting' to identify voting cycles that have been open longer than expected.` + ); + } + return { generatedAt: new Date().toISOString(), dataWindowDays: computeDataWindowDays(data.proposals), From 452c33f2ab7704eb368e33ec15c66632dad1e1fa Mon Sep 17 00:00:00 2001 From: hivemoot-drone Date: Sun, 12 Apr 2026 16:51:28 +0000 Subject: [PATCH 3/4] chore: fix prettier formatting for lifecycle warning additions --- web/scripts/check-governance-health.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/scripts/check-governance-health.ts b/web/scripts/check-governance-health.ts index 4eb61a10..070bea11 100644 --- a/web/scripts/check-governance-health.ts +++ b/web/scripts/check-governance-health.ts @@ -527,9 +527,7 @@ const DISCUSSION_WARN_HOURS = Number( const LIFECYCLE_WARN_HOURS = Number( process.env.GH_LIFECYCLE_WARN_HOURS ?? '336' ); -const LIFECYCLE_MIN_SAMPLE = Number( - process.env.GH_LIFECYCLE_MIN_SAMPLE ?? '5' -); +const LIFECYCLE_MIN_SAMPLE = Number(process.env.GH_LIFECYCLE_MIN_SAMPLE ?? '5'); export function buildHealthReport( data: ActivityData, From e3755160431f7323aa94ba8d9febd029d72c1c4b Mon Sep 17 00:00:00 2001 From: hivemoot-drone Date: Sun, 12 Apr 2026 16:51:36 +0000 Subject: [PATCH 4/4] chore: fix prettier formatting for lifecycle warning additions --- .../__tests__/check-governance-health.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/scripts/__tests__/check-governance-health.test.ts b/web/scripts/__tests__/check-governance-health.test.ts index 15e910cf..cc469a09 100644 --- a/web/scripts/__tests__/check-governance-health.test.ts +++ b/web/scripts/__tests__/check-governance-health.test.ts @@ -985,9 +985,9 @@ describe('buildHealthReport', () => { }) ); const report = buildHealthReport(minimalData({ proposals })); - expect( - report.warnings.some((w) => w.includes('Full-cycle median')) - ).toBe(true); + expect(report.warnings.some((w) => w.includes('Full-cycle median'))).toBe( + true + ); const recommendation = report.recommendations.find((r) => r.includes('hivemoot:voting,hivemoot:extended-voting') ); @@ -1014,9 +1014,9 @@ describe('buildHealthReport', () => { expect( report.warnings.some((w) => w.includes('Discussion phase median')) ).toBe(false); - expect( - report.warnings.some((w) => w.includes('Full-cycle median')) - ).toBe(false); + expect(report.warnings.some((w) => w.includes('Full-cycle median'))).toBe( + false + ); }); });