@holoscript/plugin-government-civic 2.0.1 → 2.0.2

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/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@holoscript/plugin-government-civic",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "HoloScript domain plugin for government and civic services — permits, public meetings, service requests, voting records, and ADA/FOIA compliance.",
5
5
  "main": "src/index.ts",
6
6
  "peerDependencies": {
7
- "@holoscript/core": "8.0.6"
7
+ "@holoscript/core": ">=8.0.0"
8
8
  },
9
9
  "license": "MIT",
10
10
  "scripts": {
11
11
  "test": "vitest run --passWithNoTests",
12
12
  "test:coverage": "vitest run --coverage --passWithNoTests"
13
13
  }
14
- }
14
+ }
@@ -23,12 +23,12 @@ import {
23
23
  describe('permitScoring', () => {
24
24
  it('compositeScore = weighted sum of criterion scores', () => {
25
25
  const criteria = [
26
- { name: 'safety', weight: 0.40, score: 80 },
27
- { name: 'zoning', weight: 0.35, score: 90 },
28
- { name: 'env', weight: 0.25, score: 70 },
26
+ { name: 'safety', weight: 0.4, score: 80 },
27
+ { name: 'zoning', weight: 0.35, score: 90 },
28
+ { name: 'env', weight: 0.25, score: 70 },
29
29
  ];
30
30
  const r = permitScoring(criteria);
31
- const expected = 0.40 * 80 + 0.35 * 90 + 0.25 * 70;
31
+ const expected = 0.4 * 80 + 0.35 * 90 + 0.25 * 70;
32
32
  expect(r.compositeScore).toBeCloseTo(expected, 4);
33
33
  });
34
34
 
@@ -46,12 +46,12 @@ describe('permitScoring', () => {
46
46
 
47
47
  it('breakdown includes contribution = score × weight', () => {
48
48
  const criteria = [
49
- { name: 'env', weight: 0.60, score: 75 },
50
- { name: 'traffic', weight: 0.40, score: 50 },
49
+ { name: 'env', weight: 0.6, score: 75 },
50
+ { name: 'traffic', weight: 0.4, score: 50 },
51
51
  ];
52
52
  const r = permitScoring(criteria);
53
- expect(r.breakdown[0].contribution).toBeCloseTo(0.60 * 75, 4);
54
- expect(r.breakdown[1].contribution).toBeCloseTo(0.40 * 50, 4);
53
+ expect(r.breakdown[0].contribution).toBeCloseTo(0.6 * 75, 4);
54
+ expect(r.breakdown[1].contribution).toBeCloseTo(0.4 * 50, 4);
55
55
  });
56
56
 
57
57
  it('throws when weights do not sum to 1.0', () => {
@@ -76,14 +76,14 @@ describe('mcdaAnalysis', () => {
76
76
  { id: 'project-C', scores: [50, 90, 70] },
77
77
  ];
78
78
  const criteria = [
79
- { name: 'cost-effectiveness', weight: 0.50, isBenefit: true },
80
- { name: 'community-impact', weight: 0.30, isBenefit: true },
81
- { name: 'implementation-ease',weight: 0.20, isBenefit: true },
79
+ { name: 'cost-effectiveness', weight: 0.5, isBenefit: true },
80
+ { name: 'community-impact', weight: 0.3, isBenefit: true },
81
+ { name: 'implementation-ease', weight: 0.2, isBenefit: true },
82
82
  ];
83
83
 
84
84
  it('winner is a valid candidate id', () => {
85
85
  const r = mcdaAnalysis(candidates, criteria);
86
- const ids = candidates.map(c => c.id);
86
+ const ids = candidates.map((c) => c.id);
87
87
  expect(ids).toContain(r.winner);
88
88
  });
89
89
 
@@ -102,7 +102,7 @@ describe('mcdaAnalysis', () => {
102
102
 
103
103
  it('ranks are 1-indexed and sequential', () => {
104
104
  const r = mcdaAnalysis(candidates, criteria);
105
- const ranks = r.ranking.map(x => x.rank).sort((a, b) => a - b);
105
+ const ranks = r.ranking.map((x) => x.rank).sort((a, b) => a - b);
106
106
  expect(ranks).toEqual([1, 2, 3]);
107
107
  });
108
108
 
@@ -190,19 +190,19 @@ describe('votingCohesion', () => {
190
190
  { memberId: 'A2', partyId: 'A', vote: 'yes' as const },
191
191
  { memberId: 'A3', partyId: 'A', vote: 'yes' as const },
192
192
  { memberId: 'B1', partyId: 'B', vote: 'yes' as const },
193
- { memberId: 'B2', partyId: 'B', vote: 'no' as const },
194
- { memberId: 'B3', partyId: 'B', vote: 'no' as const },
193
+ { memberId: 'B2', partyId: 'B', vote: 'no' as const },
194
+ { memberId: 'B3', partyId: 'B', vote: 'no' as const },
195
195
  ];
196
196
 
197
197
  it('unanimous party cohesion = 1.0', () => {
198
198
  const r = votingCohesion(votes);
199
- const partyA = r.partyCohesion.find(p => p.partyId === 'A');
199
+ const partyA = r.partyCohesion.find((p) => p.partyId === 'A');
200
200
  expect(partyA!.cohesion).toBeCloseTo(1.0, 4);
201
201
  });
202
202
 
203
203
  it('split party cohesion < 1', () => {
204
204
  const r = votingCohesion(votes);
205
- const partyB = r.partyCohesion.find(p => p.partyId === 'B');
205
+ const partyB = r.partyCohesion.find((p) => p.partyId === 'B');
206
206
  expect(partyB!.cohesion).toBeGreaterThanOrEqual(0);
207
207
  expect(partyB!.cohesion).toBeLessThan(1);
208
208
  });
@@ -219,7 +219,10 @@ describe('votingCohesion', () => {
219
219
  });
220
220
 
221
221
  it('majority=no when more no than yes', () => {
222
- const noMajority = votes.map(v => ({ ...v, vote: (v.partyId === 'A' ? 'no' : v.vote) as 'yes' | 'no' | 'abstain' }));
222
+ const noMajority = votes.map((v) => ({
223
+ ...v,
224
+ vote: (v.partyId === 'A' ? 'no' : v.vote) as 'yes' | 'no' | 'abstain',
225
+ }));
223
226
  const r = votingCohesion(noMajority); // 1 yes, 5 no
224
227
  expect(r.majority).toBe('no');
225
228
  });
@@ -252,7 +255,7 @@ describe('polsbyPopper', () => {
252
255
  // Very elongated: narrow strip, A=1, P=100 → PP = 4π/10000 ≈ 0.00125
253
256
  const r = polsbyPopper(1, 100);
254
257
  expect(r.classification).toBe('gerrymandered');
255
- expect(r.compactnessScore).toBeLessThan(0.20);
258
+ expect(r.compactnessScore).toBeLessThan(0.2);
256
259
  });
257
260
 
258
261
  it('moderate classification for 0.20 ≤ score < 0.50', () => {
@@ -264,9 +267,10 @@ describe('polsbyPopper', () => {
264
267
  });
265
268
 
266
269
  it('compactnessScore = 4π × area / perimeter²', () => {
267
- const A = 5, P = 15;
270
+ const A = 5,
271
+ P = 15;
268
272
  const r = polsbyPopper(A, P);
269
- expect(r.compactnessScore).toBeCloseTo((4 * Math.PI * A) / (P ** 2), 6);
273
+ expect(r.compactnessScore).toBeCloseTo((4 * Math.PI * A) / P ** 2, 6);
270
274
  });
271
275
 
272
276
  it('throws for non-positive area', () => {
@@ -278,9 +282,9 @@ describe('polsbyPopper', () => {
278
282
 
279
283
  describe('budgetVariance', () => {
280
284
  const lines = [
281
- { category: 'Roads', budgeted: 100_000, actual: 95_000 }, // under → favorable
282
- { category: 'Parks', budgeted: 50_000, actual: 60_000 }, // over → unfavorable
283
- { category: 'Admin', budgeted: 75_000, actual: 75_500 }, // near → on-target
285
+ { category: 'Roads', budgeted: 100_000, actual: 95_000 }, // under → favorable
286
+ { category: 'Parks', budgeted: 50_000, actual: 60_000 }, // over → unfavorable
287
+ { category: 'Admin', budgeted: 75_000, actual: 75_500 }, // near → on-target
284
288
  ];
285
289
 
286
290
  it('variance = actual - budgeted', () => {
@@ -306,7 +310,7 @@ describe('budgetVariance', () => {
306
310
  });
307
311
 
308
312
  it('flaggedLines contains categories with |variance%| > threshold', () => {
309
- const r = budgetVariance(lines, 0.10); // 10% threshold
313
+ const r = budgetVariance(lines, 0.1); // 10% threshold
310
314
  // Parks: 20% over → flagged. Roads: 5% under → not flagged (below 10%)
311
315
  expect(r.flaggedLines).toContain('Parks');
312
316
  expect(r.flaggedLines).not.toContain('Roads');
@@ -17,8 +17,12 @@ import type { HSPlusNode, TraitContext } from '../traits/types';
17
17
 
18
18
  // ── Helpers ──────────────────────────────────────────────────────────────────
19
19
 
20
- function makeNode(): HSPlusNode { return { id: 'n1', traits: [], state: {}, position: [0, 0, 0] }; }
21
- function makeCtx(): TraitContext { return { emit: vi.fn(), scene: {} }; }
20
+ function makeNode(): HSPlusNode {
21
+ return { id: 'n1', traits: [], state: {}, position: [0, 0, 0] };
22
+ }
23
+ function makeCtx(): TraitContext {
24
+ return { emit: vi.fn(), scene: {} };
25
+ }
22
26
 
23
27
  // ── Plugin metadata ───────────────────────────────────────────────────────────
24
28
 
@@ -77,9 +81,15 @@ describe('PermitTrait', () => {
77
81
  const node = makeNode();
78
82
  const ctx = makeCtx();
79
83
  handler.onAttach(node, cfg, ctx);
80
- handler.onEvent(node, cfg, ctx, { type: 'permit:update_status', payload: { status: 'approved' } });
84
+ handler.onEvent(node, cfg, ctx, {
85
+ type: 'permit:update_status',
86
+ payload: { status: 'approved' },
87
+ });
81
88
  expect((node.__permitState as { status: string }).status).toBe('approved');
82
- expect(ctx.emit).toHaveBeenCalledWith('permit:status_changed', expect.objectContaining({ to: 'approved' }));
89
+ expect(ctx.emit).toHaveBeenCalledWith(
90
+ 'permit:status_changed',
91
+ expect.objectContaining({ to: 'approved' })
92
+ );
83
93
  });
84
94
 
85
95
  it('cleans up on detach', () => {
@@ -106,7 +116,13 @@ describe('PublicMeetingTrait', () => {
106
116
  publicCommentMinutesPerSpeaker: 3,
107
117
  agenda: [
108
118
  { id: '1', title: 'Call to Order', type: 'action', durationMinutes: 5, requiresVote: false },
109
- { id: '2', title: 'Public Comment', type: 'public_comment', durationMinutes: 30, requiresVote: false },
119
+ {
120
+ id: '2',
121
+ title: 'Public Comment',
122
+ type: 'public_comment',
123
+ durationMinutes: 30,
124
+ requiresVote: false,
125
+ },
110
126
  ],
111
127
  accessibilityFeatures: ['captioning'],
112
128
  };
@@ -221,7 +237,10 @@ describe('VotingRecordTrait', () => {
221
237
  const ctx = makeCtx();
222
238
  handler.onAttach(node, cfg, ctx);
223
239
  handler.onEvent(node, cfg, ctx, { type: 'vote:open' });
224
- handler.onEvent(node, cfg, ctx, { type: 'vote:cast', payload: { memberId: 'CM-A', memberName: 'Councilmember A', vote: 'aye' } });
240
+ handler.onEvent(node, cfg, ctx, {
241
+ type: 'vote:cast',
242
+ payload: { memberId: 'CM-A', memberName: 'Councilmember A', vote: 'aye' },
243
+ });
225
244
  expect((node.__votingState as { ayeCount: number }).ayeCount).toBe(1);
226
245
  });
227
246
 
@@ -232,14 +251,23 @@ describe('VotingRecordTrait', () => {
232
251
  handler.onAttach(node, cfg, ctx);
233
252
  handler.onEvent(node, cfg, ctx, { type: 'vote:open' });
234
253
  for (const id of ['CM-A', 'CM-B', 'CM-C']) {
235
- handler.onEvent(node, cfg, ctx, { type: 'vote:cast', payload: { memberId: id, memberName: id, vote: 'aye' } });
254
+ handler.onEvent(node, cfg, ctx, {
255
+ type: 'vote:cast',
256
+ payload: { memberId: id, memberName: id, vote: 'aye' },
257
+ });
236
258
  }
237
259
  for (const id of ['CM-D', 'Mayor']) {
238
- handler.onEvent(node, cfg, ctx, { type: 'vote:cast', payload: { memberId: id, memberName: id, vote: 'nay' } });
260
+ handler.onEvent(node, cfg, ctx, {
261
+ type: 'vote:cast',
262
+ payload: { memberId: id, memberName: id, vote: 'nay' },
263
+ });
239
264
  }
240
265
  handler.onEvent(node, cfg, ctx, { type: 'vote:close' });
241
266
  expect((node.__votingState as { outcome: string }).outcome).toBe('passed');
242
- expect(ctx.emit).toHaveBeenCalledWith('vote:closed', expect.objectContaining({ outcome: 'passed' }));
267
+ expect(ctx.emit).toHaveBeenCalledWith(
268
+ 'vote:closed',
269
+ expect.objectContaining({ outcome: 'passed' })
270
+ );
243
271
  });
244
272
  });
245
273
 
@@ -273,9 +301,15 @@ describe('CivicComplianceTrait', () => {
273
301
  handler.onAttach(node, cfg, ctx);
274
302
  const s = node.__complianceState as { checks: Array<{ id: string; status: string }> };
275
303
  const checkId = s.checks[0]!.id;
276
- handler.onEvent(node, cfg, ctx, { type: 'compliance:update_check', payload: { checkId, status: 'compliant' } });
304
+ handler.onEvent(node, cfg, ctx, {
305
+ type: 'compliance:update_check',
306
+ payload: { checkId, status: 'compliant' },
307
+ });
277
308
  expect(s.checks[0]!.status).toBe('compliant');
278
- expect(ctx.emit).toHaveBeenCalledWith('compliance:check_updated', expect.objectContaining({ status: 'compliant' }));
309
+ expect(ctx.emit).toHaveBeenCalledWith(
310
+ 'compliance:check_updated',
311
+ expect.objectContaining({ status: 'compliant' })
312
+ );
279
313
  });
280
314
 
281
315
  it('compliance:foia_received creates a pending FOIA request', () => {
@@ -283,11 +317,17 @@ describe('CivicComplianceTrait', () => {
283
317
  const node = makeNode();
284
318
  const ctx = makeCtx();
285
319
  handler.onAttach(node, cfg, ctx);
286
- handler.onEvent(node, cfg, ctx, { type: 'compliance:foia_received', payload: { requestId: 'FOIA-001', description: 'Budget docs' } });
320
+ handler.onEvent(node, cfg, ctx, {
321
+ type: 'compliance:foia_received',
322
+ payload: { requestId: 'FOIA-001', description: 'Budget docs' },
323
+ });
287
324
  const s = node.__complianceState as { foiaRequests: unknown[]; openFoiaCount: number };
288
325
  expect(s.foiaRequests).toHaveLength(1);
289
326
  expect(s.openFoiaCount).toBe(1);
290
- expect(ctx.emit).toHaveBeenCalledWith('compliance:foia_received', expect.objectContaining({ requestId: 'FOIA-001' }));
327
+ expect(ctx.emit).toHaveBeenCalledWith(
328
+ 'compliance:foia_received',
329
+ expect.objectContaining({ requestId: 'FOIA-001' })
330
+ );
291
331
  });
292
332
 
293
333
  it('audit log entry is stored', () => {
@@ -295,7 +335,10 @@ describe('CivicComplianceTrait', () => {
295
335
  const node = makeNode();
296
336
  const ctx = makeCtx();
297
337
  handler.onAttach(node, cfg, ctx);
298
- handler.onEvent(node, cfg, ctx, { type: 'compliance:audit_log', payload: { action: 'page_view', actor: 'user-99', details: 'Viewed homepage' } });
338
+ handler.onEvent(node, cfg, ctx, {
339
+ type: 'compliance:audit_log',
340
+ payload: { action: 'page_view', actor: 'user-99', details: 'Viewed homepage' },
341
+ });
299
342
  const s = node.__complianceState as { auditLog: unknown[] };
300
343
  expect(s.auditLog).toHaveLength(1);
301
344
  });
@@ -53,7 +53,7 @@ describe('government-civic -> HoloScript runtime integration (civic_decision)',
53
53
  });
54
54
 
55
55
  await runtime.executeNode(
56
- civicDecisionOrb({ candidates: TWO_CANDIDATES, criteria: TWO_CANDIDATE_CRITERIA }) as never,
56
+ civicDecisionOrb({ candidates: TWO_CANDIDATES, criteria: TWO_CANDIDATE_CRITERIA }) as never
57
57
  );
58
58
  await flush();
59
59
 
@@ -79,7 +79,7 @@ describe('government-civic -> HoloScript runtime integration (civic_decision)',
79
79
  runtime.on('civic_decision_solved', (e: unknown) => solved.push(e));
80
80
 
81
81
  await runtime.executeNode(
82
- civicDecisionOrb({ candidates: TWO_CANDIDATES, criteria: TWO_CANDIDATE_CRITERIA }) as never,
82
+ civicDecisionOrb({ candidates: TWO_CANDIDATES, criteria: TWO_CANDIDATE_CRITERIA }) as never
83
83
  );
84
84
  await flush();
85
85
 
@@ -91,7 +91,7 @@ describe('government-civic -> HoloScript runtime integration (civic_decision)',
91
91
  registerGovernmentCivicTraitHandlers(runtime);
92
92
 
93
93
  await runtime.executeNode(
94
- civicDecisionOrb({ candidates: TWO_CANDIDATES, criteria: TWO_CANDIDATE_CRITERIA }) as never,
94
+ civicDecisionOrb({ candidates: TWO_CANDIDATES, criteria: TWO_CANDIDATE_CRITERIA }) as never
95
95
  );
96
96
  await flush();
97
97
 
@@ -118,7 +118,7 @@ describe('government-civic -> HoloScript runtime integration (civic_decision)',
118
118
  // handler's try/catch turns into a civic_decision_error rather than a throw.
119
119
  const mismatched: MCDACandidate[] = [{ id: 'A', scores: [1, 2] }];
120
120
  await runtime.executeNode(
121
- civicDecisionOrb({ candidates: mismatched, criteria: TWO_CANDIDATE_CRITERIA }) as never,
121
+ civicDecisionOrb({ candidates: mismatched, criteria: TWO_CANDIDATE_CRITERIA }) as never
122
122
  );
123
123
  await flush();
124
124
 
@@ -23,8 +23,8 @@ import { buildDomainSimulationReceipt, type DomainSimulationReceipt } from '@hol
23
23
 
24
24
  export interface PermitCriteria {
25
25
  name: string;
26
- weight: number; // sum of all weights should be 1.0
27
- score: number; // 0–100
26
+ weight: number; // sum of all weights should be 1.0
27
+ score: number; // 0–100
28
28
  }
29
29
 
30
30
  export interface PermitScoringResult {
@@ -46,7 +46,7 @@ export interface MCDACandidate {
46
46
 
47
47
  export interface MCDACriterion {
48
48
  name: string;
49
- weight: number; // 0–1
49
+ weight: number; // 0–1
50
50
  /** true = higher is better (benefit), false = lower is better (cost) */
51
51
  isBenefit: boolean;
52
52
  }
@@ -87,7 +87,13 @@ export interface CohesionResult {
87
87
  /** Overall chamber Rice index (0=perfect split, 1=unanimous) */
88
88
  chamberCohesion: number;
89
89
  /** Per-party Rice cohesion */
90
- partyCohesion: Array<{ partyId: string; cohesion: number; yesCount: number; noCount: number; size: number }>;
90
+ partyCohesion: Array<{
91
+ partyId: string;
92
+ cohesion: number;
93
+ yesCount: number;
94
+ noCount: number;
95
+ size: number;
96
+ }>;
91
97
  /** Winning coalition */
92
98
  majority: 'yes' | 'no' | 'tie';
93
99
  }
@@ -122,7 +128,7 @@ export interface BudgetVarianceResult {
122
128
  totalActual: number;
123
129
  totalVariancePct: number;
124
130
  overallStatus: 'favorable' | 'unfavorable' | 'on-target';
125
- flaggedLines: string[]; // categories with variance > 10%
131
+ flaggedLines: string[]; // categories with variance > 10%
126
132
  }
127
133
 
128
134
  export interface GovtReceiptOptions {
@@ -131,15 +137,13 @@ export interface GovtReceiptOptions {
131
137
 
132
138
  // ─── Permit Scoring ───────────────────────────────────────────────────────────
133
139
 
134
- export function permitScoring(
135
- criteria: PermitCriteria[],
136
- threshold = 70,
137
- ): PermitScoringResult {
140
+ export function permitScoring(criteria: PermitCriteria[], threshold = 70): PermitScoringResult {
138
141
  if (criteria.length === 0) throw new Error('No permit criteria provided');
139
142
  const totalWeight = criteria.reduce((s, c) => s + c.weight, 0);
140
- if (Math.abs(totalWeight - 1.0) > 0.01) throw new Error(`Weights must sum to 1.0 (got ${totalWeight.toFixed(3)})`);
143
+ if (Math.abs(totalWeight - 1.0) > 0.01)
144
+ throw new Error(`Weights must sum to 1.0 (got ${totalWeight.toFixed(3)})`);
141
145
 
142
- const breakdown = criteria.map(c => ({
146
+ const breakdown = criteria.map((c) => ({
143
147
  name: c.name,
144
148
  score: c.score,
145
149
  weight: c.weight,
@@ -155,30 +159,33 @@ export function permitScoring(
155
159
 
156
160
  /** Normalize a column to [0,1] range */
157
161
  function normalizeColumn(values: number[]): number[] {
158
- const min = Math.min(...values), max = Math.max(...values);
162
+ const min = Math.min(...values),
163
+ max = Math.max(...values);
159
164
  if (max === min) return values.map(() => 0.5);
160
- return values.map(v => (v - min) / (max - min));
165
+ return values.map((v) => (v - min) / (max - min));
161
166
  }
162
167
 
163
- export function mcdaAnalysis(
164
- candidates: MCDACandidate[],
165
- criteria: MCDACriterion[],
166
- ): MCDAResult {
168
+ export function mcdaAnalysis(candidates: MCDACandidate[], criteria: MCDACriterion[]): MCDAResult {
167
169
  if (candidates.length === 0) throw new Error('No MCDA candidates');
168
170
  if (criteria.length === 0) throw new Error('No MCDA criteria');
169
- if (candidates.some(c => c.scores.length !== criteria.length)) throw new Error('scores.length must match criteria.length');
171
+ if (candidates.some((c) => c.scores.length !== criteria.length))
172
+ throw new Error('scores.length must match criteria.length');
170
173
 
171
- const n = candidates.length, m = criteria.length;
174
+ const n = candidates.length,
175
+ m = criteria.length;
172
176
 
173
177
  // Weighted sum score
174
- const weightedScores = candidates.map(c =>
175
- c.scores.reduce((acc, s, j) => acc + s * criteria[j].weight * (criteria[j].isBenefit ? 1 : -1), 0),
178
+ const weightedScores = candidates.map((c) =>
179
+ c.scores.reduce(
180
+ (acc, s, j) => acc + s * criteria[j].weight * (criteria[j].isBenefit ? 1 : -1),
181
+ 0
182
+ )
176
183
  );
177
184
 
178
185
  // TOPSIS: normalize → weight → ideal/anti-ideal → distance
179
186
  const normalizedMatrix: number[][] = Array.from({ length: n }, () => Array(m).fill(0));
180
187
  for (let j = 0; j < m; j++) {
181
- const col = candidates.map(c => c.scores[j]);
188
+ const col = candidates.map((c) => c.scores[j]);
182
189
  const norm = normalizeColumn(col);
183
190
  for (let i = 0; i < n; i++) {
184
191
  normalizedMatrix[i][j] = norm[i] * criteria[j].weight;
@@ -186,10 +193,18 @@ export function mcdaAnalysis(
186
193
  }
187
194
 
188
195
  // Ideal best/worst per criterion
189
- const ideal = criteria.map((cr, j) => cr.isBenefit ? Math.max(...normalizedMatrix.map(r => r[j])) : Math.min(...normalizedMatrix.map(r => r[j])));
190
- const antiIdeal = criteria.map((cr, j) => cr.isBenefit ? Math.min(...normalizedMatrix.map(r => r[j])) : Math.max(...normalizedMatrix.map(r => r[j])));
196
+ const ideal = criteria.map((cr, j) =>
197
+ cr.isBenefit
198
+ ? Math.max(...normalizedMatrix.map((r) => r[j]))
199
+ : Math.min(...normalizedMatrix.map((r) => r[j]))
200
+ );
201
+ const antiIdeal = criteria.map((cr, j) =>
202
+ cr.isBenefit
203
+ ? Math.min(...normalizedMatrix.map((r) => r[j]))
204
+ : Math.max(...normalizedMatrix.map((r) => r[j]))
205
+ );
191
206
 
192
- const topsisScores = normalizedMatrix.map(row => {
207
+ const topsisScores = normalizedMatrix.map((row) => {
193
208
  const dPos = Math.sqrt(row.reduce((acc, v, j) => acc + (v - ideal[j]) ** 2, 0));
194
209
  const dNeg = Math.sqrt(row.reduce((acc, v, j) => acc + (v - antiIdeal[j]) ** 2, 0));
195
210
  return dNeg / (dPos + dNeg + 1e-15);
@@ -211,27 +226,40 @@ export function quorumCalculator(
211
226
  yesVotes: number,
212
227
  noVotes: number,
213
228
  abstentions: number,
214
- quorumType: QuorumType = 'simple',
229
+ quorumType: QuorumType = 'simple'
215
230
  ): QuorumResult {
216
231
  if (totalMembers < 1) throw new Error('totalMembers must be ≥ 1');
217
232
  if (presentMembers > totalMembers) throw new Error('presentMembers cannot exceed totalMembers');
218
- if (yesVotes + noVotes + abstentions > presentMembers) throw new Error('Votes exceed present members');
233
+ if (yesVotes + noVotes + abstentions > presentMembers)
234
+ throw new Error('Votes exceed present members');
219
235
 
220
236
  const quorumRequired = Math.floor(totalMembers / 2) + 1; // strict majority
221
237
  const quorumMet = presentMembers >= quorumRequired;
222
238
 
223
239
  const passThreshold =
224
- quorumType === 'supermajority' ? (2 / 3) :
225
- quorumType === 'absolute' ? (totalMembers / 2 + 1) / totalMembers :
226
- 0.5; // simple majority of votes cast
240
+ quorumType === 'supermajority'
241
+ ? 2 / 3
242
+ : quorumType === 'absolute'
243
+ ? (totalMembers / 2 + 1) / totalMembers
244
+ : 0.5; // simple majority of votes cast
227
245
 
228
246
  const votingBase = quorumType === 'absolute' ? totalMembers : yesVotes + noVotes;
229
- const motionPassed = quorumMet && votingBase > 0 && (yesVotes / votingBase) > passThreshold;
247
+ const motionPassed = quorumMet && votingBase > 0 && yesVotes / votingBase > passThreshold;
230
248
  const marginVotes = motionPassed
231
249
  ? yesVotes - Math.ceil((yesVotes + noVotes) * passThreshold)
232
250
  : Math.ceil((yesVotes + noVotes) * passThreshold) - yesVotes;
233
251
 
234
- return { totalMembers, presentMembers, yesVotes, noVotes, abstentions, quorumMet, passThreshold, motionPassed, marginVotes };
252
+ return {
253
+ totalMembers,
254
+ presentMembers,
255
+ yesVotes,
256
+ noVotes,
257
+ abstentions,
258
+ quorumMet,
259
+ passThreshold,
260
+ motionPassed,
261
+ marginVotes,
262
+ };
235
263
  }
236
264
 
237
265
  // ─── Voting Cohesion (Rice Index) ────────────────────────────────────────────
@@ -252,17 +280,22 @@ export function votingCohesion(votes: VotingRecord[]): CohesionResult {
252
280
  const partyCohesion = [...partyMap.entries()].map(([partyId, counts]) => {
253
281
  const total = counts.yes + counts.no;
254
282
  const cohesion = total > 0 ? Math.abs((counts.yes - counts.no) / total) : 0;
255
- return { partyId, cohesion, yesCount: counts.yes, noCount: counts.no, size: counts.yes + counts.no + counts.abstain };
283
+ return {
284
+ partyId,
285
+ cohesion,
286
+ yesCount: counts.yes,
287
+ noCount: counts.no,
288
+ size: counts.yes + counts.no + counts.abstain,
289
+ };
256
290
  });
257
291
 
258
- const totalYes = votes.filter(v => v.vote === 'yes').length;
259
- const totalNo = votes.filter(v => v.vote === 'no').length;
292
+ const totalYes = votes.filter((v) => v.vote === 'yes').length;
293
+ const totalNo = votes.filter((v) => v.vote === 'no').length;
260
294
  const totalVoting = totalYes + totalNo;
261
295
  const chamberCohesion = totalVoting > 0 ? Math.abs((totalYes - totalNo) / totalVoting) : 0;
262
296
 
263
297
  const majority: CohesionResult['majority'] =
264
- totalYes > totalNo ? 'yes' :
265
- totalNo > totalYes ? 'no' : 'tie';
298
+ totalYes > totalNo ? 'yes' : totalNo > totalYes ? 'no' : 'tie';
266
299
 
267
300
  return { chamberCohesion, partyCohesion, majority };
268
301
  }
@@ -277,42 +310,56 @@ export function polsbyPopper(areaSqKm: number, perimeterKm: number): PolsbyPoppe
277
310
  if (areaSqKm <= 0) throw new Error('Area must be positive');
278
311
  if (perimeterKm <= 0) throw new Error('Perimeter must be positive');
279
312
 
280
- const compactnessScore = (4 * Math.PI * areaSqKm) / (perimeterKm ** 2);
313
+ const compactnessScore = (4 * Math.PI * areaSqKm) / perimeterKm ** 2;
281
314
 
282
315
  const classification: PolsbyPopperResult['classification'] =
283
- compactnessScore >= 0.50 ? 'compact' :
284
- compactnessScore >= 0.20 ? 'moderate' : 'gerrymandered';
316
+ compactnessScore >= 0.5 ? 'compact' : compactnessScore >= 0.2 ? 'moderate' : 'gerrymandered';
285
317
 
286
318
  return { areaSqKm, perimeterKm, compactnessScore, classification };
287
319
  }
288
320
 
289
321
  // ─── Budget Variance ──────────────────────────────────────────────────────────
290
322
 
291
- export function budgetVariance(
292
- lines: BudgetLine[],
293
- flagThreshold = 0.10,
294
- ): BudgetVarianceResult {
323
+ export function budgetVariance(lines: BudgetLine[], flagThreshold = 0.1): BudgetVarianceResult {
295
324
  if (lines.length === 0) throw new Error('No budget lines');
296
325
 
297
- const analyzed = lines.map(l => {
326
+ const analyzed = lines.map((l) => {
298
327
  const variance = l.actual - l.budgeted;
299
328
  const variancePct = l.budgeted !== 0 ? variance / l.budgeted : 0;
300
329
  const status: 'favorable' | 'unfavorable' | 'on-target' =
301
- Math.abs(variancePct) < 0.02 ? 'on-target' :
302
- variance < 0 ? 'favorable' : 'unfavorable'; // under-budget = favorable
303
- return { category: l.category, budgeted: l.budgeted, actual: l.actual, variance, variancePct, status };
330
+ Math.abs(variancePct) < 0.02 ? 'on-target' : variance < 0 ? 'favorable' : 'unfavorable'; // under-budget = favorable
331
+ return {
332
+ category: l.category,
333
+ budgeted: l.budgeted,
334
+ actual: l.actual,
335
+ variance,
336
+ variancePct,
337
+ status,
338
+ };
304
339
  });
305
340
 
306
341
  const totalBudgeted = analyzed.reduce((s, l) => s + l.budgeted, 0);
307
- const totalActual = analyzed.reduce((s, l) => s + l.actual, 0);
342
+ const totalActual = analyzed.reduce((s, l) => s + l.actual, 0);
308
343
  const totalVariancePct = totalBudgeted !== 0 ? (totalActual - totalBudgeted) / totalBudgeted : 0;
309
344
  const overallStatus: BudgetVarianceResult['overallStatus'] =
310
- Math.abs(totalVariancePct) < 0.02 ? 'on-target' :
311
- totalVariancePct < 0 ? 'favorable' : 'unfavorable';
312
-
313
- const flaggedLines = analyzed.filter(l => Math.abs(l.variancePct) > flagThreshold).map(l => l.category);
314
-
315
- return { lines: analyzed, totalBudgeted, totalActual, totalVariancePct, overallStatus, flaggedLines };
345
+ Math.abs(totalVariancePct) < 0.02
346
+ ? 'on-target'
347
+ : totalVariancePct < 0
348
+ ? 'favorable'
349
+ : 'unfavorable';
350
+
351
+ const flaggedLines = analyzed
352
+ .filter((l) => Math.abs(l.variancePct) > flagThreshold)
353
+ .map((l) => l.category);
354
+
355
+ return {
356
+ lines: analyzed,
357
+ totalBudgeted,
358
+ totalActual,
359
+ totalVariancePct,
360
+ overallStatus,
361
+ flaggedLines,
362
+ };
316
363
  }
317
364
 
318
365
  // ─── Receipt ──────────────────────────────────────────────────────────────────
@@ -329,21 +376,37 @@ export interface GovtAnalysisResult {
329
376
 
330
377
  export function buildGovtReceipt(
331
378
  result: GovtAnalysisResult,
332
- options?: GovtReceiptOptions,
379
+ options?: GovtReceiptOptions
333
380
  ): DomainSimulationReceipt {
334
381
  const violations: Array<{ criterion: string; message: string }> = [];
335
382
 
336
383
  if (result.permit && !result.permit.approved) {
337
- violations.push({ criterion: 'permit_denied', message: `Permit score ${result.permit.compositeScore.toFixed(1)} < ${result.permit.threshold} threshold — application denied` });
384
+ violations.push({
385
+ criterion: 'permit_denied',
386
+ message: `Permit score ${result.permit.compositeScore.toFixed(1)} < ${result.permit.threshold} threshold — application denied`,
387
+ });
338
388
  }
339
389
  if (result.quorum && !result.quorum.quorumMet) {
340
- violations.push({ criterion: 'no_quorum', message: `Only ${result.quorum.presentMembers}/${result.quorum.totalMembers} members present — quorum not met` });
390
+ violations.push({
391
+ criterion: 'no_quorum',
392
+ message: `Only ${result.quorum.presentMembers}/${result.quorum.totalMembers} members present — quorum not met`,
393
+ });
341
394
  }
342
395
  if (result.compactness && result.compactness.classification === 'gerrymandered') {
343
- violations.push({ criterion: 'gerrymandering', message: `District compactness ${result.compactness.compactnessScore.toFixed(3)} < 0.20 — potential gerrymandering` });
396
+ violations.push({
397
+ criterion: 'gerrymandering',
398
+ message: `District compactness ${result.compactness.compactnessScore.toFixed(3)} < 0.20 — potential gerrymandering`,
399
+ });
344
400
  }
345
- if (result.budgetVar && result.budgetVar.overallStatus === 'unfavorable' && Math.abs(result.budgetVar.totalVariancePct) > 0.05) {
346
- violations.push({ criterion: 'budget_overrun', message: `Budget overrun ${(result.budgetVar.totalVariancePct * 100).toFixed(1)}% exceeds 5% threshold` });
401
+ if (
402
+ result.budgetVar &&
403
+ result.budgetVar.overallStatus === 'unfavorable' &&
404
+ Math.abs(result.budgetVar.totalVariancePct) > 0.05
405
+ ) {
406
+ violations.push({
407
+ criterion: 'budget_overrun',
408
+ message: `Budget overrun ${(result.budgetVar.totalVariancePct * 100).toFixed(1)}% exceeds 5% threshold`,
409
+ });
347
410
  }
348
411
 
349
412
  return buildDomainSimulationReceipt({
@@ -359,7 +422,11 @@ export function buildGovtReceipt(
359
422
  districtCompactness: result.compactness?.compactnessScore ?? null,
360
423
  budgetVariancePct: result.budgetVar?.totalVariancePct ?? null,
361
424
  },
362
- cael: { version: 'cael.v1', event: 'government_civic.civic_analysis', solverType: 'government-civic.permit-scoring' },
425
+ cael: {
426
+ version: 'cael.v1',
427
+ event: 'government_civic.civic_analysis',
428
+ solverType: 'government-civic.permit-scoring',
429
+ },
363
430
  acceptance: { accepted: violations.length === 0, violations },
364
431
  });
365
432
  }
package/src/index.ts CHANGED
@@ -1,9 +1,44 @@
1
1
  export * from './civicsolver';
2
- export { createPermitHandler, type PermitConfig, type PermitState, type PermitType, type PermitStatus } from './traits/PermitTrait';
3
- export { createPublicMeetingHandler, type PublicMeetingConfig, type PublicMeetingState, type MeetingType, type MeetingStatus, type AgendaItem } from './traits/PublicMeetingTrait';
4
- export { createServiceRequestHandler, type ServiceRequestConfig, type ServiceRequestState, type ServiceCategory, type RequestStatus, type PriorityLevel } from './traits/ServiceRequestTrait';
5
- export { createVotingRecordHandler, type VotingRecordConfig, type VotingRecordState, type VoteType, type VoteOutcome, type VoteCast } from './traits/VotingRecordTrait';
6
- export { createCivicComplianceHandler, type CivicComplianceConfig, type CivicComplianceState, type ComplianceFramework, type ComplianceStatus, type ComplianceCheck, type FoiaRequest } from './traits/CivicComplianceTrait';
2
+ export {
3
+ createPermitHandler,
4
+ type PermitConfig,
5
+ type PermitState,
6
+ type PermitType,
7
+ type PermitStatus,
8
+ } from './traits/PermitTrait';
9
+ export {
10
+ createPublicMeetingHandler,
11
+ type PublicMeetingConfig,
12
+ type PublicMeetingState,
13
+ type MeetingType,
14
+ type MeetingStatus,
15
+ type AgendaItem,
16
+ } from './traits/PublicMeetingTrait';
17
+ export {
18
+ createServiceRequestHandler,
19
+ type ServiceRequestConfig,
20
+ type ServiceRequestState,
21
+ type ServiceCategory,
22
+ type RequestStatus,
23
+ type PriorityLevel,
24
+ } from './traits/ServiceRequestTrait';
25
+ export {
26
+ createVotingRecordHandler,
27
+ type VotingRecordConfig,
28
+ type VotingRecordState,
29
+ type VoteType,
30
+ type VoteOutcome,
31
+ type VoteCast,
32
+ } from './traits/VotingRecordTrait';
33
+ export {
34
+ createCivicComplianceHandler,
35
+ type CivicComplianceConfig,
36
+ type CivicComplianceState,
37
+ type ComplianceFramework,
38
+ type ComplianceStatus,
39
+ type ComplianceCheck,
40
+ type FoiaRequest,
41
+ } from './traits/CivicComplianceTrait';
7
42
  export * from './traits/types';
8
43
 
9
44
  import { createPermitHandler } from './traits/PermitTrait';
@@ -15,7 +50,14 @@ import { createCivicComplianceHandler } from './traits/CivicComplianceTrait';
15
50
  export const pluginMeta = {
16
51
  name: '@holoscript/plugin-government-civic',
17
52
  version: '1.0.0',
18
- traits: ['permit', 'public_meeting', 'service_request', 'voting_record', 'civic_compliance', 'civic_decision'],
53
+ traits: [
54
+ 'permit',
55
+ 'public_meeting',
56
+ 'service_request',
57
+ 'voting_record',
58
+ 'civic_compliance',
59
+ 'civic_decision',
60
+ ],
19
61
  };
20
62
 
21
63
  // Runtime integration — behavioral trait handler + registrar that wire the
package/src/runtime.ts CHANGED
@@ -60,12 +60,16 @@ export interface TraitDispatchContext {
60
60
 
61
61
  export interface RuntimeTraitHandler {
62
62
  name: string;
63
- onAttach?: (node: unknown, config: CivicDecisionTraitConfig, context: TraitDispatchContext) => void;
63
+ onAttach?: (
64
+ node: unknown,
65
+ config: CivicDecisionTraitConfig,
66
+ context: TraitDispatchContext
67
+ ) => void;
64
68
  onUpdate?: (
65
69
  node: unknown,
66
70
  config: CivicDecisionTraitConfig,
67
71
  context: TraitDispatchContext,
68
- delta: number,
72
+ delta: number
69
73
  ) => void;
70
74
  }
71
75
 
@@ -80,7 +84,7 @@ interface CivicDecisionNode {
80
84
  function solveOntoNode(
81
85
  node: unknown,
82
86
  config: CivicDecisionTraitConfig | undefined,
83
- context: TraitDispatchContext,
87
+ context: TraitDispatchContext
84
88
  ): void {
85
89
  const carrier = node as CivicDecisionNode;
86
90
  const nodeId = carrier.id ?? carrier.name ?? 'unknown';
@@ -2,7 +2,12 @@
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
3
 
4
4
  export type ComplianceFramework = 'ADA' | 'FOIA' | 'WCAG' | 'OPRA' | 'GDPR' | 'CCPA' | 'section508';
5
- export type ComplianceStatus = 'compliant' | 'non_compliant' | 'pending_review' | 'exempt' | 'remediation_in_progress';
5
+ export type ComplianceStatus =
6
+ | 'compliant'
7
+ | 'non_compliant'
8
+ | 'pending_review'
9
+ | 'exempt'
10
+ | 'remediation_in_progress';
6
11
 
7
12
  export interface ComplianceCheck {
8
13
  id: string;
@@ -56,10 +61,11 @@ const defaultConfig: CivicComplianceConfig = {
56
61
  };
57
62
 
58
63
  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';
64
+ if (checks.some((c) => c.status === 'non_compliant')) return 'non_compliant';
65
+ if (checks.some((c) => c.status === 'remediation_in_progress')) return 'remediation_in_progress';
66
+ if (checks.some((c) => c.status === 'pending_review')) return 'pending_review';
67
+ if (checks.length > 0 && checks.every((c) => c.status === 'compliant' || c.status === 'exempt'))
68
+ return 'compliant';
63
69
  return 'pending_review';
64
70
  }
65
71
 
@@ -69,7 +75,7 @@ export function createCivicComplianceHandler(): TraitHandler<CivicComplianceConf
69
75
  defaultConfig,
70
76
  onAttach(node: HSPlusNode, config: CivicComplianceConfig, ctx: TraitContext) {
71
77
  // Seed initial checks based on declared frameworks
72
- const checks: ComplianceCheck[] = config.frameworks.map(fw => ({
78
+ const checks: ComplianceCheck[] = config.frameworks.map((fw) => ({
73
79
  id: `${config.entityId}-${fw.toLowerCase()}`,
74
80
  framework: fw,
75
81
  requirement: `${fw} baseline compliance`,
@@ -86,7 +92,10 @@ export function createCivicComplianceHandler(): TraitHandler<CivicComplianceConf
86
92
  overdueFoiaCount: 0,
87
93
  nonCompliantCount: 0,
88
94
  } satisfies CivicComplianceState;
89
- ctx.emit?.('compliance:initialized', { entityId: config.entityId, frameworks: config.frameworks });
95
+ ctx.emit?.('compliance:initialized', {
96
+ entityId: config.entityId,
97
+ frameworks: config.frameworks,
98
+ });
90
99
  },
91
100
  onDetach(node: HSPlusNode, _config: CivicComplianceConfig, ctx: TraitContext) {
92
101
  delete node.__complianceState;
@@ -109,8 +118,10 @@ export function createCivicComplianceHandler(): TraitHandler<CivicComplianceConf
109
118
  if (overdue > prevOverdue) {
110
119
  ctx.emit?.('compliance:foia_overdue', { entityId: config.entityId, overdueCount: overdue });
111
120
  }
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;
121
+ s.openFoiaCount = s.foiaRequests.filter(
122
+ (r) => r.status === 'received' || r.status === 'processing'
123
+ ).length;
124
+ s.nonCompliantCount = s.checks.filter((c) => c.status === 'non_compliant').length;
114
125
  s.overallStatus = computeOverallStatus(s.checks);
115
126
  },
116
127
  onEvent(node: HSPlusNode, config: CivicComplianceConfig, ctx: TraitContext, event: TraitEvent) {
@@ -118,8 +129,12 @@ export function createCivicComplianceHandler(): TraitHandler<CivicComplianceConf
118
129
  if (!s) return;
119
130
  switch (event.type) {
120
131
  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);
132
+ const { checkId, status, remediation } = event.payload as {
133
+ checkId: string;
134
+ status: ComplianceStatus;
135
+ remediation?: string;
136
+ };
137
+ const check = s.checks.find((c) => c.id === checkId);
123
138
  if (check) {
124
139
  check.status = status;
125
140
  check.lastChecked = new Date().toISOString();
@@ -135,28 +150,51 @@ export function createCivicComplianceHandler(): TraitHandler<CivicComplianceConf
135
150
  if (!req?.requestId) return;
136
151
  const receivedAt = new Date();
137
152
  // 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() });
153
+ const dueAt = new Date(
154
+ receivedAt.getTime() + config.foiaResponseDaysLimit * 1.4 * 86_400_000
155
+ );
156
+ s.foiaRequests.push({
157
+ ...req,
158
+ requestedAt: receivedAt.toISOString(),
159
+ responseDueAt: dueAt.toISOString(),
160
+ status: 'received',
161
+ });
162
+ s.openFoiaCount = s.foiaRequests.filter(
163
+ (r) => r.status === 'received' || r.status === 'processing'
164
+ ).length;
165
+ ctx.emit?.('compliance:foia_received', {
166
+ requestId: req.requestId,
167
+ dueAt: dueAt.toISOString(),
168
+ });
142
169
  break;
143
170
  }
144
171
  case 'compliance:foia_fulfill': {
145
172
  const requestId = event.payload?.requestId as string;
146
- const req = s.foiaRequests.find(r => r.requestId === requestId);
173
+ const req = s.foiaRequests.find((r) => r.requestId === requestId);
147
174
  if (req) {
148
175
  req.status = 'fulfilled';
149
- s.openFoiaCount = s.foiaRequests.filter(r => r.status === 'received' || r.status === 'processing').length;
176
+ s.openFoiaCount = s.foiaRequests.filter(
177
+ (r) => r.status === 'received' || r.status === 'processing'
178
+ ).length;
150
179
  ctx.emit?.('compliance:foia_fulfilled', { requestId });
151
180
  }
152
181
  break;
153
182
  }
154
183
  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 ?? '' });
184
+ const { action, actor, details } = event.payload as {
185
+ action: string;
186
+ actor: string;
187
+ details: string;
188
+ };
189
+ s.auditLog.push({
190
+ timestamp: new Date().toISOString(),
191
+ action,
192
+ actor,
193
+ details: details ?? '',
194
+ });
157
195
  // Prune logs older than retention window
158
196
  const cutoff = Date.now() - config.auditLogRetentionDays * 86_400_000;
159
- s.auditLog = s.auditLog.filter(e => new Date(e.timestamp).getTime() > cutoff);
197
+ s.auditLog = s.auditLog.filter((e) => new Date(e.timestamp).getTime() > cutoff);
160
198
  break;
161
199
  }
162
200
  }
@@ -1,8 +1,23 @@
1
1
  /** @permit Trait — Building and business permit lifecycle management. @trait permit */
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
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';
4
+ export type PermitType =
5
+ | 'building'
6
+ | 'business'
7
+ | 'event'
8
+ | 'demolition'
9
+ | 'electrical'
10
+ | 'plumbing'
11
+ | 'sign'
12
+ | 'zoning';
13
+ export type PermitStatus =
14
+ | 'submitted'
15
+ | 'under_review'
16
+ | 'approved'
17
+ | 'denied'
18
+ | 'expired'
19
+ | 'revoked'
20
+ | 'withdrawn';
6
21
 
7
22
  export interface PermitConfig {
8
23
  permitType: PermitType;
@@ -10,7 +25,7 @@ export interface PermitConfig {
10
25
  applicantName: string;
11
26
  projectAddress: string;
12
27
  submittedAt: string; // ISO date
13
- expiresAt?: string; // ISO date — when approved permit expires
28
+ expiresAt?: string; // ISO date — when approved permit expires
14
29
  reviewDeadlineDays: number;
15
30
  showStatusBadge: boolean;
16
31
  }
@@ -74,7 +89,10 @@ export function createPermitHandler(): TraitHandler<PermitConfig> {
74
89
  const prev = node.__permitState as PermitState | undefined;
75
90
  const next = computeState(config, prev);
76
91
  if (next.isOverdue && !prev?.isOverdue) {
77
- ctx.emit?.('permit:overdue', { applicationNumber: config.applicationNumber, daysInReview: next.daysInReview });
92
+ ctx.emit?.('permit:overdue', {
93
+ applicationNumber: config.applicationNumber,
94
+ daysInReview: next.daysInReview,
95
+ });
78
96
  }
79
97
  if (next.isExpiringSoon && !prev?.isExpiringSoon) {
80
98
  ctx.emit?.('permit:expiring_soon', { applicationNumber: config.applicationNumber });
@@ -93,13 +111,20 @@ export function createPermitHandler(): TraitHandler<PermitConfig> {
93
111
  const prev = s.status;
94
112
  s.status = newStatus;
95
113
  s.lastUpdated = new Date().toISOString();
96
- ctx.emit?.('permit:status_changed', { from: prev, to: newStatus, applicationNumber: config.applicationNumber });
114
+ ctx.emit?.('permit:status_changed', {
115
+ from: prev,
116
+ to: newStatus,
117
+ applicationNumber: config.applicationNumber,
118
+ });
97
119
  }
98
120
  if (event.type === 'permit:add_note') {
99
121
  const note = event.payload?.note as string;
100
122
  if (note) {
101
123
  s.reviewNotes.push(note);
102
- ctx.emit?.('permit:note_added', { applicationNumber: config.applicationNumber, noteCount: s.reviewNotes.length });
124
+ ctx.emit?.('permit:note_added', {
125
+ applicationNumber: config.applicationNumber,
126
+ noteCount: s.reviewNotes.length,
127
+ });
103
128
  }
104
129
  }
105
130
  },
@@ -1,8 +1,21 @@
1
1
  /** @public_meeting Trait — City council and public hearing management. @trait public_meeting */
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
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';
4
+ export type MeetingType =
5
+ | 'city_council'
6
+ | 'planning_commission'
7
+ | 'public_hearing'
8
+ | 'zoning_board'
9
+ | 'budget_session'
10
+ | 'town_hall';
11
+ export type MeetingStatus =
12
+ | 'scheduled'
13
+ | 'in_session'
14
+ | 'recess'
15
+ | 'public_comment'
16
+ | 'voting'
17
+ | 'adjourned'
18
+ | 'cancelled';
6
19
 
7
20
  export interface AgendaItem {
8
21
  id: string;
@@ -80,7 +93,9 @@ export function createPublicMeetingHandler(): TraitHandler<PublicMeetingConfig>
80
93
  const quorumMet = config.membersPresent >= config.quorumRequired;
81
94
  if (quorumMet !== s.quorumMet) {
82
95
  s.quorumMet = quorumMet;
83
- ctx.emit?.(quorumMet ? 'meeting:quorum_met' : 'meeting:quorum_lost', { meetingId: config.meetingId });
96
+ ctx.emit?.(quorumMet ? 'meeting:quorum_met' : 'meeting:quorum_lost', {
97
+ meetingId: config.meetingId,
98
+ });
84
99
  }
85
100
  s.lastUpdated = new Date().toISOString();
86
101
  },
@@ -91,11 +106,16 @@ export function createPublicMeetingHandler(): TraitHandler<PublicMeetingConfig>
91
106
  case 'meeting:call_to_order':
92
107
  s.status = 'in_session';
93
108
  s.recordingActive = true;
94
- ctx.emit?.('meeting:called_to_order', { meetingId: config.meetingId, quorumMet: s.quorumMet });
109
+ ctx.emit?.('meeting:called_to_order', {
110
+ meetingId: config.meetingId,
111
+ quorumMet: s.quorumMet,
112
+ });
95
113
  break;
96
114
  case 'meeting:open_public_comment':
97
115
  s.status = 'public_comment';
98
- ctx.emit?.('meeting:public_comment_open', { speakersRegistered: s.publicSpeakersRegistered });
116
+ ctx.emit?.('meeting:public_comment_open', {
117
+ speakersRegistered: s.publicSpeakersRegistered,
118
+ });
99
119
  break;
100
120
  case 'meeting:register_speaker':
101
121
  s.publicSpeakersRegistered++;
@@ -103,19 +123,28 @@ export function createPublicMeetingHandler(): TraitHandler<PublicMeetingConfig>
103
123
  break;
104
124
  case 'meeting:next_speaker':
105
125
  s.publicSpeakersHeard++;
106
- ctx.emit?.('meeting:speaker_started', { heard: s.publicSpeakersHeard, remaining: s.publicSpeakersRegistered - s.publicSpeakersHeard });
126
+ ctx.emit?.('meeting:speaker_started', {
127
+ heard: s.publicSpeakersHeard,
128
+ remaining: s.publicSpeakersRegistered - s.publicSpeakersHeard,
129
+ });
107
130
  break;
108
131
  case 'meeting:next_agenda_item':
109
132
  if (s.currentAgendaItemIndex < config.agenda.length - 1) {
110
133
  s.currentAgendaItemIndex++;
111
134
  const item = config.agenda[s.currentAgendaItemIndex];
112
- ctx.emit?.('meeting:agenda_advanced', { index: s.currentAgendaItemIndex, item: item?.title });
135
+ ctx.emit?.('meeting:agenda_advanced', {
136
+ index: s.currentAgendaItemIndex,
137
+ item: item?.title,
138
+ });
113
139
  }
114
140
  break;
115
141
  case 'meeting:adjourn':
116
142
  s.status = 'adjourned';
117
143
  s.recordingActive = false;
118
- ctx.emit?.('meeting:adjourned', { meetingId: config.meetingId, minutesElapsed: Math.round(s.minutesElapsed) });
144
+ ctx.emit?.('meeting:adjourned', {
145
+ meetingId: config.meetingId,
146
+ minutesElapsed: Math.round(s.minutesElapsed),
147
+ });
119
148
  break;
120
149
  }
121
150
  },
@@ -1,8 +1,25 @@
1
1
  /** @service_request Trait — 311-style civic service request tracking. @trait service_request */
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
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';
4
+ export type ServiceCategory =
5
+ | 'pothole'
6
+ | 'streetlight'
7
+ | 'graffiti'
8
+ | 'abandoned_vehicle'
9
+ | 'code_violation'
10
+ | 'tree_hazard'
11
+ | 'water_main'
12
+ | 'sidewalk'
13
+ | 'noise_complaint'
14
+ | 'other';
15
+ export type RequestStatus =
16
+ | 'submitted'
17
+ | 'acknowledged'
18
+ | 'assigned'
19
+ | 'in_progress'
20
+ | 'resolved'
21
+ | 'closed'
22
+ | 'duplicate';
6
23
  export type PriorityLevel = 'low' | 'medium' | 'high' | 'urgent';
7
24
 
8
25
  export interface ServiceRequestConfig {
@@ -11,7 +28,7 @@ export interface ServiceRequestConfig {
11
28
  description: string;
12
29
  location: string;
13
30
  coordinates?: { lat: number; lng: number };
14
- submittedAt: string; // ISO datetime
31
+ submittedAt: string; // ISO datetime
15
32
  priority: PriorityLevel;
16
33
  targetResolutionDays: number; // SLA days by priority
17
34
  department: string;
@@ -100,7 +117,11 @@ export function createServiceRequestHandler(): TraitHandler<ServiceRequestConfig
100
117
  const prev = s.status;
101
118
  s.status = newStatus;
102
119
  s.lastStatusChange = new Date().toISOString();
103
- ctx.emit?.('sr:status_changed', { from: prev, to: newStatus, requestId: config.requestId });
120
+ ctx.emit?.('sr:status_changed', {
121
+ from: prev,
122
+ to: newStatus,
123
+ requestId: config.requestId,
124
+ });
104
125
  break;
105
126
  }
106
127
  case 'sr:add_update': {
@@ -108,7 +129,10 @@ export function createServiceRequestHandler(): TraitHandler<ServiceRequestConfig
108
129
  const author = (event.payload?.author as string) ?? 'staff';
109
130
  if (message) {
110
131
  s.updates.push({ timestamp: new Date().toISOString(), message, author });
111
- ctx.emit?.('sr:update_added', { requestId: config.requestId, updateCount: s.updates.length });
132
+ ctx.emit?.('sr:update_added', {
133
+ requestId: config.requestId,
134
+ updateCount: s.updates.length,
135
+ });
112
136
  }
113
137
  break;
114
138
  }
@@ -1,7 +1,12 @@
1
1
  /** @voting_record Trait — Public voting record and election results display. @trait voting_record */
2
2
  import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
3
3
 
4
- export type VoteType = 'council_vote' | 'ballot_measure' | 'election' | 'referendum' | 'committee_vote';
4
+ export type VoteType =
5
+ | 'council_vote'
6
+ | 'ballot_measure'
7
+ | 'election'
8
+ | 'referendum'
9
+ | 'committee_vote';
5
10
  export type VoteOutcome = 'passed' | 'failed' | 'tabled' | 'withdrawn' | 'tied' | 'pending';
6
11
 
7
12
  export interface VoteCast {
@@ -71,7 +76,9 @@ export function createVotingRecordHandler(): TraitHandler<VotingRecordConfig> {
71
76
  node.__votingState = {
72
77
  outcome: 'pending' as VoteOutcome,
73
78
  votes: [],
74
- ayeCount: 0, nayCount: 0, abstainCount: 0,
79
+ ayeCount: 0,
80
+ nayCount: 0,
81
+ abstainCount: 0,
75
82
  absentCount: config.eligibleVoters.length,
76
83
  totalEligible: config.eligibleVoters.length,
77
84
  participationRate: 0,
@@ -98,24 +105,35 @@ export function createVotingRecordHandler(): TraitHandler<VotingRecordConfig> {
98
105
  const cast = event.payload as unknown as VoteCast;
99
106
  if (!cast?.memberId || !cast?.vote) return;
100
107
  // Replace if already voted
101
- const existing = s.votes.findIndex(v => v.memberId === cast.memberId);
108
+ const existing = s.votes.findIndex((v) => v.memberId === cast.memberId);
102
109
  if (existing >= 0) s.votes.splice(existing, 1);
103
110
  s.votes.push({ ...cast, timestamp: new Date().toISOString() });
104
111
  // 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;
112
+ s.ayeCount = s.votes.filter((v) => v.vote === 'aye').length;
113
+ s.nayCount = s.votes.filter((v) => v.vote === 'nay').length;
114
+ s.abstainCount = s.votes.filter((v) => v.vote === 'abstain').length;
108
115
  s.absentCount = s.totalEligible - s.votes.length;
109
116
  s.participationRate = s.votes.length / (s.totalEligible || 1);
110
117
  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 });
118
+ ctx.emit?.('vote:cast', {
119
+ voteId: config.voteId,
120
+ memberId: cast.memberId,
121
+ vote: cast.vote,
122
+ ayeCount: s.ayeCount,
123
+ nayCount: s.nayCount,
124
+ });
112
125
  break;
113
126
  }
114
127
  case 'vote:close':
115
128
  s.isOpen = false;
116
129
  s.closedAt = new Date().toISOString();
117
130
  s.outcome = computeOutcome(s, config);
118
- ctx.emit?.('vote:closed', { voteId: config.voteId, outcome: s.outcome, ayeCount: s.ayeCount, nayCount: s.nayCount });
131
+ ctx.emit?.('vote:closed', {
132
+ voteId: config.voteId,
133
+ outcome: s.outcome,
134
+ ayeCount: s.ayeCount,
135
+ nayCount: s.nayCount,
136
+ });
119
137
  break;
120
138
  case 'vote:table':
121
139
  s.isOpen = false;
@@ -1,4 +1,25 @@
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; }
1
+ export interface HSPlusNode {
2
+ id?: string;
3
+ properties?: Record<string, unknown>;
4
+ [key: string]: unknown;
5
+ }
6
+ export interface TraitContext {
7
+ emit?: (event: string, payload?: unknown) => void;
8
+ getState?: () => Record<string, unknown>;
9
+ setState?: (updates: Record<string, unknown>) => void;
10
+ [key: string]: unknown;
11
+ }
12
+ export interface TraitEvent {
13
+ type: string;
14
+ source?: string;
15
+ payload?: Record<string, unknown>;
16
+ [key: string]: unknown;
17
+ }
18
+ export interface TraitHandler<TConfig = unknown> {
19
+ name: string;
20
+ defaultConfig: TConfig;
21
+ onAttach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void;
22
+ onDetach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void;
23
+ onUpdate(node: HSPlusNode, config: TConfig, ctx: TraitContext, delta: number): void;
24
+ onEvent(node: HSPlusNode, config: TConfig, ctx: TraitContext, event: TraitEvent): void;
25
+ }
package/tsconfig.json CHANGED
@@ -1 +1,10 @@
1
- { "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true, "declarationMap": true }, "include": ["src"] }
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "declaration": true,
7
+ "declarationMap": true
8
+ },
9
+ "include": ["src"]
10
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025-2026 HoloScript Contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.