@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,153 @@
1
+ // Claim Provenance — 사실과 추론을 갈라 적고, 뒤집혀도 지우지 않는다 (C-10 §6).
2
+ //
3
+ // 실전에서 반복된 사고가 이것이다: 어느 시점에 "A가 B를 막고 있다"고 판단했고, 나중에
4
+ // 그게 아니었음이 드러났는데, 그 사이의 기록이 통째로 다시 쓰여 **언제 무엇을 근거로
5
+ // 그렇게 봤는지가 사라졌다.** 그러면 같은 오판을 또 한다.
6
+ //
7
+ // 그래서 둘을 나눈다:
8
+ //
9
+ // History 당시 판단을 그대로 보존한다. append-only
10
+ // Current View STALE을 뺀 최신 claim의 projection. 저장하지 않는다
11
+ //
12
+ // **추론을 정본으로 자동 승격하지 않는다** (C-10 불변식 ⑬). INFERRED는 INFERRED로 남고,
13
+ // CONFIRMED가 되려면 실측이 따로 있어야 한다.
14
+ import { z } from 'zod';
15
+ /**
16
+ * 이 진술을 무엇으로 보는가 (C-10 §6).
17
+ *
18
+ * CONFIRMED 실측했다
19
+ * INFERRED 근거로부터 추론했다
20
+ * PENDING 확인이 필요하다
21
+ * STALE 나중 증거가 뒤집었다
22
+ */
23
+ export const ClaimStatus = z.enum(['CONFIRMED', 'INFERRED', 'PENDING', 'STALE']);
24
+ /** id 문법. 파일명 변환이 단사가 아니라 자유 문자열을 그대로 키에 넣지 않는다. */
25
+ export const CLAIM_ID = /^[A-Za-z0-9._-]+$/;
26
+ export const Claim = z.object({
27
+ claimId: z.string().regex(CLAIM_ID),
28
+ /** 무엇을 주장하는가. 한 문장이어야 뒤집을 때 무엇이 뒤집혔는지 분명하다. */
29
+ statement: z.string().min(1),
30
+ status: ClaimStatus,
31
+ /** 어디서 왔는가 — 명령·문서·사람. 없으면 근거 없는 주장이다. */
32
+ evidenceRefs: z.array(z.string()).default([]),
33
+ observedAt: z.string().min(1),
34
+ /** 이 claim이 대체한 것. */
35
+ supersedes: z.string().optional(),
36
+ /** 이 claim을 대체한 것. **기록에 남기되 원본은 지우지 않는다.** */
37
+ supersededBy: z.string().optional(),
38
+ /** 왜 뒤집혔는가. STALE인데 이유가 없으면 다음 사람이 같은 판단을 또 한다. */
39
+ supersededReason: z.string().optional(),
40
+ });
41
+ const claimKey = (id) => `claim:${id}`;
42
+ const stalePrefix = 'claim-stale:';
43
+ /** 뒤집힘 마커. 원본을 고치지 않고 따로 append한다 — Closure Ledger와 같은 형태다. */
44
+ const staleKey = (id) => `${stalePrefix}${id}`;
45
+ const CLAIM_PREFIX = 'claim:';
46
+ export class ClaimLedger {
47
+ #scope;
48
+ #now;
49
+ constructor(scope, now = () => new Date().toISOString()) {
50
+ this.#scope = scope;
51
+ this.#now = now;
52
+ }
53
+ /**
54
+ * 새 판단을 적는다. **같은 id를 덮어쓰지 않는다** — 덮어쓰면 그게 곧 "다시 쓰기"이고,
55
+ * 이 모듈이 막으려는 것이 정확히 그것이다.
56
+ */
57
+ async record(input) {
58
+ if (!CLAIM_ID.test(input.claimId)) {
59
+ return { ok: false, reason: 'INVALID_ID', detail: `claim id 형식이 아니다: '${input.claimId}'` };
60
+ }
61
+ const claim = Claim.parse({
62
+ claimId: input.claimId,
63
+ statement: input.statement,
64
+ status: input.status,
65
+ evidenceRefs: input.evidenceRefs ?? [],
66
+ observedAt: input.observedAt ?? this.#now(),
67
+ ...(input.supersedes ? { supersedes: input.supersedes } : {}),
68
+ });
69
+ if (await this.#scope.setIfAbsent(claimKey(claim.claimId), JSON.stringify(claim))) {
70
+ return { ok: true, claim };
71
+ }
72
+ return { ok: false, reason: 'DUPLICATE_ID', detail: `${claim.claimId} 는 이미 있다 — 새 id로 적으라` };
73
+ }
74
+ /**
75
+ * 새 증거가 옛 판단을 뒤집었다.
76
+ *
77
+ * **옛 claim을 지우지도 고치지도 않는다.** 뒤집힘 마커를 따로 남기고, 읽을 때 합친다.
78
+ * 그래야 "그때는 왜 그렇게 봤는가"가 남는다.
79
+ */
80
+ async supersede(input) {
81
+ const previous = await this.#read(claimKey(input.staleId));
82
+ if (!previous)
83
+ return { ok: false, reason: 'NOT_FOUND', detail: `${input.staleId} 를 찾지 못했다` };
84
+ if (previous.status === 'STALE') {
85
+ return { ok: false, reason: 'ALREADY_STALE', detail: `${input.staleId} 는 이미 뒤집힌 기록이다` };
86
+ }
87
+ const at = input.at ?? this.#now();
88
+ const recorded = await this.record({ ...input.replacement, observedAt: at, supersedes: input.staleId });
89
+ if (!recorded.ok) {
90
+ return { ok: false, reason: 'NOT_FOUND', detail: recorded.detail };
91
+ }
92
+ const marker = { supersededBy: recorded.claim.claimId, supersededReason: input.reason, at };
93
+ if (!(await this.#scope.setIfAbsent(staleKey(input.staleId), JSON.stringify(marker)))) {
94
+ return { ok: false, reason: 'ALREADY_STALE', detail: `${input.staleId} 는 이미 뒤집힌 기록이다` };
95
+ }
96
+ return { ok: true, stale: { ...previous, status: 'STALE', ...marker, supersededReason: input.reason }, current: recorded.claim };
97
+ }
98
+ /** 당시 판단 그대로. 뒤집힌 것도 든다 — History는 지우지 않는다. */
99
+ async history() {
100
+ const keys = (await this.#scope.keys(CLAIM_PREFIX)).filter((key) => !key.startsWith(stalePrefix)).sort();
101
+ const out = [];
102
+ for (const key of keys) {
103
+ const claim = await this.#read(key);
104
+ if (claim)
105
+ out.push(await this.#compose(claim));
106
+ }
107
+ return out.sort((a, b) => a.observedAt.localeCompare(b.observedAt));
108
+ }
109
+ /**
110
+ * 지금 무엇이 사실로 서 있는가. **STALE을 뺀 최신 claim의 projection이며 저장하지 않는다**
111
+ * (C-10 불변식 ⑫).
112
+ */
113
+ async current() {
114
+ return (await this.history()).filter((claim) => claim.status !== 'STALE');
115
+ }
116
+ async get(claimId) {
117
+ const claim = await this.#read(claimKey(claimId));
118
+ return claim ? this.#compose(claim) : null;
119
+ }
120
+ async #compose(claim) {
121
+ const raw = await this.#scope.get(staleKey(claim.claimId));
122
+ if (!raw)
123
+ return claim;
124
+ const marker = JSON.parse(raw);
125
+ return { ...claim, status: 'STALE', supersededBy: marker.supersededBy, supersededReason: marker.supersededReason };
126
+ }
127
+ async #read(key) {
128
+ const raw = await this.#scope.get(key);
129
+ if (!raw)
130
+ return null;
131
+ const parsed = Claim.safeParse(JSON.parse(raw));
132
+ return parsed.success ? parsed.data : null;
133
+ }
134
+ }
135
+ const MARK = {
136
+ CONFIRMED: '확인',
137
+ INFERRED: '추론',
138
+ PENDING: '미확인',
139
+ STALE: '뒤집힘',
140
+ };
141
+ /**
142
+ * 사람이 읽는 줄. **추론을 확인처럼 보이게 하지 않는다** — 이 표시가 흐려지는 순간
143
+ * 사람이 추론 위에서 결정한다.
144
+ */
145
+ export function claimLines(claims) {
146
+ if (claims.length === 0)
147
+ return ['적힌 판단 없음'];
148
+ return claims.map((claim) => {
149
+ const evidence = claim.evidenceRefs.length > 0 ? ` [${claim.evidenceRefs.join(', ')}]` : ' [근거 없음]';
150
+ const superseded = claim.supersededBy ? ` → ${claim.supersededBy} (${claim.supersededReason ?? '이유 미기록'})` : '';
151
+ return `${claim.claimId} (${MARK[claim.status]}) ${claim.statement}${evidence}${superseded}`;
152
+ });
153
+ }
@@ -0,0 +1,73 @@
1
+ import { z } from 'zod';
2
+ import type { ScopedStore } from '../../ports/state-store.ts';
3
+ /**
4
+ * 항목 id 문법. Markdown Adapter의 파일명 변환이 단사가 아니라 `a:b`와 `a-b`가 같은
5
+ * 파일이 되고, 그러면 setIfAbsent가 EEXIST를 돌려줘 **조용히 잃는 게 아니라 "이미
6
+ * 확인됨"으로 오판**한다. Profile resolve가 선언 입구에서 먼저 막고, 여기서 한 번 더 본다.
7
+ */
8
+ export declare const CLOSURE_ITEM_ID: RegExp;
9
+ export declare const ClosureRecord: z.ZodObject<{
10
+ logicalSessionId: z.ZodString;
11
+ /**
12
+ * 회수 시점 선언의 스냅샷. Profile이 나중에 바뀌어도 이 세션이 무엇을 지고 있었는지는
13
+ * 흔들리지 않아야 한다.
14
+ */
15
+ declared: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
16
+ confirmed: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
17
+ openedAt: z.ZodString;
18
+ /** 전부 확인된 시각. 닫혀도 기록은 남는다 — 지우면 무엇을 닫았는지도 사라진다. */
19
+ closedAt: z.ZodOptional<z.ZodString>;
20
+ }, "strip", z.ZodTypeAny, {
21
+ logicalSessionId: string;
22
+ openedAt: string;
23
+ declared: string[];
24
+ confirmed: string[];
25
+ closedAt?: string | undefined;
26
+ }, {
27
+ logicalSessionId: string;
28
+ openedAt: string;
29
+ declared?: string[] | undefined;
30
+ confirmed?: string[] | undefined;
31
+ closedAt?: string | undefined;
32
+ }>;
33
+ export type ClosureRecord = z.infer<typeof ClosureRecord>;
34
+ export type ConfirmOutcome = {
35
+ ok: true;
36
+ record: ClosureRecord;
37
+ newlyClosed: boolean;
38
+ } | {
39
+ ok: false;
40
+ reason: 'NOT_FOUND';
41
+ detail: string;
42
+ } | {
43
+ ok: false;
44
+ reason: 'UNKNOWN_ITEM';
45
+ detail: string;
46
+ declared: readonly string[];
47
+ } | {
48
+ ok: false;
49
+ reason: 'INVALID_ITEM_ID';
50
+ detail: string;
51
+ };
52
+ export declare class ClosureLedger {
53
+ #private;
54
+ constructor(scope: ScopedStore, now?: () => string);
55
+ /**
56
+ * 회수 시점에 의무를 연다. **이미 있으면 손대지 않는다** — collect는 여러 번 돌고,
57
+ * 두 번째 회수가 기록을 덮으면 이미 확인한 항목이 미확인으로 되돌아간다.
58
+ * @returns 이번에 새로 연 기록. 이미 있었거나 선언이 없으면 null
59
+ */
60
+ open(logicalSessionId: string, declared: readonly string[]): Promise<ClosureRecord | null>;
61
+ /**
62
+ * Controller의 명시 확인. 항목마다 자기 키에 한 번만 쓰므로 서로 다른 항목을 동시에
63
+ * 확인해도 덮이지 않고, 같은 항목을 두 번 확인해도 처음 것이 남는다.
64
+ */
65
+ confirm(logicalSessionId: string, items: readonly string[]): Promise<ConfirmOutcome>;
66
+ get(logicalSessionId: string): Promise<ClosureRecord | null>;
67
+ /** 전부. 닫힌 것도 남는다 — 무엇을 닫았는지가 사라지면 기록이 아니다. */
68
+ list(): Promise<ClosureRecord[]>;
69
+ /** 아직 닫히지 않은 것들. 세션이 archive에 있어도 여기 남아 있다. */
70
+ pending(): Promise<ClosureRecord[]>;
71
+ }
72
+ /** 미확인 항목을 사람이 읽을 줄로. collect의 "판단이 필요한 것"에 그대로 들어간다. */
73
+ export declare function pendingLines(records: readonly ClosureRecord[]): string[];
@@ -0,0 +1,162 @@
1
+ // Project Closure — 세션이 끝난 뒤에도 남는 마무리 의무 (B-20).
2
+ //
3
+ // 근거(B-16): Logical Session이 DONE 됐을 때 프로젝트 쪽은 하나도 닫혀 있지 않았다 —
4
+ // tasks 0/17, backlog 미갱신, 작업일지 미기록, 수동 검증 미실행. 별도 closure 세션이
5
+ // 그걸 다 맞추고 나서야 실제로 끝났다. `Logical Session DONE ≠ Project Closure DONE`.
6
+ //
7
+ // 왜 Session/Handoff가 아니라 별도 기록인가:
8
+ // ① closure는 회수 **이후**에 온다. Handoff에 넣으면 done 시점에 모르는 것을 적어야 한다.
9
+ // ② 세션은 회수되면 archive로 간다. 거기 매달아 두면 다음 collect에서 사라진다 —
10
+ // 그게 정확히 B-20이 고치려는 문제(미완료 사실의 소실)다.
11
+ // ③ 이건 Controller의 기록이지 세션이 스스로 선언한 계약이 아니다.
12
+ //
13
+ // 확인은 **추론하지 않는다.** Handoff 텍스트에 "backlog 갱신함"이라고 쓰여 있어도 확인이
14
+ // 아니다. Controller가 항목 id를 명시로 줄 때만 확인이다 — 문자열을 읽어 판단하는 순간
15
+ // Gate가 휴리스틱이 되고, 사람이 확인하지 않은 것을 확인됐다고 기록하게 된다.
16
+ //
17
+ // 저장은 **전부 setIfAbsent 위에 선다.** ScopedStore에서 원자성이 약속된 것은 그것뿐이고
18
+ // (`set`은 계약이 침묵한다), 잃는 것이 표시값이 아니라 사람이 한 확인이기 때문이다.
19
+ // 읽고-고쳐-쓰기를 하면 서로 다른 항목을 동시에 확인할 때 하나가 조용히 덮인다 —
20
+ // 미완료 사실의 소실을 막겠다는 모듈이 그 기록을 잃는 셈이라, 경쟁 자체가 생기지 않도록
21
+ // 항목마다 다른 키에 한 번만 쓴다. 세션당 파일이 1+N개가 되지만, 그 비용이 옳다.
22
+ import { z } from 'zod';
23
+ /**
24
+ * 항목 id 문법. Markdown Adapter의 파일명 변환이 단사가 아니라 `a:b`와 `a-b`가 같은
25
+ * 파일이 되고, 그러면 setIfAbsent가 EEXIST를 돌려줘 **조용히 잃는 게 아니라 "이미
26
+ * 확인됨"으로 오판**한다. Profile resolve가 선언 입구에서 먼저 막고, 여기서 한 번 더 본다.
27
+ */
28
+ export const CLOSURE_ITEM_ID = /^[A-Za-z0-9._-]+$/;
29
+ export const ClosureRecord = z.object({
30
+ logicalSessionId: z.string().min(1),
31
+ /**
32
+ * 회수 시점 선언의 스냅샷. Profile이 나중에 바뀌어도 이 세션이 무엇을 지고 있었는지는
33
+ * 흔들리지 않아야 한다.
34
+ */
35
+ declared: z.array(z.string()).default([]),
36
+ confirmed: z.array(z.string()).default([]),
37
+ openedAt: z.string().min(1),
38
+ /** 전부 확인된 시각. 닫혀도 기록은 남는다 — 지우면 무엇을 닫았는지도 사라진다. */
39
+ closedAt: z.string().optional(),
40
+ });
41
+ /** 선언 스냅샷. 회수 시점에 한 번 쓰고 이후 바뀌지 않는다. */
42
+ const recordKey = (id) => `closure:rec:${id}`;
43
+ /** 항목별 확인 마커. 이 키가 존재하면 확인된 것이다. */
44
+ const confirmKey = (id, item) => `closure:cnf:${id}:${item}`;
45
+ const confirmPrefix = (id) => `closure:cnf:${id}:`;
46
+ /** CLOSED 전이 마커. 전이를 정확히 한 번만 보고하기 위한 것이다. */
47
+ const doneKey = (id) => `closure:done:${id}`;
48
+ export class ClosureLedger {
49
+ #scope;
50
+ #now;
51
+ constructor(scope, now = () => new Date().toISOString()) {
52
+ this.#scope = scope;
53
+ this.#now = now;
54
+ }
55
+ /**
56
+ * 회수 시점에 의무를 연다. **이미 있으면 손대지 않는다** — collect는 여러 번 돌고,
57
+ * 두 번째 회수가 기록을 덮으면 이미 확인한 항목이 미확인으로 되돌아간다.
58
+ * @returns 이번에 새로 연 기록. 이미 있었거나 선언이 없으면 null
59
+ */
60
+ async open(logicalSessionId, declared) {
61
+ if (declared.length === 0)
62
+ return null; // 선언하지 않은 프로젝트에 의무를 지우지 않는다
63
+ // Profile resolve가 이미 걸렀어야 하지만, Core를 직접 부르는 경로가 우회로가 되면 안 된다
64
+ const invalid = declared.filter((item) => !CLOSURE_ITEM_ID.test(item));
65
+ if (invalid.length > 0) {
66
+ throw new Error(`마무리 항목 id로 쓸 수 없는 값: ${invalid.join(', ')} (허용: A-Z a-z 0-9 . _ -)`);
67
+ }
68
+ const stored = { logicalSessionId, declared: [...declared], openedAt: this.#now() };
69
+ const written = await this.#scope.setIfAbsent(recordKey(logicalSessionId), JSON.stringify(stored));
70
+ if (!written)
71
+ return null;
72
+ return ClosureRecord.parse({ ...stored, confirmed: [] });
73
+ }
74
+ /**
75
+ * Controller의 명시 확인. 항목마다 자기 키에 한 번만 쓰므로 서로 다른 항목을 동시에
76
+ * 확인해도 덮이지 않고, 같은 항목을 두 번 확인해도 처음 것이 남는다.
77
+ */
78
+ async confirm(logicalSessionId, items) {
79
+ const stored = await this.#storedRecord(logicalSessionId);
80
+ if (!stored) {
81
+ return { ok: false, reason: 'NOT_FOUND', detail: `${logicalSessionId} 에 열린 마무리 항목이 없다` };
82
+ }
83
+ const malformed = items.filter((item) => !CLOSURE_ITEM_ID.test(item));
84
+ if (malformed.length > 0) {
85
+ return {
86
+ ok: false,
87
+ reason: 'INVALID_ITEM_ID',
88
+ detail: `항목 id로 쓸 수 없는 값: ${malformed.join(', ')} (허용: A-Z a-z 0-9 . _ -)`,
89
+ };
90
+ }
91
+ // 선언에 없는 id는 받지 않는다 — 오타를 삼키면 확인한 줄 안다
92
+ const unknown = items.filter((item) => !stored.declared.includes(item));
93
+ if (unknown.length > 0) {
94
+ return {
95
+ ok: false,
96
+ reason: 'UNKNOWN_ITEM',
97
+ detail: `선언에 없는 항목: ${unknown.join(', ')}`,
98
+ declared: stored.declared,
99
+ };
100
+ }
101
+ const at = this.#now();
102
+ for (const item of items) {
103
+ await this.#scope.setIfAbsent(confirmKey(logicalSessionId, item), at);
104
+ }
105
+ const confirmed = await this.#confirmedItems(logicalSessionId);
106
+ const allDone = stored.declared.every((item) => confirmed.includes(item));
107
+ // 마지막 두 항목을 동시에 확인하면 양쪽 다 "이제 전부 찼다"고 본다.
108
+ // 전이를 계산으로 내면 두 번 보고되므로, 마커를 집은 쪽만 새로 닫은 것으로 친다.
109
+ let newlyClosed = false;
110
+ if (allDone)
111
+ newlyClosed = await this.#scope.setIfAbsent(doneKey(logicalSessionId), at);
112
+ return { ok: true, record: await this.#compose(stored), newlyClosed };
113
+ }
114
+ async get(logicalSessionId) {
115
+ const stored = await this.#storedRecord(logicalSessionId);
116
+ return stored ? this.#compose(stored) : null;
117
+ }
118
+ /** 전부. 닫힌 것도 남는다 — 무엇을 닫았는지가 사라지면 기록이 아니다. */
119
+ async list() {
120
+ const records = [];
121
+ for (const key of await this.#scope.keys('closure:rec:')) {
122
+ const raw = await this.#scope.get(key);
123
+ if (!raw)
124
+ continue;
125
+ records.push(await this.#compose(JSON.parse(raw)));
126
+ }
127
+ return records.sort((a, b) => a.logicalSessionId.localeCompare(b.logicalSessionId));
128
+ }
129
+ /** 아직 닫히지 않은 것들. 세션이 archive에 있어도 여기 남아 있다. */
130
+ async pending() {
131
+ return (await this.list()).filter((r) => r.closedAt === undefined);
132
+ }
133
+ async #storedRecord(logicalSessionId) {
134
+ const raw = await this.#scope.get(recordKey(logicalSessionId));
135
+ return raw ? JSON.parse(raw) : null;
136
+ }
137
+ async #confirmedItems(logicalSessionId) {
138
+ const prefix = confirmPrefix(logicalSessionId);
139
+ return (await this.#scope.keys(prefix)).map((key) => key.slice(prefix.length));
140
+ }
141
+ /** 선언 스냅샷과 확인 마커를 합쳐 하나의 기록으로 보인다. */
142
+ async #compose(stored) {
143
+ const confirmed = await this.#confirmedItems(stored.logicalSessionId);
144
+ const closedAt = await this.#scope.get(doneKey(stored.logicalSessionId));
145
+ return ClosureRecord.parse({
146
+ ...stored,
147
+ // 선언 순서대로 보인다 — 파일 나열 순서가 화면에 새지 않게
148
+ confirmed: stored.declared.filter((item) => confirmed.includes(item)),
149
+ ...(closedAt ? { closedAt } : {}),
150
+ });
151
+ }
152
+ }
153
+ /** 미확인 항목을 사람이 읽을 줄로. collect의 "판단이 필요한 것"에 그대로 들어간다. */
154
+ export function pendingLines(records) {
155
+ const lines = [];
156
+ for (const record of records) {
157
+ const pending = record.declared.filter((item) => !record.confirmed.includes(item));
158
+ for (const item of pending)
159
+ lines.push(`${record.logicalSessionId}: 마무리 미확인 — ${item}`);
160
+ }
161
+ return lines;
162
+ }
@@ -0,0 +1,40 @@
1
+ import type { Session } from '../model/entities.ts';
2
+ import type { StateStore } from '../../ports/state-store.ts';
3
+ import { ClosureLedger } from './closure.ts';
4
+ import type { AuditLedger } from './audit.ts';
5
+ import { type QueryLedger } from './query.ts';
6
+ import type { EscalationLedger } from './escalation.ts';
7
+ export type CollectOutcome = {
8
+ active: string[];
9
+ /** 이번에 거둔 세션들. Handoff를 읽었다는 뜻이다. */
10
+ collected: string[];
11
+ /** 사람이 판단할 것 — 미결과 막힌 세션. */
12
+ awaiting: string[];
13
+ occupancy: {
14
+ sessionId: string;
15
+ paths: string[];
16
+ }[];
17
+ };
18
+ export type CollectOptions = {
19
+ /** Profile이 선언한 마무리 항목. Core는 Profile을 모른다 — Surface가 꺼내 넘긴다. */
20
+ closureChecklist?: readonly string[];
21
+ closureLedger?: ClosureLedger;
22
+ /** 답을 기다리는 질의와 막힌 되던지기 (B-25). 사람이 보는 유일한 창구가 여기다. */
23
+ queryLedger?: QueryLedger;
24
+ /** 아직 결정되지 않은 상신 (C-13). 사람이 결정해야 풀리는 것들이다. */
25
+ escalationLedger?: EscalationLedger;
26
+ /**
27
+ * 회수 주체 (C-10 §2.4). 모르면 `'controller'` 라는 익명 문자열이 History에 남는데,
28
+ * 그건 감사 대상이 아니라 감사 공백이다 — 누가 거뒀는지 아는 쪽이 넘긴다.
29
+ */
30
+ reclaimedBy?: string;
31
+ /** 회수 사실을 증거로도 남긴다. archive 뒤에는 세션에서 복원할 수 없다. */
32
+ auditLedger?: AuditLedger;
33
+ };
34
+ /**
35
+ * 지금 세션들을 훑어 Controller 상태를 다시 쓴다.
36
+ * 요약하지 않는다 — 미결은 미결대로, 점유는 점유대로 남긴다.
37
+ */
38
+ export declare function collectSessions(store: StateStore, at: string, options?: CollectOptions): Promise<CollectOutcome>;
39
+ /** 사람이 읽는 회수 결과. 다음에 무엇을 할지가 맨 아래 오도록 짠다. */
40
+ export declare function renderCollect(outcome: CollectOutcome, sessions: readonly Session[]): string;
@@ -0,0 +1,121 @@
1
+ // Controller 회수 — 끝난 세션을 사람이 거둬들이는 절차 (OM §7.2·§9).
2
+ //
3
+ // 세션은 자기 파일에 Handoff를 쓰는 데까지 하고 멈춘다. state·block·queue를 세션이
4
+ // 직접 고치게 두면 여러 세션이 같은 문서를 두고 다투게 되고, 무엇보다 Controller가
5
+ // 상태를 모르는 채로 흘러간다. 그래서 회수는 별도 행위이고 사람이 시작한다.
6
+ import { ClosureLedger, pendingLines } from "./closure.js";
7
+ import { escalatedLines, queryLines } from "./query.js";
8
+ /**
9
+ * 지금 세션들을 훑어 Controller 상태를 다시 쓴다.
10
+ * 요약하지 않는다 — 미결은 미결대로, 점유는 점유대로 남긴다.
11
+ */
12
+ export async function collectSessions(store, at, options = {}) {
13
+ const sessions = await store.list('session');
14
+ const active = sessions.filter((s) => s.status === 'ACTIVE' || s.status === 'PAUSED').map((s) => s.id);
15
+ const finished = sessions.filter((s) => s.status === 'DONE');
16
+ const blocked = sessions.filter((s) => s.status === 'BLOCKED' || s.status === 'FAILED');
17
+ // 미결은 세션이 끝났다고 사라지지 않는다. 누가 판단해야 하는지 이름과 함께 남긴다.
18
+ const awaiting = [];
19
+ for (const session of finished) {
20
+ for (const item of session.handoff?.unresolved ?? [])
21
+ awaiting.push(`${session.id}: ${item}`);
22
+ }
23
+ for (const session of blocked)
24
+ awaiting.push(`${session.id}: ${session.status}`);
25
+ // 프로젝트 마무리 의무. 세션은 회수되면 archive로 가지만 이 기록은 남아, 확인될 때까지
26
+ // 매 회수에 계속 올라온다 — 한 번 보이고 사라지면 그게 B-20이 고치려는 문제다.
27
+ const ledger = options.closureLedger;
28
+ if (ledger) {
29
+ for (const session of finished)
30
+ await ledger.open(session.id, options.closureChecklist ?? []);
31
+ awaiting.push(...pendingLines(await ledger.pending()));
32
+ }
33
+ // 결정이 Agent 사이에서 멈춰 있거나 돌고 있었다는 사실. 세션 상태로는 드러나지 않는다 —
34
+ // 답을 기다리는 쪽은 멀쩡히 ACTIVE이고, 막힌 되던지기는 세션 어디에도 남지 않는다.
35
+ const queries = options.queryLedger;
36
+ if (queries) {
37
+ awaiting.push(...queryLines(await queries.pending(), await queries.violations()));
38
+ // 사람에게 넘긴 질의도 아직 끝난 것이 아니다 — 답이 쓰였다고 pending에서만 빼면
39
+ // 상신은 어느 화면에도 뜨지 않는 write-only 로그가 된다.
40
+ awaiting.push(...escalatedLines(await queries.escalated()));
41
+ }
42
+ // 아직 결정되지 않은 상신 (C-13). 무엇이 막혔고 무엇이 계속 가는지 함께 든다.
43
+ const escalations = options.escalationLedger;
44
+ if (escalations) {
45
+ for (const record of await escalations.pending()) {
46
+ awaiting.push(`${record.escalationId} [${record.predicates.join(', ')}] ${record.question}` +
47
+ ` — 막힘 ${record.blockedNodes.join(', ')}` +
48
+ (record.stillRunnableNodes.length > 0 ? ` · 계속 ${record.stillRunnableNodes.join(', ')}` : ''));
49
+ }
50
+ }
51
+ // 멈춰 있는 세션이 무엇 때문에 멈췄다고 적었는지 (C-13 불변식 ⑤ — 표면화까지다).
52
+ for (const session of sessions) {
53
+ if (session.status !== 'PAUSED')
54
+ continue;
55
+ for (const blocker of session.checkpoint?.blockers ?? []) {
56
+ awaiting.push(`${session.id}: 진행 중 막힌 것 — ${blocker}`);
57
+ }
58
+ }
59
+ // 병렬 세션이 같은 경로를 잡고 있는지 보이게 한다. 잠금은 걸지 않는다 —
60
+ // 겹치게 발급하지 않는 것이 Controller의 몫이고, 이 표가 그 판단의 근거다 (OM §9).
61
+ const occupancy = sessions
62
+ .filter((s) => (s.status === 'ACTIVE' || s.status === 'PAUSED') && (s.writeBoundary?.length ?? 0) > 0)
63
+ .map((s) => ({ sessionId: s.id, paths: s.writeBoundary ?? [] }));
64
+ const state = await store.getControlState();
65
+ await store.setControlState(state.version, {
66
+ ...state,
67
+ version: state.version + 1,
68
+ activeSessions: active,
69
+ ...(finished.at(-1) ? { recentHandoff: finished.at(-1).id } : {}),
70
+ writeBoundaryOccupancy: occupancy,
71
+ awaitingController: awaiting,
72
+ });
73
+ const reclaimedBy = options.reclaimedBy ?? 'controller';
74
+ for (const session of finished) {
75
+ await store.appendHistory({
76
+ at,
77
+ actor: reclaimedBy,
78
+ kind: 'session_collected',
79
+ ref: session.id,
80
+ detail: session.handoff?.next ?? '',
81
+ });
82
+ // archive 직전이 실행 증거가 아직 살아 있는 마지막 순간이다 (C-10 §2.4).
83
+ await options.auditLedger?.reclaim({
84
+ sessionId: session.id,
85
+ reclaimedBy,
86
+ reclaimedAt: at,
87
+ ...(session.handoff ? { handoffRef: session.handoff.recordedAt } : {}),
88
+ });
89
+ // 거둔 것은 보관소로 옮긴다. 옮기고 나면 목록에 나오지 않으므로 두 번째 회수가
90
+ // 같은 세션을 다시 거두지 않는다 — 멱등을 따로 구현할 필요가 없다 (OM §7.4).
91
+ await store.archive('session', session.id);
92
+ }
93
+ return { active, collected: finished.map((s) => s.id), awaiting, occupancy };
94
+ }
95
+ /** 사람이 읽는 회수 결과. 다음에 무엇을 할지가 맨 아래 오도록 짠다. */
96
+ export function renderCollect(outcome, sessions) {
97
+ const lines = [];
98
+ lines.push(`활성 세션: ${outcome.active.join(', ') || '없음'}`);
99
+ if (outcome.occupancy.length > 0) {
100
+ lines.push('', '쓰기 범위 점유:');
101
+ for (const item of outcome.occupancy)
102
+ lines.push(` ${item.sessionId} → ${item.paths.join(', ')}`);
103
+ }
104
+ if (outcome.collected.length > 0) {
105
+ lines.push('', '거둔 세션:');
106
+ for (const id of outcome.collected) {
107
+ const session = sessions.find((s) => s.id === id);
108
+ lines.push(` ${id} — ${session?.handoff?.next ?? '(다음 작업 없음)'}`);
109
+ for (const done of session?.handoff?.done ?? [])
110
+ lines.push(` 완료: ${done}`);
111
+ if (session?.handoff?.verified)
112
+ lines.push(` 검증: ${session.handoff.verified}`);
113
+ }
114
+ }
115
+ if (outcome.awaiting.length > 0) {
116
+ lines.push('', '판단이 필요한 것:');
117
+ for (const item of outcome.awaiting)
118
+ lines.push(` - ${item}`);
119
+ }
120
+ return lines.join('\n');
121
+ }