@asc-agent/runtime 0.4.0 → 0.5.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 (38) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/fixture-surface/index.d.ts +33 -0
  3. package/dist/adapters/fixture-surface/index.js +97 -0
  4. package/dist/adapters/gitlab/adapter.js +1 -0
  5. package/dist/adapters/gitlab/client.d.ts +16 -2
  6. package/dist/adapters/gitlab/client.js +27 -0
  7. package/dist/adapters/gitlab/coordination.d.ts +19 -0
  8. package/dist/adapters/gitlab/coordination.js +111 -0
  9. package/dist/adapters/gitlab/ports.d.ts +6 -0
  10. package/dist/adapters/gitlab/ports.js +29 -2
  11. package/dist/adapters/local/canonical.d.ts +20 -0
  12. package/dist/adapters/local/canonical.js +58 -0
  13. package/dist/adapters/markdown/state-store.js +2 -1
  14. package/dist/adapters/service/launchd.js +18 -1
  15. package/dist/adapters/service/systemd-user.js +4 -1
  16. package/dist/adapters/webhook/ingress.d.ts +2 -2
  17. package/dist/cli/asc.js +244 -6
  18. package/dist/composition/runtime.d.ts +3 -0
  19. package/dist/composition/runtime.js +6 -1
  20. package/dist/core/binding/types.d.ts +1 -1
  21. package/dist/core/binding/types.js +1 -0
  22. package/dist/core/distribution/external-command.d.ts +26 -0
  23. package/dist/core/distribution/external-command.js +58 -1
  24. package/dist/core/distribution/persistent-runtime.d.ts +9 -0
  25. package/dist/core/distribution/release.d.ts +3 -3
  26. package/dist/core/distribution/release.js +1 -1
  27. package/dist/core/runtime/coordination.d.ts +312 -0
  28. package/dist/core/runtime/coordination.js +247 -0
  29. package/dist/core/runtime/front.d.ts +10 -0
  30. package/dist/core/runtime/front.js +16 -1
  31. package/dist/core/runtime/publish.d.ts +107 -0
  32. package/dist/core/runtime/publish.js +153 -0
  33. package/dist/core/runtime/workspaces.d.ts +22 -0
  34. package/dist/core/runtime/workspaces.js +18 -0
  35. package/dist/ports/coordination-surface.d.ts +59 -0
  36. package/dist/ports/coordination-surface.js +16 -0
  37. package/dist/ports/resource-context.d.ts +5 -0
  38. package/package.json +1 -1
@@ -0,0 +1,312 @@
1
+ import { z } from 'zod';
2
+ import type { ScopedStore } from '../../ports/state-store.ts';
3
+ /**
4
+ * 외부 시스템이 준 **안정적인** 신원 (F3).
5
+ *
6
+ * 사람이 보는 링크는 여기 있어도 되지만 그것이 정본은 아니다. 링크의 모양이 예상과
7
+ * 다르다는 이유로 생성이 실패했다고 읽고 하나 더 만든 사고가 실제로 있었다 — 그때
8
+ * 대조할 수 있는 다른 값이 계약에 없었다.
9
+ */
10
+ export declare const RemoteIdentity: z.ZodObject<{
11
+ /** 어느 adapter 가 이 사실을 만들었는가. provider 이름이 아니라 adapter id 다. */
12
+ adapter: z.ZodString;
13
+ /** 그 adapter 의 어휘로 된 객체 종류. Core 는 이 문자열로 분기하지 않는다. */
14
+ objectType: z.ZodString;
15
+ /** 그 시스템 안에서 이 객체를 다시 찾을 수 있는 값. **성공의 정본이다.** */
16
+ objectId: z.ZodString;
17
+ /** 어느 자원 아래인가 (저장소·프로젝트 등). adapter 어휘 그대로. */
18
+ resource: z.ZodOptional<z.ZodString>;
19
+ /**
20
+ * 사람이 열어 보는 주소. **판정에 쓰지 않는다** — 모양이 바뀌어도 같은 객체다.
21
+ */
22
+ locator: z.ZodOptional<z.ZodString>;
23
+ /** 이 시점의 판본. 있으면 응답 중복을 가리는 데 쓴다. */
24
+ revisionMarker: z.ZodOptional<z.ZodString>;
25
+ }, "strip", z.ZodTypeAny, {
26
+ adapter: string;
27
+ objectType: string;
28
+ objectId: string;
29
+ resource?: string | undefined;
30
+ revisionMarker?: string | undefined;
31
+ locator?: string | undefined;
32
+ }, {
33
+ adapter: string;
34
+ objectType: string;
35
+ objectId: string;
36
+ resource?: string | undefined;
37
+ revisionMarker?: string | undefined;
38
+ locator?: string | undefined;
39
+ }>;
40
+ export type RemoteIdentity = z.infer<typeof RemoteIdentity>;
41
+ /**
42
+ * 기대가 실제로 밖에 게시됐다는 증거.
43
+ *
44
+ * **이 레코드를 만드는 것이 게시가 아니다.** 게시는 adapter 가 하고, 여기 남는 것은
45
+ * 그것이 일어났다는 관측이다 — 그 구분이 사라지면 "적어 뒀으니 됐다"가 다시 성립한다.
46
+ */
47
+ export declare const CommunicationEvidence: z.ZodObject<{
48
+ evidenceId: z.ZodString;
49
+ /** 어느 기대에 대한 것인가. 지금은 Bounded Query 가 기대의 정본이다 (C-04). */
50
+ queryId: z.ZodString;
51
+ /** 어느 binding 을 통해 나갔는가 (C-09). 역할 이름이며 provider 이름이 아니다. */
52
+ bindingRole: z.ZodOptional<z.ZodString>;
53
+ identity: z.ZodObject<{
54
+ /** 어느 adapter 가 이 사실을 만들었는가. provider 이름이 아니라 adapter id 다. */
55
+ adapter: z.ZodString;
56
+ /** 그 adapter 의 어휘로 된 객체 종류. Core 는 이 문자열로 분기하지 않는다. */
57
+ objectType: z.ZodString;
58
+ /** 그 시스템 안에서 이 객체를 다시 찾을 수 있는 값. **성공의 정본이다.** */
59
+ objectId: z.ZodString;
60
+ /** 어느 자원 아래인가 (저장소·프로젝트 등). adapter 어휘 그대로. */
61
+ resource: z.ZodOptional<z.ZodString>;
62
+ /**
63
+ * 사람이 열어 보는 주소. **판정에 쓰지 않는다** — 모양이 바뀌어도 같은 객체다.
64
+ */
65
+ locator: z.ZodOptional<z.ZodString>;
66
+ /** 이 시점의 판본. 있으면 응답 중복을 가리는 데 쓴다. */
67
+ revisionMarker: z.ZodOptional<z.ZodString>;
68
+ }, "strip", z.ZodTypeAny, {
69
+ adapter: string;
70
+ objectType: string;
71
+ objectId: string;
72
+ resource?: string | undefined;
73
+ revisionMarker?: string | undefined;
74
+ locator?: string | undefined;
75
+ }, {
76
+ adapter: string;
77
+ objectType: string;
78
+ objectId: string;
79
+ resource?: string | undefined;
80
+ revisionMarker?: string | undefined;
81
+ locator?: string | undefined;
82
+ }>;
83
+ /** 누구에게 닿아야 하는가. 프로젝트 정책이 정하고 여기서는 그대로 보관한다. */
84
+ audience: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
85
+ publishedAt: z.ZodString;
86
+ /** 이 사실을 우리가 언제 봤는가. 게시 시각과 다를 수 있다. */
87
+ observedAt: z.ZodString;
88
+ /** 선언인가 관측인가 (C-10 의 같은 필드와 같은 뜻). */
89
+ evidenceSource: z.ZodString;
90
+ }, "strip", z.ZodTypeAny, {
91
+ observedAt: string;
92
+ identity: {
93
+ adapter: string;
94
+ objectType: string;
95
+ objectId: string;
96
+ resource?: string | undefined;
97
+ revisionMarker?: string | undefined;
98
+ locator?: string | undefined;
99
+ };
100
+ evidenceSource: string;
101
+ evidenceId: string;
102
+ queryId: string;
103
+ audience: string[];
104
+ publishedAt: string;
105
+ bindingRole?: string | undefined;
106
+ }, {
107
+ observedAt: string;
108
+ identity: {
109
+ adapter: string;
110
+ objectType: string;
111
+ objectId: string;
112
+ resource?: string | undefined;
113
+ revisionMarker?: string | undefined;
114
+ locator?: string | undefined;
115
+ };
116
+ evidenceSource: string;
117
+ evidenceId: string;
118
+ queryId: string;
119
+ publishedAt: string;
120
+ bindingRole?: string | undefined;
121
+ audience?: string[] | undefined;
122
+ }>;
123
+ export type CommunicationEvidence = z.infer<typeof CommunicationEvidence>;
124
+ /**
125
+ * 그 게시물에 외부 응답이 도착했다는 증거.
126
+ *
127
+ * **답의 의미를 판정하지 않는다.** 이것은 `QueryAnswer` 가 아니고, 자동으로 그것이
128
+ * 되지도 않는다 — 누가 무엇을 결정할 수 있는지는 C-04 의 권한 판정이고, 사람 승인은
129
+ * C-13 이다. 여기 있는 것은 "왔다"까지다.
130
+ */
131
+ export declare const ResponseEvidence: z.ZodObject<{
132
+ evidenceId: z.ZodString;
133
+ /** 어느 게시물에 대한 응답인가. */
134
+ communicationId: z.ZodString;
135
+ identity: z.ZodObject<{
136
+ /** 어느 adapter 가 이 사실을 만들었는가. provider 이름이 아니라 adapter id 다. */
137
+ adapter: z.ZodString;
138
+ /** 그 adapter 의 어휘로 된 객체 종류. Core 는 이 문자열로 분기하지 않는다. */
139
+ objectType: z.ZodString;
140
+ /** 그 시스템 안에서 이 객체를 다시 찾을 수 있는 값. **성공의 정본이다.** */
141
+ objectId: z.ZodString;
142
+ /** 어느 자원 아래인가 (저장소·프로젝트 등). adapter 어휘 그대로. */
143
+ resource: z.ZodOptional<z.ZodString>;
144
+ /**
145
+ * 사람이 열어 보는 주소. **판정에 쓰지 않는다** — 모양이 바뀌어도 같은 객체다.
146
+ */
147
+ locator: z.ZodOptional<z.ZodString>;
148
+ /** 이 시점의 판본. 있으면 응답 중복을 가리는 데 쓴다. */
149
+ revisionMarker: z.ZodOptional<z.ZodString>;
150
+ }, "strip", z.ZodTypeAny, {
151
+ adapter: string;
152
+ objectType: string;
153
+ objectId: string;
154
+ resource?: string | undefined;
155
+ revisionMarker?: string | undefined;
156
+ locator?: string | undefined;
157
+ }, {
158
+ adapter: string;
159
+ objectType: string;
160
+ objectId: string;
161
+ resource?: string | undefined;
162
+ revisionMarker?: string | undefined;
163
+ locator?: string | undefined;
164
+ }>;
165
+ /** 그 시스템이 말하는 응답자. 우리 역할 이름으로 번역하지 않는다. */
166
+ responder: z.ZodOptional<z.ZodString>;
167
+ receivedAt: z.ZodString;
168
+ observedAt: z.ZodString;
169
+ evidenceSource: z.ZodString;
170
+ }, "strip", z.ZodTypeAny, {
171
+ observedAt: string;
172
+ identity: {
173
+ adapter: string;
174
+ objectType: string;
175
+ objectId: string;
176
+ resource?: string | undefined;
177
+ revisionMarker?: string | undefined;
178
+ locator?: string | undefined;
179
+ };
180
+ evidenceSource: string;
181
+ evidenceId: string;
182
+ communicationId: string;
183
+ receivedAt: string;
184
+ responder?: string | undefined;
185
+ }, {
186
+ observedAt: string;
187
+ identity: {
188
+ adapter: string;
189
+ objectType: string;
190
+ objectId: string;
191
+ resource?: string | undefined;
192
+ revisionMarker?: string | undefined;
193
+ locator?: string | undefined;
194
+ };
195
+ evidenceSource: string;
196
+ evidenceId: string;
197
+ communicationId: string;
198
+ receivedAt: string;
199
+ responder?: string | undefined;
200
+ }>;
201
+ export type ResponseEvidence = z.infer<typeof ResponseEvidence>;
202
+ /**
203
+ * 파생 상태. **저장하지 않는다** — 위 증거들에서 매번 계산한다.
204
+ */
205
+ export type CoordinationState =
206
+ /** 기대는 있는데 밖에 나간 적이 없다. */
207
+ 'UNPUBLISHED'
208
+ /** 나갔고, 답을 기다린다. */
209
+ | 'WAITING_EXTERNAL'
210
+ /** 답이 왔다. 그 답이 무엇을 뜻하는지는 여기서 정하지 않는다. */
211
+ | 'RESPONSE_RECEIVED'
212
+ /** 나갔고, 애초에 답을 기대하지 않는다. */
213
+ | 'PUBLISHED';
214
+ export type CoordinationView = {
215
+ queryId: string;
216
+ state: CoordinationState;
217
+ /** 이 기대가 답을 기다리는가. 기대 쪽 사실이며 증거가 아니다. */
218
+ expectsResponse: boolean;
219
+ communications: CommunicationEvidence[];
220
+ responses: ResponseEvidence[];
221
+ };
222
+ /**
223
+ * 기대 하나의 지금 상태를 계산한다.
224
+ *
225
+ * 순서가 곧 규칙이다: 응답이 있으면 왔다, 없으면 기다리는지 아닌지, 게시가 없으면
226
+ * 아직 나가지 않았다. **없는 것을 있는 것으로 올리는 방향은 없다.**
227
+ */
228
+ export declare function deriveCoordination(input: {
229
+ queryId: string;
230
+ expectsResponse: boolean;
231
+ communications: readonly CommunicationEvidence[];
232
+ responses: readonly ResponseEvidence[];
233
+ }): CoordinationView;
234
+ export type AppendOutcome<T> = {
235
+ ok: true;
236
+ evidence: T;
237
+ }
238
+ /** 같은 id 가 이미 있다. 덮지 않는다 — 증거는 한 번만 쓰인다 (C-10 과 같은 이유). */
239
+ | {
240
+ ok: false;
241
+ reason: 'ALREADY_EXISTS';
242
+ detail: string;
243
+ }
244
+ /** 가리키는 게시물이 없다. 없는 것에 응답을 붙이지 않는다. */
245
+ | {
246
+ ok: false;
247
+ reason: 'COMMUNICATION_NOT_FOUND';
248
+ detail: string;
249
+ };
250
+ /**
251
+ * 조율 증거 원장. append-only 이며 `setIfAbsent` 위에 선다.
252
+ *
253
+ * 잃는 것이 표시값이 아니라 **누가 무엇을 밖에 내보냈고 누가 답했는가**이기 때문에,
254
+ * 나중 쓰기가 앞선 것을 덮지 않는다.
255
+ */
256
+ /** 밖에서 본 글 하나. adapter 어휘를 그대로 받되 Core 는 세 가지만 본다. */
257
+ export type ObservedRemark = {
258
+ id: string;
259
+ author: string;
260
+ at: string;
261
+ /** 사람이 쓴 것이 아니라 그 시스템이 남긴 자국인가. */
262
+ system?: boolean;
263
+ };
264
+ /**
265
+ * 게시물에 붙은 글 중 **응답으로 셀 것**을 고른다.
266
+ *
267
+ * 두 가지를 뺀다: 그 시스템이 스스로 남긴 자국(커밋 언급·배정 변경)과, 우리가 쓴 글.
268
+ * 둘 다 세면 아무도 답하지 않은 스레드가 답이 온 것으로 보인다 — 이 구조가 막으려는
269
+ * 바로 그 착각이다.
270
+ *
271
+ * **답의 의미는 판정하지 않는다.** 여기서 나오는 것은 "왔다"까지이고, 그것이 결정인지
272
+ * 승인인지는 C-04·C-13 의 몫이다.
273
+ */
274
+ export declare function responsesFrom(communication: CommunicationEvidence, remarks: readonly ObservedRemark[], mine: ReadonlySet<string>): Omit<ResponseEvidence, 'observedAt'>[];
275
+ export declare class CoordinationLedger {
276
+ #private;
277
+ constructor(scope: ScopedStore, now?: () => string);
278
+ publishRecorded(input: Omit<CommunicationEvidence, 'observedAt'> & {
279
+ observedAt?: string;
280
+ }): Promise<AppendOutcome<CommunicationEvidence>>;
281
+ responseRecorded(input: Omit<ResponseEvidence, 'observedAt'> & {
282
+ observedAt?: string;
283
+ }): Promise<AppendOutcome<ResponseEvidence>>;
284
+ communications(): Promise<CommunicationEvidence[]>;
285
+ responses(): Promise<ResponseEvidence[]>;
286
+ /**
287
+ * 이미 이 게시물이 있는가 — **원격 신원으로** 찾는다 (F3).
288
+ *
289
+ * 같은 것을 두 번 만들지 않기 위한 조회다. 제목이나 링크가 아니라 adapter 가 준
290
+ * 안정 id 로 본다.
291
+ */
292
+ findByIdentity(identity: Pick<RemoteIdentity, 'adapter' | 'objectType' | 'objectId'>): Promise<CommunicationEvidence | null>;
293
+ /** 기대 하나에 달린 증거 전부. */
294
+ forQuery(queryId: string): Promise<{
295
+ communications: CommunicationEvidence[];
296
+ responses: ResponseEvidence[];
297
+ }>;
298
+ }
299
+ /**
300
+ * 기대 목록과 증거를 합쳐 지금 상태를 낸다.
301
+ *
302
+ * 기대는 이 모듈이 만들지 않는다 — Bounded Query 가 정본이고, 여기서는 그것을 받아
303
+ * 증거와 맞춘다. 기대가 없으면 조율도 없다.
304
+ */
305
+ export declare function viewCoordination(ledger: CoordinationLedger, expectations: readonly {
306
+ id: string;
307
+ expectsResponse: boolean;
308
+ }[]): Promise<CoordinationView[]>;
309
+ /**
310
+ * 사람이 읽는 줄. **없는 것을 0 으로 그리지 않는다** — 기대가 없으면 그렇게 말한다.
311
+ */
312
+ export declare function coordinationLines(views: readonly CoordinationView[]): string[];
@@ -0,0 +1,247 @@
1
+ // Coordination Evidence — 물었다는 사실과, 전달됐다는 사실과, 답이 왔다는 사실을 나눈다.
2
+ //
3
+ // 반복된 실패는 이것이었다:
4
+ //
5
+ // dependency 가 있다
6
+ // ≠ 상대에게 전달됐다
7
+ // ≠ 상대가 응답했다
8
+ //
9
+ // 세 사실이 한 레코드로 뭉개지면 "Task 도 있고 무언가 오갔으니 됐다"가 성립한다.
10
+ // 그래서 뭉개지지 않게 **따로 적는다.**
11
+ //
12
+ // 새 subsystem 을 만들지 않는다. 기대는 이미 Bounded Query 가 지고 있고(C-04), 여기서
13
+ // 더하는 것은 C-10 과 같은 모양의 append-only 원장 둘뿐이다:
14
+ //
15
+ // ExecutionEvidence 누가 이 세션을 실제로 집었는가 (C-10, 이미 있음)
16
+ // CommunicationEvidence 그 기대가 실제로 밖에 게시됐는가 (여기)
17
+ // ResponseEvidence 그 게시물에 실제로 답이 왔는가 (여기)
18
+ //
19
+ // 상태를 저장하지 않는다. `UNPUBLISHED`·`WAITING_EXTERNAL`·`RESPONSE_RECEIVED` 는
20
+ // 전부 위 셋에서 **파생**한다 — 저장하면 그것이 곧 두 번째 정본이 되고, 증거와 어긋나는
21
+ // 순간 어느 쪽이 맞는지 정할 방법이 없어진다.
22
+ //
23
+ // **provider 이름을 모른다.** 아는 것은 adapter id 와 그 adapter 가 준 opaque identity 다
24
+ // (C-09 §6 과 같은 선). 어느 외부 시스템의 이름도 이 파일에 나오지 않으며, 그것은
25
+ // core/** 전체에 걸린 기존 규칙이기도 하다.
26
+ import { z } from 'zod';
27
+ import { QUERY_ID } from "../model/ids.js";
28
+ /**
29
+ * 외부 시스템이 준 **안정적인** 신원 (F3).
30
+ *
31
+ * 사람이 보는 링크는 여기 있어도 되지만 그것이 정본은 아니다. 링크의 모양이 예상과
32
+ * 다르다는 이유로 생성이 실패했다고 읽고 하나 더 만든 사고가 실제로 있었다 — 그때
33
+ * 대조할 수 있는 다른 값이 계약에 없었다.
34
+ */
35
+ export const RemoteIdentity = z.object({
36
+ /** 어느 adapter 가 이 사실을 만들었는가. provider 이름이 아니라 adapter id 다. */
37
+ adapter: z.string().min(1),
38
+ /** 그 adapter 의 어휘로 된 객체 종류. Core 는 이 문자열로 분기하지 않는다. */
39
+ objectType: z.string().min(1),
40
+ /** 그 시스템 안에서 이 객체를 다시 찾을 수 있는 값. **성공의 정본이다.** */
41
+ objectId: z.string().min(1),
42
+ /** 어느 자원 아래인가 (저장소·프로젝트 등). adapter 어휘 그대로. */
43
+ resource: z.string().min(1).optional(),
44
+ /**
45
+ * 사람이 열어 보는 주소. **판정에 쓰지 않는다** — 모양이 바뀌어도 같은 객체다.
46
+ */
47
+ locator: z.string().min(1).optional(),
48
+ /** 이 시점의 판본. 있으면 응답 중복을 가리는 데 쓴다. */
49
+ revisionMarker: z.string().min(1).optional(),
50
+ });
51
+ /**
52
+ * 기대가 실제로 밖에 게시됐다는 증거.
53
+ *
54
+ * **이 레코드를 만드는 것이 게시가 아니다.** 게시는 adapter 가 하고, 여기 남는 것은
55
+ * 그것이 일어났다는 관측이다 — 그 구분이 사라지면 "적어 뒀으니 됐다"가 다시 성립한다.
56
+ */
57
+ export const CommunicationEvidence = z.object({
58
+ evidenceId: z.string().min(1),
59
+ /** 어느 기대에 대한 것인가. 지금은 Bounded Query 가 기대의 정본이다 (C-04). */
60
+ queryId: z.string().regex(QUERY_ID),
61
+ /** 어느 binding 을 통해 나갔는가 (C-09). 역할 이름이며 provider 이름이 아니다. */
62
+ bindingRole: z.string().min(1).optional(),
63
+ identity: RemoteIdentity,
64
+ /** 누구에게 닿아야 하는가. 프로젝트 정책이 정하고 여기서는 그대로 보관한다. */
65
+ audience: z.array(z.string()).default([]),
66
+ publishedAt: z.string().min(1),
67
+ /** 이 사실을 우리가 언제 봤는가. 게시 시각과 다를 수 있다. */
68
+ observedAt: z.string().min(1),
69
+ /** 선언인가 관측인가 (C-10 의 같은 필드와 같은 뜻). */
70
+ evidenceSource: z.string().min(1),
71
+ });
72
+ /**
73
+ * 그 게시물에 외부 응답이 도착했다는 증거.
74
+ *
75
+ * **답의 의미를 판정하지 않는다.** 이것은 `QueryAnswer` 가 아니고, 자동으로 그것이
76
+ * 되지도 않는다 — 누가 무엇을 결정할 수 있는지는 C-04 의 권한 판정이고, 사람 승인은
77
+ * C-13 이다. 여기 있는 것은 "왔다"까지다.
78
+ */
79
+ export const ResponseEvidence = z.object({
80
+ evidenceId: z.string().min(1),
81
+ /** 어느 게시물에 대한 응답인가. */
82
+ communicationId: z.string().min(1),
83
+ identity: RemoteIdentity,
84
+ /** 그 시스템이 말하는 응답자. 우리 역할 이름으로 번역하지 않는다. */
85
+ responder: z.string().min(1).optional(),
86
+ receivedAt: z.string().min(1),
87
+ observedAt: z.string().min(1),
88
+ evidenceSource: z.string().min(1),
89
+ });
90
+ /**
91
+ * 기대 하나의 지금 상태를 계산한다.
92
+ *
93
+ * 순서가 곧 규칙이다: 응답이 있으면 왔다, 없으면 기다리는지 아닌지, 게시가 없으면
94
+ * 아직 나가지 않았다. **없는 것을 있는 것으로 올리는 방향은 없다.**
95
+ */
96
+ export function deriveCoordination(input) {
97
+ const communications = [...input.communications].sort((a, b) => a.evidenceId.localeCompare(b.evidenceId));
98
+ const responses = [...input.responses].sort((a, b) => a.evidenceId.localeCompare(b.evidenceId));
99
+ const state = communications.length === 0
100
+ ? 'UNPUBLISHED'
101
+ : responses.length > 0
102
+ ? 'RESPONSE_RECEIVED'
103
+ : input.expectsResponse
104
+ ? 'WAITING_EXTERNAL'
105
+ : 'PUBLISHED';
106
+ return { queryId: input.queryId, state, expectsResponse: input.expectsResponse, communications, responses };
107
+ }
108
+ const communicationKey = (id) => `coord:pub:${id}`;
109
+ const responseKey = (id) => `coord:res:${id}`;
110
+ /**
111
+ * 게시물에 붙은 글 중 **응답으로 셀 것**을 고른다.
112
+ *
113
+ * 두 가지를 뺀다: 그 시스템이 스스로 남긴 자국(커밋 언급·배정 변경)과, 우리가 쓴 글.
114
+ * 둘 다 세면 아무도 답하지 않은 스레드가 답이 온 것으로 보인다 — 이 구조가 막으려는
115
+ * 바로 그 착각이다.
116
+ *
117
+ * **답의 의미는 판정하지 않는다.** 여기서 나오는 것은 "왔다"까지이고, 그것이 결정인지
118
+ * 승인인지는 C-04·C-13 의 몫이다.
119
+ */
120
+ export function responsesFrom(communication, remarks, mine) {
121
+ return remarks
122
+ .filter((remark) => !remark.system && !mine.has(remark.author))
123
+ .map((remark) => ({
124
+ // 같은 글을 다시 봐도 같은 id 다 — append-only 원장이 두 번째를 거절한다.
125
+ evidenceId: `${communication.evidenceId}:${remark.id}`,
126
+ communicationId: communication.evidenceId,
127
+ identity: { ...communication.identity, revisionMarker: remark.at },
128
+ responder: remark.author,
129
+ receivedAt: remark.at,
130
+ evidenceSource: 'surface-remark',
131
+ }));
132
+ }
133
+ export class CoordinationLedger {
134
+ #scope;
135
+ #now;
136
+ constructor(scope, now = () => new Date().toISOString()) {
137
+ this.#scope = scope;
138
+ this.#now = now;
139
+ }
140
+ async publishRecorded(input) {
141
+ const evidence = CommunicationEvidence.parse({ ...input, observedAt: input.observedAt ?? this.#now() });
142
+ const written = await this.#scope.setIfAbsent(communicationKey(evidence.evidenceId), JSON.stringify(evidence));
143
+ return written
144
+ ? { ok: true, evidence }
145
+ : { ok: false, reason: 'ALREADY_EXISTS', detail: `${evidence.evidenceId} 는 이미 있다` };
146
+ }
147
+ async responseRecorded(input) {
148
+ const target = await this.#scope.get(communicationKey(input.communicationId));
149
+ if (!target) {
150
+ return {
151
+ ok: false,
152
+ reason: 'COMMUNICATION_NOT_FOUND',
153
+ detail: `${input.communicationId} 를 찾지 못했다 — 게시된 적 없는 것에 응답을 붙이지 않는다`,
154
+ };
155
+ }
156
+ const evidence = ResponseEvidence.parse({ ...input, observedAt: input.observedAt ?? this.#now() });
157
+ const written = await this.#scope.setIfAbsent(responseKey(evidence.evidenceId), JSON.stringify(evidence));
158
+ return written
159
+ ? { ok: true, evidence }
160
+ : { ok: false, reason: 'ALREADY_EXISTS', detail: `${evidence.evidenceId} 는 이미 있다` };
161
+ }
162
+ async communications() {
163
+ const out = [];
164
+ for (const key of await this.#scope.keys('coord:pub:')) {
165
+ const raw = await this.#scope.get(key);
166
+ if (raw)
167
+ out.push(CommunicationEvidence.parse(JSON.parse(raw)));
168
+ }
169
+ return out.sort((a, b) => a.evidenceId.localeCompare(b.evidenceId));
170
+ }
171
+ async responses() {
172
+ const out = [];
173
+ for (const key of await this.#scope.keys('coord:res:')) {
174
+ const raw = await this.#scope.get(key);
175
+ if (raw)
176
+ out.push(ResponseEvidence.parse(JSON.parse(raw)));
177
+ }
178
+ return out.sort((a, b) => a.evidenceId.localeCompare(b.evidenceId));
179
+ }
180
+ /**
181
+ * 이미 이 게시물이 있는가 — **원격 신원으로** 찾는다 (F3).
182
+ *
183
+ * 같은 것을 두 번 만들지 않기 위한 조회다. 제목이나 링크가 아니라 adapter 가 준
184
+ * 안정 id 로 본다.
185
+ */
186
+ async findByIdentity(identity) {
187
+ for (const evidence of await this.communications()) {
188
+ const seen = evidence.identity;
189
+ if (seen.adapter === identity.adapter && seen.objectType === identity.objectType && seen.objectId === identity.objectId) {
190
+ return evidence;
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+ /** 기대 하나에 달린 증거 전부. */
196
+ async forQuery(queryId) {
197
+ const communications = (await this.communications()).filter((evidence) => evidence.queryId === queryId);
198
+ const ids = new Set(communications.map((evidence) => evidence.evidenceId));
199
+ const responses = (await this.responses()).filter((evidence) => ids.has(evidence.communicationId));
200
+ return { communications, responses };
201
+ }
202
+ }
203
+ /**
204
+ * 기대 목록과 증거를 합쳐 지금 상태를 낸다.
205
+ *
206
+ * 기대는 이 모듈이 만들지 않는다 — Bounded Query 가 정본이고, 여기서는 그것을 받아
207
+ * 증거와 맞춘다. 기대가 없으면 조율도 없다.
208
+ */
209
+ export async function viewCoordination(ledger, expectations) {
210
+ const views = [];
211
+ for (const expectation of expectations) {
212
+ const { communications, responses } = await ledger.forQuery(expectation.id);
213
+ views.push(deriveCoordination({
214
+ queryId: expectation.id,
215
+ expectsResponse: expectation.expectsResponse,
216
+ communications,
217
+ responses,
218
+ }));
219
+ }
220
+ return views.sort((a, b) => a.queryId.localeCompare(b.queryId));
221
+ }
222
+ /**
223
+ * 사람이 읽는 줄. **없는 것을 0 으로 그리지 않는다** — 기대가 없으면 그렇게 말한다.
224
+ */
225
+ export function coordinationLines(views) {
226
+ if (views.length === 0)
227
+ return ['No external expectation is recorded here.'];
228
+ const lines = [];
229
+ const counts = {
230
+ UNPUBLISHED: 0,
231
+ WAITING_EXTERNAL: 0,
232
+ RESPONSE_RECEIVED: 0,
233
+ PUBLISHED: 0,
234
+ };
235
+ for (const view of views)
236
+ counts[view.state] += 1;
237
+ lines.push(`Coordination: ${views.length} expectation${views.length === 1 ? '' : 's'} — ` +
238
+ `unpublished ${counts.UNPUBLISHED} · waiting ${counts.WAITING_EXTERNAL} · ` +
239
+ `answered ${counts.RESPONSE_RECEIVED} · published ${counts.PUBLISHED}`);
240
+ for (const view of views) {
241
+ const where = view.communications[0]?.identity;
242
+ // 링크는 사람이 열어 보는 값이다. 없으면 안정 신원을 보인다 — 판정의 정본은 그쪽이다.
243
+ const at = where ? ` @ ${where.locator ?? `${where.adapter}:${where.objectType}:${where.objectId}`}` : '';
244
+ lines.push(` ${view.queryId} ${view.state}${at}`);
245
+ }
246
+ return lines;
247
+ }
@@ -2,6 +2,7 @@ import type { StateStore } from '../../ports/state-store.ts';
2
2
  import type { HealthAlert } from '../monitor/health-alerts.ts';
3
3
  import type { EscalationRecord } from './escalation.ts';
4
4
  import type { DecisionSummary } from '../view/decision-view.ts';
5
+ import type { CoordinationView } from './coordination.ts';
5
6
  export type FrontState = {
6
7
  /** 지금 돌고 있는 것. */
7
8
  active: {
@@ -57,6 +58,13 @@ export type FrontState = {
57
58
  workspaceId: string;
58
59
  locator: string;
59
60
  };
61
+ /**
62
+ * 밖에 물어 둔 것들의 지금 상태.
63
+ *
64
+ * **저장된 값이 아니다** — 기대와 증거에서 파생해 받은 것을 그대로 보인다. 이것이
65
+ * 화면에 없으면 "안에서 할 일이 없다"가 "끝났다"로 읽힌다.
66
+ */
67
+ coordination?: readonly CoordinationView[];
60
68
  };
61
69
  export type RestoreInput = {
62
70
  store: StateStore;
@@ -68,6 +76,8 @@ export type RestoreInput = {
68
76
  workspaceId: string;
69
77
  locator: string;
70
78
  };
79
+ /** 파생된 조율 상태. 없으면 이 축을 그리지 않는다 — 모르는 것을 "없음"으로 그리지 않는다. */
80
+ coordination?: readonly CoordinationView[];
71
81
  /**
72
82
  * 세션별 소유권 조회. **구조로만 받는다** — Core가 Host adapter를 알면 안 된다.
73
83
  * 없으면 이 축은 그리지 않는다(모르는 것을 "없음"으로 그리지 않는다).
@@ -61,6 +61,7 @@ export async function restoreFront(input) {
61
61
  runnable: [...record.stillRunnableNodes],
62
62
  })),
63
63
  ...(input.workspace ? { workspace: input.workspace } : {}),
64
+ ...(input.coordination ? { coordination: input.coordination } : {}),
64
65
  };
65
66
  }
66
67
  /**
@@ -163,12 +164,26 @@ export function renderFront(state) {
163
164
  lines.push(` … and ${state.awaitingController.length - 5} more`);
164
165
  }
165
166
  }
167
+ // 밖에 걸린 것을 안에서 할 일과 나눠 든다. 나가지 않은 기대는 **아무도 기다리지
168
+ // 않는다** — 그것이 가장 조용한 실패다.
169
+ const outward = (state.coordination ?? []).filter((view) => view.state !== 'PUBLISHED');
170
+ if (outward.length > 0) {
171
+ const unpublished = outward.filter((view) => view.state === 'UNPUBLISHED');
172
+ const waiting = outward.filter((view) => view.state === 'WAITING_EXTERNAL');
173
+ const answered = outward.filter((view) => view.state === 'RESPONSE_RECEIVED');
174
+ lines.push(`Asked outside (${outward.length}): not sent ${unpublished.length} · waiting ${waiting.length} · answered ${answered.length}`);
175
+ // 안 나간 것을 먼저 든다 — 기다리는 것보다 나쁜 상태다
176
+ for (const view of [...unpublished, ...answered, ...waiting]) {
177
+ lines.push(` ${view.queryId} ${view.state}`);
178
+ }
179
+ }
166
180
  if (state.pendingDecisions.length === 0 &&
167
181
  state.active.length === 0 &&
168
182
  state.escalations.length === 0 &&
169
183
  state.unclaimed.length === 0 &&
170
184
  state.awaitingCollect.length === 0 &&
171
- state.awaitingController.length === 0) {
185
+ state.awaitingController.length === 0 &&
186
+ outward.length === 0) {
172
187
  // 비어 있는 것과 못 보는 것을 구분한다 (C-12 불변식 ⑫과 같은 태도)
173
188
  lines.push(state.health.length > 0
174
189
  ? 'Nothing is pending — but read the monitoring state above first.'