@operato/twin-kernel 0.7.50 → 0.7.51

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/dist/contract.js CHANGED
@@ -1,6 +1,36 @@
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
  */
5
35
  /**
6
36
  * 자리의 상태 — **포화도에서 파생한다.** 저장하는 값이 아니다.
@@ -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
  /**
@@ -946,6 +946,15 @@ export class FlowEngine {
946
946
  id: p.id, personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds,
947
947
  status: purpose === 'observe' ? (p.status ?? 'idle') : 'idle',
948
948
  taskId: purpose === 'observe' ? (p.taskId ?? null) : null,
949
+ /*
950
+ * 원본이 말한 근무 밖 표시 — **관측일 때만, 그리고 선언이 없을 때만 쓰인다.**
951
+ *
952
+ * 이 규율의 원본 자리는 리듀서다(§`offShift`: 「선언이 없을 때만 받는다 — 선언이 있으면
953
+ * `snapshot()` 이 시각으로 판정하고, 저장값을 함께 두면 교대 안으로 돌아왔을 때 옛 `true` 가
954
+ * 남는다」). 그런데 이 자리가 그 값을 **떨어뜨리고 있었다** — 리듀서가 지킨 것을 여기서 잃었다.
955
+ * 근무 캘린더가 없는 원본(실 시스템에 흔하다)에서는 그것이 유일한 답이다.
956
+ */
957
+ ...(purpose === 'observe' && p.offShift ? { offShift: true } : {}),
949
958
  ...(p.location ? { location: p.location } : {}),
950
959
  ...(p.properties ? { properties: p.properties } : {}),
951
960
  ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}),
@@ -1026,11 +1035,35 @@ export class FlowEngine {
1026
1035
  const remaining = lines.length
1027
1036
  ? lines.reduce((s, l) => s + l.requested, 0)
1028
1037
  : Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
1029
- if (remaining <= 0)
1030
- continue; // 이미 이행 완료 예측 대상 아님
1038
+ /*
1039
+ * ── **씨앗은 남은 데맨드로, 미러는 들은 그대로** (2026-08-23) ──────────────
1040
+ *
1041
+ * 아래 세 값(`status`·`requested`·`fulfilled`)은 씨앗의 규약이다: 씨앗은 「무엇이 남았나」로
1042
+ * 미래를 굴리므로 요청 10·이행 4 를 **요청 6·이행 0·상태 created** 로 다시 표현한다. 그리고 이미
1043
+ * 이행된 오더는 예측 대상이 아니므로 건너뛴다.
1044
+ *
1045
+ * **미러에는 전부 틀렸다.** 미러는 굴리지 않고 들은 것을 말한다. 실측(포천 미러):
1046
+ *
1047
+ * 원본이 말한 것 picking · 요청 10 · 이행 4 · 진척 0.4
1048
+ * 커널이 낸 것 created · 요청 6 · 이행 0 · 진척 0 ← 40% 된 오더가 0% 로 보였다
1049
+ *
1050
+ * 그리고 이행 완료된 오더는 **상태에서 아예 사라졌다** — 원본이 그 오더가 있다고 말하는데
1051
+ * 트윈은 없다고 말한다. 오더의 상태 낱말은 계약이 도메인에 넘긴 것인데(`picking`·`shipped`…)
1052
+ * 씨앗 규약이 그것을 커널 낱말로 덮고 있었다.
1053
+ *
1054
+ * 이것은 작업에서 고친 것과 **같은 결함의 세 번째 사례**다(§`purpose`): 한 함수가 두 목적을 겸하고
1055
+ * 씨앗 규약이 관측에 새어 나온다.
1056
+ */
1057
+ const observing = purpose === 'observe';
1058
+ if (!observing && remaining <= 0)
1059
+ continue; // 씨앗: 이미 이행 완료 → 예측 대상 아님
1031
1060
  const resolved = this.resolvedAllocated(o.allocated);
1032
1061
  this.orders.set(o.orderId, {
1033
- id: o.orderId, kind: o.kind, status: 'created', requested: remaining, fulfilled: 0,
1062
+ id: o.orderId,
1063
+ kind: o.kind,
1064
+ status: observing ? (o.status ?? 'created') : 'created',
1065
+ requested: observing ? (o.requested ?? 0) : remaining,
1066
+ fulfilled: observing ? (o.fulfilled ?? 0) : 0,
1034
1067
  /* **확보해 둔 것과 거래번호를 이어받는다.** 비우면 되살아난 오더가 아무것도 안 잡은 것처럼
1035
1068
  보이고, 그 오더의 진행 중 작업이 완료될 때 계보가 입력 없이 나간다. 상태가 말해 주지
1036
1069
  않으면(옛 저널) 그때는 비는 것이 사실이다 — 없는 것을 지어내지 않는다. */
@@ -1039,7 +1072,9 @@ export class FlowEngine {
1039
1072
  /* 레시피도 함께 — 품목만 이어받으면 되살아난 오더가 「무엇으로」를 모르고, 그 오더의
1040
1073
  남은 공정·소요가 다른 레시피 기준으로 계산된다(같은 품목에 대체 레시피가 있다). */
1041
1074
  ...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
1042
- bizTransaction: o.bizTransaction ?? '', allocated: resolved.kept, picked: [], shipmentEpc: null, lines,
1075
+ bizTransaction: o.bizTransaction ?? '', allocated: resolved.kept, picked: [], shipmentEpc: null,
1076
+ /* 라인도 같다 — 씨앗은 남은 양만 남기고, 미러는 원본이 말한 요청·이행을 그대로 든다. */
1077
+ lines: observing ? (o.lines ?? []).map(l => ({ ...l })) : lines,
1043
1078
  ...(resolved.dropped ? { seedIncomplete: true } : {}),
1044
1079
  /* **보류를 이어받는다** — 잃으면 씨앗이 사람이 일부러 멈춘 오더를 다시 계획해 내보낸다.
1045
1080
  씨앗 왕복 대조가 이것을 잡았다(그 전에는 사람이 코드를 읽어야만 알 수 있었다). */
@@ -1112,7 +1147,12 @@ export class FlowEngine {
1112
1147
  startedAtSimMs: t.startedAtSimMs,
1113
1148
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
1114
1149
  orderId: t.orderId,
1115
- intent: t.intent
1150
+ intent: t.intent,
1151
+ /*
1152
+ * 원본이 말한 진척 — **관측일 때만, 그리고 커널이 계산할 수 없을 때 쓰인다**(§`progressOf`).
1153
+ * 씨앗 경로에서는 싣지 않는다: 씨앗은 굴러가면서 자기 소요로 진척을 만든다.
1154
+ */
1155
+ ...(purpose === 'observe' && typeof t.progress === 'number' ? { progress: t.progress } : {})
1116
1156
  });
1117
1157
  /*
1118
1158
  * 투입 인원은 **작업이 들고 있는 사실**이므로 관측에서도 그대로 옮긴다(위 `set` 에서 빠져 있어
@@ -1425,8 +1465,13 @@ export class FlowEngine {
1425
1465
  equipment: [...this.equipment.values()].map(m => {
1426
1466
  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
1467
  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 };
1468
+ /*
1469
+ * 이동 구간은 **소요를 때만** 낸다. 모르면 아래 산수가 전부 0 이나 음수가 되고, 화면은 「제자리에서
1470
+ * 다 끝난 이동」을 그린다 — 원본이 소요를 주지 않는 미러에서 실제로 그랬다(§`progressOf`).
1471
+ */
1472
+ const motionProgress = t && t.status === 'in-progress' && t.intent !== 'process' ? this.progressOf(t) : undefined;
1473
+ if (t && motionProgress !== undefined)
1474
+ 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
1475
  return s;
1431
1476
  }),
1432
1477
  assets: [...this.assets.values()].map(a => {
@@ -1455,10 +1500,17 @@ export class FlowEngine {
1455
1500
  물을 수 없다(사실은 자기 집에서 나온다). 판정은 아래 `capability` 가 이미 답한다. */
1456
1501
  if (p.testResults)
1457
1502
  st.testResults = p.testResults;
1503
+ /*
1504
+ * 근무 밖 판정 — **선언이 있으면 선언이 이기고, 없을 때만 원본이 말한 값을 쓴다.**
1505
+ * 진척(`progressOf`)과 같은 순서다. 선언이 있는데 저장값을 함께 보면 교대 안으로 돌아왔을 때
1506
+ * 옛 `true` 가 남는다(리듀서가 그 이유를 적어 두었다).
1507
+ */
1458
1508
  if (this.personOffShift(p)) {
1459
1509
  st.offShift = true;
1460
1510
  Object.assign(st, this.offReason(p));
1461
1511
  }
1512
+ else if (p.offShift && !p.workCalendar?.length)
1513
+ st.offShift = true;
1462
1514
  const sh = this.shiftOf(p);
1463
1515
  if (sh)
1464
1516
  st.shift = sh;
@@ -1479,7 +1531,26 @@ export class FlowEngine {
1479
1531
  fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
1480
1532
  ...(t.intent ? { intent: t.intent } : {}),
1481
1533
  ...(t.durationMs ? { durationMs: t.durationMs } : {}),
1482
- ...(t.status === 'in-progress' ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {}),
1534
+ /*
1535
+ * 진척은 **커널이 계산한 값이 먼저, 못 하면 원본이 말한 값**이다(§`progressOf`).
1536
+ *
1537
+ * 이 순서는 사람 교대(`offShift`)가 세워 둔 규율과 같다: 파생이 가능하면 파생이 이기고, 파생이
1538
+ * 불가능할 때만 원본의 단정을 쓴다. 둘 다 없으면 **싣지 않는다** — 「모른다」를 숫자로 만들지 않는다.
1539
+ *
1540
+ * 예전에는 원본이 진척을 말해도 조용히 버렸다(문이 받아 놓고 파생이 덮었다). 넣었는데 사라지는
1541
+ * 것이 이 시스템에서 가장 비싼 침묵이다.
1542
+ */
1543
+ ...(t.status === 'in-progress'
1544
+ ? {
1545
+ remainingMs: t.remainingMs,
1546
+ startedAtSimMs: t.startedAtSimMs,
1547
+ ...(() => {
1548
+ const derived = this.progressOf(t);
1549
+ const p = derived ?? t.progress;
1550
+ return p === undefined ? {} : { progress: p };
1551
+ })()
1552
+ }
1553
+ : {}),
1483
1554
  ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
1484
1555
  ...(t.assets?.length ? { assets: t.assets.slice() } : {}),
1485
1556
  ...(t.resources?.length ? { resources: t.resources.slice() } : {}),
@@ -3418,7 +3489,29 @@ export class FlowEngine {
3418
3489
  ...(i.ilmd ? { ilmd: i.ilmd } : {})
3419
3490
  };
3420
3491
  }
3421
- progressOf(t) { return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs)); }
3492
+ /**
3493
+ * 진척 — **모르면 답하지 않는다.**
3494
+ *
3495
+ * ── 무엇이 틀려 있었나 (2026-08-23 실측) ───────────────────────────────────
3496
+ * 예전에는 `durationMs <= 0` 이면 `1` 을 돌려줬다. 시뮬에서는 그것이 맞다(소요 0 = 즉시 끝나는 일).
3497
+ * 그런데 **미러에서 `durationMs` 0 은 「즉시」가 아니라 「모른다」다** — 원본이 소요를 말해 주지
3498
+ * 않으면 씨앗 경로가 0 으로 메우고, 그러면 이 함수가 **진행 중인 작업을 100% 라고 말한다.**
3499
+ *
3500
+ * 실측: 소요를 주지 않는 원본의 진행 중 작업이 `progress: 1` 로 나왔다. 「모름」을 「완료」로 말하는
3501
+ * 것이고, 화면은 그것을 사실로 그린다.
3502
+ *
3503
+ * 시뮬에서는 이 반환이 바뀌지 않는다: 소요 0 인 작업은 배정된 tick 안에서 끝나므로 **진행 중으로
3504
+ * 관측되지 않는다**(`status = 'in-progress'; remainingMs = durationMs` → 다음 진행에서 즉시 완료).
3505
+ * 즉 `durationMs <= 0` 이면서 진행 중인 작업은 미러 경로에서만 나온다.
3506
+ *
3507
+ * 모를 때 원본이 진척을 말해 주었다면 그 값을 쓴다 — 그 규칙은 부르는 쪽에 있다(§`getSnapshot`).
3508
+ * 이 함수는 **커널이 계산할 수 있나**만 답한다.
3509
+ */
3510
+ progressOf(t) {
3511
+ if (!(t.durationMs > 0))
3512
+ return undefined;
3513
+ return Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
3514
+ }
3422
3515
  generate() {
3423
3516
  for (const g of this.gens) {
3424
3517
  while (this.clockMs >= g.nextMs) {
@@ -4043,6 +4043,15 @@ var FlowEngine = class {
4043
4043
  personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds,
4044
4044
  status: purpose === "observe" ? p.status ?? "idle" : "idle",
4045
4045
  taskId: purpose === "observe" ? p.taskId ?? null : null,
4046
+ /*
4047
+ * 원본이 말한 근무 밖 표시 — **관측일 때만, 그리고 선언이 없을 때만 쓰인다.**
4048
+ *
4049
+ * 이 규율의 원본 자리는 리듀서다(§`offShift`: 「선언이 없을 때만 받는다 — 선언이 있으면
4050
+ * `snapshot()` 이 시각으로 판정하고, 저장값을 함께 두면 교대 안으로 돌아왔을 때 옛 `true` 가
4051
+ * 남는다」). 그런데 이 자리가 그 값을 **떨어뜨리고 있었다** — 리듀서가 지킨 것을 여기서 잃었다.
4052
+ * 근무 캘린더가 없는 원본(실 시스템에 흔하다)에서는 그것이 유일한 답이다.
4053
+ */
4054
+ ...purpose === "observe" && p.offShift ? { offShift: true } : {},
4046
4055
  ...p.location ? { location: p.location } : {},
4047
4056
  ...p.properties ? { properties: p.properties } : {},
4048
4057
  ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {},
@@ -4094,14 +4103,15 @@ var FlowEngine = class {
4094
4103
  for (const o of observedOrders) {
4095
4104
  const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
4096
4105
  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;
4106
+ const observing = purpose === "observe";
4107
+ if (!observing && remaining <= 0) continue;
4098
4108
  const resolved = this.resolvedAllocated(o.allocated);
4099
4109
  this.orders.set(o.orderId, {
4100
4110
  id: o.orderId,
4101
4111
  kind: o.kind,
4102
- status: "created",
4103
- requested: remaining,
4104
- fulfilled: 0,
4112
+ status: observing ? o.status ?? "created" : "created",
4113
+ requested: observing ? o.requested ?? 0 : remaining,
4114
+ fulfilled: observing ? o.fulfilled ?? 0 : 0,
4105
4115
  /* **확보해 둔 것과 거래번호를 이어받는다.** 비우면 되살아난 오더가 아무것도 안 잡은 것처럼
4106
4116
  보이고, 그 오더의 진행 중 작업이 완료될 때 계보가 입력 없이 나간다. 상태가 말해 주지
4107
4117
  않으면(옛 저널) 그때는 비는 것이 사실이다 — 없는 것을 지어내지 않는다. */
@@ -4114,7 +4124,8 @@ var FlowEngine = class {
4114
4124
  allocated: resolved.kept,
4115
4125
  picked: [],
4116
4126
  shipmentEpc: null,
4117
- lines,
4127
+ /* 라인도 같다 — 씨앗은 남은 양만 남기고, 미러는 원본이 말한 요청·이행을 그대로 든다. */
4128
+ lines: observing ? (o.lines ?? []).map((l) => ({ ...l })) : lines,
4118
4129
  ...resolved.dropped ? { seedIncomplete: true } : {},
4119
4130
  /* **보류를 이어받는다** — 잃으면 씨앗이 사람이 일부러 멈춘 오더를 다시 계획해 내보낸다.
4120
4131
  씨앗 왕복 대조가 이것을 잡았다(그 전에는 사람이 코드를 읽어야만 알 수 있었다). */
@@ -4175,7 +4186,12 @@ var FlowEngine = class {
4175
4186
  startedAtSimMs: t.startedAtSimMs,
4176
4187
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
4177
4188
  orderId: t.orderId,
4178
- intent: t.intent
4189
+ intent: t.intent,
4190
+ /*
4191
+ * 원본이 말한 진척 — **관측일 때만, 그리고 커널이 계산할 수 없을 때 쓰인다**(§`progressOf`).
4192
+ * 씨앗 경로에서는 싣지 않는다: 씨앗은 굴러가면서 자기 소요로 진척을 만든다.
4193
+ */
4194
+ ...purpose === "observe" && typeof t.progress === "number" ? { progress: t.progress } : {}
4179
4195
  });
4180
4196
  if (purpose === "observe" && t.personnel?.length) {
4181
4197
  const restored = this.tasks.get(t.id);
@@ -4436,7 +4452,8 @@ var FlowEngine = class {
4436
4452
  equipment: [...this.equipment.values()].map((m) => {
4437
4453
  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
4454
  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 };
4455
+ const motionProgress = t && t.status === "in-progress" && t.intent !== "process" ? this.progressOf(t) : void 0;
4456
+ 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
4457
  return s;
4441
4458
  }),
4442
4459
  assets: [...this.assets.values()].map((a) => {
@@ -4458,7 +4475,7 @@ var FlowEngine = class {
4458
4475
  if (this.personOffShift(p)) {
4459
4476
  st.offShift = true;
4460
4477
  Object.assign(st, this.offReason(p));
4461
- }
4478
+ } else if (p.offShift && !p.workCalendar?.length) st.offShift = true;
4462
4479
  const sh = this.shiftOf(p);
4463
4480
  if (sh) st.shift = sh;
4464
4481
  Object.assign(st, this.effectivePart(p));
@@ -4484,7 +4501,24 @@ var FlowEngine = class {
4484
4501
  orderId: t.orderId,
4485
4502
  ...t.intent ? { intent: t.intent } : {},
4486
4503
  ...t.durationMs ? { durationMs: t.durationMs } : {},
4487
- ...t.status === "in-progress" ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {},
4504
+ /*
4505
+ * 진척은 **커널이 계산한 값이 먼저, 못 하면 원본이 말한 값**이다(§`progressOf`).
4506
+ *
4507
+ * 이 순서는 사람 교대(`offShift`)가 세워 둔 규율과 같다: 파생이 가능하면 파생이 이기고, 파생이
4508
+ * 불가능할 때만 원본의 단정을 쓴다. 둘 다 없으면 **싣지 않는다** — 「모른다」를 숫자로 만들지 않는다.
4509
+ *
4510
+ * 예전에는 원본이 진척을 말해도 조용히 버렸다(문이 받아 놓고 파생이 덮었다). 넣었는데 사라지는
4511
+ * 것이 이 시스템에서 가장 비싼 침묵이다.
4512
+ */
4513
+ ...t.status === "in-progress" ? {
4514
+ remainingMs: t.remainingMs,
4515
+ startedAtSimMs: t.startedAtSimMs,
4516
+ ...(() => {
4517
+ const derived = this.progressOf(t);
4518
+ const p = derived ?? t.progress;
4519
+ return p === void 0 ? {} : { progress: p };
4520
+ })()
4521
+ } : {},
4488
4522
  ...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
4489
4523
  ...t.assets?.length ? { assets: t.assets.slice() } : {},
4490
4524
  ...t.resources?.length ? { resources: t.resources.slice() } : {},
@@ -6186,8 +6220,27 @@ var FlowEngine = class {
6186
6220
  ...i.ilmd ? { ilmd: i.ilmd } : {}
6187
6221
  };
6188
6222
  }
6223
+ /**
6224
+ * 진척 — **모르면 답하지 않는다.**
6225
+ *
6226
+ * ── 무엇이 틀려 있었나 (2026-08-23 실측) ───────────────────────────────────
6227
+ * 예전에는 `durationMs <= 0` 이면 `1` 을 돌려줬다. 시뮬에서는 그것이 맞다(소요 0 = 즉시 끝나는 일).
6228
+ * 그런데 **미러에서 `durationMs` 0 은 「즉시」가 아니라 「모른다」다** — 원본이 소요를 말해 주지
6229
+ * 않으면 씨앗 경로가 0 으로 메우고, 그러면 이 함수가 **진행 중인 작업을 100% 라고 말한다.**
6230
+ *
6231
+ * 실측: 소요를 주지 않는 원본의 진행 중 작업이 `progress: 1` 로 나왔다. 「모름」을 「완료」로 말하는
6232
+ * 것이고, 화면은 그것을 사실로 그린다.
6233
+ *
6234
+ * 시뮬에서는 이 반환이 바뀌지 않는다: 소요 0 인 작업은 배정된 tick 안에서 끝나므로 **진행 중으로
6235
+ * 관측되지 않는다**(`status = 'in-progress'; remainingMs = durationMs` → 다음 진행에서 즉시 완료).
6236
+ * 즉 `durationMs <= 0` 이면서 진행 중인 작업은 미러 경로에서만 나온다.
6237
+ *
6238
+ * 모를 때 원본이 진척을 말해 주었다면 그 값을 쓴다 — 그 규칙은 부르는 쪽에 있다(§`getSnapshot`).
6239
+ * 이 함수는 **커널이 계산할 수 있나**만 답한다.
6240
+ */
6189
6241
  progressOf(t) {
6190
- return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
6242
+ if (!(t.durationMs > 0)) return void 0;
6243
+ return Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
6191
6244
  }
6192
6245
  generate() {
6193
6246
  for (const g of this.gens) {
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.51",
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": {