@operato/twin-kernel 0.7.22 → 0.7.24

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.
@@ -1329,6 +1329,14 @@ export interface RecommendedAction {
1329
1329
  code: string;
1330
1330
  command?: string;
1331
1331
  args?: unknown;
1332
+ /**
1333
+ * 표현용 원시 파라미터 — 권고가 **무엇을 가리키는지** 말해야 할 때(예: 「어느 부하를 줄이나」).
1334
+ *
1335
+ * 실행 가능한 조치는 `args` 가 대상을 나른다(커맨드가 그것을 먹는다). 권고(command 없음)에는 그 자리가
1336
+ * 없어서, 예전에는 「부하를 줄이세요」까지만 말할 수 있었다 — 감축 가능 설비가 여럿이면 사람이 어느
1337
+ * 것인지 알 길이 없다. `Attention.params` 와 같은 규약이다(언어중립 원시값만, 문장은 표현계층이 만든다).
1338
+ */
1339
+ params?: Record<string, string | number>;
1332
1340
  }
1333
1341
  export interface StateSnapshot {
1334
1342
  revision: number;
@@ -1368,6 +1376,19 @@ export interface StateSnapshot {
1368
1376
  * 왕복시켜야 재기동·재계산에서 확인 상태가 유지된다.
1369
1377
  */
1370
1378
  acked?: string[];
1379
+ /**
1380
+ * 주목 신호가 **처음 성립한 시각**(id → ISO) — 확인과 마찬가지로 **계산으로 되살릴 수 없다.**
1381
+ *
1382
+ * 신호는 매 스냅샷 다시 계산되지만 「언제부터인가」는 그 계산 안에 없다. 왕복시키지 않으면 재기동·
1383
+ * 웜스타트 뒤 **세 시간째 지속된 조건이 「0초째」로 되살아난다** — 방금 찬 자리와 세 시간째 막힌
1384
+ * 자리는 할 일이 다르므로, 그 값이 거짓이면 화면은 사람을 잘못된 순서로 움직인다.
1385
+ *
1386
+ * 사라진 조건은 함께 지운다(재발은 새 시작이다) — 그 규율은 `computeAttentions` 가 지킨다.
1387
+ */
1388
+ attentionSince?: {
1389
+ id: string;
1390
+ since: ISOTime;
1391
+ }[];
1371
1392
  /**
1372
1393
  * 에너지 — **에너지 트윈만 채운다**(계량 지점·수요 구간·피크). 다른 종류에서는 없다.
1373
1394
  *
@@ -1,6 +1,6 @@
1
1
  import { FlowEngine } from './flow-engine.ts';
2
2
  import { type AllocationPolicy } from './allocation-policy.ts';
3
- import { type CanonicalEnvelope, type DemandWindowState, type StateSnapshot } from './contract.ts';
3
+ import { type CanonicalEnvelope, type DemandWindowState, type EnergyState, type StateSnapshot, type Attention, type StructureShift, type TwinModelDef } from './contract.ts';
4
4
  /** 수요 구간 — 요금의 알갱이다. 15분은 한국·다수 요금제의 최대수요 산정 단위다. */
5
5
  export declare const DEMAND_WINDOW_MS: number;
6
6
  /** 그 시각이 속한 구간의 시작 — 벽시계 경계(00·15·30·45분)에 맞춘다. */
@@ -63,6 +63,19 @@ export declare class EmsKernel extends FlowEngine {
63
63
  * 「계약 안쪽」이라는 더 위험한 거짓을 만든다).
64
64
  */
65
65
  private rootMeterIds;
66
+ /**
67
+ * 구조가 바뀌면 **열린 구간의 계약도 다시 읽는다.**
68
+ *
69
+ * ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
70
+ * 계약전력은 구간이 **열릴 때** 찍힌다(그 구간의 판정 기준이 도중에 바뀌지 않게 하려는 규율이다). 그런데
71
+ * 현장이 계약을 고쳐 선언하면(`declareTwinProperties` → 미러는 동작 중 구조 전환) 열린 구간은 **옛 한계로
72
+ * 계속 판정한다** — 실측으로 잡았다: 선언 800 kW, 열린 구간 3,500 kW. 미러는 계측이 멈추지 않으므로 최대
73
+ * 15분 동안 틀린 기준으로 초과를 판정하고, 그 사이의 경보는 있어야 할 것이 없거나 없어야 할 것이 있다.
74
+ *
75
+ * 구조 전환은 「현장이 달라졌다」는 선언이므로, 그 순간 기준을 다시 읽는 것이 맞다. 이미 지나간 구간의
76
+ * 판정은 건드리지 않는다(마감된 사실을 뒤늦게 고쳐 쓰지 않는다).
77
+ */
78
+ adoptStructure(def: TwinModelDef): StructureShift;
66
79
  /**
67
80
  * 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
68
81
  *
@@ -119,6 +132,25 @@ export declare class EmsKernel extends FlowEngine {
119
132
  * 우리는 끄지 않는다.
120
133
  */
121
134
  private judgeOpenWindow;
135
+ /**
136
+ * 주목 신호 — **계약을 넘길 것 같으면 사람이 보는 자리에 올린다.**
137
+ *
138
+ * ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
139
+ * 커널은 이미 초과 투영을 사실로 냈다(`energy.dr.suggested` — 실측으로 확인: 투영 705kW·계약 600kW·
140
+ * 부족 105kW). 그런데 그 사실이 **주의로 올라오지 않았다**: 저널에는 있고 화면에는 없었다. 요금은 15분
141
+ * 구간이 닫히면 확정되므로, 닫히기 전에 사람이 보지 못하면 그 경보는 아무 일도 하지 못한다.
142
+ *
143
+ * ── 왜 저널 사실과 따로 두나 ────────────────────────────────────────────────
144
+ * 저널의 `dr.suggested` 는 **한 번 일어난 일**이고(그 구간에 한 번만 낸다), 주의는 **지금 성립하는
145
+ * 조건**이다. 조건이 사라지면 주의는 스스로 사라져야 한다(상위 층이 그 수명을 관리한다 — 처음 성립한
146
+ * 시각을 기억하고, 조건이 없어지면 지운다). 둘을 합치면 「지나간 경보」와 「지금 위험」이 섞인다.
147
+ *
148
+ * 계약을 모르면 판정하지 않는다. 심각도는 **초과 폭**으로 가른다 — 5% 안쪽은 계량 오차와 구별하기
149
+ * 어려우므로 높게 부르지 않는다(경보가 소음이 되면 사람이 경보 자체를 읽지 않는다).
150
+ */
151
+ protected collectAttentions(): Attention[];
152
+ /** 계약을 선언한 수전 자리 — 주의가 가리킬 대상. 없으면 대상을 지어내지 않는다. */
153
+ private incomingLocationId;
122
154
  /** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
123
155
  private curtailableIds;
124
156
  private flowRequests;
@@ -176,5 +208,27 @@ export declare class EmsKernel extends FlowEngine {
176
208
  /** 글자로 선언된 값(발전 형상·통화 같은 것) — 비어 있으면 없는 것으로 본다. */
177
209
  private stringProperty;
178
210
  tick(dtMs: number): void;
211
+ /**
212
+ * 에너지의 연속성 — **SCADA 는 「이번 15분에 지금까지 얼마」를 모른다.**
213
+ *
214
+ * 계량기는 순간값과 적산값을 준다. 「이 구간의 최대」·「구간이 열릴 때의 적산」·「관측 이후 최대」는
215
+ * 우리가 접어 만든 값이라, 재기동하면 원천이 되풀어 주지 않는다. 잃으면 오류 없이 값이 작아진다 —
216
+ * 구간 전력량이 기준점을 새로 잡아 짧아지고, 그 구간의 피크가 부팅 이후로만 잡힌다(요금이 걸린 수다).
217
+ *
218
+ * 지난 마감 구간도 이어받는다: 이미 저널에 있는 사실이지만, 화면이 그것을 보려고 매번 저널을 접지
219
+ * 않게 한다(자른 사실은 `closedTotal` 이 그대로 나른다).
220
+ *
221
+ * 지금 열린 구간이 **이미 지나간 것**이어도 그대로 받는다 — 다음 표본이 오면 커널이 그 구간을 정상
222
+ * 경로로 마감한다(그 사실은 자기 시각을 달고 있어 늦게 나가도 참이다). 여기서 버리면 그 15분은
223
+ * 통째로 사라진다.
224
+ */
225
+ hydrateContinuity(snap: {
226
+ acked?: string[];
227
+ attentionSince?: {
228
+ id: string;
229
+ since: string;
230
+ }[];
231
+ energy?: EnergyState;
232
+ }): void;
179
233
  getSnapshot(): StateSnapshot;
180
234
  }
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { FlowEngine } from "./flow-engine.js";
29
29
  import { firstFitPolicy } from "./allocation-policy.js";
30
- import { ENERGY_EVENT, minuteOfDayAt } from "./contract.js";
30
+ import { CMD, ENERGY_EVENT, minuteOfDayAt } from "./contract.js";
31
31
  import { EMS_PROPERTY, electricalUpstreamOf, generationFractionAt } from "./ems-profile.js";
32
32
  /** 수요 구간 — 요금의 알갱이다. 15분은 한국·다수 요금제의 최대수요 산정 단위다. */
33
33
  export const DEMAND_WINDOW_MS = 15 * 60 * 1000;
@@ -170,6 +170,31 @@ export class EmsKernel extends FlowEngine {
170
170
  }
171
171
  return out;
172
172
  }
173
+ /**
174
+ * 구조가 바뀌면 **열린 구간의 계약도 다시 읽는다.**
175
+ *
176
+ * ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
177
+ * 계약전력은 구간이 **열릴 때** 찍힌다(그 구간의 판정 기준이 도중에 바뀌지 않게 하려는 규율이다). 그런데
178
+ * 현장이 계약을 고쳐 선언하면(`declareTwinProperties` → 미러는 동작 중 구조 전환) 열린 구간은 **옛 한계로
179
+ * 계속 판정한다** — 실측으로 잡았다: 선언 800 kW, 열린 구간 3,500 kW. 미러는 계측이 멈추지 않으므로 최대
180
+ * 15분 동안 틀린 기준으로 초과를 판정하고, 그 사이의 경보는 있어야 할 것이 없거나 없어야 할 것이 있다.
181
+ *
182
+ * 구조 전환은 「현장이 달라졌다」는 선언이므로, 그 순간 기준을 다시 읽는 것이 맞다. 이미 지나간 구간의
183
+ * 판정은 건드리지 않는다(마감된 사실을 뒤늦게 고쳐 쓰지 않는다).
184
+ */
185
+ adoptStructure(def) {
186
+ const shift = super.adoptStructure(def);
187
+ if (this.open) {
188
+ const contractKW = this.declaredContractKW();
189
+ if (contractKW === undefined)
190
+ delete this.open.contractKW;
191
+ else
192
+ this.open.contractKW = contractKW;
193
+ /* 새 기준으로 다시 판정할 수 있게 이 구간의 「이미 제안했다」 표식을 지운다. */
194
+ this.suggestedFor = undefined;
195
+ }
196
+ return shift;
197
+ }
173
198
  /**
174
199
  * 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
175
200
  *
@@ -463,6 +488,75 @@ export class EmsKernel extends FlowEngine {
463
488
  curtailableCandidates: this.curtailableIds()
464
489
  });
465
490
  }
491
+ /**
492
+ * 주목 신호 — **계약을 넘길 것 같으면 사람이 보는 자리에 올린다.**
493
+ *
494
+ * ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
495
+ * 커널은 이미 초과 투영을 사실로 냈다(`energy.dr.suggested` — 실측으로 확인: 투영 705kW·계약 600kW·
496
+ * 부족 105kW). 그런데 그 사실이 **주의로 올라오지 않았다**: 저널에는 있고 화면에는 없었다. 요금은 15분
497
+ * 구간이 닫히면 확정되므로, 닫히기 전에 사람이 보지 못하면 그 경보는 아무 일도 하지 못한다.
498
+ *
499
+ * ── 왜 저널 사실과 따로 두나 ────────────────────────────────────────────────
500
+ * 저널의 `dr.suggested` 는 **한 번 일어난 일**이고(그 구간에 한 번만 낸다), 주의는 **지금 성립하는
501
+ * 조건**이다. 조건이 사라지면 주의는 스스로 사라져야 한다(상위 층이 그 수명을 관리한다 — 처음 성립한
502
+ * 시각을 기억하고, 조건이 없어지면 지운다). 둘을 합치면 「지나간 경보」와 「지금 위험」이 섞인다.
503
+ *
504
+ * 계약을 모르면 판정하지 않는다. 심각도는 **초과 폭**으로 가른다 — 5% 안쪽은 계량 오차와 구별하기
505
+ * 어려우므로 높게 부르지 않는다(경보가 소음이 되면 사람이 경보 자체를 읽지 않는다).
506
+ */
507
+ collectAttentions() {
508
+ const out = super.collectAttentions();
509
+ const w = this.open;
510
+ if (!w || w.contractKW === undefined || w.meanKW === undefined)
511
+ return out;
512
+ /* 투영은 커널이 이미 쓰는 규칙과 같다(`mean-so-far`) — 두 벌이 되면 화면과 저널이 다른 말을 한다. */
513
+ const projected = w.meanKW;
514
+ if (projected <= w.contractKW)
515
+ return out;
516
+ const over = projected - w.contractKW;
517
+ const ratio = over / w.contractKW;
518
+ const candidates = this.curtailableIds();
519
+ out.push({
520
+ /* 구간마다 새 신호다 — 15분이 지나면 다른 요금 단위이고, 확인(ack)도 그 구간에만 걸려야 한다. */
521
+ id: `contract-projected-over:${w.startMs}`,
522
+ kind: 'contract-projected-over',
523
+ severity: ratio > 0.15 ? 'critical' : ratio > 0.05 ? 'high' : 'medium',
524
+ anchor: { locationId: this.incomingLocationId() },
525
+ /* 언어중립 원시값만 — 문장은 표현계층이 kind 로 골라 렌더한다(커널은 사람 말을 만들지 않는다). */
526
+ params: {
527
+ projectedKW: Math.round(projected),
528
+ contractKW: w.contractKW,
529
+ gapKW: Math.round(over),
530
+ windowEndMs: w.endMs,
531
+ curtailableCount: candidates.length
532
+ },
533
+ /*
534
+ * 조치는 **선언된 감축 가능 설비**에만 건다 — 커널이 무엇을 끌 수 있는지 짐작하지 않는다.
535
+ *
536
+ * ── 구동 방식이 조치의 성질을 바꾼다 ─────────────────────────────────────
537
+ * 시뮬레이션에서 감축은 **실행할 수 있는 조치**다: 설비를 세우면 그 설비의 부하가 실제로 빠지고
538
+ * 투영이 내려간다(what-if 의 값이 여기서 나온다). 미러에서는 그렇지 않다 — 진실은 계량기가 정하고
539
+ * 이 커널은 **제어하지 않는다**(설계 §1). 트윈 안에서 설비를 세워도 현장 전기는 그대로 흐르므로,
540
+ * 실행 버튼을 주면 화면이 「눌렀으니 내려갈 것」이라고 거짓말한다. 그래서 미러에서는 **권고**로만
541
+ * 낸다(command 없이 code 만 — 표현계층이 advice 로 렌더한다).
542
+ */
543
+ recommendedActions: candidates.map(id => this.observing
544
+ ? { code: 'advice.curtail-load', params: { resourceId: id } }
545
+ : { code: 'act.curtail-load', command: CMD.resourceHold, args: { resourceId: id } }),
546
+ ...(candidates.length && !this.observing
547
+ ? { suggestedAction: { code: 'act.curtail-load', command: CMD.resourceHold, args: { resourceId: candidates[0] } } }
548
+ : {})
549
+ });
550
+ return out;
551
+ }
552
+ /** 계약을 선언한 수전 자리 — 주의가 가리킬 대상. 없으면 대상을 지어내지 않는다. */
553
+ incomingLocationId() {
554
+ for (const loc of this.boardDef?.locations ?? []) {
555
+ if ((loc.properties ?? []).some(p => p.id === EMS_PROPERTY.contractKW))
556
+ return loc.id;
557
+ }
558
+ return undefined;
559
+ }
466
560
  /** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
467
561
  curtailableIds() {
468
562
  return (this.boardDef?.equipment ?? [])
@@ -781,6 +875,38 @@ export class EmsKernel extends FlowEngine {
781
875
  this.deriveLoad(at, dtMs);
782
876
  this.closeDue(at);
783
877
  }
878
+ /**
879
+ * 에너지의 연속성 — **SCADA 는 「이번 15분에 지금까지 얼마」를 모른다.**
880
+ *
881
+ * 계량기는 순간값과 적산값을 준다. 「이 구간의 최대」·「구간이 열릴 때의 적산」·「관측 이후 최대」는
882
+ * 우리가 접어 만든 값이라, 재기동하면 원천이 되풀어 주지 않는다. 잃으면 오류 없이 값이 작아진다 —
883
+ * 구간 전력량이 기준점을 새로 잡아 짧아지고, 그 구간의 피크가 부팅 이후로만 잡힌다(요금이 걸린 수다).
884
+ *
885
+ * 지난 마감 구간도 이어받는다: 이미 저널에 있는 사실이지만, 화면이 그것을 보려고 매번 저널을 접지
886
+ * 않게 한다(자른 사실은 `closedTotal` 이 그대로 나른다).
887
+ *
888
+ * 지금 열린 구간이 **이미 지나간 것**이어도 그대로 받는다 — 다음 표본이 오면 커널이 그 구간을 정상
889
+ * 경로로 마감한다(그 사실은 자기 시각을 달고 있어 늦게 나가도 참이다). 여기서 버리면 그 15분은
890
+ * 통째로 사라진다.
891
+ */
892
+ hydrateContinuity(snap) {
893
+ super.hydrateContinuity(snap);
894
+ const e = snap?.energy;
895
+ if (!e)
896
+ return;
897
+ for (const p of e.points ?? [])
898
+ if (p?.id)
899
+ this.points.set(p.id, { ...p });
900
+ if (e.open)
901
+ this.open = { ...e.open };
902
+ if (e.closed?.length)
903
+ this.closed = e.closed.map(w => ({ ...w })).slice(-KEEP_CLOSED);
904
+ /* 자른 수는 이어받은 목록으로 다시 세지 않는다 — 그러면 잘려 나간 과거가 조용히 0 이 된다. */
905
+ if (Number.isFinite(e.closedTotal))
906
+ this.closedTotal = Number(e.closedTotal);
907
+ if (e.peakSince && Number.isFinite(e.peakSince.kW))
908
+ this.peak = { kW: e.peakSince.kW, windowStartMs: e.peakSince.windowStartMs };
909
+ }
784
910
  getSnapshot() {
785
911
  const snap = super.getSnapshot();
786
912
  const contractKW = this.declaredContractKW();
@@ -407,6 +407,29 @@ export declare abstract class FlowEngine implements TwinKernel {
407
407
  * 것이고, 그것을 허용하면 이 함수가 막으려던 일이 이 함수를 통해 일어난다.
408
408
  */
409
409
  resumeRevision(from: number): void;
410
+ /**
411
+ * **원천이 되풀어 주지 않는 것만 잇는다** — 미러(관측 구동) 재기동의 연속성.
412
+ *
413
+ * ── 왜 따로 두나 (2026-08-18) ──────────────────────────────────────────────
414
+ * 미러의 진실은 원천이다. 그래서 재고·위치·설비 상태는 스냅샷에서 심지 않는다 — 다음 계측·다음
415
+ * 마스터 재읽기가 곧 정정하므로, 심으면 **떠난 물건이 되살아나는** 위험만 남는다.
416
+ *
417
+ * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 접어 만든 것들이다:
418
+ * · 확인(ack)·조건이 처음 성립한 시각 — 사람이 한 일과 우리가 기억한 시각
419
+ * · 열린 구간의 누적·적산 기준점·관측 이후 최대 — SCADA 는 「이번 15분에 지금까지 얼마」를 모른다
420
+ *
421
+ * 이것을 잃으면 오류 없이 값이 작아진다(실측: 재기동 뒤 그 구간의 전력량·피크가 작게 나오고, 세
422
+ * 시간째 지속된 경보가 「0초째」가 된다). 그래서 축을 갈라, 되풀어 주지 않는 것만 이어받는다.
423
+ *
424
+ * 도메인 축(에너지 등)은 서브클래스가 override 로 덧붙인다(`super.hydrateContinuity(snap)` 합성).
425
+ */
426
+ hydrateContinuity(snap: {
427
+ acked?: string[];
428
+ attentionSince?: {
429
+ id: string;
430
+ since: string;
431
+ }[];
432
+ }): void;
410
433
  hydrateObserved(snap: {
411
434
  locations: LocationState[];
412
435
  items: ItemState[];
@@ -415,8 +438,13 @@ export declare abstract class FlowEngine implements TwinKernel {
415
438
  assets?: AssetState[];
416
439
  tasks?: TaskState[];
417
440
  orders?: OrderState[];
418
- /** 확인해 둔 주목 신호 id — 계산으로 되살릴 수 없는 유일한 축이라 스냅샷에서 이어받는다. */
441
+ /** 확인해 둔 주목 신호 id — 계산으로 되살릴 수 없어 스냅샷에서 이어받는다. */
419
442
  acked?: string[];
443
+ /** 주목 신호가 처음 성립한 시각 — 이어받지 않으면 지속된 조건이 「방금」으로 되살아난다. */
444
+ attentionSince?: {
445
+ id: string;
446
+ since: string;
447
+ }[];
420
448
  }, orders?: OrderStatusDelta[]): void;
421
449
  /**
422
450
  * what-if 구성 변주 — **선언을 덮어쓴다**(fork 대상). 바꿨으면 true.
@@ -462,6 +490,7 @@ export declare abstract class FlowEngine implements TwinKernel {
462
490
  tick(dtMs: number): void;
463
491
  getSnapshot(): StateSnapshot;
464
492
  protected computeAttentions(): Attention[];
493
+ protected collectAttentions(): Attention[];
465
494
  /**
466
495
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
467
496
  * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
@@ -465,11 +465,39 @@ export class FlowEngine {
465
465
  throw new Error(`리비전을 뒤로 되돌릴 수 없다: ${this.revision} → ${from} (겹치는 번호가 생긴다)`);
466
466
  this.revision = from;
467
467
  }
468
+ /**
469
+ * **원천이 되풀어 주지 않는 것만 잇는다** — 미러(관측 구동) 재기동의 연속성.
470
+ *
471
+ * ── 왜 따로 두나 (2026-08-18) ──────────────────────────────────────────────
472
+ * 미러의 진실은 원천이다. 그래서 재고·위치·설비 상태는 스냅샷에서 심지 않는다 — 다음 계측·다음
473
+ * 마스터 재읽기가 곧 정정하므로, 심으면 **떠난 물건이 되살아나는** 위험만 남는다.
474
+ *
475
+ * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 접어 만든 것들이다:
476
+ * · 확인(ack)·조건이 처음 성립한 시각 — 사람이 한 일과 우리가 기억한 시각
477
+ * · 열린 구간의 누적·적산 기준점·관측 이후 최대 — SCADA 는 「이번 15분에 지금까지 얼마」를 모른다
478
+ *
479
+ * 이것을 잃으면 오류 없이 값이 작아진다(실측: 재기동 뒤 그 구간의 전력량·피크가 작게 나오고, 세
480
+ * 시간째 지속된 경보가 「0초째」가 된다). 그래서 축을 갈라, 되풀어 주지 않는 것만 이어받는다.
481
+ *
482
+ * 도메인 축(에너지 등)은 서브클래스가 override 로 덧붙인다(`super.hydrateContinuity(snap)` 합성).
483
+ */
484
+ hydrateContinuity(snap) {
485
+ for (const id of snap?.acked ?? [])
486
+ this._acked.add(id);
487
+ for (const e of snap?.attentionSince ?? [])
488
+ if (e?.id && e.since)
489
+ this._attentionSince.set(e.id, e.since);
490
+ }
468
491
  hydrateObserved(snap, orders = []) {
469
492
  /* 확인 처리를 먼저 이어받는다 — 아래에서 상태를 주입하면 곧바로 주목 신호가 계산되므로, 늦게
470
493
  * 이어받으면 그 한 번은 확인 안 된 것으로 계산된다(화면이 잠깐 빨개진다). */
471
494
  for (const id of snap.acked ?? [])
472
495
  this._acked.add(id);
496
+ /* 「언제부터인가」도 함께 이어받는다 — 상태를 심으면 곧바로 신호가 계산되므로, 늦게 받으면 그 한 번은
497
+ 모두 방금 생긴 것으로 찍히고 그 값이 그대로 굳는다(다음 계산은 이미 기억된 시각을 쓴다). */
498
+ for (const e of snap.attentionSince ?? [])
499
+ if (e?.id && e.since)
500
+ this._attentionSince.set(e.id, e.since);
473
501
  /* **관측된 것을 버리지 않는다.** 예전에는 자리·설비 상태를 'idle' 로, OEE 누적을 0 으로 덮고
474
502
  * 물품의 로트·단위·소속·마스터데이터를 떨어뜨렸다. 씨앗이 잃은 것은 **예측도 모른다** —
475
503
  * 고장 난 설비를 정상으로, 진행 중인 일을 없는 것으로 놓고 미래를 실행하면 답이 낙관 쪽으로 치우친다. */
@@ -748,7 +776,7 @@ export class FlowEngine {
748
776
  */
749
777
  abandonedTasks = 0;
750
778
  /** 주목 신호가 **처음 성립한 시각**(id → ISO). 조건이 사라지면 지운다 — 재발은 새 시작이다. */
751
- _attentionSince = new Map(); // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
779
+ _attentionSince = new Map();
752
780
  dispatch(cmd) {
753
781
  /*
754
782
  * 이 커맨드가 낳는 이벤트에 상관값을 단다. 명시값이 없으면 **commandId 를 쓴다** — 호출자가
@@ -1022,24 +1050,22 @@ export class FlowEngine {
1022
1050
  })),
1023
1051
  attentions: this.computeAttentions(),
1024
1052
  /* 확인해 둔 신호 — 스냅샷으로 왕복해야 재기동 후에도 확인 상태가 유지된다. */
1025
- acked: [...this._acked]
1053
+ acked: [...this._acked],
1054
+ /* 언제부터인가 — 이것도 계산으로 되살릴 수 없다. 위 계산이 사라진 조건을 이미 지웠으므로 지금 남은 것이 곧 진실이다. */
1055
+ attentionSince: [...this._attentionSince].map(([id, since]) => ({ id, since }))
1026
1056
  };
1027
1057
  }
1028
1058
  /*
1029
- * 주목 신호 판단 상태(자리·설비·오더)에서 도메인 조건을 평가해 Attention 방출.
1030
- * severity=ISA-18.2 우선순위 계열, kind=도메인 라벨. UI 는 판단 안 함(임계값 여기 소유).
1031
- * 도메인별 추가 판단은 서브클래스가 override 확장(super.computeAttentions() 합성).
1059
+ * 주목 신호 — **수명 관리는 여기 한 곳**이다(확장하지 것, 확장점은 `collectAttentions`).
1060
+ *
1061
+ * 확인(ack)·처음 성립한 시각·사라진 조건 프루닝은 신호를 **누가 냈든** 같아야 한다. 예전에는 판단과
1062
+ * 수명이 이 메서드 하나에 섞여 있어서, 서브클래스가 `super.computeAttentions()` 뒤에 신호를 덧붙이면
1063
+ * 그 신호만 **timeStamp 도 ack 도 못 받았다**(화면은 「몇 분째」를 쓸 수 없고, 확인 버튼이 듣지 않는다).
1064
+ * 그래서 판단(`collectAttentions`)과 수명(여기)을 갈랐다.
1032
1065
  */
1033
1066
  computeAttentions() {
1034
- // 계산 층은 순수 함수 deriveAttentions 로 위임 — sim(여기)과 live projector 미러가 공유(face2-inbound-live §1.1).
1067
+ const out = this.collectAttentions();
1035
1068
  const now = this.now();
1036
- const out = deriveAttentions({
1037
- equipment: [...this.equipment.values()],
1038
- locations: [...this.locations.values()],
1039
- orders: [...this.orders.values()],
1040
- tasks: [...this.tasks.values()]
1041
- }, this._acked, now // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
1042
- );
1043
1069
  const present = new Set(out.map(a => a.id));
1044
1070
  /*
1045
1071
  * **조건이 처음 성립한 시각**을 기억해 붙인다(표준 `WorkAlert.TimeStamp`).
@@ -1052,6 +1078,9 @@ export class FlowEngine {
1052
1078
  const first = this._attentionSince.get(a.id) ?? now;
1053
1079
  this._attentionSince.set(a.id, first);
1054
1080
  a.timeStamp = first;
1081
+ /* 확인 상태도 신호를 낸 주체와 무관하게 적용한다 — 순수 함수가 낸 것만 확인되면 반쪽이다. */
1082
+ if (this._acked.has(a.id))
1083
+ a.state = 'acknowledged';
1055
1084
  }
1056
1085
  // 확인(ack)·시작 시각 프루닝 — 사라진 조건은 함께 지운다(재발하면 그때가 새 시작이다).
1057
1086
  for (const id of [...this._acked])
@@ -1062,6 +1091,24 @@ export class FlowEngine {
1062
1091
  this._attentionSince.delete(id);
1063
1092
  return out;
1064
1093
  }
1094
+ /*
1095
+ * 주목 신호 판단 — 상태(자리·설비·오더)에서 도메인 조건을 평가해 Attention 방출.
1096
+ * severity=ISA-18.2 우선순위 계열, kind=도메인 라벨. UI 는 판단 안 함(임계값 여기 소유).
1097
+ * **도메인별 추가 판단은 이것을 override 로 확장한다**(`super.collectAttentions()` 합성) — 수명은
1098
+ * 상위 `computeAttentions` 가 붙여 준다.
1099
+ */
1100
+ collectAttentions() {
1101
+ // 계산 층은 순수 함수 deriveAttentions 로 위임 — sim(여기)과 live projector 미러가 공유(face2-inbound-live §1.1).
1102
+ const now = this.now();
1103
+ const out = deriveAttentions({
1104
+ equipment: [...this.equipment.values()],
1105
+ locations: [...this.locations.values()],
1106
+ orders: [...this.orders.values()],
1107
+ tasks: [...this.tasks.values()]
1108
+ }, this._acked, now // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
1109
+ );
1110
+ return out;
1111
+ }
1065
1112
  /**
1066
1113
  * fork — 현재 상태를 정확히 복제한 새 엔진 (디지털트윈 본연: "현재로부터 예측").
1067
1114
  * 원본(live/sim)은 계속 진행, fork 는 what-if 를 앞으로 시뮬레이션해 forecast·발산(predicted vs actual) 검사에 쓴다.
@@ -3254,8 +3254,29 @@ var FlowEngine = class {
3254
3254
  if (from < this.revision) throw new Error(`\uB9AC\uBE44\uC804\uC744 \uB4A4\uB85C \uB418\uB3CC\uB9B4 \uC218 \uC5C6\uB2E4: ${this.revision} \u2192 ${from} (\uACB9\uCE58\uB294 \uBC88\uD638\uAC00 \uC0DD\uAE34\uB2E4)`);
3255
3255
  this.revision = from;
3256
3256
  }
3257
+ /**
3258
+ * **원천이 되풀어 주지 않는 것만 잇는다** — 미러(관측 구동) 재기동의 연속성.
3259
+ *
3260
+ * ── 왜 따로 두나 (2026-08-18) ──────────────────────────────────────────────
3261
+ * 미러의 진실은 원천이다. 그래서 재고·위치·설비 상태는 스냅샷에서 심지 않는다 — 다음 계측·다음
3262
+ * 마스터 재읽기가 곧 정정하므로, 심으면 **떠난 물건이 되살아나는** 위험만 남는다.
3263
+ *
3264
+ * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 접어 만든 것들이다:
3265
+ * · 확인(ack)·조건이 처음 성립한 시각 — 사람이 한 일과 우리가 기억한 시각
3266
+ * · 열린 구간의 누적·적산 기준점·관측 이후 최대 — SCADA 는 「이번 15분에 지금까지 얼마」를 모른다
3267
+ *
3268
+ * 이것을 잃으면 오류 없이 값이 작아진다(실측: 재기동 뒤 그 구간의 전력량·피크가 작게 나오고, 세
3269
+ * 시간째 지속된 경보가 「0초째」가 된다). 그래서 축을 갈라, 되풀어 주지 않는 것만 이어받는다.
3270
+ *
3271
+ * 도메인 축(에너지 등)은 서브클래스가 override 로 덧붙인다(`super.hydrateContinuity(snap)` 합성).
3272
+ */
3273
+ hydrateContinuity(snap) {
3274
+ for (const id of snap?.acked ?? []) this._acked.add(id);
3275
+ for (const e of snap?.attentionSince ?? []) if (e?.id && e.since) this._attentionSince.set(e.id, e.since);
3276
+ }
3257
3277
  hydrateObserved(snap, orders = []) {
3258
3278
  for (const id of snap.acked ?? []) this._acked.add(id);
3279
+ for (const e of snap.attentionSince ?? []) if (e?.id && e.since) this._attentionSince.set(e.id, e.since);
3259
3280
  for (const n of snap.locations) {
3260
3281
  this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity ?? 0, parallelism: n.parallelism, occupancy: n.occupancy ?? 0, status: n.status ?? "idle", parentId: n.parentId });
3261
3282
  }
@@ -3515,7 +3536,6 @@ var FlowEngine = class {
3515
3536
  abandonedTasks = 0;
3516
3537
  /** 주목 신호가 **처음 성립한 시각**(id → ISO). 조건이 사라지면 지운다 — 재발은 새 시작이다. */
3517
3538
  _attentionSince = /* @__PURE__ */ new Map();
3518
- // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
3519
3539
  dispatch(cmd) {
3520
3540
  this._correlationId = cmd.correlationId ?? cmd.commandId;
3521
3541
  try {
@@ -3758,15 +3778,40 @@ var FlowEngine = class {
3758
3778
  })),
3759
3779
  attentions: this.computeAttentions(),
3760
3780
  /* 확인해 둔 신호 — 스냅샷으로 왕복해야 재기동 후에도 확인 상태가 유지된다. */
3761
- acked: [...this._acked]
3781
+ acked: [...this._acked],
3782
+ /* 언제부터인가 — 이것도 계산으로 되살릴 수 없다. 위 계산이 사라진 조건을 이미 지웠으므로 지금 남은 것이 곧 진실이다. */
3783
+ attentionSince: [...this._attentionSince].map(([id, since]) => ({ id, since }))
3762
3784
  };
3763
3785
  }
3786
+ /*
3787
+ * 주목 신호 — **수명 관리는 여기 한 곳**이다(확장하지 말 것, 확장점은 `collectAttentions`).
3788
+ *
3789
+ * 확인(ack)·처음 성립한 시각·사라진 조건 프루닝은 신호를 **누가 냈든** 같아야 한다. 예전에는 판단과
3790
+ * 수명이 이 메서드 하나에 섞여 있어서, 서브클래스가 `super.computeAttentions()` 뒤에 신호를 덧붙이면
3791
+ * 그 신호만 **timeStamp 도 ack 도 못 받았다**(화면은 「몇 분째」를 쓸 수 없고, 확인 버튼이 듣지 않는다).
3792
+ * 그래서 판단(`collectAttentions`)과 수명(여기)을 갈랐다.
3793
+ */
3794
+ computeAttentions() {
3795
+ const out = this.collectAttentions();
3796
+ const now = this.now();
3797
+ const present = new Set(out.map((a) => a.id));
3798
+ for (const a of out) {
3799
+ const first = this._attentionSince.get(a.id) ?? now;
3800
+ this._attentionSince.set(a.id, first);
3801
+ a.timeStamp = first;
3802
+ if (this._acked.has(a.id)) a.state = "acknowledged";
3803
+ }
3804
+ for (const id of [...this._acked]) if (!present.has(id)) this._acked.delete(id);
3805
+ for (const id of [...this._attentionSince.keys()]) if (!present.has(id)) this._attentionSince.delete(id);
3806
+ return out;
3807
+ }
3764
3808
  /*
3765
3809
  * 주목 신호 판단 — 상태(자리·설비·오더)에서 도메인 조건을 평가해 Attention 방출.
3766
3810
  * severity=ISA-18.2 우선순위 계열, kind=도메인 라벨. UI 는 판단 안 함(임계값 여기 소유).
3767
- * 도메인별 추가 판단은 서브클래스가 override 로 확장(super.computeAttentions() 합성).
3811
+ * **도메인별 추가 판단은 이것을 override 로 확장한다**(`super.collectAttentions()` 합성) — 수명은
3812
+ * 상위 `computeAttentions` 가 붙여 준다.
3768
3813
  */
3769
- computeAttentions() {
3814
+ collectAttentions() {
3770
3815
  const now = this.now();
3771
3816
  const out = deriveAttentions(
3772
3817
  {
@@ -3779,14 +3824,6 @@ var FlowEngine = class {
3779
3824
  now
3780
3825
  // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
3781
3826
  );
3782
- const present = new Set(out.map((a) => a.id));
3783
- for (const a of out) {
3784
- const first = this._attentionSince.get(a.id) ?? now;
3785
- this._attentionSince.set(a.id, first);
3786
- a.timeStamp = first;
3787
- }
3788
- for (const id of [...this._acked]) if (!present.has(id)) this._acked.delete(id);
3789
- for (const id of [...this._attentionSince.keys()]) if (!present.has(id)) this._attentionSince.delete(id);
3790
3827
  return out;
3791
3828
  }
3792
3829
  /**
@@ -6129,6 +6166,28 @@ var EmsKernel = class extends FlowEngine {
6129
6166
  }
6130
6167
  return out;
6131
6168
  }
6169
+ /**
6170
+ * 구조가 바뀌면 **열린 구간의 계약도 다시 읽는다.**
6171
+ *
6172
+ * ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
6173
+ * 계약전력은 구간이 **열릴 때** 찍힌다(그 구간의 판정 기준이 도중에 바뀌지 않게 하려는 규율이다). 그런데
6174
+ * 현장이 계약을 고쳐 선언하면(`declareTwinProperties` → 미러는 동작 중 구조 전환) 열린 구간은 **옛 한계로
6175
+ * 계속 판정한다** — 실측으로 잡았다: 선언 800 kW, 열린 구간 3,500 kW. 미러는 계측이 멈추지 않으므로 최대
6176
+ * 15분 동안 틀린 기준으로 초과를 판정하고, 그 사이의 경보는 있어야 할 것이 없거나 없어야 할 것이 있다.
6177
+ *
6178
+ * 구조 전환은 「현장이 달라졌다」는 선언이므로, 그 순간 기준을 다시 읽는 것이 맞다. 이미 지나간 구간의
6179
+ * 판정은 건드리지 않는다(마감된 사실을 뒤늦게 고쳐 쓰지 않는다).
6180
+ */
6181
+ adoptStructure(def) {
6182
+ const shift = super.adoptStructure(def);
6183
+ if (this.open) {
6184
+ const contractKW = this.declaredContractKW();
6185
+ if (contractKW === void 0) delete this.open.contractKW;
6186
+ else this.open.contractKW = contractKW;
6187
+ this.suggestedFor = void 0;
6188
+ }
6189
+ return shift;
6190
+ }
6132
6191
  /**
6133
6192
  * 계측 표본을 받는다 — 에너지 사건만 가로채고 나머지는 그대로 상위에 넘긴다.
6134
6193
  *
@@ -6355,6 +6414,69 @@ var EmsKernel = class extends FlowEngine {
6355
6414
  curtailableCandidates: this.curtailableIds()
6356
6415
  });
6357
6416
  }
6417
+ /**
6418
+ * 주목 신호 — **계약을 넘길 것 같으면 사람이 보는 자리에 올린다.**
6419
+ *
6420
+ * ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────────
6421
+ * 커널은 이미 초과 투영을 사실로 냈다(`energy.dr.suggested` — 실측으로 확인: 투영 705kW·계약 600kW·
6422
+ * 부족 105kW). 그런데 그 사실이 **주의로 올라오지 않았다**: 저널에는 있고 화면에는 없었다. 요금은 15분
6423
+ * 구간이 닫히면 확정되므로, 닫히기 전에 사람이 보지 못하면 그 경보는 아무 일도 하지 못한다.
6424
+ *
6425
+ * ── 왜 저널 사실과 따로 두나 ────────────────────────────────────────────────
6426
+ * 저널의 `dr.suggested` 는 **한 번 일어난 일**이고(그 구간에 한 번만 낸다), 주의는 **지금 성립하는
6427
+ * 조건**이다. 조건이 사라지면 주의는 스스로 사라져야 한다(상위 층이 그 수명을 관리한다 — 처음 성립한
6428
+ * 시각을 기억하고, 조건이 없어지면 지운다). 둘을 합치면 「지나간 경보」와 「지금 위험」이 섞인다.
6429
+ *
6430
+ * 계약을 모르면 판정하지 않는다. 심각도는 **초과 폭**으로 가른다 — 5% 안쪽은 계량 오차와 구별하기
6431
+ * 어려우므로 높게 부르지 않는다(경보가 소음이 되면 사람이 경보 자체를 읽지 않는다).
6432
+ */
6433
+ collectAttentions() {
6434
+ const out = super.collectAttentions();
6435
+ const w = this.open;
6436
+ if (!w || w.contractKW === void 0 || w.meanKW === void 0) return out;
6437
+ const projected = w.meanKW;
6438
+ if (projected <= w.contractKW) return out;
6439
+ const over = projected - w.contractKW;
6440
+ const ratio = over / w.contractKW;
6441
+ const candidates = this.curtailableIds();
6442
+ out.push({
6443
+ /* 구간마다 새 신호다 — 15분이 지나면 다른 요금 단위이고, 확인(ack)도 그 구간에만 걸려야 한다. */
6444
+ id: `contract-projected-over:${w.startMs}`,
6445
+ kind: "contract-projected-over",
6446
+ severity: ratio > 0.15 ? "critical" : ratio > 0.05 ? "high" : "medium",
6447
+ anchor: { locationId: this.incomingLocationId() },
6448
+ /* 언어중립 원시값만 — 문장은 표현계층이 kind 로 골라 렌더한다(커널은 사람 말을 만들지 않는다). */
6449
+ params: {
6450
+ projectedKW: Math.round(projected),
6451
+ contractKW: w.contractKW,
6452
+ gapKW: Math.round(over),
6453
+ windowEndMs: w.endMs,
6454
+ curtailableCount: candidates.length
6455
+ },
6456
+ /*
6457
+ * 조치는 **선언된 감축 가능 설비**에만 건다 — 커널이 무엇을 끌 수 있는지 짐작하지 않는다.
6458
+ *
6459
+ * ── 구동 방식이 조치의 성질을 바꾼다 ─────────────────────────────────────
6460
+ * 시뮬레이션에서 감축은 **실행할 수 있는 조치**다: 설비를 세우면 그 설비의 부하가 실제로 빠지고
6461
+ * 투영이 내려간다(what-if 의 값이 여기서 나온다). 미러에서는 그렇지 않다 — 진실은 계량기가 정하고
6462
+ * 이 커널은 **제어하지 않는다**(설계 §1). 트윈 안에서 설비를 세워도 현장 전기는 그대로 흐르므로,
6463
+ * 실행 버튼을 주면 화면이 「눌렀으니 내려갈 것」이라고 거짓말한다. 그래서 미러에서는 **권고**로만
6464
+ * 낸다(command 없이 code 만 — 표현계층이 advice 로 렌더한다).
6465
+ */
6466
+ recommendedActions: candidates.map(
6467
+ (id) => this.observing ? { code: "advice.curtail-load", params: { resourceId: id } } : { code: "act.curtail-load", command: CMD.resourceHold, args: { resourceId: id } }
6468
+ ),
6469
+ ...candidates.length && !this.observing ? { suggestedAction: { code: "act.curtail-load", command: CMD.resourceHold, args: { resourceId: candidates[0] } } } : {}
6470
+ });
6471
+ return out;
6472
+ }
6473
+ /** 계약을 선언한 수전 자리 — 주의가 가리킬 대상. 없으면 대상을 지어내지 않는다. */
6474
+ incomingLocationId() {
6475
+ for (const loc of this.boardDef?.locations ?? []) {
6476
+ if ((loc.properties ?? []).some((p) => p.id === EMS_PROPERTY.contractKW)) return loc.id;
6477
+ }
6478
+ return void 0;
6479
+ }
6358
6480
  /** 감축 가능으로 **선언된** 설비 — 커널이 능력을 짐작하지 않는다(타입이 선언한다). */
6359
6481
  curtailableIds() {
6360
6482
  return (this.boardDef?.equipment ?? []).filter((e) => String(e.kind ?? "") === "curtailable-load").map((e) => e.id);
@@ -6576,6 +6698,30 @@ var EmsKernel = class extends FlowEngine {
6576
6698
  this.deriveLoad(at, dtMs);
6577
6699
  this.closeDue(at);
6578
6700
  }
6701
+ /**
6702
+ * 에너지의 연속성 — **SCADA 는 「이번 15분에 지금까지 얼마」를 모른다.**
6703
+ *
6704
+ * 계량기는 순간값과 적산값을 준다. 「이 구간의 최대」·「구간이 열릴 때의 적산」·「관측 이후 최대」는
6705
+ * 우리가 접어 만든 값이라, 재기동하면 원천이 되풀어 주지 않는다. 잃으면 오류 없이 값이 작아진다 —
6706
+ * 구간 전력량이 기준점을 새로 잡아 짧아지고, 그 구간의 피크가 부팅 이후로만 잡힌다(요금이 걸린 수다).
6707
+ *
6708
+ * 지난 마감 구간도 이어받는다: 이미 저널에 있는 사실이지만, 화면이 그것을 보려고 매번 저널을 접지
6709
+ * 않게 한다(자른 사실은 `closedTotal` 이 그대로 나른다).
6710
+ *
6711
+ * 지금 열린 구간이 **이미 지나간 것**이어도 그대로 받는다 — 다음 표본이 오면 커널이 그 구간을 정상
6712
+ * 경로로 마감한다(그 사실은 자기 시각을 달고 있어 늦게 나가도 참이다). 여기서 버리면 그 15분은
6713
+ * 통째로 사라진다.
6714
+ */
6715
+ hydrateContinuity(snap) {
6716
+ super.hydrateContinuity(snap);
6717
+ const e = snap?.energy;
6718
+ if (!e) return;
6719
+ for (const p of e.points ?? []) if (p?.id) this.points.set(p.id, { ...p });
6720
+ if (e.open) this.open = { ...e.open };
6721
+ if (e.closed?.length) this.closed = e.closed.map((w) => ({ ...w })).slice(-KEEP_CLOSED);
6722
+ if (Number.isFinite(e.closedTotal)) this.closedTotal = Number(e.closedTotal);
6723
+ if (e.peakSince && Number.isFinite(e.peakSince.kW)) this.peak = { kW: e.peakSince.kW, windowStartMs: e.peakSince.windowStartMs };
6724
+ }
6579
6725
  getSnapshot() {
6580
6726
  const snap = super.getSnapshot();
6581
6727
  const contractKW = this.declaredContractKW();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.22",
3
+ "version": "0.7.24",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {