@asc-agent/runtime 0.1.0

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.
Files changed (210) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +22 -0
  3. package/dist/adapters/claude-code/binding.d.ts +5 -0
  4. package/dist/adapters/claude-code/binding.js +14 -0
  5. package/dist/adapters/claude-code/guard.d.ts +54 -0
  6. package/dist/adapters/claude-code/guard.js +295 -0
  7. package/dist/adapters/claude-code/install.d.ts +67 -0
  8. package/dist/adapters/claude-code/install.js +231 -0
  9. package/dist/adapters/claude-code/observer.d.ts +38 -0
  10. package/dist/adapters/claude-code/observer.js +77 -0
  11. package/dist/adapters/claude-code/probe.d.ts +53 -0
  12. package/dist/adapters/claude-code/probe.js +129 -0
  13. package/dist/adapters/claude-code/skill.d.ts +8 -0
  14. package/dist/adapters/claude-code/skill.js +267 -0
  15. package/dist/adapters/fixture-work/index.d.ts +34 -0
  16. package/dist/adapters/fixture-work/index.js +78 -0
  17. package/dist/adapters/github/adapter.d.ts +32 -0
  18. package/dist/adapters/github/adapter.js +95 -0
  19. package/dist/adapters/github/client.d.ts +30 -0
  20. package/dist/adapters/github/client.js +90 -0
  21. package/dist/adapters/github/context.d.ts +44 -0
  22. package/dist/adapters/github/context.js +170 -0
  23. package/dist/adapters/github/event-source.d.ts +55 -0
  24. package/dist/adapters/github/event-source.js +183 -0
  25. package/dist/adapters/github/scm.d.ts +50 -0
  26. package/dist/adapters/github/scm.js +104 -0
  27. package/dist/adapters/gitlab/adapter.d.ts +36 -0
  28. package/dist/adapters/gitlab/adapter.js +117 -0
  29. package/dist/adapters/gitlab/client.d.ts +30 -0
  30. package/dist/adapters/gitlab/client.js +47 -0
  31. package/dist/adapters/gitlab/ports.d.ts +40 -0
  32. package/dist/adapters/gitlab/ports.js +186 -0
  33. package/dist/adapters/jam/adapter.d.ts +53 -0
  34. package/dist/adapters/jam/adapter.js +144 -0
  35. package/dist/adapters/jam/event-source.d.ts +28 -0
  36. package/dist/adapters/jam/event-source.js +65 -0
  37. package/dist/adapters/jam/mcp-client.d.ts +50 -0
  38. package/dist/adapters/jam/mcp-client.js +218 -0
  39. package/dist/adapters/jam/ports.d.ts +51 -0
  40. package/dist/adapters/jam/ports.js +167 -0
  41. package/dist/adapters/local/identity.d.ts +27 -0
  42. package/dist/adapters/local/identity.js +40 -0
  43. package/dist/adapters/local/presentation.d.ts +14 -0
  44. package/dist/adapters/local/presentation.js +42 -0
  45. package/dist/adapters/markdown/layout.d.ts +15 -0
  46. package/dist/adapters/markdown/layout.js +49 -0
  47. package/dist/adapters/markdown/serialize.d.ts +9 -0
  48. package/dist/adapters/markdown/serialize.js +120 -0
  49. package/dist/adapters/markdown/state-store.d.ts +23 -0
  50. package/dist/adapters/markdown/state-store.js +316 -0
  51. package/dist/adapters/mattermost/client.d.ts +23 -0
  52. package/dist/adapters/mattermost/client.js +54 -0
  53. package/dist/adapters/mattermost/presentation.d.ts +43 -0
  54. package/dist/adapters/mattermost/presentation.js +88 -0
  55. package/dist/adapters/memory/mocks.d.ts +87 -0
  56. package/dist/adapters/memory/mocks.js +163 -0
  57. package/dist/adapters/memory/runtime-binding.d.ts +23 -0
  58. package/dist/adapters/memory/runtime-binding.js +118 -0
  59. package/dist/adapters/memory/state-store.d.ts +17 -0
  60. package/dist/adapters/memory/state-store.js +125 -0
  61. package/dist/adapters/text/renderer.d.ts +7 -0
  62. package/dist/adapters/text/renderer.js +81 -0
  63. package/dist/adapters/webhook/ingress.d.ts +103 -0
  64. package/dist/adapters/webhook/ingress.js +150 -0
  65. package/dist/cli/asc.d.ts +16 -0
  66. package/dist/cli/asc.js +2914 -0
  67. package/dist/cli/identity-config.d.ts +7 -0
  68. package/dist/cli/identity-config.js +31 -0
  69. package/dist/composition/observe.d.ts +21 -0
  70. package/dist/composition/observe.js +72 -0
  71. package/dist/composition/registry.d.ts +33 -0
  72. package/dist/composition/registry.js +62 -0
  73. package/dist/composition/runtime.d.ts +63 -0
  74. package/dist/composition/runtime.js +155 -0
  75. package/dist/core/approval/service.d.ts +17 -0
  76. package/dist/core/approval/service.js +129 -0
  77. package/dist/core/attach/bootstrap.d.ts +102 -0
  78. package/dist/core/attach/bootstrap.js +178 -0
  79. package/dist/core/attach/init.d.ts +29 -0
  80. package/dist/core/attach/init.js +100 -0
  81. package/dist/core/attach/setup-plan.d.ts +125 -0
  82. package/dist/core/attach/setup-plan.js +177 -0
  83. package/dist/core/attach/setup.d.ts +40 -0
  84. package/dist/core/attach/setup.js +140 -0
  85. package/dist/core/binding/types.d.ts +93 -0
  86. package/dist/core/binding/types.js +74 -0
  87. package/dist/core/distribution/release.d.ts +15 -0
  88. package/dist/core/distribution/release.js +27 -0
  89. package/dist/core/distribution/runtime-install.d.ts +50 -0
  90. package/dist/core/distribution/runtime-install.js +90 -0
  91. package/dist/core/distribution/runtime-select.d.ts +74 -0
  92. package/dist/core/distribution/runtime-select.js +149 -0
  93. package/dist/core/execution/executor.d.ts +51 -0
  94. package/dist/core/execution/executor.js +106 -0
  95. package/dist/core/execution/grant.d.ts +42 -0
  96. package/dist/core/execution/grant.js +78 -0
  97. package/dist/core/model/entities.d.ts +872 -0
  98. package/dist/core/model/entities.js +285 -0
  99. package/dist/core/model/ids.d.ts +41 -0
  100. package/dist/core/model/ids.js +50 -0
  101. package/dist/core/model/transitions.d.ts +26 -0
  102. package/dist/core/model/transitions.js +124 -0
  103. package/dist/core/monitor/coverage.d.ts +111 -0
  104. package/dist/core/monitor/coverage.js +129 -0
  105. package/dist/core/monitor/engine.d.ts +134 -0
  106. package/dist/core/monitor/engine.js +574 -0
  107. package/dist/core/monitor/health-alerts.d.ts +35 -0
  108. package/dist/core/monitor/health-alerts.js +88 -0
  109. package/dist/core/monitor/investigation.d.ts +82 -0
  110. package/dist/core/monitor/investigation.js +232 -0
  111. package/dist/core/monitor/observation.d.ts +79 -0
  112. package/dist/core/monitor/observation.js +110 -0
  113. package/dist/core/monitor/relevance.d.ts +47 -0
  114. package/dist/core/monitor/relevance.js +100 -0
  115. package/dist/core/monitor/signals.d.ts +59 -0
  116. package/dist/core/monitor/signals.js +105 -0
  117. package/dist/core/operator/local-operator.d.ts +60 -0
  118. package/dist/core/operator/local-operator.js +82 -0
  119. package/dist/core/operator/preflight.d.ts +60 -0
  120. package/dist/core/operator/preflight.js +139 -0
  121. package/dist/core/operator/proceed.d.ts +98 -0
  122. package/dist/core/operator/proceed.js +167 -0
  123. package/dist/core/operator/progress.d.ts +124 -0
  124. package/dist/core/operator/progress.js +135 -0
  125. package/dist/core/operator/render.d.ts +34 -0
  126. package/dist/core/operator/render.js +170 -0
  127. package/dist/core/operator/runtime-binding.d.ts +61 -0
  128. package/dist/core/operator/runtime-binding.js +22 -0
  129. package/dist/core/policy/ownership.d.ts +44 -0
  130. package/dist/core/policy/ownership.js +46 -0
  131. package/dist/core/policy/policy.d.ts +99 -0
  132. package/dist/core/policy/policy.js +147 -0
  133. package/dist/core/policy/remote-freeze.d.ts +101 -0
  134. package/dist/core/policy/remote-freeze.js +151 -0
  135. package/dist/core/policy/scope.d.ts +23 -0
  136. package/dist/core/policy/scope.js +93 -0
  137. package/dist/core/presentation/digest.d.ts +98 -0
  138. package/dist/core/presentation/digest.js +160 -0
  139. package/dist/core/resolver/load.d.ts +121 -0
  140. package/dist/core/resolver/load.js +246 -0
  141. package/dist/core/resolver/render.d.ts +4 -0
  142. package/dist/core/resolver/render.js +62 -0
  143. package/dist/core/resolver/resolve.d.ts +42 -0
  144. package/dist/core/resolver/resolve.js +51 -0
  145. package/dist/core/resolver/version.d.ts +22 -0
  146. package/dist/core/resolver/version.js +49 -0
  147. package/dist/core/runtime/audit.d.ts +374 -0
  148. package/dist/core/runtime/audit.js +454 -0
  149. package/dist/core/runtime/claims.d.ts +115 -0
  150. package/dist/core/runtime/claims.js +153 -0
  151. package/dist/core/runtime/closure.d.ts +73 -0
  152. package/dist/core/runtime/closure.js +162 -0
  153. package/dist/core/runtime/controller.d.ts +40 -0
  154. package/dist/core/runtime/controller.js +121 -0
  155. package/dist/core/runtime/escalation.d.ts +188 -0
  156. package/dist/core/runtime/escalation.js +322 -0
  157. package/dist/core/runtime/execution-state.d.ts +43 -0
  158. package/dist/core/runtime/execution-state.js +81 -0
  159. package/dist/core/runtime/front.d.ts +95 -0
  160. package/dist/core/runtime/front.js +144 -0
  161. package/dist/core/runtime/orchestrator.d.ts +54 -0
  162. package/dist/core/runtime/orchestrator.js +98 -0
  163. package/dist/core/runtime/query.d.ts +184 -0
  164. package/dist/core/runtime/query.js +213 -0
  165. package/dist/core/runtime/report.d.ts +33 -0
  166. package/dist/core/runtime/report.js +108 -0
  167. package/dist/core/runtime/session.d.ts +156 -0
  168. package/dist/core/runtime/session.js +281 -0
  169. package/dist/core/runtime/store-ops.d.ts +22 -0
  170. package/dist/core/runtime/store-ops.js +26 -0
  171. package/dist/core/view/build-view.d.ts +36 -0
  172. package/dist/core/view/build-view.js +131 -0
  173. package/dist/core/view/decision-view.d.ts +564 -0
  174. package/dist/core/view/decision-view.js +103 -0
  175. package/dist/core/workspace/identity.d.ts +82 -0
  176. package/dist/core/workspace/identity.js +133 -0
  177. package/dist/core/workspace/index-store.d.ts +140 -0
  178. package/dist/core/workspace/index-store.js +125 -0
  179. package/dist/core/workspace/migrate.d.ts +73 -0
  180. package/dist/core/workspace/migrate.js +123 -0
  181. package/dist/core/workspace/resolve.d.ts +34 -0
  182. package/dist/core/workspace/resolve.js +89 -0
  183. package/dist/ports/adapter.d.ts +35 -0
  184. package/dist/ports/adapter.js +12 -0
  185. package/dist/ports/approval.d.ts +98 -0
  186. package/dist/ports/approval.js +7 -0
  187. package/dist/ports/change-context.d.ts +22 -0
  188. package/dist/ports/change-context.js +8 -0
  189. package/dist/ports/event-source.d.ts +42 -0
  190. package/dist/ports/event-source.js +6 -0
  191. package/dist/ports/inventory.d.ts +53 -0
  192. package/dist/ports/inventory.js +12 -0
  193. package/dist/ports/presentation.d.ts +62 -0
  194. package/dist/ports/presentation.js +13 -0
  195. package/dist/ports/renderer.d.ts +23 -0
  196. package/dist/ports/renderer.js +7 -0
  197. package/dist/ports/resource-context.d.ts +51 -0
  198. package/dist/ports/resource-context.js +9 -0
  199. package/dist/ports/scm.d.ts +40 -0
  200. package/dist/ports/scm.js +6 -0
  201. package/dist/ports/state-store.d.ts +97 -0
  202. package/dist/ports/state-store.js +12 -0
  203. package/dist/presets/balanced.json +8 -0
  204. package/dist/presets/conservative.json +9 -0
  205. package/dist/presets/lightweight.json +8 -0
  206. package/dist/profiles/example-team/profile.json +101 -0
  207. package/dist/profiles/pilot-local/profile.json +56 -0
  208. package/dist/schemas/profile.d.ts +631 -0
  209. package/dist/schemas/profile.js +287 -0
  210. package/package.json +55 -0
@@ -0,0 +1,574 @@
1
+ // Monitor Engine — 이벤트를 발견하고, 사람이 판단할 것만 골라 보고서로 만든다.
2
+ //
3
+ // 두 단계로 나뉘는 이유는 값이다 (OM §10.2~10.3). Phase A는 들어온 것 전부를 훑지만
4
+ // 싸게 훑고, Phase B는 비싸게 파지만 수신함에 올릴 것만 판다. 전부를 깊게 조사하면
5
+ // 조용한 날에도 비용이 나가고, 전부를 얕게 두면 정작 답해야 할 것 앞에서 사람이
6
+ // 스레드를 처음부터 읽어야 한다.
7
+ //
8
+ // 이 파일에도 프로젝트 고유값은 없다. 누가 "나"인지, 어떤 라벨이 급한지는 config가 들고 온다.
9
+ import { ApprovalRequest } from "../model/entities.js";
10
+ import { classify } from "./signals.js";
11
+ import { evaluateRelevance } from "./relevance.js";
12
+ import { fingerprintOf } from "./observation.js";
13
+ import { CoverageLedger } from "./coverage.js";
14
+ import { investigate } from "./investigation.js";
15
+ /**
16
+ * 회수 경로가 찾은 변화를 이벤트 하나로 만든다.
17
+ *
18
+ * key에 marker를 넣는 이유: 같은 리소스라도 달라진 것은 다른 사건이다. marker가 같으면
19
+ * 애초에 diff에 오르지 않으므로 회차마다 새 key가 쏟아지지 않는다 (C-07 §1.4).
20
+ */
21
+ function sweepEvent(kind, diff, at) {
22
+ const item = diff.kind === 'RESOURCE_MISSING' ? null : diff.item;
23
+ const reference = item?.reference ?? (diff.kind === 'RESOURCE_MISSING' ? diff.reference : '');
24
+ return {
25
+ eventKey: `${kind}:${reference}:${item?.revisionMarker ?? at}`,
26
+ detectedAt: at,
27
+ reference,
28
+ // assignee를 actors로 넘기지 않는다 — actors는 "누가 했는가"이고, 그걸로 자기 글
29
+ // 억제가 걸린다. 배정 사실은 signal context로 따로 전달한다.
30
+ ...(item
31
+ ? {
32
+ ...(item.labels ? { hints: { labels: item.labels } } : {}),
33
+ raw: { kind: 'inventory', title: item.title, state: item.state },
34
+ }
35
+ : {}),
36
+ };
37
+ }
38
+ /** cursor는 entity가 아니라 Monitor 계약의 일부라 Adapter scope에 둔다. */
39
+ const CURSOR_KEY = 'cursor';
40
+ const LEASE_KEY = 'scan-lease';
41
+ /** 이보다 오래된 lease는 죽은 프로세스가 남긴 것으로 보고 회수한다. */
42
+ const LEASE_STALE_MS = 5 * 60_000;
43
+ export class MonitorEngine {
44
+ #store;
45
+ #source;
46
+ #config;
47
+ #scm;
48
+ #approver;
49
+ #canonicalSources;
50
+ #observe;
51
+ #observations;
52
+ #inventory;
53
+ #investigation;
54
+ #investigationContext;
55
+ #now;
56
+ #runId;
57
+ #startFrom;
58
+ constructor(deps) {
59
+ this.#store = deps.store;
60
+ this.#source = deps.source;
61
+ this.#config = deps.config;
62
+ this.#scm = deps.scm;
63
+ this.#approver = deps.authorizedApprover;
64
+ this.#canonicalSources = deps.canonicalSources ?? [];
65
+ this.#observe = deps.observe;
66
+ this.#observations = deps.observations;
67
+ this.#inventory = deps.inventory;
68
+ this.#investigation = deps.investigation;
69
+ this.#investigationContext = deps.investigationContext;
70
+ this.#now = deps.now ?? (() => new Date().toISOString());
71
+ this.#runId = deps.runId ?? `${process.pid}-${this.#source.id}`;
72
+ this.#startFrom = deps.startFrom;
73
+ }
74
+ /**
75
+ * 한 회차. 겹쳐 돌면 같은 이벤트로 요청이 두 개 생기므로 하나만 돈다 (OM §7.2·§10.1).
76
+ *
77
+ * 같은 객체 안에서만 막으면 소용이 없다 — CLI를 두 번 띄우면 프로세스가 둘이고, 둘 다
78
+ * 자기 안에서는 첫 Run이다. 그래서 잠금을 `.asc/` 안에 둔다: 프로젝트 하나에 Run 하나.
79
+ *
80
+ * 대기시키지 않고 건너뛰는 이유는, 밀린 Run이 쌓이는 것보다 다음 회차가 다시 보는 편이
81
+ * 싸기 때문이다 — 이벤트는 사라지지 않고 cursor에 남아 있다.
82
+ */
83
+ async scan() {
84
+ const scope = this.#store.scope(`monitor:${this.#source.id}`);
85
+ if (!(await this.#acquire(scope))) {
86
+ return { skipped: true, detected: 0, duplicates: 0, logged: 0, packets: [], retries: [], cursor: null };
87
+ }
88
+ try {
89
+ return await this.#scan(scope);
90
+ }
91
+ finally {
92
+ await scope.delete(LEASE_KEY);
93
+ }
94
+ }
95
+ /** 비정상 종료로 남은 lease는 시간이 지나면 회수한다 — 영영 잠긴 채로 두지 않는다. */
96
+ async #acquire(scope) {
97
+ const mine = JSON.stringify({ owner: this.#runId, at: this.#now() });
98
+ if (await scope.setIfAbsent(LEASE_KEY, mine))
99
+ return true;
100
+ const held = await scope.get(LEASE_KEY);
101
+ if (!held)
102
+ return scope.setIfAbsent(LEASE_KEY, mine);
103
+ try {
104
+ const { at } = JSON.parse(held);
105
+ // 같은 시계로 잰다. 기록은 주입된 시계로 하고 판정만 벽시계로 하면, 시계가 서로
106
+ // 어긋나는 순간 살아 있는 lease를 죽은 것으로 회수한다 — 테스트의 고정 시각에서
107
+ // 실제로 터졌던 결함이다.
108
+ if (new Date(this.#now()).getTime() - new Date(at).getTime() < LEASE_STALE_MS)
109
+ return false;
110
+ }
111
+ catch {
112
+ // 읽을 수 없는 lease도 죽은 것으로 본다
113
+ }
114
+ await scope.delete(LEASE_KEY);
115
+ return scope.setIfAbsent(LEASE_KEY, mine);
116
+ }
117
+ /**
118
+ * 이번 관측을 판단 대기함에 올릴 것인가 (C-07 §4·§5).
119
+ *
120
+ * 관측 기록이 없으면 예전처럼 신호만으로 정한다 — 없는 기능을 조용히 켜지 않는다.
121
+ * 관련성 판정이 없으면 억제도 하지 않는다: 근거 없이 숨기는 것이 가장 나쁜 결과다.
122
+ */
123
+ async #shouldSurface(event, relevance, revisionMarker) {
124
+ if (!this.#observations || !relevance)
125
+ return true;
126
+ const fingerprint = fingerprintOf(relevance, revisionMarker ?? event.eventKey);
127
+ const decision = await this.#observations.decide(event.reference, fingerprint, relevance.disposition);
128
+ await this.#observations.record(event.reference, fingerprint, relevance.disposition, relevance.disposition === 'SHADOW' ? '관련 근거 없음' : undefined);
129
+ return decision.surface;
130
+ }
131
+ /**
132
+ * 이벤트 하나를 Phase A로 통과시킨다 — dedupe · 신호 · 관련성 · 억제 · 기록.
133
+ *
134
+ * 빠른 경로(scan)와 회수 경로(reconcile·census)가 같은 문을 지나야 한다. 경로마다 다른
135
+ * 판정을 하면 "늦게 발견됐으니 덜 중요하다"가 코드에 새겨진다 (C-07 §1.6).
136
+ */
137
+ async #intake(event, extra = {}) {
138
+ // dedupe는 log를 훑는 게 아니라 key 하나로 본다 (OM §10.4)
139
+ if (await this.#store.get('event', event.eventKey))
140
+ return { duplicate: true };
141
+ // 밖에서 알아 온 사실을 먼저 모은다 — 없으면 신호만으로 판정한다.
142
+ const observed = this.#observe ? await this.#observe(event) : {};
143
+ const verdict = classify(event, this.#config, { ...extra, ...(observed.signal ?? {}) });
144
+ // 신호와 관련성은 다른 층이다 (C-07 §2). 신호는 "무슨 일이 있었나"이고
145
+ // 관련성은 "그래서 내 일인가"다.
146
+ const relevance = observed.relevance ? evaluateRelevance(verdict.signals, observed.relevance) : undefined;
147
+ const surfaced = await this.#shouldSurface(event, relevance, observed.revisionMarker);
148
+ const inboxCandidate = verdict.inboxCandidate && surfaced;
149
+ // 빠른 경로가 본 것을 회수 경로도 알아야 한다. 안 그러면 다음 대조가 같은 변화를
150
+ // "처음 본다"로 판단해 패킷이 둘이 된다 (C-07 §1.7).
151
+ if (observed.revisionMarker) {
152
+ await this.#coverage().record({ reference: event.reference, revisionMarker: observed.revisionMarker });
153
+ }
154
+ await this.#store.create('event', {
155
+ eventKey: event.eventKey,
156
+ version: 0,
157
+ detectedAt: event.detectedAt,
158
+ type: verdict.type,
159
+ suggestedPriority: verdict.priority,
160
+ processing: inboxCandidate ? 'PENDING_RETRY' : 'LOGGED',
161
+ inboxCandidate,
162
+ ...(relevance
163
+ ? {
164
+ relevance: {
165
+ explicit: relevance.explicit,
166
+ actual: relevance.actual,
167
+ disposition: relevance.disposition,
168
+ evidence: relevance.evidence.map((e) => `${e.supports ? '+' : '-'} ${e.detail}`),
169
+ },
170
+ }
171
+ : {}),
172
+ // 조사가 실패해도 다시 해볼 수 있게 원본을 남긴다. cursor는 이미 지나갔고,
173
+ // provider가 같은 것을 또 주리라는 보장이 없다.
174
+ ...(inboxCandidate
175
+ ? {
176
+ replay: {
177
+ reference: event.reference,
178
+ ...(event.raw !== undefined ? { raw: event.raw } : {}),
179
+ ...(event.hints !== undefined ? { hints: event.hints } : {}),
180
+ },
181
+ }
182
+ : {}),
183
+ });
184
+ // log는 전 이벤트, 수신함은 행동할 것만 (OM §10.2)
185
+ await this.#store.appendHistory({
186
+ at: event.detectedAt,
187
+ actor: this.#source.id,
188
+ kind: 'monitor_event',
189
+ ref: event.eventKey,
190
+ detail: `${event.reference} · ${verdict.type} · ${verdict.priority} · ` +
191
+ `${verdict.signals.join(',') || 'no-signal'}` +
192
+ (relevance ? ` · relevance ${relevance.explicit}/${relevance.actual}` : '') +
193
+ // 올리지 않은 것도 log에는 남는다. 숨김은 폐기가 아니다 (OM §10.7 허용 write).
194
+ (verdict.inboxCandidate && !inboxCandidate ? ' · 억제' : ''),
195
+ });
196
+ return { duplicate: false, ...(inboxCandidate ? { fresh: { event, verdict } } : {}) };
197
+ }
198
+ /** Coverage 기록은 scan lease와 같은 scope에 둔다 — 회수 경로도 같은 문을 지난다. */
199
+ #coverage() {
200
+ return new CoverageLedger(this.#store.scope(`monitor:${this.#source.id}`), this.#now);
201
+ }
202
+ /**
203
+ * 빠른 경로가 놓친 것을 회수한다 (C-07 §1.2). 알림 재생이 아니라 **목록 재조회**다.
204
+ *
205
+ * `updatedSince` 기준선 이후만 본다 — 전수는 census의 몫이고, 매번 전부 훑으면
206
+ * 30분마다 도는 물건이 될 수 없다.
207
+ */
208
+ async reconcile() {
209
+ return this.#sweep('reconcile');
210
+ }
211
+ /**
212
+ * 우리가 아는 세계와 provider의 실제 세계가 일치하는지 본다 (C-07 §1.3).
213
+ * 기준선 없이 전부 훑고, 알던 것 중 이번에 없는 것도 찾는다.
214
+ */
215
+ async census() {
216
+ return this.#sweep('census');
217
+ }
218
+ async #sweep(kind) {
219
+ const scope = this.#store.scope(`monitor:${this.#source.id}`);
220
+ if (!this.#inventory) {
221
+ return { kind, seen: 0, changed: 0, packets: [], missing: [], complete: false, detail: '목록을 셀 통로가 없다' };
222
+ }
223
+ // scan과 같은 lease를 쓴다. 회수와 빠른 경로가 겹쳐 돌면 같은 변화로 요청이 둘 생긴다.
224
+ if (!(await this.#acquire(scope))) {
225
+ return { skipped: true, kind, seen: 0, changed: 0, packets: [], missing: [], complete: false };
226
+ }
227
+ try {
228
+ const coverage = this.#coverage();
229
+ const health = await coverage.health();
230
+ const query = kind === 'reconcile' && health.coverageWatermark ? { updatedSince: health.coverageWatermark } : {};
231
+ const items = [];
232
+ const seen = new Set();
233
+ let complete = false;
234
+ let cursor;
235
+ let failure;
236
+ // 페이지를 끝까지 돈다. 완주 여부는 **마지막 페이지가 말한다** — 중간 페이지는
237
+ // 아직 알 수 없으므로 false이고, 그것을 그대로 받으면 어떤 다중 페이지 열거도
238
+ // 완주로 인정되지 않는다.
239
+ for (;;) {
240
+ const page = await this.#inventory.enumerate(query, cursor).catch((error) => {
241
+ failure = String(error);
242
+ return { items: [], complete: false };
243
+ });
244
+ for (const item of page.items) {
245
+ items.push(item);
246
+ seen.add(item.reference);
247
+ }
248
+ complete = page.complete;
249
+ if (!page.next)
250
+ break;
251
+ cursor = page.next;
252
+ }
253
+ const diffs = await coverage.diff(items);
254
+ // 상실 판정은 census에서, 그것도 완주한 목록에서만 한다.
255
+ const missing = kind === 'census' ? await coverage.missing(seen, complete && !failure) : [];
256
+ const at = this.#now();
257
+ const packets = [];
258
+ const fresh = [];
259
+ const me = this.#config.identities ?? [];
260
+ for (const diff of diffs) {
261
+ if (diff.kind === 'RESOURCE_MISSING')
262
+ continue;
263
+ // 목록에서만 알 수 있는 사실을 신호 판정에 넘긴다. 알림이 오지 않은 배정이
264
+ // 정확히 이 경로로 잡힌다.
265
+ const assignedToMe = (diff.item.assignees ?? []).some((who) => me.includes(who));
266
+ const taken = await this.#intake(sweepEvent(kind, diff, at), assignedToMe ? { assignedToMe } : {});
267
+ if (!taken.duplicate && taken.fresh)
268
+ fresh.push(taken.fresh);
269
+ await coverage.record(diff.item);
270
+ }
271
+ for (const { event, verdict } of fresh) {
272
+ const made = await this.#buildPacket(event, verdict);
273
+ if (made)
274
+ packets.push(made);
275
+ }
276
+ for (const gone of missing) {
277
+ // 사라진 이유를 여기서 정하지 않는다. 사실만 남기고 사람이 본다.
278
+ await this.#store.appendHistory({
279
+ at,
280
+ actor: this.#source.id,
281
+ kind: 'coverage_anomaly',
282
+ ref: gone.kind === 'RESOURCE_MISSING' ? gone.reference : '',
283
+ detail: 'RESOURCE_MISSING — 알던 리소스가 이번 목록에 없다 (삭제·권한·가시성·조회 오류 중 무엇인지는 모른다)',
284
+ });
285
+ }
286
+ // 기준선은 **provider가 말한 시각**으로 옮긴다. 우리 시계로 옮기면 시계 차이만큼의
287
+ // 변경이 영영 회수되지 않는다 — 그 창이 정확히 이 경로가 막으려던 구멍이다.
288
+ const watermark = items.reduce((max, item) => (max === undefined || item.updatedAt > max ? item.updatedAt : max), undefined);
289
+ await coverage.updateHealth({
290
+ ...(kind === 'reconcile' ? { lastReconcileAt: at } : { lastCensusAt: at }),
291
+ // 완주하지 못한 회차로 기준선을 옮기면 그 사이 변경이 영영 회수되지 않는다.
292
+ ...(complete && !failure && watermark ? { coverageWatermark: watermark } : {}),
293
+ paginationComplete: complete && !failure,
294
+ sourceHealthy: !failure,
295
+ ...(failure ? { detail: failure } : {}),
296
+ });
297
+ return {
298
+ kind,
299
+ seen: items.length,
300
+ changed: diffs.filter((d) => d.kind !== 'RESOURCE_MISSING').length,
301
+ packets,
302
+ missing: missing.flatMap((m) => (m.kind === 'RESOURCE_MISSING' ? [m.reference] : [])),
303
+ complete: complete && !failure,
304
+ ...(failure ? { detail: failure } : {}),
305
+ };
306
+ }
307
+ finally {
308
+ await scope.delete(LEASE_KEY);
309
+ }
310
+ }
311
+ /** 지금까지 어디까지 확인했는가. 판정하지 않고 보여주기만 한다. */
312
+ async health() {
313
+ return this.#coverage().health();
314
+ }
315
+ async #scan(scope) {
316
+ // 지난 회차에 조사하다 실패한 것부터. 새 이벤트에 밀려 영영 안 보는 일이 없게 한다.
317
+ const pending = await this.#store.list('event', { where: { processing: 'PENDING_RETRY' } });
318
+ // 처음 도는 회차라면 어디서부터 볼지 정해 둔다. 과거를 전부 긁으면 사람이 읽을 수 없다.
319
+ let cursor = await scope.get(CURSOR_KEY);
320
+ if (cursor === null && this.#startFrom && this.#source.cursorFrom) {
321
+ cursor = this.#source.cursorFrom(this.#startFrom);
322
+ await scope.set(CURSOR_KEY, cursor ?? '');
323
+ }
324
+ const batch = await this.#source.drain(cursor);
325
+ let duplicates = 0;
326
+ let logged = 0;
327
+ const packets = [];
328
+ const retries = [];
329
+ // ── Phase A — 전부, 싸게 ──────────────────────────────────────────────
330
+ const fresh = [];
331
+ for (const event of batch.events) {
332
+ const taken = await this.#intake(event);
333
+ if (taken.duplicate) {
334
+ duplicates++;
335
+ continue;
336
+ }
337
+ logged++;
338
+ if (taken.fresh)
339
+ fresh.push(taken.fresh);
340
+ }
341
+ // ── Phase B — 수신함에 올릴 것만, 깊게 ────────────────────────────────
342
+ // 지난 회차 실패분을 먼저 처리한다. 새 이벤트가 계속 들어오면 영영 뒤로 밀리기 때문이다.
343
+ for (const stale of pending) {
344
+ const restored = restore(stale);
345
+ if (!restored) {
346
+ retries.push(stale.eventKey);
347
+ continue;
348
+ }
349
+ const made = await this.#buildPacket(restored.event, restored.verdict, restored.steps);
350
+ if (made)
351
+ packets.push(made);
352
+ else
353
+ retries.push(stale.eventKey);
354
+ }
355
+ for (const { event, verdict } of fresh) {
356
+ const made = await this.#buildPacket(event, verdict);
357
+ if (made)
358
+ packets.push(made);
359
+ else
360
+ retries.push(event.eventKey);
361
+ }
362
+ // cursor는 Phase B까지 끝난 뒤에 옮긴다. 중간에 죽으면 다시 받는 편이 안전하다 (OM §10.5)
363
+ if (batch.cursor)
364
+ await scope.set(CURSOR_KEY, batch.cursor);
365
+ // 빠른 경로가 마지막으로 무언가를 본 시각. 이 값이 오래됐다는 것 자체가 신호다 —
366
+ // 조용한 것과 끊긴 것을 사람이 구분할 수 있어야 한다 (C-07 §8.2).
367
+ if (batch.events.length > 0) {
368
+ await this.#coverage().updateHealth({ lastHotEventAt: batch.events.at(-1).detectedAt });
369
+ }
370
+ return {
371
+ detected: batch.events.length,
372
+ duplicates,
373
+ logged,
374
+ packets,
375
+ retries,
376
+ cursor: batch.cursor,
377
+ };
378
+ }
379
+ /** 조사에 실패하면 null. 이벤트는 PENDING_RETRY로 남아 다음 회차가 다시 본다. */
380
+ async #buildPacket(event, verdict, resume = []) {
381
+ // 끝난 단계는 실패해도 남긴다. 재시도가 처음부터 다시 하면 비싼 단계에서 걸린 사건은
382
+ // 영영 넘지 못한다 (C-07 §6.3).
383
+ let progress = resume;
384
+ try {
385
+ const requestId = await this.#nextRequestId();
386
+ const snapshot = this.#scm && this.#canonicalSources.length > 0
387
+ ? await this.#scm.getBaselines(this.#canonicalSources.map((sourceId) => ({ sourceId })))
388
+ : [];
389
+ const thread = this.#scm ? await this.#scm.getThread(event.reference) : null;
390
+ const control = await this.#store.getControlState();
391
+ const depth = packetDepth(verdict.type, verdict.priority);
392
+ const raw = (event.raw ?? {});
393
+ // 단계 조사 (C-07 §6). 각 단계는 필요한 Port만 요청하고, 없으면 판정 불성립으로 남는다.
394
+ const known = await this.#store.get('event', event.eventKey);
395
+ const context = this.#investigationContext?.(event) ?? {};
396
+ const previous = await this.#coverage().get(event.reference);
397
+ const inquiry = await investigate({
398
+ reference: event.reference,
399
+ ...(known?.relevance
400
+ ? {
401
+ relevance: {
402
+ explicit: known.relevance.explicit,
403
+ actual: known.relevance.actual,
404
+ disposition: known.relevance.disposition,
405
+ evidence: known.relevance.evidence.map((line) => ({
406
+ kind: 'ownership',
407
+ detail: line.replace(/^[+-] /, ''),
408
+ supports: line.startsWith('+'),
409
+ })),
410
+ },
411
+ }
412
+ : {}),
413
+ ...(previous ? { previous: { revisionMarker: previous.revisionMarker, state: previous.state } } : {}),
414
+ ...(control.activeSessions.length > 0 ? { activeSessions: control.activeSessions } : {}),
415
+ ...context,
416
+ }, {
417
+ ...(this.#investigation ?? {}),
418
+ ...(this.#scm && this.#canonicalSources.length > 0
419
+ ? { baselines: async () => snapshot }
420
+ : {}),
421
+ }, resume);
422
+ progress = inquiry.steps;
423
+ const request = ApprovalRequest.parse({
424
+ id: requestId,
425
+ version: 0,
426
+ status: 'AWAITING_APPROVAL',
427
+ type: verdict.type,
428
+ priority: verdict.priority,
429
+ title: raw.title ?? `${event.reference} 확인 필요`,
430
+ detectedAt: event.detectedAt,
431
+ source: {
432
+ eventKey: event.eventKey,
433
+ reference: event.reference,
434
+ ...(thread && !thread.missing ? { threadLastEventId: thread.lastEventId } : {}),
435
+ },
436
+ situation: situationOf(event, verdict, raw),
437
+ // 깊이는 유형이 정한다. 참고용 알림에 전체 보고서를 붙이면 정작 급한 것이 묻힌다.
438
+ // 조사 결과는 요약하지 않고 그대로 잇는다 — 무엇을 못 봤는지가 특히 남아야 한다.
439
+ context: depth === 'full' ? [contextOf(event, verdict), ...inquiry.situation].join('\n') : '',
440
+ impact: {
441
+ interruptRequired: verdict.priority === 'P0' && verdict.type !== 'informational',
442
+ affectedSessions: control.activeSessions,
443
+ rationale: depth === 'brief' ? '' : rationaleOf(verdict, control.activeSessions.length),
444
+ },
445
+ recommendation: inquiry.undecidable.length > 0
446
+ ? `${inquiry.recommendation} (확인 못 함: ${inquiry.undecidable.join(' / ')})`
447
+ : inquiry.recommendation,
448
+ // 정본은 Monitor가 답변 초안까지 준비하도록 한다 (OM §10.3 — 대응형 전체 패킷).
449
+ // 지금 비어 있는 것은 계약이 아니라 미구현이다: 초안을 짓는 Draft Generator가
450
+ // 아직 없다. 금지된 것은 초안 작성이 아니라 승인 없는 게시다 (OM §11.5).
451
+ snapshot,
452
+ authorizedApprover: this.#approver,
453
+ allowedDecisions: allowedDecisionsFor(verdict.type),
454
+ });
455
+ const created = await this.#store.create('request', request);
456
+ if (!created.ok) {
457
+ // 만들지 못했어도 조사한 것은 남긴다 — 다음 회차가 같은 조사를 다시 하지 않게.
458
+ await this.#saveProgress(event.eventKey, progress);
459
+ return null;
460
+ }
461
+ // 조사까지 끝났으니 이 이벤트는 처리된 것이다
462
+ const stored = (await this.#store.get('event', event.eventKey));
463
+ await this.#store.compareAndSet('event', event.eventKey, stored.version, {
464
+ ...stored,
465
+ version: stored.version + 1,
466
+ processing: 'PROCESSED',
467
+ requestId,
468
+ });
469
+ await this.#store.appendHistory({
470
+ at: this.#now(),
471
+ actor: 'monitor',
472
+ kind: 'packet_created',
473
+ ref: requestId,
474
+ detail: `${event.reference} · ${depth}`,
475
+ });
476
+ return requestId;
477
+ }
478
+ catch {
479
+ // 한 건이 실패해도 나머지는 계속 간다 (OM §10.5)
480
+ await this.#saveProgress(event.eventKey, progress);
481
+ return null;
482
+ }
483
+ }
484
+ /** 실패한 조사의 부분 결과를 replay에 남긴다. 다음 회차가 여기서부터 잇는다. */
485
+ async #saveProgress(eventKey, steps) {
486
+ if (steps.length === 0)
487
+ return;
488
+ const stored = await this.#store.get('event', eventKey);
489
+ if (!stored?.replay)
490
+ return;
491
+ await this.#store.compareAndSet('event', eventKey, stored.version, {
492
+ ...stored,
493
+ version: stored.version + 1,
494
+ replay: {
495
+ ...stored.replay,
496
+ steps: steps.map((step) => ({
497
+ id: step.id,
498
+ kind: step.kind,
499
+ findings: step.kind === 'DONE' ? step.findings : [],
500
+ ...(step.kind === 'DONE' ? {} : { detail: step.detail }),
501
+ })),
502
+ },
503
+ });
504
+ }
505
+ async #nextRequestId() {
506
+ const existing = await this.#store.list('request');
507
+ const numbers = existing.map((r) => Number(r.id.slice(4))).filter((n) => Number.isFinite(n));
508
+ const next = numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
509
+ return `REQ-${String(next).padStart(4, '0')}`;
510
+ }
511
+ }
512
+ /**
513
+ * 저장해 둔 이벤트를 조사 가능한 형태로 되살린다.
514
+ * 분류는 이미 끝나 있으므로 다시 하지 않는다 — 그때의 판정이 지금도 그 판정이다.
515
+ */
516
+ function restore(stored) {
517
+ if (!stored.replay)
518
+ return null;
519
+ return {
520
+ // 지난번에 끝낸 단계. 여기서부터 잇는다.
521
+ steps: (stored.replay.steps ?? []).map((step) => step.kind === 'DONE'
522
+ ? { id: step.id, kind: 'DONE', findings: step.findings }
523
+ : { id: step.id, kind: step.kind, detail: step.detail ?? '' }),
524
+ event: {
525
+ eventKey: stored.eventKey,
526
+ detectedAt: stored.detectedAt,
527
+ reference: stored.replay.reference,
528
+ ...(stored.replay.raw !== undefined ? { raw: stored.replay.raw } : {}),
529
+ ...(stored.replay.hints !== undefined ? { hints: stored.replay.hints } : {}),
530
+ },
531
+ verdict: {
532
+ signals: [],
533
+ type: stored.type,
534
+ priority: stored.suggestedPriority,
535
+ inboxCandidate: stored.inboxCandidate,
536
+ },
537
+ };
538
+ }
539
+ /** OM §10.3 — 대응형·작업형은 전부, 정보형은 우선순위에 따라 접는다. */
540
+ export function packetDepth(type, priority) {
541
+ if (type !== 'informational')
542
+ return 'full';
543
+ return priority === 'P2' ? 'brief' : 'compact';
544
+ }
545
+ function situationOf(event, verdict, raw) {
546
+ const what = verdict.signals.length > 0 ? verdict.signals.join(', ') : '변화 감지';
547
+ const snippet = raw.body ? ` — ${raw.body.slice(0, 140)}${raw.body.length > 140 ? '…' : ''}` : '';
548
+ return `${event.reference}: ${what}${snippet}`;
549
+ }
550
+ function contextOf(event, verdict) {
551
+ const labels = event.hints?.labels ?? [];
552
+ const actors = event.hints?.actors ?? [];
553
+ return [
554
+ actors.length > 0 ? `관련 인물: ${actors.join(', ')}` : '',
555
+ labels.length > 0 ? `라벨: ${labels.join(', ')}` : '',
556
+ `신호: ${verdict.signals.join(', ') || '없음'}`,
557
+ ]
558
+ .filter(Boolean)
559
+ .join('\n');
560
+ }
561
+ function rationaleOf(verdict, activeSessions) {
562
+ return `${verdict.type}/${verdict.priority} 판정. 활성 세션 ${activeSessions}건 기준으로 영향 산정.`;
563
+ }
564
+ function recommendationOf(type) {
565
+ if (type === 'work')
566
+ return '작업으로 승격할지 판단이 필요하다';
567
+ if (type === 'actionable')
568
+ return '답변이 필요하다 — 초안을 검토하고 승인하라';
569
+ return '확인만 하면 된다';
570
+ }
571
+ /** 작업형은 작업 큐로, 대응형은 답변으로 간다 (OM §11.10). */
572
+ function allowedDecisionsFor(type) {
573
+ return type === 'work' ? ['queue', 'defer', 'dismiss'] : ['approve', 'revise', 'defer', 'dismiss'];
574
+ }
@@ -0,0 +1,35 @@
1
+ import type { CoverageHealth } from './coverage.ts';
2
+ export type HealthAlertKind =
3
+ /** 빠른 경로로 사건이 온 지 오래됐다. 조용한 것인지 끊긴 것인지 모른다. */
4
+ 'HOT_PATH_STALE'
5
+ /** 회수 경로가 오래 돌지 않았다. 놓친 것이 쌓여 있을 수 있다. */
6
+ | 'RECONCILE_STALE'
7
+ /** 목록 무결성 확인이 오래됐다. */
8
+ | 'CENSUS_STALE'
9
+ /** 목록을 끝까지 못 보는 상태가 이어진다 — 상실 판정 자체가 서지 않는다. */
10
+ | 'PAGINATION_INCOMPLETE'
11
+ /** 외부 소스가 응답하지 않거나 자격이 상했다. */
12
+ | 'SOURCE_UNHEALTHY'
13
+ /** 한 번도 돌지 않았다. 설정만 하고 켜지 않은 상태다. */
14
+ | 'NEVER_RAN';
15
+ export type HealthAlert = {
16
+ kind: HealthAlertKind;
17
+ /** 사람이 읽는 한 줄. 무엇을 모르는지가 여기 있어야 한다. */
18
+ detail: string;
19
+ /** 마지막으로 확인된 시각. 없으면 확인된 적이 없다. */
20
+ lastAt?: string;
21
+ };
22
+ export type HealthThresholds = {
23
+ hotPathMs: number;
24
+ reconcileMs: number;
25
+ censusMs: number;
26
+ };
27
+ /**
28
+ * 지금 감시가 어디까지 성립하는가.
29
+ *
30
+ * **추측하지 않는다.** 사건이 안 오는 것이 조용한 것인지 끊긴 것인지 여기서 정하지 않고,
31
+ * "오래 안 왔다"는 사실만 든다 — 사람이 그 둘을 가른다.
32
+ */
33
+ export declare function evaluateHealth(health: CoverageHealth, at: string, thresholds: HealthThresholds): HealthAlert[];
34
+ /** 사람이 읽는 블록. 조용한 실패를 조용하게 두지 않는 것이 목적이다. */
35
+ export declare function healthAlertLines(alerts: readonly HealthAlert[]): string[];