@holoscript/plugin-government-civic 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ /** @civic_compliance Trait — ADA, FOIA, and public records compliance enforcement. @trait civic_compliance */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export type ComplianceFramework = 'ADA' | 'FOIA' | 'WCAG' | 'OPRA' | 'GDPR' | 'CCPA' | 'section508';
5
+ export type ComplianceStatus = 'compliant' | 'non_compliant' | 'pending_review' | 'exempt' | 'remediation_in_progress';
6
+
7
+ export interface ComplianceCheck {
8
+ id: string;
9
+ framework: ComplianceFramework;
10
+ requirement: string;
11
+ status: ComplianceStatus;
12
+ lastChecked: string;
13
+ remediation?: string;
14
+ dueDate?: string;
15
+ }
16
+
17
+ export interface FoiaRequest {
18
+ requestId: string;
19
+ requestedAt: string;
20
+ description: string;
21
+ status: 'received' | 'processing' | 'fulfilled' | 'denied' | 'appealed';
22
+ responseDueAt: string; // 20 business days under US federal FOIA
23
+ }
24
+
25
+ export interface CivicComplianceConfig {
26
+ entityId: string;
27
+ entityType: 'website' | 'facility' | 'service' | 'document' | 'meeting';
28
+ frameworks: ComplianceFramework[];
29
+ foiaEnabled: boolean;
30
+ foiaResponseDaysLimit: number; // default 20 federal, varies by state
31
+ accessibilityStandard: 'WCAG_2_1_AA' | 'WCAG_2_1_AAA' | 'WCAG_2_2_AA' | 'section508';
32
+ auditLogRetentionDays: number;
33
+ isPublicRecord: boolean;
34
+ }
35
+
36
+ export interface CivicComplianceState {
37
+ overallStatus: ComplianceStatus;
38
+ checks: ComplianceCheck[];
39
+ foiaRequests: FoiaRequest[];
40
+ auditLog: Array<{ timestamp: string; action: string; actor: string; details: string }>;
41
+ lastAuditAt: string;
42
+ openFoiaCount: number;
43
+ overdueFoiaCount: number;
44
+ nonCompliantCount: number;
45
+ }
46
+
47
+ const defaultConfig: CivicComplianceConfig = {
48
+ entityId: '',
49
+ entityType: 'service',
50
+ frameworks: ['ADA', 'FOIA', 'WCAG'],
51
+ foiaEnabled: true,
52
+ foiaResponseDaysLimit: 20,
53
+ accessibilityStandard: 'WCAG_2_1_AA',
54
+ auditLogRetentionDays: 2555, // 7 years
55
+ isPublicRecord: true,
56
+ };
57
+
58
+ function computeOverallStatus(checks: ComplianceCheck[]): ComplianceStatus {
59
+ if (checks.some(c => c.status === 'non_compliant')) return 'non_compliant';
60
+ if (checks.some(c => c.status === 'remediation_in_progress')) return 'remediation_in_progress';
61
+ if (checks.some(c => c.status === 'pending_review')) return 'pending_review';
62
+ if (checks.length > 0 && checks.every(c => c.status === 'compliant' || c.status === 'exempt')) return 'compliant';
63
+ return 'pending_review';
64
+ }
65
+
66
+ export function createCivicComplianceHandler(): TraitHandler<CivicComplianceConfig> {
67
+ return {
68
+ name: 'civic_compliance',
69
+ defaultConfig,
70
+ onAttach(node: HSPlusNode, config: CivicComplianceConfig, ctx: TraitContext) {
71
+ // Seed initial checks based on declared frameworks
72
+ const checks: ComplianceCheck[] = config.frameworks.map(fw => ({
73
+ id: `${config.entityId}-${fw.toLowerCase()}`,
74
+ framework: fw,
75
+ requirement: `${fw} baseline compliance`,
76
+ status: 'pending_review' as ComplianceStatus,
77
+ lastChecked: new Date().toISOString(),
78
+ }));
79
+ node.__complianceState = {
80
+ overallStatus: 'pending_review' as ComplianceStatus,
81
+ checks,
82
+ foiaRequests: [],
83
+ auditLog: [],
84
+ lastAuditAt: new Date().toISOString(),
85
+ openFoiaCount: 0,
86
+ overdueFoiaCount: 0,
87
+ nonCompliantCount: 0,
88
+ } satisfies CivicComplianceState;
89
+ ctx.emit?.('compliance:initialized', { entityId: config.entityId, frameworks: config.frameworks });
90
+ },
91
+ onDetach(node: HSPlusNode, _config: CivicComplianceConfig, ctx: TraitContext) {
92
+ delete node.__complianceState;
93
+ ctx.emit?.('compliance:removed');
94
+ },
95
+ onUpdate(node: HSPlusNode, config: CivicComplianceConfig, ctx: TraitContext, _delta: number) {
96
+ const s = node.__complianceState as CivicComplianceState | undefined;
97
+ if (!s) return;
98
+ const now = Date.now();
99
+ // Check FOIA deadlines
100
+ let overdue = 0;
101
+ for (const req of s.foiaRequests) {
102
+ if (req.status === 'received' || req.status === 'processing') {
103
+ const dueAt = new Date(req.responseDueAt).getTime();
104
+ if (now > dueAt) overdue++;
105
+ }
106
+ }
107
+ const prevOverdue = s.overdueFoiaCount;
108
+ s.overdueFoiaCount = overdue;
109
+ if (overdue > prevOverdue) {
110
+ ctx.emit?.('compliance:foia_overdue', { entityId: config.entityId, overdueCount: overdue });
111
+ }
112
+ s.openFoiaCount = s.foiaRequests.filter(r => r.status === 'received' || r.status === 'processing').length;
113
+ s.nonCompliantCount = s.checks.filter(c => c.status === 'non_compliant').length;
114
+ s.overallStatus = computeOverallStatus(s.checks);
115
+ },
116
+ onEvent(node: HSPlusNode, config: CivicComplianceConfig, ctx: TraitContext, event: TraitEvent) {
117
+ const s = node.__complianceState as CivicComplianceState | undefined;
118
+ if (!s) return;
119
+ switch (event.type) {
120
+ case 'compliance:update_check': {
121
+ const { checkId, status, remediation } = event.payload as { checkId: string; status: ComplianceStatus; remediation?: string };
122
+ const check = s.checks.find(c => c.id === checkId);
123
+ if (check) {
124
+ check.status = status;
125
+ check.lastChecked = new Date().toISOString();
126
+ if (remediation) check.remediation = remediation;
127
+ s.overallStatus = computeOverallStatus(s.checks);
128
+ ctx.emit?.('compliance:check_updated', { checkId, status, overall: s.overallStatus });
129
+ }
130
+ break;
131
+ }
132
+ case 'compliance:foia_received': {
133
+ if (!config.foiaEnabled) return;
134
+ const req = event.payload as unknown as FoiaRequest;
135
+ if (!req?.requestId) return;
136
+ const receivedAt = new Date();
137
+ // Approx 20 business days = 28 calendar days
138
+ const dueAt = new Date(receivedAt.getTime() + config.foiaResponseDaysLimit * 1.4 * 86_400_000);
139
+ s.foiaRequests.push({ ...req, requestedAt: receivedAt.toISOString(), responseDueAt: dueAt.toISOString(), status: 'received' });
140
+ s.openFoiaCount = s.foiaRequests.filter(r => r.status === 'received' || r.status === 'processing').length;
141
+ ctx.emit?.('compliance:foia_received', { requestId: req.requestId, dueAt: dueAt.toISOString() });
142
+ break;
143
+ }
144
+ case 'compliance:foia_fulfill': {
145
+ const requestId = event.payload?.requestId as string;
146
+ const req = s.foiaRequests.find(r => r.requestId === requestId);
147
+ if (req) {
148
+ req.status = 'fulfilled';
149
+ s.openFoiaCount = s.foiaRequests.filter(r => r.status === 'received' || r.status === 'processing').length;
150
+ ctx.emit?.('compliance:foia_fulfilled', { requestId });
151
+ }
152
+ break;
153
+ }
154
+ case 'compliance:audit_log': {
155
+ const { action, actor, details } = event.payload as { action: string; actor: string; details: string };
156
+ s.auditLog.push({ timestamp: new Date().toISOString(), action, actor, details: details ?? '' });
157
+ // Prune logs older than retention window
158
+ const cutoff = Date.now() - config.auditLogRetentionDays * 86_400_000;
159
+ s.auditLog = s.auditLog.filter(e => new Date(e.timestamp).getTime() > cutoff);
160
+ break;
161
+ }
162
+ }
163
+ },
164
+ };
165
+ }
@@ -0,0 +1,107 @@
1
+ /** @permit Trait — Building and business permit lifecycle management. @trait permit */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export type PermitType = 'building' | 'business' | 'event' | 'demolition' | 'electrical' | 'plumbing' | 'sign' | 'zoning';
5
+ export type PermitStatus = 'submitted' | 'under_review' | 'approved' | 'denied' | 'expired' | 'revoked' | 'withdrawn';
6
+
7
+ export interface PermitConfig {
8
+ permitType: PermitType;
9
+ applicationNumber: string;
10
+ applicantName: string;
11
+ projectAddress: string;
12
+ submittedAt: string; // ISO date
13
+ expiresAt?: string; // ISO date — when approved permit expires
14
+ reviewDeadlineDays: number;
15
+ showStatusBadge: boolean;
16
+ }
17
+
18
+ export interface PermitState {
19
+ status: PermitStatus;
20
+ applicationNumber: string;
21
+ daysInReview: number;
22
+ isOverdue: boolean;
23
+ isExpiringSoon: boolean; // within 30 days
24
+ isExpired: boolean;
25
+ reviewNotes: string[];
26
+ lastUpdated: string;
27
+ }
28
+
29
+ const defaultConfig: PermitConfig = {
30
+ permitType: 'building',
31
+ applicationNumber: '',
32
+ applicantName: '',
33
+ projectAddress: '',
34
+ submittedAt: new Date().toISOString(),
35
+ reviewDeadlineDays: 30,
36
+ showStatusBadge: true,
37
+ };
38
+
39
+ function computeState(config: PermitConfig, existing?: Partial<PermitState>): PermitState {
40
+ const now = Date.now();
41
+ const submitted = new Date(config.submittedAt).getTime();
42
+ const daysInReview = Math.floor((now - submitted) / 86_400_000);
43
+ const expiresAt = config.expiresAt ? new Date(config.expiresAt).getTime() : null;
44
+ const daysUntilExpiry = expiresAt ? Math.floor((expiresAt - now) / 86_400_000) : null;
45
+
46
+ return {
47
+ status: (existing?.status as PermitStatus) ?? 'submitted',
48
+ applicationNumber: config.applicationNumber,
49
+ daysInReview,
50
+ isOverdue: daysInReview > config.reviewDeadlineDays,
51
+ isExpiringSoon: daysUntilExpiry !== null && daysUntilExpiry >= 0 && daysUntilExpiry <= 30,
52
+ isExpired: daysUntilExpiry !== null && daysUntilExpiry < 0,
53
+ reviewNotes: (existing?.reviewNotes as string[]) ?? [],
54
+ lastUpdated: new Date().toISOString(),
55
+ };
56
+ }
57
+
58
+ export function createPermitHandler(): TraitHandler<PermitConfig> {
59
+ return {
60
+ name: 'permit',
61
+ defaultConfig,
62
+ onAttach(node: HSPlusNode, config: PermitConfig, ctx: TraitContext) {
63
+ node.__permitState = computeState(config);
64
+ ctx.emit?.('permit:attached', {
65
+ applicationNumber: config.applicationNumber,
66
+ type: config.permitType,
67
+ });
68
+ },
69
+ onDetach(node: HSPlusNode, _config: PermitConfig, ctx: TraitContext) {
70
+ delete node.__permitState;
71
+ ctx.emit?.('permit:detached');
72
+ },
73
+ onUpdate(node: HSPlusNode, config: PermitConfig, ctx: TraitContext, _delta: number) {
74
+ const prev = node.__permitState as PermitState | undefined;
75
+ const next = computeState(config, prev);
76
+ if (next.isOverdue && !prev?.isOverdue) {
77
+ ctx.emit?.('permit:overdue', { applicationNumber: config.applicationNumber, daysInReview: next.daysInReview });
78
+ }
79
+ if (next.isExpiringSoon && !prev?.isExpiringSoon) {
80
+ ctx.emit?.('permit:expiring_soon', { applicationNumber: config.applicationNumber });
81
+ }
82
+ if (next.isExpired && !prev?.isExpired) {
83
+ ctx.emit?.('permit:expired', { applicationNumber: config.applicationNumber });
84
+ }
85
+ node.__permitState = next;
86
+ },
87
+ onEvent(node: HSPlusNode, config: PermitConfig, ctx: TraitContext, event: TraitEvent) {
88
+ const s = node.__permitState as PermitState | undefined;
89
+ if (!s) return;
90
+ if (event.type === 'permit:update_status') {
91
+ const newStatus = event.payload?.status as PermitStatus;
92
+ if (!newStatus) return;
93
+ const prev = s.status;
94
+ s.status = newStatus;
95
+ s.lastUpdated = new Date().toISOString();
96
+ ctx.emit?.('permit:status_changed', { from: prev, to: newStatus, applicationNumber: config.applicationNumber });
97
+ }
98
+ if (event.type === 'permit:add_note') {
99
+ const note = event.payload?.note as string;
100
+ if (note) {
101
+ s.reviewNotes.push(note);
102
+ ctx.emit?.('permit:note_added', { applicationNumber: config.applicationNumber, noteCount: s.reviewNotes.length });
103
+ }
104
+ }
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,123 @@
1
+ /** @public_meeting Trait — City council and public hearing management. @trait public_meeting */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export type MeetingType = 'city_council' | 'planning_commission' | 'public_hearing' | 'zoning_board' | 'budget_session' | 'town_hall';
5
+ export type MeetingStatus = 'scheduled' | 'in_session' | 'recess' | 'public_comment' | 'voting' | 'adjourned' | 'cancelled';
6
+
7
+ export interface AgendaItem {
8
+ id: string;
9
+ title: string;
10
+ type: 'presentation' | 'discussion' | 'action' | 'public_comment' | 'vote';
11
+ durationMinutes: number;
12
+ requiresVote: boolean;
13
+ }
14
+
15
+ export interface PublicMeetingConfig {
16
+ meetingType: MeetingType;
17
+ meetingId: string;
18
+ title: string;
19
+ scheduledAt: string; // ISO datetime
20
+ location: string;
21
+ quorumRequired: number;
22
+ membersPresent: number;
23
+ publicCommentMinutesPerSpeaker: number;
24
+ agenda: AgendaItem[];
25
+ streamUrl?: string;
26
+ accessibilityFeatures: string[]; // e.g. ['captioning', 'sign_language', 'hearing_loop']
27
+ }
28
+
29
+ export interface PublicMeetingState {
30
+ status: MeetingStatus;
31
+ quorumMet: boolean;
32
+ currentAgendaItemIndex: number;
33
+ publicSpeakersRegistered: number;
34
+ publicSpeakersHeard: number;
35
+ minutesElapsed: number;
36
+ recordingActive: boolean;
37
+ lastUpdated: string;
38
+ }
39
+
40
+ const defaultConfig: PublicMeetingConfig = {
41
+ meetingType: 'city_council',
42
+ meetingId: '',
43
+ title: '',
44
+ scheduledAt: new Date().toISOString(),
45
+ location: '',
46
+ quorumRequired: 4,
47
+ membersPresent: 0,
48
+ publicCommentMinutesPerSpeaker: 3,
49
+ agenda: [],
50
+ accessibilityFeatures: ['captioning'],
51
+ };
52
+
53
+ export function createPublicMeetingHandler(): TraitHandler<PublicMeetingConfig> {
54
+ return {
55
+ name: 'public_meeting',
56
+ defaultConfig,
57
+ onAttach(node: HSPlusNode, config: PublicMeetingConfig, ctx: TraitContext) {
58
+ node.__meetingState = {
59
+ status: 'scheduled' as MeetingStatus,
60
+ quorumMet: config.membersPresent >= config.quorumRequired,
61
+ currentAgendaItemIndex: 0,
62
+ publicSpeakersRegistered: 0,
63
+ publicSpeakersHeard: 0,
64
+ minutesElapsed: 0,
65
+ recordingActive: false,
66
+ lastUpdated: new Date().toISOString(),
67
+ } satisfies PublicMeetingState;
68
+ ctx.emit?.('meeting:scheduled', { meetingId: config.meetingId, type: config.meetingType });
69
+ },
70
+ onDetach(node: HSPlusNode, _config: PublicMeetingConfig, ctx: TraitContext) {
71
+ delete node.__meetingState;
72
+ ctx.emit?.('meeting:removed');
73
+ },
74
+ onUpdate(node: HSPlusNode, config: PublicMeetingConfig, ctx: TraitContext, delta: number) {
75
+ const s = node.__meetingState as PublicMeetingState | undefined;
76
+ if (!s) return;
77
+ if (s.status === 'in_session' || s.status === 'public_comment') {
78
+ s.minutesElapsed += delta / 60; // delta in seconds
79
+ }
80
+ const quorumMet = config.membersPresent >= config.quorumRequired;
81
+ if (quorumMet !== s.quorumMet) {
82
+ s.quorumMet = quorumMet;
83
+ ctx.emit?.(quorumMet ? 'meeting:quorum_met' : 'meeting:quorum_lost', { meetingId: config.meetingId });
84
+ }
85
+ s.lastUpdated = new Date().toISOString();
86
+ },
87
+ onEvent(node: HSPlusNode, config: PublicMeetingConfig, ctx: TraitContext, event: TraitEvent) {
88
+ const s = node.__meetingState as PublicMeetingState | undefined;
89
+ if (!s) return;
90
+ switch (event.type) {
91
+ case 'meeting:call_to_order':
92
+ s.status = 'in_session';
93
+ s.recordingActive = true;
94
+ ctx.emit?.('meeting:called_to_order', { meetingId: config.meetingId, quorumMet: s.quorumMet });
95
+ break;
96
+ case 'meeting:open_public_comment':
97
+ s.status = 'public_comment';
98
+ ctx.emit?.('meeting:public_comment_open', { speakersRegistered: s.publicSpeakersRegistered });
99
+ break;
100
+ case 'meeting:register_speaker':
101
+ s.publicSpeakersRegistered++;
102
+ ctx.emit?.('meeting:speaker_registered', { total: s.publicSpeakersRegistered });
103
+ break;
104
+ case 'meeting:next_speaker':
105
+ s.publicSpeakersHeard++;
106
+ ctx.emit?.('meeting:speaker_started', { heard: s.publicSpeakersHeard, remaining: s.publicSpeakersRegistered - s.publicSpeakersHeard });
107
+ break;
108
+ case 'meeting:next_agenda_item':
109
+ if (s.currentAgendaItemIndex < config.agenda.length - 1) {
110
+ s.currentAgendaItemIndex++;
111
+ const item = config.agenda[s.currentAgendaItemIndex];
112
+ ctx.emit?.('meeting:agenda_advanced', { index: s.currentAgendaItemIndex, item: item?.title });
113
+ }
114
+ break;
115
+ case 'meeting:adjourn':
116
+ s.status = 'adjourned';
117
+ s.recordingActive = false;
118
+ ctx.emit?.('meeting:adjourned', { meetingId: config.meetingId, minutesElapsed: Math.round(s.minutesElapsed) });
119
+ break;
120
+ }
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,124 @@
1
+ /** @service_request Trait — 311-style civic service request tracking. @trait service_request */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export type ServiceCategory = 'pothole' | 'streetlight' | 'graffiti' | 'abandoned_vehicle' | 'code_violation' | 'tree_hazard' | 'water_main' | 'sidewalk' | 'noise_complaint' | 'other';
5
+ export type RequestStatus = 'submitted' | 'acknowledged' | 'assigned' | 'in_progress' | 'resolved' | 'closed' | 'duplicate';
6
+ export type PriorityLevel = 'low' | 'medium' | 'high' | 'urgent';
7
+
8
+ export interface ServiceRequestConfig {
9
+ requestId: string;
10
+ category: ServiceCategory;
11
+ description: string;
12
+ location: string;
13
+ coordinates?: { lat: number; lng: number };
14
+ submittedAt: string; // ISO datetime
15
+ priority: PriorityLevel;
16
+ targetResolutionDays: number; // SLA days by priority
17
+ department: string;
18
+ contactEmail?: string;
19
+ isAnonymous: boolean;
20
+ }
21
+
22
+ export interface ServiceRequestState {
23
+ status: RequestStatus;
24
+ requestId: string;
25
+ assignedTo?: string;
26
+ daysOpen: number;
27
+ isSlaBreached: boolean;
28
+ lastStatusChange: string;
29
+ updates: Array<{ timestamp: string; message: string; author: string }>;
30
+ }
31
+
32
+ const SLA_DAYS: Record<PriorityLevel, number> = {
33
+ urgent: 1,
34
+ high: 3,
35
+ medium: 7,
36
+ low: 14,
37
+ };
38
+
39
+ const defaultConfig: ServiceRequestConfig = {
40
+ requestId: '',
41
+ category: 'other',
42
+ description: '',
43
+ location: '',
44
+ submittedAt: new Date().toISOString(),
45
+ priority: 'medium',
46
+ targetResolutionDays: SLA_DAYS.medium,
47
+ department: 'Public Works',
48
+ isAnonymous: false,
49
+ };
50
+
51
+ export function createServiceRequestHandler(): TraitHandler<ServiceRequestConfig> {
52
+ return {
53
+ name: 'service_request',
54
+ defaultConfig,
55
+ onAttach(node: HSPlusNode, config: ServiceRequestConfig, ctx: TraitContext) {
56
+ const now = Date.now();
57
+ const submitted = new Date(config.submittedAt).getTime();
58
+ const daysOpen = Math.floor((now - submitted) / 86_400_000);
59
+ const slaLimit = config.targetResolutionDays || SLA_DAYS[config.priority];
60
+
61
+ node.__srState = {
62
+ status: 'submitted' as RequestStatus,
63
+ requestId: config.requestId,
64
+ daysOpen,
65
+ isSlaBreached: daysOpen > slaLimit,
66
+ lastStatusChange: config.submittedAt,
67
+ updates: [],
68
+ } satisfies ServiceRequestState;
69
+
70
+ ctx.emit?.('sr:created', { requestId: config.requestId, category: config.category });
71
+ },
72
+ onDetach(node: HSPlusNode, _config: ServiceRequestConfig, ctx: TraitContext) {
73
+ delete node.__srState;
74
+ ctx.emit?.('sr:removed');
75
+ },
76
+ onUpdate(node: HSPlusNode, config: ServiceRequestConfig, _ctx: TraitContext, _delta: number) {
77
+ const s = node.__srState as ServiceRequestState | undefined;
78
+ if (!s) return;
79
+ const now = Date.now();
80
+ const submitted = new Date(config.submittedAt).getTime();
81
+ s.daysOpen = Math.floor((now - submitted) / 86_400_000);
82
+ const slaLimit = config.targetResolutionDays || SLA_DAYS[config.priority];
83
+ s.isSlaBreached = s.daysOpen > slaLimit && s.status !== 'resolved' && s.status !== 'closed';
84
+ },
85
+ onEvent(node: HSPlusNode, config: ServiceRequestConfig, ctx: TraitContext, event: TraitEvent) {
86
+ const s = node.__srState as ServiceRequestState | undefined;
87
+ if (!s) return;
88
+ switch (event.type) {
89
+ case 'sr:assign': {
90
+ const assignedTo = event.payload?.assignedTo as string;
91
+ s.assignedTo = assignedTo;
92
+ s.status = 'assigned';
93
+ s.lastStatusChange = new Date().toISOString();
94
+ ctx.emit?.('sr:assigned', { requestId: config.requestId, assignedTo });
95
+ break;
96
+ }
97
+ case 'sr:update_status': {
98
+ const newStatus = event.payload?.status as RequestStatus;
99
+ if (!newStatus) return;
100
+ const prev = s.status;
101
+ s.status = newStatus;
102
+ s.lastStatusChange = new Date().toISOString();
103
+ ctx.emit?.('sr:status_changed', { from: prev, to: newStatus, requestId: config.requestId });
104
+ break;
105
+ }
106
+ case 'sr:add_update': {
107
+ const message = event.payload?.message as string;
108
+ const author = (event.payload?.author as string) ?? 'staff';
109
+ if (message) {
110
+ s.updates.push({ timestamp: new Date().toISOString(), message, author });
111
+ ctx.emit?.('sr:update_added', { requestId: config.requestId, updateCount: s.updates.length });
112
+ }
113
+ break;
114
+ }
115
+ case 'sr:resolve': {
116
+ s.status = 'resolved';
117
+ s.lastStatusChange = new Date().toISOString();
118
+ ctx.emit?.('sr:resolved', { requestId: config.requestId, daysOpen: s.daysOpen });
119
+ break;
120
+ }
121
+ }
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,128 @@
1
+ /** @voting_record Trait — Public voting record and election results display. @trait voting_record */
2
+ import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
+
4
+ export type VoteType = 'council_vote' | 'ballot_measure' | 'election' | 'referendum' | 'committee_vote';
5
+ export type VoteOutcome = 'passed' | 'failed' | 'tabled' | 'withdrawn' | 'tied' | 'pending';
6
+
7
+ export interface VoteCast {
8
+ memberId: string;
9
+ memberName: string;
10
+ vote: 'aye' | 'nay' | 'abstain' | 'absent';
11
+ timestamp?: string;
12
+ }
13
+
14
+ export interface VotingRecordConfig {
15
+ voteId: string;
16
+ voteType: VoteType;
17
+ title: string;
18
+ description: string;
19
+ motionText: string;
20
+ scheduledAt: string; // ISO datetime
21
+ requiredMajority: 'simple' | 'supermajority' | 'unanimous'; // simple=50%+1, supermajority=2/3, unanimous=100%
22
+ eligibleVoters: string[]; // member IDs
23
+ showLiveResults: boolean;
24
+ showMemberVotes: boolean; // public record
25
+ }
26
+
27
+ export interface VotingRecordState {
28
+ outcome: VoteOutcome;
29
+ votes: VoteCast[];
30
+ ayeCount: number;
31
+ nayCount: number;
32
+ abstainCount: number;
33
+ absentCount: number;
34
+ totalEligible: number;
35
+ participationRate: number; // 0-1
36
+ quorumMet: boolean;
37
+ isOpen: boolean;
38
+ closedAt?: string;
39
+ }
40
+
41
+ const MAJORITY_THRESHOLDS = { simple: 0.5, supermajority: 2 / 3, unanimous: 1 } as const;
42
+
43
+ function computeOutcome(state: VotingRecordState, config: VotingRecordConfig): VoteOutcome {
44
+ if (state.isOpen) return 'pending';
45
+ const total = state.ayeCount + state.nayCount + state.abstainCount;
46
+ if (total === 0) return 'pending';
47
+ const threshold = MAJORITY_THRESHOLDS[config.requiredMajority];
48
+ const ayeRatio = state.ayeCount / total;
49
+ if (state.ayeCount === state.nayCount) return 'tied';
50
+ return ayeRatio > threshold ? 'passed' : 'failed';
51
+ }
52
+
53
+ const defaultConfig: VotingRecordConfig = {
54
+ voteId: '',
55
+ voteType: 'council_vote',
56
+ title: '',
57
+ description: '',
58
+ motionText: '',
59
+ scheduledAt: new Date().toISOString(),
60
+ requiredMajority: 'simple',
61
+ eligibleVoters: [],
62
+ showLiveResults: true,
63
+ showMemberVotes: true,
64
+ };
65
+
66
+ export function createVotingRecordHandler(): TraitHandler<VotingRecordConfig> {
67
+ return {
68
+ name: 'voting_record',
69
+ defaultConfig,
70
+ onAttach(node: HSPlusNode, config: VotingRecordConfig, ctx: TraitContext) {
71
+ node.__votingState = {
72
+ outcome: 'pending' as VoteOutcome,
73
+ votes: [],
74
+ ayeCount: 0, nayCount: 0, abstainCount: 0,
75
+ absentCount: config.eligibleVoters.length,
76
+ totalEligible: config.eligibleVoters.length,
77
+ participationRate: 0,
78
+ quorumMet: false,
79
+ isOpen: false,
80
+ } satisfies VotingRecordState;
81
+ ctx.emit?.('vote:created', { voteId: config.voteId, type: config.voteType });
82
+ },
83
+ onDetach(node: HSPlusNode, _config: VotingRecordConfig, ctx: TraitContext) {
84
+ delete node.__votingState;
85
+ ctx.emit?.('vote:removed');
86
+ },
87
+ onUpdate() {},
88
+ onEvent(node: HSPlusNode, config: VotingRecordConfig, ctx: TraitContext, event: TraitEvent) {
89
+ const s = node.__votingState as VotingRecordState | undefined;
90
+ if (!s) return;
91
+ switch (event.type) {
92
+ case 'vote:open':
93
+ s.isOpen = true;
94
+ ctx.emit?.('vote:opened', { voteId: config.voteId, eligible: s.totalEligible });
95
+ break;
96
+ case 'vote:cast': {
97
+ if (!s.isOpen) return;
98
+ const cast = event.payload as unknown as VoteCast;
99
+ if (!cast?.memberId || !cast?.vote) return;
100
+ // Replace if already voted
101
+ const existing = s.votes.findIndex(v => v.memberId === cast.memberId);
102
+ if (existing >= 0) s.votes.splice(existing, 1);
103
+ s.votes.push({ ...cast, timestamp: new Date().toISOString() });
104
+ // Recount
105
+ s.ayeCount = s.votes.filter(v => v.vote === 'aye').length;
106
+ s.nayCount = s.votes.filter(v => v.vote === 'nay').length;
107
+ s.abstainCount = s.votes.filter(v => v.vote === 'abstain').length;
108
+ s.absentCount = s.totalEligible - s.votes.length;
109
+ s.participationRate = s.votes.length / (s.totalEligible || 1);
110
+ s.quorumMet = s.participationRate > 0.5;
111
+ ctx.emit?.('vote:cast', { voteId: config.voteId, memberId: cast.memberId, vote: cast.vote, ayeCount: s.ayeCount, nayCount: s.nayCount });
112
+ break;
113
+ }
114
+ case 'vote:close':
115
+ s.isOpen = false;
116
+ s.closedAt = new Date().toISOString();
117
+ s.outcome = computeOutcome(s, config);
118
+ ctx.emit?.('vote:closed', { voteId: config.voteId, outcome: s.outcome, ayeCount: s.ayeCount, nayCount: s.nayCount });
119
+ break;
120
+ case 'vote:table':
121
+ s.isOpen = false;
122
+ s.outcome = 'tabled';
123
+ ctx.emit?.('vote:tabled', { voteId: config.voteId });
124
+ break;
125
+ }
126
+ },
127
+ };
128
+ }
@@ -0,0 +1,4 @@
1
+ export interface HSPlusNode { id?: string; properties?: Record<string, unknown>; [key: string]: unknown; }
2
+ export interface TraitContext { emit?: (event: string, payload?: unknown) => void; getState?: () => Record<string, unknown>; setState?: (updates: Record<string, unknown>) => void; [key: string]: unknown; }
3
+ export interface TraitEvent { type: string; source?: string; payload?: Record<string, unknown>; [key: string]: unknown; }
4
+ export interface TraitHandler<TConfig = unknown> { name: string; defaultConfig: TConfig; onAttach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void; onDetach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void; onUpdate(node: HSPlusNode, config: TConfig, ctx: TraitContext, delta: number): void; onEvent(node: HSPlusNode, config: TConfig, ctx: TraitContext, event: TraitEvent): void; }
package/tsconfig.json ADDED
@@ -0,0 +1 @@
1
+ { "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true, "declarationMap": true }, "include": ["src"] }
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import { resolve } from 'path';
3
+
4
+ // resolve.alias is REQUIRED: without it, `@holoscript/core/runtime` resolves to
5
+ // the STALE built dist and registerPluginTraits is missing ("not a function").
6
+ // Point the subpath imports at core/engine source so the runtime barrel
7
+ // (core/src/runtime.ts) and shared registrar resolve from source. Mirrors
8
+ // energy-grid-plugin/vitest.config.ts.
9
+ export default defineConfig({
10
+ resolve: {
11
+ alias: {
12
+ '@holoscript/engine': resolve(__dirname, '../../engine/src'),
13
+ '@holoscript/core': resolve(__dirname, '../../core/src'),
14
+ },
15
+ },
16
+ test: { globals: true },
17
+ });