@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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/package.json +14 -0
- package/src/__tests__/civicsolver.test.ts +360 -0
- package/src/__tests__/government-civic.test.ts +302 -0
- package/src/__tests__/runtime-integration.test.ts +128 -0
- package/src/civicsolver.ts +365 -0
- package/src/index.ts +41 -0
- package/src/runtime.ts +150 -0
- package/src/traits/CivicComplianceTrait.ts +165 -0
- package/src/traits/PermitTrait.ts +107 -0
- package/src/traits/PublicMeetingTrait.ts +123 -0
- package/src/traits/ServiceRequestTrait.ts +124 -0
- package/src/traits/VotingRecordTrait.ts +128 -0
- package/src/traits/types.ts +4 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +17 -0
|
@@ -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"] }
|
package/vitest.config.ts
ADDED
|
@@ -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
|
+
});
|