@operato/twin-kernel 0.7.50 → 0.7.52

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.
@@ -7,7 +7,60 @@ export interface CanonicalEnvelope<T = unknown> {
7
7
  correlationId?: string;
8
8
  data: T;
9
9
  }
10
+ /**
11
+ * 작업의 상태 — **ISA-95 근거.** 1차 출처: `JobOrderType.DispatchStatus`(지시 쪽) +
12
+ * `JobResponseType.JobState`(실적 쪽). 대조 기록: `design/plans/isa95-coverage.md` §3-2
13
+ * (「`tasks.status` ↔ `DispatchStatus`+`JobState`」).
14
+ *
15
+ * ── 왜 우리 이름을 쓰나 ─────────────────────────────────────────────────────
16
+ * 표준은 **지시(`JobOrder`)와 실적(`JobResponse`)을 나눈다.** 우리 `TaskState` 는 그 둘을 한 몸에
17
+ * 든다 — 상태 전이와 배정 결과를 함께 들고 진행·완료까지 같은 개체가 표현한다. 그래서 `jobOrders`
18
+ * 로 부르면 **표준 이름을 쓰면서 다른 뜻으로 쓰는 것**이 된다. 대조 문서가 그 판정을 적어 두었다.
19
+ *
20
+ * ── 왜 **닫혀** 있나 ────────────────────────────────────────────────────────
21
+ * 커널이 이 넷으로 접는다: 성과 폴드가 착수·완료로 갈리고(`foldTaskRecords`), 주목 계산이
22
+ * `created`·`assigned` 를 대기로 센다, 배정 루프는 `created` 만 집는다. 그러니 낱말이 하나 늘면
23
+ * 그 셋이 조용히 달라진다 — 유입 문이 이 넷만 받는 이유다(다른 낱말은 오류 없이 사라진다).
24
+ */
10
25
  export type TaskStatus = 'created' | 'assigned' | 'in-progress' | 'completed';
26
+ /** 오더가 **종결 상태**로 인정되는 낱말 — 이 밖은 진행 중이다(§`isOrderTerminal`). */
27
+ export declare const ORDER_TERMINAL_STATUS: readonly ["completed", "cancelled"];
28
+ /**
29
+ * 이 오더는 **끝났나** — 판정을 한 곳에 둔다(`locationStatusOf`·`dueStatusOf` 와 같은 규율).
30
+ *
31
+ * ── 왜 함수여야 하나 (2026-08-23) ───────────────────────────────────────────
32
+ * 이 판정이 없던 동안 호스트의 성과 폴드가 **낱말을 동의어 목록으로 추론했다**
33
+ * (`completed`·`fulfilled`·`done`·`finished`). 그 방식이 낸 대가가 둘이다.
34
+ *
35
+ * ① **원본의 낱말이 목록에 없으면 완료가 조용히 0 이 된다.** 실제로 그랬다 — 첫 실 시스템의
36
+ * `FINISHED` 가 빠져 있었고, 사람이 화면을 보고 의심해서야 드러났다. 그리고 그때 목록에 낱말을
37
+ * 더한 것은 진짜 빈 자리(커넥터가 자기 상태 표를 안 쓰고 있었다)를 가린 땜빵이었다.
38
+ * ② **시뮬 트윈에서는 언제나 0 이었다.** 시뮬은 오더를 이행하면서 `fulfilled` 만 올리고 `status` 는
39
+ * 바꾸지 않는다(그런 대입이 코드에 없다). 그래서 낱말로 묻는 판정은 시뮬의 완료를 **한 건도**
40
+ * 세지 못했다. 낱말이 틀린 것이 아니라 **묻는 축이 틀렸다.**
41
+ *
42
+ * ── 표준이 그 축을 이미 갈라 두었다 ─────────────────────────────────────────
43
+ * `orders` 는 `OperationsRequest` 쪽이고 그 상태는 `RequestState` 다 — **표준도 그 값을 열거하지
44
+ * 않는다.** 그래서 이 축이 열려 있는 것은 방언이 아니라 표준 정합이다. 대신 표준은 「됐나」를 **실적**
45
+ * 에서 읽는다(`JobResponse`·`SegmentResponse`, 그리고 요구는 `SegmentRequirement.Quantity`).
46
+ *
47
+ * 그러므로 이 판정의 1차 근거는 **양**이다: 요구한 만큼 이행됐으면 끝난 것이고, 그 사실은 어느 원본의
48
+ * 어느 낱말과도 무관하다. 낱말은 **양으로 말할 수 없는 종결**을 위해 함께 본다 — 취소, 그리고 요구량을
49
+ * 채우지 못한 채 닫힌 오더.
50
+ *
51
+ * ── 도메인이 상태에 다른 것을 적는다 ────────────────────────────────────────
52
+ * MES 커널은 이 자리에 **진행 단계**를 적는다(`op-<공정키>`, `blocked-seed-incomplete`). 그 값들은
53
+ * 종결이 아니므로 이 판정이 옳게 「아니다」로 답한다. 다만 진행 단계를 상태 문자열에 적는 것 자체가
54
+ * 표준의 모양은 아니다(그것은 `SegmentResponse` 의 일이다) — 그 빚은 ADR-0039 에 조건과 함께 적었다.
55
+ *
56
+ * 모르면 `false` 다 — 「끝나지 않았다」가 아니라 **「끝났다고 말할 근거가 없다」**이고, 성과는 근거가
57
+ * 있는 것만 센다(없는 완료를 세면 처리량이 조용히 부풀려진다).
58
+ */
59
+ export declare function isOrderTerminal(o: {
60
+ status?: string;
61
+ requested?: number;
62
+ fulfilled?: number;
63
+ }): boolean;
11
64
  /**
12
65
  * 자리의 상태 — **포화도에서 파생한다.** 저장하는 값이 아니다.
13
66
  *
package/dist/contract.js CHANGED
@@ -1,7 +1,81 @@
1
1
  /*
2
2
  * Face 1 — 3채널 계약 (walking skeleton 범위).
3
3
  * 설계 SoT: operato-twin/design/integration/face1-contract.md
4
+ *
5
+ * ── 이 계약을 넓히려는 사람이 먼저 읽을 것 (2026-08-23) ──────────────────────
6
+ *
7
+ * 사실은 두 종류이고, 종류가 그 자리를 정한다.
8
+ *
9
+ * **파생** — 시각만으로 넘어가는 판정. 근무 밖 · 교대 · 능력 · 자리 상태 · 납기 대비 · 진척.
10
+ * · **저장하지 않는다.** 저장하면 장부가 둘이 되고, 시각이 지나도 옛 값이 남는다.
11
+ * · **입력으로 받지 않는다.** 원본이 단정하면 답이 둘이 되고 커널이 어느 쪽을 믿을지 정해야 한다.
12
+ * · 계산할 수 없으면 **답하지 않는다.** 모르는 값을 숫자로 만들면 화면이 그것을 사실로 그린다
13
+ * (`dueStatusOf` 가 그 규율을 먼저 적었다: 납기가 없으면 「늦지 않았다」가 아니라 「판단할 수 없다」).
14
+ * · 파생이 **불가능할 때만** 원본의 단정을 쓴다. 그 순서가 선언돼 있어야 한다(`offShift`·`progress`).
15
+ *
16
+ * **누적** — 열린 구간에 쌓이는 값. 가동·준비·고장 시간 · 양품/불량 수 · 조건이 성립한 시각.
17
+ * · **다시 계산할 수 없다.** 원천은 「이번 창에서 지금까지 얼마」를 모른다.
18
+ * · 그래서 **재기동을 넘어 이어받는다.** 잃으면 오류 없이 값이 작아진다 — 가장 비싼 종류의 침묵이다.
19
+ *
20
+ * 그리고 **자리를 늘리는 조건은 하나다: 전이로 표현할 수 없는 사실.**
21
+ *
22
+ * 「자리가 없다」와 「담을 수 없다」는 다르다. 전자는 대개 이미 있는 축을 못 찾은 것이다 — 작업의
23
+ * 실제 착수·완료·대기는 **전이의 `eventTime`** 이 말하고(`task-fold`), 대기는 그것으로 계산된다
24
+ * (`kpi-fold`: `waitMs = startedMs − createdMs`). 원본이 전이를 놓쳐 보내면 **놓친 전이를 그 시각으로
25
+ * 함께 내는 것**이 답이고, 속성을 새로 여는 것이 아니다.
26
+ *
27
+ * 그러므로 원본을 붙이는 사람이 물어야 하는 것은 「이 컬럼을 실을 자리가 있나」가 아니라
28
+ * **「원본이 말하는 이 사실을 커널의 어휘로 어떻게 옮기나」**다. 컬럼 단위로 물으면 원본이 열이면
29
+ * 축도 열이 된다 — 2026-08-23 에 축 다섯을 열었다가 그 이유로 되돌렸다.
30
+ *
31
+ * 열지 않기로 한 것과 그 조건은 `design/04-decisions.md` ADR-0039 에 있다. 넓히기 전에 그 표를 본다.
32
+ * 지키는 하네스: `derived-not-input` · `actuals-come-from-transitions` · `accumulators-survive-restart` ·
33
+ * `purpose-conformance`.
4
34
  */
35
+ /** 오더가 **종결 상태**로 인정되는 낱말 — 이 밖은 진행 중이다(§`isOrderTerminal`). */
36
+ export const ORDER_TERMINAL_STATUS = ['completed', 'cancelled'];
37
+ /**
38
+ * 이 오더는 **끝났나** — 판정을 한 곳에 둔다(`locationStatusOf`·`dueStatusOf` 와 같은 규율).
39
+ *
40
+ * ── 왜 함수여야 하나 (2026-08-23) ───────────────────────────────────────────
41
+ * 이 판정이 없던 동안 호스트의 성과 폴드가 **낱말을 동의어 목록으로 추론했다**
42
+ * (`completed`·`fulfilled`·`done`·`finished`). 그 방식이 낸 대가가 둘이다.
43
+ *
44
+ * ① **원본의 낱말이 목록에 없으면 완료가 조용히 0 이 된다.** 실제로 그랬다 — 첫 실 시스템의
45
+ * `FINISHED` 가 빠져 있었고, 사람이 화면을 보고 의심해서야 드러났다. 그리고 그때 목록에 낱말을
46
+ * 더한 것은 진짜 빈 자리(커넥터가 자기 상태 표를 안 쓰고 있었다)를 가린 땜빵이었다.
47
+ * ② **시뮬 트윈에서는 언제나 0 이었다.** 시뮬은 오더를 이행하면서 `fulfilled` 만 올리고 `status` 는
48
+ * 바꾸지 않는다(그런 대입이 코드에 없다). 그래서 낱말로 묻는 판정은 시뮬의 완료를 **한 건도**
49
+ * 세지 못했다. 낱말이 틀린 것이 아니라 **묻는 축이 틀렸다.**
50
+ *
51
+ * ── 표준이 그 축을 이미 갈라 두었다 ─────────────────────────────────────────
52
+ * `orders` 는 `OperationsRequest` 쪽이고 그 상태는 `RequestState` 다 — **표준도 그 값을 열거하지
53
+ * 않는다.** 그래서 이 축이 열려 있는 것은 방언이 아니라 표준 정합이다. 대신 표준은 「됐나」를 **실적**
54
+ * 에서 읽는다(`JobResponse`·`SegmentResponse`, 그리고 요구는 `SegmentRequirement.Quantity`).
55
+ *
56
+ * 그러므로 이 판정의 1차 근거는 **양**이다: 요구한 만큼 이행됐으면 끝난 것이고, 그 사실은 어느 원본의
57
+ * 어느 낱말과도 무관하다. 낱말은 **양으로 말할 수 없는 종결**을 위해 함께 본다 — 취소, 그리고 요구량을
58
+ * 채우지 못한 채 닫힌 오더.
59
+ *
60
+ * ── 도메인이 상태에 다른 것을 적는다 ────────────────────────────────────────
61
+ * MES 커널은 이 자리에 **진행 단계**를 적는다(`op-<공정키>`, `blocked-seed-incomplete`). 그 값들은
62
+ * 종결이 아니므로 이 판정이 옳게 「아니다」로 답한다. 다만 진행 단계를 상태 문자열에 적는 것 자체가
63
+ * 표준의 모양은 아니다(그것은 `SegmentResponse` 의 일이다) — 그 빚은 ADR-0039 에 조건과 함께 적었다.
64
+ *
65
+ * 모르면 `false` 다 — 「끝나지 않았다」가 아니라 **「끝났다고 말할 근거가 없다」**이고, 성과는 근거가
66
+ * 있는 것만 센다(없는 완료를 세면 처리량이 조용히 부풀려진다).
67
+ */
68
+ export function isOrderTerminal(o) {
69
+ const status = String(o?.status ?? '').trim().toLowerCase();
70
+ if (ORDER_TERMINAL_STATUS.includes(status))
71
+ return true;
72
+ /* 양으로 판정 — 요구량이 없으면 판정하지 않는다(0 을 「다 됐다」로 읽지 않는다). */
73
+ const requested = o?.requested;
74
+ const fulfilled = o?.fulfilled;
75
+ if (typeof requested !== 'number' || !(requested > 0))
76
+ return false;
77
+ return typeof fulfilled === 'number' && fulfilled >= requested;
78
+ }
5
79
  /**
6
80
  * 자리의 상태 — **포화도에서 파생한다.** 저장하는 값이 아니다.
7
81
  *
@@ -78,6 +78,15 @@ export interface FlowPerson extends EffectivePeriod {
78
78
  };
79
79
  /** 근무 캘린더 — 표준 `WorkCalendarEntry`(복수). 있으면 `window` 보다 이것을 본다. */
80
80
  workCalendar?: WorkCalendarEntry[];
81
+ /**
82
+ * **원본이 말한 근무 밖 표시** — 캘린더가 없을 때만 쓰는 값이다.
83
+ *
84
+ * 캘린더가 있으면 시각으로 판정한다(`offCalendar`) — 저장값을 함께 보면 교대 안으로 돌아왔을 때 옛
85
+ * `true` 가 남는다. 그런데 근무 캘린더를 주지 않는 원본이 실 시스템에 흔하고, 그때 이것이 **유일한
86
+ * 답**이다. 리듀서가 이미 그 규율로 이 값을 지키고 있었는데(§`observed-reducer` offShift) 커널 상태에
87
+ * 자리가 없어 옮기는 자리에서 잃고 있었다.
88
+ */
89
+ offShift?: boolean;
81
90
  /** 지금 어디에 있나 — 표준 `Person.OperationalLocation`. 커널은 사람을 움직이지 않는다(마스터·관측이 말한다). */
82
91
  location?: string;
83
92
  /** 자원 속성 — 표준 `PersonProperty`. */
@@ -165,6 +174,15 @@ export interface FlowTask {
165
174
  /** 예정 창 — 표준 `JobOrder.StartTime`/`EndTime`. 실제 착수와 다른 축. */
166
175
  startTime?: string;
167
176
  endTime?: string;
177
+ /**
178
+ * **원본이 말한 진척** — 커널이 계산할 수 없을 때만 쓰는 값이다(§`progressOf`).
179
+ *
180
+ * 파생이 가능하면(소요를 알면) 파생이 이긴다. 이 자리는 파생을 대신하는 것이 아니라 **파생이
181
+ * 불가능할 때 침묵하지 않기 위한 것**이다 — 사람 교대(`offShift`)가 세워 둔 규율과 같다.
182
+ *
183
+ * 관측에서만 채운다. 시뮬은 자기 소요를 알므로 이 값을 쓸 일이 없다.
184
+ */
185
+ progress?: number;
168
186
  /** 이 작업에 투입된 사람들 — 설비와 별개 축(설비 1대 + 작업자 2명이 동시에 잡힌다). */
169
187
  personnel?: string[];
170
188
  /** 이 작업에 투입된 물리 자산(팔레트 등). */
@@ -1559,7 +1577,25 @@ export declare abstract class FlowEngine implements TwinKernel {
1559
1577
  * 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
1560
1578
  */
1561
1579
  protected itemState(i: FlowItem): ItemState;
1562
- protected progressOf(t: FlowTask): number;
1580
+ /**
1581
+ * 진척 — **모르면 답하지 않는다.**
1582
+ *
1583
+ * ── 무엇이 틀려 있었나 (2026-08-23 실측) ───────────────────────────────────
1584
+ * 예전에는 `durationMs <= 0` 이면 `1` 을 돌려줬다. 시뮬에서는 그것이 맞다(소요 0 = 즉시 끝나는 일).
1585
+ * 그런데 **미러에서 `durationMs` 0 은 「즉시」가 아니라 「모른다」다** — 원본이 소요를 말해 주지
1586
+ * 않으면 씨앗 경로가 0 으로 메우고, 그러면 이 함수가 **진행 중인 작업을 100% 라고 말한다.**
1587
+ *
1588
+ * 실측: 소요를 주지 않는 원본의 진행 중 작업이 `progress: 1` 로 나왔다. 「모름」을 「완료」로 말하는
1589
+ * 것이고, 화면은 그것을 사실로 그린다.
1590
+ *
1591
+ * 시뮬에서는 이 반환이 바뀌지 않는다: 소요 0 인 작업은 배정된 tick 안에서 끝나므로 **진행 중으로
1592
+ * 관측되지 않는다**(`status = 'in-progress'; remainingMs = durationMs` → 다음 진행에서 즉시 완료).
1593
+ * 즉 `durationMs <= 0` 이면서 진행 중인 작업은 미러 경로에서만 나온다.
1594
+ *
1595
+ * 모를 때 원본이 진척을 말해 주었다면 그 값을 쓴다 — 그 규칙은 부르는 쪽에 있다(§`getSnapshot`).
1596
+ * 이 함수는 **커널이 계산할 수 있나**만 답한다.
1597
+ */
1598
+ protected progressOf(t: FlowTask): number | undefined;
1563
1599
  private generate;
1564
1600
  private processOrders;
1565
1601
  /**
@@ -9,7 +9,7 @@
9
9
  * 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
10
10
  * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowLocation 단일 base 방향과 정합.)
11
11
  */
12
- import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf } from "./contract.js";
12
+ import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf } from "./contract.js";
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
14
  import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP, objectUri, bizTransactionUri, gdtiUri } from "./epcis.js";
15
15
  import { OP_PARAM } from "./domain-definition.js";
@@ -217,7 +217,9 @@ thresholds) {
217
217
  지연으로도 정시로도 말하지 않는다). 이미 끝난 오더는 대상이 아니다.
218
218
  심각도는 얼마나 늦었는지로 구분한다 — 방금 넘긴 것과 하루 넘긴 것을 같게 부르면 신호가 무의미해진다. */
219
219
  for (const o of view.orders) {
220
- if (o.status === 'completed' || o.status === 'fulfilled')
220
+ /* 끝난 오더는 지연 신호의 대상이 아니다 — 판정은 한 곳에 있다(§`isOrderTerminal`).
221
+ 예전에는 여기서 낱말 둘을 견줬고, 그러면 양으로만 끝난 오더(시뮬이 그렇다)가 영원히 늦었다고 말한다. */
222
+ if (isOrderTerminal(o))
221
223
  continue;
222
224
  if (dueStatusOf(o, nowIso) !== 'late')
223
225
  continue;
@@ -946,6 +948,15 @@ export class FlowEngine {
946
948
  id: p.id, personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds,
947
949
  status: purpose === 'observe' ? (p.status ?? 'idle') : 'idle',
948
950
  taskId: purpose === 'observe' ? (p.taskId ?? null) : null,
951
+ /*
952
+ * 원본이 말한 근무 밖 표시 — **관측일 때만, 그리고 선언이 없을 때만 쓰인다.**
953
+ *
954
+ * 이 규율의 원본 자리는 리듀서다(§`offShift`: 「선언이 없을 때만 받는다 — 선언이 있으면
955
+ * `snapshot()` 이 시각으로 판정하고, 저장값을 함께 두면 교대 안으로 돌아왔을 때 옛 `true` 가
956
+ * 남는다」). 그런데 이 자리가 그 값을 **떨어뜨리고 있었다** — 리듀서가 지킨 것을 여기서 잃었다.
957
+ * 근무 캘린더가 없는 원본(실 시스템에 흔하다)에서는 그것이 유일한 답이다.
958
+ */
959
+ ...(purpose === 'observe' && p.offShift ? { offShift: true } : {}),
949
960
  ...(p.location ? { location: p.location } : {}),
950
961
  ...(p.properties ? { properties: p.properties } : {}),
951
962
  ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}),
@@ -1026,11 +1037,35 @@ export class FlowEngine {
1026
1037
  const remaining = lines.length
1027
1038
  ? lines.reduce((s, l) => s + l.requested, 0)
1028
1039
  : Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
1029
- if (remaining <= 0)
1030
- continue; // 이미 이행 완료 예측 대상 아님
1040
+ /*
1041
+ * ── **씨앗은 남은 데맨드로, 미러는 들은 그대로** (2026-08-23) ──────────────
1042
+ *
1043
+ * 아래 세 값(`status`·`requested`·`fulfilled`)은 씨앗의 규약이다: 씨앗은 「무엇이 남았나」로
1044
+ * 미래를 굴리므로 요청 10·이행 4 를 **요청 6·이행 0·상태 created** 로 다시 표현한다. 그리고 이미
1045
+ * 이행된 오더는 예측 대상이 아니므로 건너뛴다.
1046
+ *
1047
+ * **미러에는 전부 틀렸다.** 미러는 굴리지 않고 들은 것을 말한다. 실측(포천 미러):
1048
+ *
1049
+ * 원본이 말한 것 picking · 요청 10 · 이행 4 · 진척 0.4
1050
+ * 커널이 낸 것 created · 요청 6 · 이행 0 · 진척 0 ← 40% 된 오더가 0% 로 보였다
1051
+ *
1052
+ * 그리고 이행 완료된 오더는 **상태에서 아예 사라졌다** — 원본이 그 오더가 있다고 말하는데
1053
+ * 트윈은 없다고 말한다. 오더의 상태 낱말은 계약이 도메인에 넘긴 것인데(`picking`·`shipped`…)
1054
+ * 씨앗 규약이 그것을 커널 낱말로 덮고 있었다.
1055
+ *
1056
+ * 이것은 작업에서 고친 것과 **같은 결함의 세 번째 사례**다(§`purpose`): 한 함수가 두 목적을 겸하고
1057
+ * 씨앗 규약이 관측에 새어 나온다.
1058
+ */
1059
+ const observing = purpose === 'observe';
1060
+ if (!observing && remaining <= 0)
1061
+ continue; // 씨앗: 이미 이행 완료 → 예측 대상 아님
1031
1062
  const resolved = this.resolvedAllocated(o.allocated);
1032
1063
  this.orders.set(o.orderId, {
1033
- id: o.orderId, kind: o.kind, status: 'created', requested: remaining, fulfilled: 0,
1064
+ id: o.orderId,
1065
+ kind: o.kind,
1066
+ status: observing ? (o.status ?? 'created') : 'created',
1067
+ requested: observing ? (o.requested ?? 0) : remaining,
1068
+ fulfilled: observing ? (o.fulfilled ?? 0) : 0,
1034
1069
  /* **확보해 둔 것과 거래번호를 이어받는다.** 비우면 되살아난 오더가 아무것도 안 잡은 것처럼
1035
1070
  보이고, 그 오더의 진행 중 작업이 완료될 때 계보가 입력 없이 나간다. 상태가 말해 주지
1036
1071
  않으면(옛 저널) 그때는 비는 것이 사실이다 — 없는 것을 지어내지 않는다. */
@@ -1039,7 +1074,9 @@ export class FlowEngine {
1039
1074
  /* 레시피도 함께 — 품목만 이어받으면 되살아난 오더가 「무엇으로」를 모르고, 그 오더의
1040
1075
  남은 공정·소요가 다른 레시피 기준으로 계산된다(같은 품목에 대체 레시피가 있다). */
1041
1076
  ...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
1042
- bizTransaction: o.bizTransaction ?? '', allocated: resolved.kept, picked: [], shipmentEpc: null, lines,
1077
+ bizTransaction: o.bizTransaction ?? '', allocated: resolved.kept, picked: [], shipmentEpc: null,
1078
+ /* 라인도 같다 — 씨앗은 남은 양만 남기고, 미러는 원본이 말한 요청·이행을 그대로 든다. */
1079
+ lines: observing ? (o.lines ?? []).map(l => ({ ...l })) : lines,
1043
1080
  ...(resolved.dropped ? { seedIncomplete: true } : {}),
1044
1081
  /* **보류를 이어받는다** — 잃으면 씨앗이 사람이 일부러 멈춘 오더를 다시 계획해 내보낸다.
1045
1082
  씨앗 왕복 대조가 이것을 잡았다(그 전에는 사람이 코드를 읽어야만 알 수 있었다). */
@@ -1112,7 +1149,12 @@ export class FlowEngine {
1112
1149
  startedAtSimMs: t.startedAtSimMs,
1113
1150
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
1114
1151
  orderId: t.orderId,
1115
- intent: t.intent
1152
+ intent: t.intent,
1153
+ /*
1154
+ * 원본이 말한 진척 — **관측일 때만, 그리고 커널이 계산할 수 없을 때 쓰인다**(§`progressOf`).
1155
+ * 씨앗 경로에서는 싣지 않는다: 씨앗은 굴러가면서 자기 소요로 진척을 만든다.
1156
+ */
1157
+ ...(purpose === 'observe' && typeof t.progress === 'number' ? { progress: t.progress } : {})
1116
1158
  });
1117
1159
  /*
1118
1160
  * 투입 인원은 **작업이 들고 있는 사실**이므로 관측에서도 그대로 옮긴다(위 `set` 에서 빠져 있어
@@ -1425,8 +1467,13 @@ export class FlowEngine {
1425
1467
  equipment: [...this.equipment.values()].map(m => {
1426
1468
  const s = { id: m.id, kind: m.kind, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}), ...energyFieldsOf(m) };
1427
1469
  const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
1428
- if (t && t.status === 'in-progress' && t.intent !== 'process')
1429
- s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
1470
+ /*
1471
+ * 이동 구간은 **소요를 때만** 낸다. 모르면 아래 산수가 전부 0 이나 음수가 되고, 화면은 「제자리에서
1472
+ * 다 끝난 이동」을 그린다 — 원본이 소요를 주지 않는 미러에서 실제로 그랬다(§`progressOf`).
1473
+ */
1474
+ const motionProgress = t && t.status === 'in-progress' && t.intent !== 'process' ? this.progressOf(t) : undefined;
1475
+ if (t && motionProgress !== undefined)
1476
+ s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: motionProgress, elapsedMs: t.durationMs - t.remainingMs };
1430
1477
  return s;
1431
1478
  }),
1432
1479
  assets: [...this.assets.values()].map(a => {
@@ -1455,10 +1502,17 @@ export class FlowEngine {
1455
1502
  물을 수 없다(사실은 자기 집에서 나온다). 판정은 아래 `capability` 가 이미 답한다. */
1456
1503
  if (p.testResults)
1457
1504
  st.testResults = p.testResults;
1505
+ /*
1506
+ * 근무 밖 판정 — **선언이 있으면 선언이 이기고, 없을 때만 원본이 말한 값을 쓴다.**
1507
+ * 진척(`progressOf`)과 같은 순서다. 선언이 있는데 저장값을 함께 보면 교대 안으로 돌아왔을 때
1508
+ * 옛 `true` 가 남는다(리듀서가 그 이유를 적어 두었다).
1509
+ */
1458
1510
  if (this.personOffShift(p)) {
1459
1511
  st.offShift = true;
1460
1512
  Object.assign(st, this.offReason(p));
1461
1513
  }
1514
+ else if (p.offShift && !p.workCalendar?.length)
1515
+ st.offShift = true;
1462
1516
  const sh = this.shiftOf(p);
1463
1517
  if (sh)
1464
1518
  st.shift = sh;
@@ -1479,7 +1533,26 @@ export class FlowEngine {
1479
1533
  fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
1480
1534
  ...(t.intent ? { intent: t.intent } : {}),
1481
1535
  ...(t.durationMs ? { durationMs: t.durationMs } : {}),
1482
- ...(t.status === 'in-progress' ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {}),
1536
+ /*
1537
+ * 진척은 **커널이 계산한 값이 먼저, 못 하면 원본이 말한 값**이다(§`progressOf`).
1538
+ *
1539
+ * 이 순서는 사람 교대(`offShift`)가 세워 둔 규율과 같다: 파생이 가능하면 파생이 이기고, 파생이
1540
+ * 불가능할 때만 원본의 단정을 쓴다. 둘 다 없으면 **싣지 않는다** — 「모른다」를 숫자로 만들지 않는다.
1541
+ *
1542
+ * 예전에는 원본이 진척을 말해도 조용히 버렸다(문이 받아 놓고 파생이 덮었다). 넣었는데 사라지는
1543
+ * 것이 이 시스템에서 가장 비싼 침묵이다.
1544
+ */
1545
+ ...(t.status === 'in-progress'
1546
+ ? {
1547
+ remainingMs: t.remainingMs,
1548
+ startedAtSimMs: t.startedAtSimMs,
1549
+ ...(() => {
1550
+ const derived = this.progressOf(t);
1551
+ const p = derived ?? t.progress;
1552
+ return p === undefined ? {} : { progress: p };
1553
+ })()
1554
+ }
1555
+ : {}),
1483
1556
  ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
1484
1557
  ...(t.assets?.length ? { assets: t.assets.slice() } : {}),
1485
1558
  ...(t.resources?.length ? { resources: t.resources.slice() } : {}),
@@ -2042,7 +2115,8 @@ export class FlowEngine {
2042
2115
  committedDemands() {
2043
2116
  const out = [];
2044
2117
  for (const o of this.orders.values()) {
2045
- if (o.status === 'completed' || o.status === 'cancelled')
2118
+ /* 같은 판정을 쓴다 남은 수요를 세는 자리와 지연을 세는 자리가 다른 규칙을 쓰면 어긋난다. */
2119
+ if (isOrderTerminal(o))
2046
2120
  continue;
2047
2121
  const remaining = Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
2048
2122
  if (!remaining)
@@ -3418,7 +3492,29 @@ export class FlowEngine {
3418
3492
  ...(i.ilmd ? { ilmd: i.ilmd } : {})
3419
3493
  };
3420
3494
  }
3421
- progressOf(t) { return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs)); }
3495
+ /**
3496
+ * 진척 — **모르면 답하지 않는다.**
3497
+ *
3498
+ * ── 무엇이 틀려 있었나 (2026-08-23 실측) ───────────────────────────────────
3499
+ * 예전에는 `durationMs <= 0` 이면 `1` 을 돌려줬다. 시뮬에서는 그것이 맞다(소요 0 = 즉시 끝나는 일).
3500
+ * 그런데 **미러에서 `durationMs` 0 은 「즉시」가 아니라 「모른다」다** — 원본이 소요를 말해 주지
3501
+ * 않으면 씨앗 경로가 0 으로 메우고, 그러면 이 함수가 **진행 중인 작업을 100% 라고 말한다.**
3502
+ *
3503
+ * 실측: 소요를 주지 않는 원본의 진행 중 작업이 `progress: 1` 로 나왔다. 「모름」을 「완료」로 말하는
3504
+ * 것이고, 화면은 그것을 사실로 그린다.
3505
+ *
3506
+ * 시뮬에서는 이 반환이 바뀌지 않는다: 소요 0 인 작업은 배정된 tick 안에서 끝나므로 **진행 중으로
3507
+ * 관측되지 않는다**(`status = 'in-progress'; remainingMs = durationMs` → 다음 진행에서 즉시 완료).
3508
+ * 즉 `durationMs <= 0` 이면서 진행 중인 작업은 미러 경로에서만 나온다.
3509
+ *
3510
+ * 모를 때 원본이 진척을 말해 주었다면 그 값을 쓴다 — 그 규칙은 부르는 쪽에 있다(§`getSnapshot`).
3511
+ * 이 함수는 **커널이 계산할 수 있나**만 답한다.
3512
+ */
3513
+ progressOf(t) {
3514
+ if (!(t.durationMs > 0))
3515
+ return undefined;
3516
+ return Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
3517
+ }
3422
3518
  generate() {
3423
3519
  for (const g of this.gens) {
3424
3520
  while (this.clockMs >= g.nextMs) {
@@ -56,6 +56,7 @@ __export(index_exports, {
56
56
  OPERATION_PROPERTY: () => OPERATION_PROPERTY,
57
57
  OP_EVENT: () => OP_EVENT,
58
58
  OP_PARAM: () => OP_PARAM,
59
+ ORDER_TERMINAL_STATUS: () => ORDER_TERMINAL_STATUS,
59
60
  ObservedReducer: () => ObservedReducer,
60
61
  PRIORITY_UNSET: () => PRIORITY_UNSET,
61
62
  RETIRED_VOCABULARY: () => RETIRED_VOCABULARY,
@@ -126,6 +127,7 @@ __export(index_exports, {
126
127
  isEnergyRecord: () => isEnergyRecord,
127
128
  isEquipmentLevel: () => isEquipmentLevel,
128
129
  isOperationalRecord: () => isOperationalRecord,
130
+ isOrderTerminal: () => isOrderTerminal,
129
131
  isTransformationRecord: () => isTransformationRecord,
130
132
  isoDurationHours: () => isoDurationHours,
131
133
  itemKeyOf: () => itemKeyOf,
@@ -179,6 +181,15 @@ __export(index_exports, {
179
181
  module.exports = __toCommonJS(index_exports);
180
182
 
181
183
  // src/contract.ts
184
+ var ORDER_TERMINAL_STATUS = ["completed", "cancelled"];
185
+ function isOrderTerminal(o) {
186
+ const status = String(o?.status ?? "").trim().toLowerCase();
187
+ if (ORDER_TERMINAL_STATUS.includes(status)) return true;
188
+ const requested = o?.requested;
189
+ const fulfilled = o?.fulfilled;
190
+ if (typeof requested !== "number" || !(requested > 0)) return false;
191
+ return typeof fulfilled === "number" && fulfilled >= requested;
192
+ }
182
193
  var LOCATION_SATURATION_NEAR = 0.9;
183
194
  function locationStatusOf(n) {
184
195
  const cap = n.capacity;
@@ -3429,7 +3440,7 @@ function deriveAttentions(view, acked, nowIso, thresholds) {
3429
3440
  });
3430
3441
  }
3431
3442
  for (const o of view.orders) {
3432
- if (o.status === "completed" || o.status === "fulfilled") continue;
3443
+ if (isOrderTerminal(o)) continue;
3433
3444
  if (dueStatusOf(o, nowIso) !== "late") continue;
3434
3445
  const overdueMs = Date.parse(nowIso) - Date.parse(o.endTime);
3435
3446
  out.push({
@@ -4043,6 +4054,15 @@ var FlowEngine = class {
4043
4054
  personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds,
4044
4055
  status: purpose === "observe" ? p.status ?? "idle" : "idle",
4045
4056
  taskId: purpose === "observe" ? p.taskId ?? null : null,
4057
+ /*
4058
+ * 원본이 말한 근무 밖 표시 — **관측일 때만, 그리고 선언이 없을 때만 쓰인다.**
4059
+ *
4060
+ * 이 규율의 원본 자리는 리듀서다(§`offShift`: 「선언이 없을 때만 받는다 — 선언이 있으면
4061
+ * `snapshot()` 이 시각으로 판정하고, 저장값을 함께 두면 교대 안으로 돌아왔을 때 옛 `true` 가
4062
+ * 남는다」). 그런데 이 자리가 그 값을 **떨어뜨리고 있었다** — 리듀서가 지킨 것을 여기서 잃었다.
4063
+ * 근무 캘린더가 없는 원본(실 시스템에 흔하다)에서는 그것이 유일한 답이다.
4064
+ */
4065
+ ...purpose === "observe" && p.offShift ? { offShift: true } : {},
4046
4066
  ...p.location ? { location: p.location } : {},
4047
4067
  ...p.properties ? { properties: p.properties } : {},
4048
4068
  ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {},
@@ -4094,14 +4114,15 @@ var FlowEngine = class {
4094
4114
  for (const o of observedOrders) {
4095
4115
  const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
4096
4116
  const remaining = lines.length ? lines.reduce((s, l) => s + l.requested, 0) : Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
4097
- if (remaining <= 0) continue;
4117
+ const observing = purpose === "observe";
4118
+ if (!observing && remaining <= 0) continue;
4098
4119
  const resolved = this.resolvedAllocated(o.allocated);
4099
4120
  this.orders.set(o.orderId, {
4100
4121
  id: o.orderId,
4101
4122
  kind: o.kind,
4102
- status: "created",
4103
- requested: remaining,
4104
- fulfilled: 0,
4123
+ status: observing ? o.status ?? "created" : "created",
4124
+ requested: observing ? o.requested ?? 0 : remaining,
4125
+ fulfilled: observing ? o.fulfilled ?? 0 : 0,
4105
4126
  /* **확보해 둔 것과 거래번호를 이어받는다.** 비우면 되살아난 오더가 아무것도 안 잡은 것처럼
4106
4127
  보이고, 그 오더의 진행 중 작업이 완료될 때 계보가 입력 없이 나간다. 상태가 말해 주지
4107
4128
  않으면(옛 저널) 그때는 비는 것이 사실이다 — 없는 것을 지어내지 않는다. */
@@ -4114,7 +4135,8 @@ var FlowEngine = class {
4114
4135
  allocated: resolved.kept,
4115
4136
  picked: [],
4116
4137
  shipmentEpc: null,
4117
- lines,
4138
+ /* 라인도 같다 — 씨앗은 남은 양만 남기고, 미러는 원본이 말한 요청·이행을 그대로 든다. */
4139
+ lines: observing ? (o.lines ?? []).map((l) => ({ ...l })) : lines,
4118
4140
  ...resolved.dropped ? { seedIncomplete: true } : {},
4119
4141
  /* **보류를 이어받는다** — 잃으면 씨앗이 사람이 일부러 멈춘 오더를 다시 계획해 내보낸다.
4120
4142
  씨앗 왕복 대조가 이것을 잡았다(그 전에는 사람이 코드를 읽어야만 알 수 있었다). */
@@ -4175,7 +4197,12 @@ var FlowEngine = class {
4175
4197
  startedAtSimMs: t.startedAtSimMs,
4176
4198
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
4177
4199
  orderId: t.orderId,
4178
- intent: t.intent
4200
+ intent: t.intent,
4201
+ /*
4202
+ * 원본이 말한 진척 — **관측일 때만, 그리고 커널이 계산할 수 없을 때 쓰인다**(§`progressOf`).
4203
+ * 씨앗 경로에서는 싣지 않는다: 씨앗은 굴러가면서 자기 소요로 진척을 만든다.
4204
+ */
4205
+ ...purpose === "observe" && typeof t.progress === "number" ? { progress: t.progress } : {}
4179
4206
  });
4180
4207
  if (purpose === "observe" && t.personnel?.length) {
4181
4208
  const restored = this.tasks.get(t.id);
@@ -4436,7 +4463,8 @@ var FlowEngine = class {
4436
4463
  equipment: [...this.equipment.values()].map((m) => {
4437
4464
  const s = { id: m.id, kind: m.kind, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: "master", ...this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}, ...this.effectivePart(m), ...this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}, ...energyFieldsOf(m) };
4438
4465
  const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
4439
- if (t && t.status === "in-progress" && t.intent !== "process") s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
4466
+ const motionProgress = t && t.status === "in-progress" && t.intent !== "process" ? this.progressOf(t) : void 0;
4467
+ if (t && motionProgress !== void 0) s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: motionProgress, elapsedMs: t.durationMs - t.remainingMs };
4440
4468
  return s;
4441
4469
  }),
4442
4470
  assets: [...this.assets.values()].map((a) => {
@@ -4458,7 +4486,7 @@ var FlowEngine = class {
4458
4486
  if (this.personOffShift(p)) {
4459
4487
  st.offShift = true;
4460
4488
  Object.assign(st, this.offReason(p));
4461
- }
4489
+ } else if (p.offShift && !p.workCalendar?.length) st.offShift = true;
4462
4490
  const sh = this.shiftOf(p);
4463
4491
  if (sh) st.shift = sh;
4464
4492
  Object.assign(st, this.effectivePart(p));
@@ -4484,7 +4512,24 @@ var FlowEngine = class {
4484
4512
  orderId: t.orderId,
4485
4513
  ...t.intent ? { intent: t.intent } : {},
4486
4514
  ...t.durationMs ? { durationMs: t.durationMs } : {},
4487
- ...t.status === "in-progress" ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {},
4515
+ /*
4516
+ * 진척은 **커널이 계산한 값이 먼저, 못 하면 원본이 말한 값**이다(§`progressOf`).
4517
+ *
4518
+ * 이 순서는 사람 교대(`offShift`)가 세워 둔 규율과 같다: 파생이 가능하면 파생이 이기고, 파생이
4519
+ * 불가능할 때만 원본의 단정을 쓴다. 둘 다 없으면 **싣지 않는다** — 「모른다」를 숫자로 만들지 않는다.
4520
+ *
4521
+ * 예전에는 원본이 진척을 말해도 조용히 버렸다(문이 받아 놓고 파생이 덮었다). 넣었는데 사라지는
4522
+ * 것이 이 시스템에서 가장 비싼 침묵이다.
4523
+ */
4524
+ ...t.status === "in-progress" ? {
4525
+ remainingMs: t.remainingMs,
4526
+ startedAtSimMs: t.startedAtSimMs,
4527
+ ...(() => {
4528
+ const derived = this.progressOf(t);
4529
+ const p = derived ?? t.progress;
4530
+ return p === void 0 ? {} : { progress: p };
4531
+ })()
4532
+ } : {},
4488
4533
  ...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
4489
4534
  ...t.assets?.length ? { assets: t.assets.slice() } : {},
4490
4535
  ...t.resources?.length ? { resources: t.resources.slice() } : {},
@@ -4999,7 +5044,7 @@ var FlowEngine = class {
4999
5044
  committedDemands() {
5000
5045
  const out = [];
5001
5046
  for (const o of this.orders.values()) {
5002
- if (o.status === "completed" || o.status === "cancelled") continue;
5047
+ if (isOrderTerminal(o)) continue;
5003
5048
  const remaining = Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
5004
5049
  if (!remaining) continue;
5005
5050
  out.push({ remaining, ...o.endTime ? { dueTime: o.endTime } : {} });
@@ -6186,8 +6231,27 @@ var FlowEngine = class {
6186
6231
  ...i.ilmd ? { ilmd: i.ilmd } : {}
6187
6232
  };
6188
6233
  }
6234
+ /**
6235
+ * 진척 — **모르면 답하지 않는다.**
6236
+ *
6237
+ * ── 무엇이 틀려 있었나 (2026-08-23 실측) ───────────────────────────────────
6238
+ * 예전에는 `durationMs <= 0` 이면 `1` 을 돌려줬다. 시뮬에서는 그것이 맞다(소요 0 = 즉시 끝나는 일).
6239
+ * 그런데 **미러에서 `durationMs` 0 은 「즉시」가 아니라 「모른다」다** — 원본이 소요를 말해 주지
6240
+ * 않으면 씨앗 경로가 0 으로 메우고, 그러면 이 함수가 **진행 중인 작업을 100% 라고 말한다.**
6241
+ *
6242
+ * 실측: 소요를 주지 않는 원본의 진행 중 작업이 `progress: 1` 로 나왔다. 「모름」을 「완료」로 말하는
6243
+ * 것이고, 화면은 그것을 사실로 그린다.
6244
+ *
6245
+ * 시뮬에서는 이 반환이 바뀌지 않는다: 소요 0 인 작업은 배정된 tick 안에서 끝나므로 **진행 중으로
6246
+ * 관측되지 않는다**(`status = 'in-progress'; remainingMs = durationMs` → 다음 진행에서 즉시 완료).
6247
+ * 즉 `durationMs <= 0` 이면서 진행 중인 작업은 미러 경로에서만 나온다.
6248
+ *
6249
+ * 모를 때 원본이 진척을 말해 주었다면 그 값을 쓴다 — 그 규칙은 부르는 쪽에 있다(§`getSnapshot`).
6250
+ * 이 함수는 **커널이 계산할 수 있나**만 답한다.
6251
+ */
6189
6252
  progressOf(t) {
6190
- return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
6253
+ if (!(t.durationMs > 0)) return void 0;
6254
+ return Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
6191
6255
  }
6192
6256
  generate() {
6193
6257
  for (const g of this.gens) {
@@ -8962,6 +9026,7 @@ function retiredVocabularyIn(line) {
8962
9026
  OPERATION_PROPERTY,
8963
9027
  OP_EVENT,
8964
9028
  OP_PARAM,
9029
+ ORDER_TERMINAL_STATUS,
8965
9030
  ObservedReducer,
8966
9031
  PRIORITY_UNSET,
8967
9032
  RETIRED_VOCABULARY,
@@ -9032,6 +9097,7 @@ function retiredVocabularyIn(line) {
9032
9097
  isEnergyRecord,
9033
9098
  isEquipmentLevel,
9034
9099
  isOperationalRecord,
9100
+ isOrderTerminal,
9035
9101
  isTransformationRecord,
9036
9102
  isoDurationHours,
9037
9103
  itemKeyOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.50",
3
+ "version": "0.7.52",
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": {