@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,43 @@
1
+ import type { DecisionSummary } from '../../core/view/decision-view.ts';
2
+ import type { DeliveryOutcome, DigestBatch, PresentationCapability, PresentationPort } from '../../ports/presentation.ts';
3
+ export type MattermostPost = {
4
+ channel_id: string;
5
+ message: string;
6
+ root_id?: string;
7
+ };
8
+ export type MattermostTransport = {
9
+ /** 게시 한 번. 성공하면 provider의 post id를 돌려준다. */
10
+ post(payload: MattermostPost): Promise<{
11
+ ok: true;
12
+ id: string;
13
+ } | {
14
+ ok: false;
15
+ error: string;
16
+ }>;
17
+ };
18
+ export type MattermostPresentationDeps = {
19
+ transport: MattermostTransport;
20
+ channelId: string;
21
+ /** 이 채널의 이름. 사람이 읽는 표시에만 쓰고 판정에는 쓰지 않는다. */
22
+ id?: string;
23
+ };
24
+ export declare class MattermostPresentation implements PresentationPort {
25
+ #private;
26
+ readonly id: string;
27
+ readonly capabilities: ReadonlySet<PresentationCapability>;
28
+ constructor(deps: MattermostPresentationDeps);
29
+ presentDigest(batch: DigestBatch): Promise<DeliveryOutcome>;
30
+ /**
31
+ * 지금 끊어야 하는 한 건.
32
+ *
33
+ * 요약만 받는다 — 전체 view를 실어 나르면 전달 한 번에 조사 한 번이 딸려 붙는다
34
+ * (Port 주석). 더 필요하면 사람이 `requestId` 로 ASC에서 읽는다.
35
+ */
36
+ presentUrgent(item: DecisionSummary): Promise<DeliveryOutcome>;
37
+ }
38
+ /**
39
+ * 묶음 한 덩어리를 메시지로. **결정 버튼을 만들지 않는다** — 여기에 버튼이 생기는 순간
40
+ * 메신저가 결정 경로가 되고, 그건 다른 계약이다.
41
+ */
42
+ export declare function renderBatch(batch: DigestBatch): string;
43
+ export declare function renderUrgent(item: DecisionSummary): string;
@@ -0,0 +1,88 @@
1
+ // Mattermost Presentation — 묶음과 급한 것을 메신저로 건넨다 (C-08 §1, B-13).
2
+ //
3
+ // **PresentationPort를 바꾸지 않는다.** 채널 하나가 늘었다고 계약이 흔들리면 그건 계약이
4
+ // 아니라 첫 구현의 형태였다는 뜻이다. 여기서 하는 일은 그 계약을 이 provider의 어휘로
5
+ // 옮기는 것뿐이다.
6
+ //
7
+ // 이 adapter가 지키는 세 가지:
8
+ //
9
+ // ① 결정 표면이 없다 — 묶어서 보여주는 데까지다. approve/dismiss는 기존 결정 경로로만
10
+ // 간다 (C-08 §4). 메신저에서 버튼을 눌러 승인하는 것은 별도 계약(ApprovalChannel)이며
11
+ // 그 자리는 아직 열지 않는다.
12
+ // ② 전달 실패가 canonical state를 흔들지 않는다 (C-08 §1.3). 못 보냈으면 못 보냈다고
13
+ // 돌려주고, 그 사실로 요청 상태를 바꾸지 않는다.
14
+ // ③ 자격을 남기지 않는다. 토큰은 환경에서만 오고 어디에도 적히지 않는다.
15
+ /** 이 provider가 실제로 하는 것만 적는다. 결정 수신은 없다. */
16
+ const CAPABILITIES = new Set(['presentation.digest', 'presentation.priority']);
17
+ const PRIORITY_LABEL = { P0: '🔴 지금', P1: '🟡 오늘', P2: '⚪ 이번 주' };
18
+ export class MattermostPresentation {
19
+ id;
20
+ capabilities = CAPABILITIES;
21
+ #transport;
22
+ #channelId;
23
+ constructor(deps) {
24
+ this.id = deps.id ?? 'mattermost';
25
+ this.#transport = deps.transport;
26
+ this.#channelId = deps.channelId;
27
+ }
28
+ async presentDigest(batch) {
29
+ return this.#send(renderBatch(batch));
30
+ }
31
+ /**
32
+ * 지금 끊어야 하는 한 건.
33
+ *
34
+ * 요약만 받는다 — 전체 view를 실어 나르면 전달 한 번에 조사 한 번이 딸려 붙는다
35
+ * (Port 주석). 더 필요하면 사람이 `requestId` 로 ASC에서 읽는다.
36
+ */
37
+ async presentUrgent(item) {
38
+ return this.#send(renderUrgent(item));
39
+ }
40
+ async #send(message) {
41
+ let result;
42
+ try {
43
+ result = await this.#transport.post({ channel_id: this.#channelId, message });
44
+ }
45
+ catch (error) {
46
+ // 전달 실패는 전달 실패일 뿐이다 — 요청 상태를 바꾸지 않는다 (C-08 §1.3)
47
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
48
+ }
49
+ return result.ok ? { ok: true, externalRef: result.id } : { ok: false, error: result.error };
50
+ }
51
+ }
52
+ /**
53
+ * 묶음 한 덩어리를 메시지로. **결정 버튼을 만들지 않는다** — 여기에 버튼이 생기는 순간
54
+ * 메신저가 결정 경로가 되고, 그건 다른 계약이다.
55
+ */
56
+ export function renderBatch(batch) {
57
+ const lines = [`**ASC** · ${batch.at}`];
58
+ for (const group of batch.groups) {
59
+ lines.push(`${PRIORITY_LABEL[group.priority] ?? group.priority} ${group.items.length}건`);
60
+ for (const item of group.items) {
61
+ lines.push(`- \`${item.requestId}\` ${item.reference} — ${item.title}`);
62
+ // 묶은 뒤 값이 변한 것은 새 상태를 만들지 않고 freshness로 말한다 (C-08 §3.3)
63
+ if (item.freshness !== 'CURRENT')
64
+ lines.push(` _(${item.freshness})_`);
65
+ }
66
+ }
67
+ if (batch.suppressed?.shadow)
68
+ lines.push(`⚪ 숨김 ${batch.suppressed.shadow} — 관련 근거 없음(계속 본다)`);
69
+ if (batch.suppressed?.alreadyDecided)
70
+ lines.push(`⚪ 이미 결정됨 ${batch.suppressed.alreadyDecided}`);
71
+ if (batch.recovered)
72
+ lines.push(`↺ 회수 경로에서 발견 ${batch.recovered}`);
73
+ if (batch.groups.length === 0)
74
+ lines.push('건넬 것이 없다.');
75
+ // 결정은 ASC에서 한다. 그 사실을 매번 같이 보낸다 — 채널이 결정 표면이 아님을 사람이 알게.
76
+ lines.push('');
77
+ lines.push('_결정은 `asc inbox decide` 로 한다 — 이 메시지는 알림이지 승인 창구가 아니다._');
78
+ return lines.join('\n');
79
+ }
80
+ export function renderUrgent(item) {
81
+ return [
82
+ `🔴 **지금 판단이 필요하다** · \`${item.requestId}\``,
83
+ `${item.reference} — ${item.title}`,
84
+ `감지 ${item.detectedAt}${item.freshness === 'CURRENT' ? '' : ` · ${item.freshness}`}`,
85
+ '',
86
+ '_결정은 `asc inbox decide` 로 한다._',
87
+ ].join('\n');
88
+ }
@@ -0,0 +1,87 @@
1
+ import type { CanonicalSnapshot } from '../../core/model/entities.ts';
2
+ import type { DecisionSummary, DecisionView } from '../../core/view/decision-view.ts';
3
+ import type { ApprovalCapability, ApprovalChannel, IdentityBinding, PresentationOutcome } from '../../ports/approval.ts';
4
+ import type { Cursor, EventBatch, EventSource, RawEvent } from '../../ports/event-source.ts';
5
+ import type { InventoryItem, InventoryPage, InventoryPort, InventoryQuery } from '../../ports/inventory.ts';
6
+ import type { DeliveryOutcome, DigestBatch, PresentationCapability, PresentationPort } from '../../ports/presentation.ts';
7
+ import type { BaselineQuery, ExternalAction, ExternalActionResult, ScmPort, ThreadSnapshot } from '../../ports/scm.ts';
8
+ import type { ScopedStore } from '../../ports/state-store.ts';
9
+ /**
10
+ * 채널 구현 참조. presentation 매핑은 Core entity가 아니라 이 Adapter가 받은
11
+ * ScopedStore에 둔다 — PresentationRecord 소유 결정(ports/approval.ts)의 실물이다.
12
+ */
13
+ export declare class MemoryChannel implements ApprovalChannel {
14
+ #private;
15
+ readonly id: string;
16
+ readonly capabilities: ReadonlySet<ApprovalCapability>;
17
+ /** 테스트 관찰용 — 어떤 view가 몇 번 표시·갱신됐는지. */
18
+ readonly presented: DecisionView[];
19
+ readonly updated: DecisionView[];
20
+ constructor(id: string, store: ScopedStore, capabilities?: readonly ApprovalCapability[]);
21
+ /** 채널이 죽었거나 메시지 수정을 지원하지 않는 상황을 흉내 낸다. */
22
+ breakUpdates(): void;
23
+ present(view: DecisionView): Promise<PresentationOutcome>;
24
+ update(view: DecisionView): Promise<PresentationOutcome>;
25
+ }
26
+ /** 로컬 결정도 무조건 신뢰하지 않는다 — 매핑된 identity만 승인자로 인정한다 (OM §11.6). */
27
+ export declare class MapIdentityBinding implements IdentityBinding {
28
+ #private;
29
+ constructor(entries: Record<string, string>);
30
+ verify({ channel, actor, authorizedApprover }: {
31
+ channel: string;
32
+ actor: string;
33
+ authorizedApprover: string;
34
+ }): Promise<boolean>;
35
+ }
36
+ export declare class FakeScm implements ScmPort {
37
+ #private;
38
+ readonly id = "fake-scm";
39
+ readonly executed: ExternalAction[];
40
+ setThread(reference: string, lastEventId: string): void;
41
+ setBaseline(sourceId: string, baseline: string): void;
42
+ failNextExecute(error: string): void;
43
+ getThread(reference: string): Promise<ThreadSnapshot>;
44
+ getBaselines(queries: readonly BaselineQuery[]): Promise<CanonicalSnapshot[]>;
45
+ execute(action: ExternalAction): Promise<ExternalActionResult>;
46
+ }
47
+ /** 미리 넣어둔 배치를 순서대로 돌려준다. push형 Adapter의 내부 버퍼 drain과 같은 모양. */
48
+ export declare class FixtureEventSource implements EventSource {
49
+ #private;
50
+ readonly id = "fixture";
51
+ constructor(batches: RawEvent[][]);
52
+ drain(cursor: Cursor): Promise<EventBatch>;
53
+ }
54
+ /**
55
+ * 목록을 통째로 들고 있는 Inventory. 페이지 나눔과 "완주하지 못함"을 흉내 낼 수 있어야
56
+ * Coverage 판정(C-07 §1.5)을 실제로 검사할 수 있다.
57
+ */
58
+ export declare class FixtureInventory implements InventoryPort {
59
+ #private;
60
+ readonly id = "fixture";
61
+ /** 회차마다 다른 목록을 주려면 여기를 바꿔 끼운다. */
62
+ items: InventoryItem[];
63
+ /** true면 어떤 페이지도 complete를 말하지 않는다 — 목록을 다 못 본 상황. */
64
+ incomplete: boolean;
65
+ /** 설정하면 enumerate가 실패한다. */
66
+ failWith: string | null;
67
+ constructor(items?: InventoryItem[], options?: {
68
+ pageSize?: number;
69
+ incomplete?: boolean;
70
+ });
71
+ enumerate(query: InventoryQuery, cursor?: string): Promise<InventoryPage>;
72
+ }
73
+ /**
74
+ * 교체 검증용 전달 채널. 능력을 골라 끼울 수 있어야 "일부만 제공하는 채널"(C-08 §1.2)이
75
+ * 실제로 degrade되는지 볼 수 있다.
76
+ */
77
+ export declare class FixturePresentation implements PresentationPort {
78
+ readonly id: string;
79
+ readonly capabilities: ReadonlySet<PresentationCapability>;
80
+ digests: DigestBatch[];
81
+ urgent: DecisionSummary[];
82
+ /** 설정하면 전달이 실패한다 — best-effort 계약을 확인하기 위해서다. */
83
+ failWith: string | null;
84
+ constructor(id?: string, capabilities?: PresentationCapability[]);
85
+ presentDigest(batch: DigestBatch): Promise<DeliveryOutcome>;
86
+ presentUrgent(item: DecisionSummary): Promise<DeliveryOutcome>;
87
+ }
@@ -0,0 +1,163 @@
1
+ // 나머지 4개 Port(Approval Channel / SCM / Event Source / Renderer)의 in-memory 구현.
2
+ // Port 계약이 실제로 구현 가능한지, 그리고 Core가 특정 플랫폼을 몰라도 되는지 확인하는
3
+ // 참조 구현이다. 실 Adapter는 B-04·B-08·B-13에서 별도로 만든다.
4
+ /**
5
+ * 채널 구현 참조. presentation 매핑은 Core entity가 아니라 이 Adapter가 받은
6
+ * ScopedStore에 둔다 — PresentationRecord 소유 결정(ports/approval.ts)의 실물이다.
7
+ */
8
+ export class MemoryChannel {
9
+ id;
10
+ capabilities;
11
+ /** 테스트 관찰용 — 어떤 view가 몇 번 표시·갱신됐는지. */
12
+ presented = [];
13
+ updated = [];
14
+ #store;
15
+ #failUpdates = false;
16
+ constructor(id, store, capabilities = ['interactive_actions']) {
17
+ this.id = id;
18
+ this.#store = store;
19
+ this.capabilities = new Set(capabilities);
20
+ }
21
+ /** 채널이 죽었거나 메시지 수정을 지원하지 않는 상황을 흉내 낸다. */
22
+ breakUpdates() {
23
+ this.#failUpdates = true;
24
+ }
25
+ async present(view) {
26
+ this.presented.push(view);
27
+ const externalRef = `${this.id}:msg:${view.requestId}`;
28
+ await this.#store.set(`presentation:${view.requestId}`, JSON.stringify({
29
+ requestId: view.requestId,
30
+ channel: this.id,
31
+ externalMessageRef: externalRef,
32
+ renderedAt: view.stored.detectedAt,
33
+ }));
34
+ return { ok: true, externalRef };
35
+ }
36
+ async update(view) {
37
+ if (this.#failUpdates)
38
+ return { ok: false, error: 'channel unreachable' };
39
+ const record = await this.#store.get(`presentation:${view.requestId}`);
40
+ if (!record)
41
+ return { ok: false, error: 'no presentation to update' };
42
+ this.updated.push(view);
43
+ return { ok: true, externalRef: JSON.parse(record).externalMessageRef };
44
+ }
45
+ }
46
+ /** 로컬 결정도 무조건 신뢰하지 않는다 — 매핑된 identity만 승인자로 인정한다 (OM §11.6). */
47
+ export class MapIdentityBinding {
48
+ #map; // `${channel}:${actor}` → controller identity
49
+ constructor(entries) {
50
+ this.#map = new Map(Object.entries(entries));
51
+ }
52
+ async verify({ channel, actor, authorizedApprover }) {
53
+ return this.#map.get(`${channel}:${actor}`) === authorizedApprover;
54
+ }
55
+ }
56
+ export class FakeScm {
57
+ id = 'fake-scm';
58
+ executed = [];
59
+ #threads = new Map();
60
+ #baselines = new Map();
61
+ #failNext = null;
62
+ setThread(reference, lastEventId) {
63
+ this.#threads.set(reference, { reference, lastEventId });
64
+ }
65
+ setBaseline(sourceId, baseline) {
66
+ this.#baselines.set(sourceId, baseline);
67
+ }
68
+ failNextExecute(error) {
69
+ this.#failNext = error;
70
+ }
71
+ async getThread(reference) {
72
+ return this.#threads.get(reference) ?? { reference, lastEventId: '', missing: true };
73
+ }
74
+ async getBaselines(queries) {
75
+ return queries.map((q) => ({ sourceId: q.sourceId, baseline: this.#baselines.get(q.sourceId) ?? 'unknown' }));
76
+ }
77
+ async execute(action) {
78
+ if (this.#failNext) {
79
+ const error = this.#failNext;
80
+ this.#failNext = null;
81
+ return { ok: false, error };
82
+ }
83
+ this.executed.push(action);
84
+ return { ok: true, resultRef: `${this.id}://${action.target}/${this.executed.length}` };
85
+ }
86
+ }
87
+ /** 미리 넣어둔 배치를 순서대로 돌려준다. push형 Adapter의 내부 버퍼 drain과 같은 모양. */
88
+ export class FixtureEventSource {
89
+ id = 'fixture';
90
+ #batches;
91
+ constructor(batches) {
92
+ this.#batches = batches.map((b) => [...b]);
93
+ }
94
+ async drain(cursor) {
95
+ const index = cursor ? Number(cursor) : 0;
96
+ const events = this.#batches[index] ?? [];
97
+ const hasMore = index + 1 < this.#batches.length;
98
+ return { events, cursor: String(index + 1), hasMore };
99
+ }
100
+ }
101
+ /**
102
+ * 목록을 통째로 들고 있는 Inventory. 페이지 나눔과 "완주하지 못함"을 흉내 낼 수 있어야
103
+ * Coverage 판정(C-07 §1.5)을 실제로 검사할 수 있다.
104
+ */
105
+ export class FixtureInventory {
106
+ id = 'fixture';
107
+ /** 회차마다 다른 목록을 주려면 여기를 바꿔 끼운다. */
108
+ items;
109
+ #pageSize;
110
+ /** true면 어떤 페이지도 complete를 말하지 않는다 — 목록을 다 못 본 상황. */
111
+ incomplete;
112
+ /** 설정하면 enumerate가 실패한다. */
113
+ failWith = null;
114
+ constructor(items = [], options = {}) {
115
+ this.items = items;
116
+ this.#pageSize = options.pageSize ?? 100;
117
+ this.incomplete = options.incomplete ?? false;
118
+ }
119
+ async enumerate(query, cursor) {
120
+ if (this.failWith)
121
+ throw new Error(this.failWith);
122
+ // 경계는 포함해서 준다. 겹쳐 읽는 편이 놓치는 것보다 싸다 (OM §10.5).
123
+ const filtered = query.updatedSince
124
+ ? this.items.filter((item) => item.updatedAt >= query.updatedSince)
125
+ : this.items;
126
+ const page = cursor ? Number(cursor) : 0;
127
+ const slice = filtered.slice(page * this.#pageSize, (page + 1) * this.#pageSize);
128
+ const more = (page + 1) * this.#pageSize < filtered.length;
129
+ return {
130
+ items: slice,
131
+ ...(more ? { next: String(page + 1) } : {}),
132
+ complete: !more && !this.incomplete,
133
+ };
134
+ }
135
+ }
136
+ /**
137
+ * 교체 검증용 전달 채널. 능력을 골라 끼울 수 있어야 "일부만 제공하는 채널"(C-08 §1.2)이
138
+ * 실제로 degrade되는지 볼 수 있다.
139
+ */
140
+ export class FixturePresentation {
141
+ id;
142
+ capabilities;
143
+ digests = [];
144
+ urgent = [];
145
+ /** 설정하면 전달이 실패한다 — best-effort 계약을 확인하기 위해서다. */
146
+ failWith = null;
147
+ constructor(id = 'fixture-channel', capabilities = ['presentation.digest']) {
148
+ this.id = id;
149
+ this.capabilities = new Set(capabilities);
150
+ }
151
+ async presentDigest(batch) {
152
+ if (this.failWith)
153
+ return { ok: false, error: this.failWith };
154
+ this.digests.push(batch);
155
+ return { ok: true, externalRef: `${this.id}://digest/${this.digests.length}` };
156
+ }
157
+ async presentUrgent(item) {
158
+ if (this.failWith)
159
+ return { ok: false, error: this.failWith };
160
+ this.urgent.push(item);
161
+ return { ok: true, externalRef: `${this.id}://urgent/${item.requestId}` };
162
+ }
163
+ }
@@ -0,0 +1,23 @@
1
+ import type { ScopedStore } from '../../ports/state-store.ts';
2
+ import { RuntimeBinding, type ClaimOutcome, type RuntimeBindings } from '../../core/operator/runtime-binding.ts';
3
+ /** 내려놓음·승계의 흔적. 현재 view가 아니라 이력이다. */
4
+ export type BindingLogEntry = {
5
+ logicalSessionId: string;
6
+ physicalSessionId: string;
7
+ provider: string;
8
+ workerId?: string;
9
+ kind: 'RELEASED' | 'SUPERSEDED';
10
+ claimedAt: string;
11
+ endedAt: string;
12
+ };
13
+ export declare class ScopedRuntimeBindings implements RuntimeBindings {
14
+ #private;
15
+ constructor(scope: ScopedStore, now?: () => string);
16
+ claim(binding: Omit<RuntimeBinding, 'updatedAt'>, at: string): Promise<ClaimOutcome>;
17
+ observe(logicalSessionId: string, physicalSessionId: string, patch: Partial<Pick<RuntimeBinding, 'workerId' | 'runtimeKind' | 'lastObservedState' | 'capabilitySnapshot'>>, at: string): Promise<ClaimOutcome>;
18
+ release(logicalSessionId: string, physicalSessionId: string): Promise<boolean>;
19
+ rebind(binding: Omit<RuntimeBinding, 'updatedAt'>, at: string): Promise<RuntimeBinding>;
20
+ /** 이 세션을 거쳐 간 소유권 이력. 현재 owner는 여기 없다 — get()이 답한다. */
21
+ history(logicalSessionId: string): Promise<BindingLogEntry[]>;
22
+ get(logicalSessionId: string): Promise<RuntimeBinding | null>;
23
+ }
@@ -0,0 +1,118 @@
1
+ // RuntimeBindings의 ScopedStore 구현 — B-14의 fake host 검증용이자 참조 구현.
2
+ //
3
+ // memory 디렉터리에 있지만 특정 저장소에 매이지 않는다: ScopedStore를 받아 그 위에서
4
+ // 동작하므로, Host Adapter가 자기 scope를 넘겨 그대로 재사용한다 —
5
+ // 소유권 로직을 provider마다 다시 쓰는 순간 원자성 보장이 갈라진다.
6
+ import { RuntimeBinding, } from "../../core/operator/runtime-binding.js";
7
+ const keyOf = (logicalSessionId) => `runtime-binding:${logicalSessionId}`;
8
+ /**
9
+ * 내려놓은 소유권의 묘비 (C-10 §1.3 불변식 ④).
10
+ *
11
+ * 현재 소유권은 사라져도 "그때 그 Physical Run이 이 세션을 잡고 있었다"는 사실은 남아야
12
+ * 한다. 지금까지는 release가 파일을 지워서 회수 뒤에는 몇 개의 실행이 거쳐 갔는지조차
13
+ * 복원할 수 없었다.
14
+ *
15
+ * **접두어가 `runtime-binding`이면 안 된다.** Host Adapter의 guard hook은 이 scope에서
16
+ * `runtime-binding` 으로 시작하는 파일을 훑어 관리 대상 세션을 찾는다. 묘비가 그 앞에
17
+ * 걸리면 이미 내려놓은 세션이 계속 관리 대상으로 읽혀 죽은 소유권으로 차단 판정이 선다.
18
+ */
19
+ const logKey = (logicalSessionId, seq) => `binding-log:${logicalSessionId}:${seq}`;
20
+ const logPrefix = (logicalSessionId) => `binding-log:${logicalSessionId}:`;
21
+ export class ScopedRuntimeBindings {
22
+ #scope;
23
+ #now;
24
+ constructor(scope, now = () => new Date().toISOString()) {
25
+ this.#scope = scope;
26
+ this.#now = now;
27
+ }
28
+ async claim(binding, at) {
29
+ const parsed = RuntimeBinding.parse({ ...binding, updatedAt: at });
30
+ // setIfAbsent가 원자성을 진다 — 확인과 쓰기 사이에 다른 Physical Session이 끼지 못한다.
31
+ if (await this.#scope.setIfAbsent(keyOf(parsed.logicalSessionId), JSON.stringify(parsed))) {
32
+ return { ok: true, binding: parsed };
33
+ }
34
+ const current = await this.get(parsed.logicalSessionId);
35
+ if (!current) {
36
+ // 그 사이 owner가 내려놨다 — 한 번 더 집어 본다. 또 지면 진 것이다.
37
+ if (await this.#scope.setIfAbsent(keyOf(parsed.logicalSessionId), JSON.stringify(parsed))) {
38
+ return { ok: true, binding: parsed };
39
+ }
40
+ return { ok: false, reason: 'RUNTIME_CONFLICT', current: (await this.get(parsed.logicalSessionId)) };
41
+ }
42
+ // 같은 Physical Session의 재-claim은 충돌이 아니라 이어 잡기다 (respawn 아님)
43
+ if (current.physicalSessionId === parsed.physicalSessionId) {
44
+ const refreshed = { ...current, updatedAt: at };
45
+ await this.#scope.set(keyOf(parsed.logicalSessionId), JSON.stringify(refreshed));
46
+ return { ok: true, binding: refreshed };
47
+ }
48
+ return { ok: false, reason: 'RUNTIME_CONFLICT', current };
49
+ }
50
+ async observe(logicalSessionId, physicalSessionId, patch, at) {
51
+ const current = await this.get(logicalSessionId);
52
+ if (!current || current.physicalSessionId !== physicalSessionId) {
53
+ // owner가 아니면 관찰도 못 쓴다 — 남의 binding을 덮는 경로를 만들지 않는다
54
+ return current
55
+ ? { ok: false, reason: 'RUNTIME_CONFLICT', current }
56
+ : { ok: false, reason: 'RUNTIME_CONFLICT', current: RuntimeBinding.parse({
57
+ logicalSessionId, provider: 'unknown', physicalSessionId: '(none)', updatedAt: at,
58
+ }) };
59
+ }
60
+ const next = RuntimeBinding.parse({ ...current, ...patch, updatedAt: at });
61
+ await this.#scope.set(keyOf(logicalSessionId), JSON.stringify(next));
62
+ return { ok: true, binding: next };
63
+ }
64
+ async release(logicalSessionId, physicalSessionId) {
65
+ const current = await this.get(logicalSessionId);
66
+ if (!current || current.physicalSessionId !== physicalSessionId)
67
+ return false;
68
+ // 지우기 전에 남긴다. 순서가 반대면 그 사이에 죽었을 때 흔적 없이 사라진다.
69
+ await this.#log(current, 'RELEASED');
70
+ await this.#scope.delete(keyOf(logicalSessionId));
71
+ return true;
72
+ }
73
+ async rebind(binding, at) {
74
+ const previous = await this.get(binding.logicalSessionId);
75
+ // 승계는 덮어쓰기지만, 덮이는 쪽도 있었던 일이다.
76
+ if (previous && previous.physicalSessionId !== binding.physicalSessionId) {
77
+ await this.#log(previous, 'SUPERSEDED');
78
+ }
79
+ const next = RuntimeBinding.parse({ ...binding, updatedAt: at });
80
+ await this.#scope.set(keyOf(next.logicalSessionId), JSON.stringify(next));
81
+ return next;
82
+ }
83
+ /** 이 세션을 거쳐 간 소유권 이력. 현재 owner는 여기 없다 — get()이 답한다. */
84
+ async history(logicalSessionId) {
85
+ const keys = await this.#scope.keys(logPrefix(logicalSessionId));
86
+ const out = [];
87
+ for (const key of keys.sort()) {
88
+ const raw = await this.#scope.get(key);
89
+ if (raw)
90
+ out.push(JSON.parse(raw));
91
+ }
92
+ return out;
93
+ }
94
+ /**
95
+ * 묘비를 append한다. 순번은 setIfAbsent가 성공할 때까지 올린다 — 같은 세션의 두
96
+ * 내려놓음이 겹쳐도 하나가 조용히 사라지지 않는다.
97
+ */
98
+ async #log(binding, kind) {
99
+ const entry = {
100
+ logicalSessionId: binding.logicalSessionId,
101
+ physicalSessionId: binding.physicalSessionId,
102
+ provider: binding.provider,
103
+ ...(binding.workerId ? { workerId: binding.workerId } : {}),
104
+ kind,
105
+ claimedAt: binding.updatedAt,
106
+ endedAt: this.#now(),
107
+ };
108
+ const existing = await this.#scope.keys(logPrefix(binding.logicalSessionId));
109
+ let seq = existing.reduce((max, key) => Math.max(max, Number(key.slice(key.lastIndexOf(':') + 1)) || 0), 0) + 1;
110
+ while (!(await this.#scope.setIfAbsent(logKey(binding.logicalSessionId, seq), JSON.stringify(entry)))) {
111
+ seq += 1;
112
+ }
113
+ }
114
+ async get(logicalSessionId) {
115
+ const raw = await this.#scope.get(keyOf(logicalSessionId));
116
+ return raw ? RuntimeBinding.parse(JSON.parse(raw)) : null;
117
+ }
118
+ }
@@ -0,0 +1,17 @@
1
+ import type { ControlState } from '../../core/model/entities.ts';
2
+ import { type CasResult, type CreateResult, type EntityKind, type EntityMap, type HistoryEntry, type ListFilter, type ScopedStore, type StateStore } from '../../ports/state-store.ts';
3
+ export declare class MemoryStateStore implements StateStore {
4
+ #private;
5
+ get<K extends EntityKind>(kind: K, id: string): Promise<EntityMap[K] | null>;
6
+ list<K extends EntityKind>(kind: K, filter?: ListFilter<K>): Promise<EntityMap[K][]>;
7
+ create<K extends EntityKind>(kind: K, entity: EntityMap[K]): Promise<CreateResult<EntityMap[K]>>;
8
+ compareAndSet<K extends EntityKind>(kind: K, id: string, expectedVersion: number, next: EntityMap[K]): Promise<CasResult<EntityMap[K]>>;
9
+ archive<K extends EntityKind>(kind: K, id: string): Promise<boolean>;
10
+ /** 테스트에서 옮겨진 것을 확인할 때 쓴다. 옮겼을 뿐 잃지 않았음을 보이기 위함이다. */
11
+ archived<K extends EntityKind>(kind: K, id: string): EntityMap[K] | null;
12
+ getControlState(): Promise<ControlState>;
13
+ setControlState(expectedVersion: number, next: ControlState): Promise<CasResult<ControlState>>;
14
+ appendHistory(entry: HistoryEntry): Promise<void>;
15
+ readHistory(limit?: number): Promise<HistoryEntry[]>;
16
+ scope(adapterId: string): ScopedStore;
17
+ }
@@ -0,0 +1,125 @@
1
+ // In-memory State Store — Port 계약의 참조 구현이자 테스트 기본값.
2
+ // 실제 운용 저장소는 Markdown Adapter(B-04)이고, 이 구현은 Core 로직을 파일 시스템 없이
3
+ // 검증하기 위해 존재한다. 두 Adapter가 같은 CAS semantics를 보이는지가 계약의 핵심이다.
4
+ import { ENTITY_KEY, } from "../../ports/state-store.js";
5
+ const clone = (value) => structuredClone(value);
6
+ const keyOf = (kind, entity) => String(entity[ENTITY_KEY[kind]]);
7
+ export class MemoryStateStore {
8
+ #tables = new Map();
9
+ #archived = new Map();
10
+ #scopes = new Map();
11
+ #history = [];
12
+ #control = { version: 0, activeSessions: [], writeBoundaryOccupancy: [], awaitingController: [], controllerAttention: [] };
13
+ #table(kind) {
14
+ let table = this.#tables.get(kind);
15
+ if (!table) {
16
+ table = new Map();
17
+ this.#tables.set(kind, table);
18
+ }
19
+ return table;
20
+ }
21
+ async get(kind, id) {
22
+ const found = this.#table(kind).get(id);
23
+ return found ? clone(found) : null;
24
+ }
25
+ async list(kind, filter = {}) {
26
+ const where = filter.where;
27
+ let rows = [...this.#table(kind).values()];
28
+ if (where) {
29
+ rows = rows.filter((row) => Object.entries(where).every(([field, value]) => row[field] === value));
30
+ }
31
+ return rows.slice(0, filter.limit ?? rows.length).map(clone);
32
+ }
33
+ async create(kind, entity) {
34
+ const table = this.#table(kind);
35
+ const id = keyOf(kind, entity);
36
+ const existing = table.get(id);
37
+ if (existing)
38
+ return { ok: false, reason: 'ALREADY_EXISTS', current: clone(existing) };
39
+ table.set(id, clone(entity));
40
+ return { ok: true, entity: clone(entity) };
41
+ }
42
+ async compareAndSet(kind, id, expectedVersion, next) {
43
+ // 버전을 올리지 않은 갱신은 계약 위반이지 경쟁 실패가 아니다 — 호출자 버그이므로 던진다.
44
+ if (next.version !== expectedVersion + 1) {
45
+ throw new Error(`CAS contract: next.version must be ${expectedVersion + 1}, got ${next.version}`);
46
+ }
47
+ const table = this.#table(kind);
48
+ const current = table.get(id);
49
+ if (!current)
50
+ return { ok: false, reason: 'NOT_FOUND' };
51
+ if (current.version !== expectedVersion) {
52
+ return { ok: false, reason: 'VERSION_CONFLICT', current: clone(current) };
53
+ }
54
+ table.set(id, clone(next));
55
+ return { ok: true, entity: clone(next) };
56
+ }
57
+ async archive(kind, id) {
58
+ const table = this.#table(kind);
59
+ const entity = table.get(id);
60
+ if (!entity)
61
+ return false;
62
+ let bucket = this.#archived.get(kind);
63
+ if (!bucket) {
64
+ bucket = new Map();
65
+ this.#archived.set(kind, bucket);
66
+ }
67
+ bucket.set(id, entity);
68
+ table.delete(id);
69
+ return true;
70
+ }
71
+ /** 테스트에서 옮겨진 것을 확인할 때 쓴다. 옮겼을 뿐 잃지 않았음을 보이기 위함이다. */
72
+ archived(kind, id) {
73
+ const found = this.#archived.get(kind)?.get(id);
74
+ return found ? clone(found) : null;
75
+ }
76
+ async getControlState() {
77
+ return clone(this.#control);
78
+ }
79
+ async setControlState(expectedVersion, next) {
80
+ if (next.version !== expectedVersion + 1) {
81
+ throw new Error(`CAS contract: next.version must be ${expectedVersion + 1}, got ${next.version}`);
82
+ }
83
+ if (this.#control.version !== expectedVersion) {
84
+ return { ok: false, reason: 'VERSION_CONFLICT', current: clone(this.#control) };
85
+ }
86
+ this.#control = clone(next);
87
+ return { ok: true, entity: clone(next) };
88
+ }
89
+ async appendHistory(entry) {
90
+ this.#history.push(clone(entry));
91
+ }
92
+ async readHistory(limit) {
93
+ const rows = this.#history.map(clone);
94
+ return limit ? rows.slice(-limit) : rows;
95
+ }
96
+ scope(adapterId) {
97
+ let bucket = this.#scopes.get(adapterId);
98
+ if (!bucket) {
99
+ bucket = new Map();
100
+ this.#scopes.set(adapterId, bucket);
101
+ }
102
+ const store = bucket;
103
+ return {
104
+ async get(key) {
105
+ return store.get(key) ?? null;
106
+ },
107
+ async set(key, value) {
108
+ store.set(key, value);
109
+ },
110
+ async setIfAbsent(key, value) {
111
+ // 단일 스레드라 확인과 쓰기 사이에 다른 코드가 끼어들지 않는다
112
+ if (store.has(key))
113
+ return false;
114
+ store.set(key, value);
115
+ return true;
116
+ },
117
+ async delete(key) {
118
+ store.delete(key);
119
+ },
120
+ async keys(prefix = '') {
121
+ return [...store.keys()].filter((k) => k.startsWith(prefix));
122
+ },
123
+ };
124
+ }
125
+ }