adde-acp 0.1.3

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 (136) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +88 -0
  3. package/README.md +88 -0
  4. package/dist/backend/acp/client.d.ts +149 -0
  5. package/dist/backend/acp/client.js +538 -0
  6. package/dist/backend/acp/client.js.map +1 -0
  7. package/dist/backend/acp/index.d.ts +8 -0
  8. package/dist/backend/acp/index.js +7 -0
  9. package/dist/backend/acp/index.js.map +1 -0
  10. package/dist/backend/acp/lifecycle.d.ts +15 -0
  11. package/dist/backend/acp/lifecycle.js +56 -0
  12. package/dist/backend/acp/lifecycle.js.map +1 -0
  13. package/dist/backend/acp/perm-diff.d.ts +37 -0
  14. package/dist/backend/acp/perm-diff.js +58 -0
  15. package/dist/backend/acp/perm-diff.js.map +1 -0
  16. package/dist/backend/acp/spawn.d.ts +20 -0
  17. package/dist/backend/acp/spawn.js +70 -0
  18. package/dist/backend/acp/spawn.js.map +1 -0
  19. package/dist/cli/adde.d.ts +2 -0
  20. package/dist/cli/adde.js +11 -0
  21. package/dist/cli/adde.js.map +1 -0
  22. package/dist/cli/alias.d.ts +45 -0
  23. package/dist/cli/alias.js +94 -0
  24. package/dist/cli/alias.js.map +1 -0
  25. package/dist/cli/completion.d.ts +4 -0
  26. package/dist/cli/completion.js +209 -0
  27. package/dist/cli/completion.js.map +1 -0
  28. package/dist/cli/init.d.ts +3 -0
  29. package/dist/cli/init.js +114 -0
  30. package/dist/cli/init.js.map +1 -0
  31. package/dist/cli/lane.d.ts +20 -0
  32. package/dist/cli/lane.js +350 -0
  33. package/dist/cli/lane.js.map +1 -0
  34. package/dist/cli/ops.d.ts +5 -0
  35. package/dist/cli/ops.js +230 -0
  36. package/dist/cli/ops.js.map +1 -0
  37. package/dist/cli/prompt.d.ts +15 -0
  38. package/dist/cli/prompt.js +41 -0
  39. package/dist/cli/prompt.js.map +1 -0
  40. package/dist/cli/run.d.ts +5 -0
  41. package/dist/cli/run.js +216 -0
  42. package/dist/cli/run.js.map +1 -0
  43. package/dist/cli/spec.d.ts +48 -0
  44. package/dist/cli/spec.js +98 -0
  45. package/dist/cli/spec.js.map +1 -0
  46. package/dist/core/diagnostics.d.ts +73 -0
  47. package/dist/core/diagnostics.js +333 -0
  48. package/dist/core/diagnostics.js.map +1 -0
  49. package/dist/core/index.d.ts +11 -0
  50. package/dist/core/index.js +9 -0
  51. package/dist/core/index.js.map +1 -0
  52. package/dist/core/injector.d.ts +27 -0
  53. package/dist/core/injector.js +297 -0
  54. package/dist/core/injector.js.map +1 -0
  55. package/dist/core/lane-config.d.ts +80 -0
  56. package/dist/core/lane-config.js +303 -0
  57. package/dist/core/lane-config.js.map +1 -0
  58. package/dist/core/launchd.d.ts +81 -0
  59. package/dist/core/launchd.js +216 -0
  60. package/dist/core/launchd.js.map +1 -0
  61. package/dist/core/messages.d.ts +31 -0
  62. package/dist/core/messages.js +71 -0
  63. package/dist/core/messages.js.map +1 -0
  64. package/dist/core/queue.d.ts +74 -0
  65. package/dist/core/queue.js +227 -0
  66. package/dist/core/queue.js.map +1 -0
  67. package/dist/core/runtime-state.d.ts +52 -0
  68. package/dist/core/runtime-state.js +90 -0
  69. package/dist/core/runtime-state.js.map +1 -0
  70. package/dist/core/session-ledger.d.ts +25 -0
  71. package/dist/core/session-ledger.js +89 -0
  72. package/dist/core/session-ledger.js.map +1 -0
  73. package/dist/core/supervisor.d.ts +41 -0
  74. package/dist/core/supervisor.js +315 -0
  75. package/dist/core/supervisor.js.map +1 -0
  76. package/dist/core/transcript.d.ts +22 -0
  77. package/dist/core/transcript.js +93 -0
  78. package/dist/core/transcript.js.map +1 -0
  79. package/dist/core/update-check.d.ts +25 -0
  80. package/dist/core/update-check.js +142 -0
  81. package/dist/core/update-check.js.map +1 -0
  82. package/dist/core/version.d.ts +7 -0
  83. package/dist/core/version.js +32 -0
  84. package/dist/core/version.js.map +1 -0
  85. package/dist/gate/gate.d.ts +41 -0
  86. package/dist/gate/gate.js +28 -0
  87. package/dist/gate/gate.js.map +1 -0
  88. package/dist/gate/index.d.ts +6 -0
  89. package/dist/gate/index.js +6 -0
  90. package/dist/gate/index.js.map +1 -0
  91. package/dist/shared/conf.d.ts +54 -0
  92. package/dist/shared/conf.js +85 -0
  93. package/dist/shared/conf.js.map +1 -0
  94. package/dist/shared/deny-match.d.ts +19 -0
  95. package/dist/shared/deny-match.js +122 -0
  96. package/dist/shared/deny-match.js.map +1 -0
  97. package/dist/shared/envelope.d.ts +37 -0
  98. package/dist/shared/envelope.js +91 -0
  99. package/dist/shared/envelope.js.map +1 -0
  100. package/dist/shared/errors.d.ts +8 -0
  101. package/dist/shared/errors.js +23 -0
  102. package/dist/shared/errors.js.map +1 -0
  103. package/dist/shared/fs-atomic.d.ts +17 -0
  104. package/dist/shared/fs-atomic.js +31 -0
  105. package/dist/shared/fs-atomic.js.map +1 -0
  106. package/dist/shared/i18n.d.ts +23 -0
  107. package/dist/shared/i18n.js +53 -0
  108. package/dist/shared/i18n.js.map +1 -0
  109. package/dist/shared/locales/en.d.ts +393 -0
  110. package/dist/shared/locales/en.js +447 -0
  111. package/dist/shared/locales/en.js.map +1 -0
  112. package/dist/shared/locales/ko.d.ts +389 -0
  113. package/dist/shared/locales/ko.js +443 -0
  114. package/dist/shared/locales/ko.js.map +1 -0
  115. package/dist/shared/mask.d.ts +6 -0
  116. package/dist/shared/mask.js +28 -0
  117. package/dist/shared/mask.js.map +1 -0
  118. package/dist/shared/notify.d.ts +15 -0
  119. package/dist/shared/notify.js +20 -0
  120. package/dist/shared/notify.js.map +1 -0
  121. package/dist/shared/paths.d.ts +42 -0
  122. package/dist/shared/paths.js +83 -0
  123. package/dist/shared/paths.js.map +1 -0
  124. package/dist/src-adapters/index.d.ts +8 -0
  125. package/dist/src-adapters/index.js +6 -0
  126. package/dist/src-adapters/index.js.map +1 -0
  127. package/dist/src-adapters/markdown.d.ts +80 -0
  128. package/dist/src-adapters/markdown.js +794 -0
  129. package/dist/src-adapters/markdown.js.map +1 -0
  130. package/dist/src-adapters/source.d.ts +33 -0
  131. package/dist/src-adapters/source.js +3 -0
  132. package/dist/src-adapters/source.js.map +1 -0
  133. package/dist/src-adapters/telegram.d.ts +48 -0
  134. package/dist/src-adapters/telegram.js +412 -0
  135. package/dist/src-adapters/telegram.js.map +1 -0
  136. package/package.json +62 -0
@@ -0,0 +1,794 @@
1
+ /**
2
+ * Markdown 소스 어댑터 — 파일 핸드셰이크(버튼 없는 채널).
3
+ * 임의 마크다운 에디터/동기 도구(대표 예: Obsidian)에서 노트 파일 편집만으로 구동.
4
+ * 인박스 노트 편집 + send 체크박스 → envelope → 큐.
5
+ * 권한: approvals 노트에 ⏳ 블록 append → allow/deny 체크 감지 → 게이트 반영. 무응답 → 타임아웃 deny.
6
+ * 출력: out/<id>.out 감시 → 마크다운 출력 노트(one-file-per-message, atomic).
7
+ * 동기 내성: *.sync-conflict* 격리·상태 마커 멱등 자기쓰기 가드·tmp→rename.
8
+ */
9
+ import { t, tFor } from "../shared/i18n.js";
10
+ import { errMsg, errCode } from "../shared/errors.js";
11
+ import { watch, existsSync, mkdirSync, statSync } from "node:fs";
12
+ import { readFile, rename, mkdir, stat, readdir } from "node:fs/promises";
13
+ import { join, dirname, isAbsolute, resolve } from "node:path";
14
+ import { randomUUID } from "node:crypto";
15
+ import { isPathInside, normCasePath, pathsOverlap } from "../shared/paths.js";
16
+ import { atomicWrite as atomicWriteFile } from "../shared/fs-atomic.js";
17
+ import { enqueue, hasId, readSidecar } from "../core/queue.js";
18
+ import { readLedger, resolveResumeControl } from "../core/session-ledger.js";
19
+ import { DEFAULT_GATE_TIMEOUT_MS } from "../gate/gate.js";
20
+ import { ENQUEUE_FAIL_THRESHOLD } from "./source.js";
21
+ import { formatException } from "../shared/notify.js";
22
+ const DEBOUNCE_MS = 150;
23
+ /** fs.watch 가 놓친 편집을 보정하는 저빈도 폴링 주기(B2 백스톱). */
24
+ const POLL_INTERVAL_MS = 2_000;
25
+ /** 내용 안정화 재확인 간격(B1) — 동기 중 잘린 파일 읽기 방지. */
26
+ const READ_SETTLE_MS = 50;
27
+ // --- 순수 파싱 (테스트 대상) -------------------------------------------------
28
+ /** 동기 충돌 파일 판별 — 파싱·실행 금지 대상(Obsidian Sync/Syncthing 등). */
29
+ export function isConflictFile(filename) {
30
+ return /\.sync-conflict|conflicted copy|\.conflicted\./i.test(filename);
31
+ }
32
+ /** 체크박스 라인 파싱: `- [ ]`/`- [x]` + 라벨. */
33
+ const CHECKBOX = /^\s*-\s*\[([ xX])\]\s+(.*)$/;
34
+ /** 라벨 앞쪽의 이모지·기호·공백을 제거한 본문(대소문자 보존 — resume 인자의 세션 id 는 대문자 포함 가능). */
35
+ function labelBody(label) {
36
+ return label.replace(/^[^\p{L}]+/u, "");
37
+ }
38
+ /** 라벨 앞쪽의 이모지·기호·공백을 제거하고 소문자화한 코어 토큰. */
39
+ function labelCore(label) {
40
+ return labelBody(label).toLowerCase();
41
+ }
42
+ /** send 트리거 라벨 판별 — 코어가 정확히 'send'(부분일치 금지). */
43
+ function isSendLabel(label) {
44
+ return labelCore(label) === "send";
45
+ }
46
+ // --- 전송 스탬프 -------------------------------------------
47
+ // 형식 `YYYYMMDD-HHmmss`(로컬 시각) — 파일명 안전(콜론 없음)하면서 inbox 마커와
48
+ // out 노트 파일명에 동일 표기. 기준 시각은 전송(enqueue) 시각이며 envelope.ts 로
49
+ // 영속돼 재렌더에도 파일명이 결정론적이다.
50
+ /** 전송 스탬프 표기 — 로컬 시각 `YYYYMMDD-HHmmss`. */
51
+ export function formatStamp(d) {
52
+ const p = (n, w = 2) => String(n).padStart(w, "0");
53
+ return (`${p(d.getFullYear(), 4)}${p(d.getMonth() + 1)}${p(d.getDate())}` +
54
+ `-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`);
55
+ }
56
+ /** ISO 타임스탬프(envelope.ts/sidecar)로부터 스탬프 도출. */
57
+ export function stampFromIso(iso) {
58
+ return formatStamp(new Date(iso));
59
+ }
60
+ /** out 응답 노트 basename(확장자 제외) — sent 위키링크 텍스트와 동일해야 한다. */
61
+ export function outNoteBase(stamp, id) {
62
+ return `${stamp} ${id}`;
63
+ }
64
+ /**
65
+ * 스탬프를 로컬 시각으로 되돌려 ISO 로 복원 — 재개(re-enqueue) 시 envelope.ts 가
66
+ * sending 라인의 스탬프와 같은 값을 재현해야 sent 위키링크와 노트 파일명이 일치한다.
67
+ * 형식 불일치(구버전 라인 등)면 null.
68
+ */
69
+ export function isoFromStamp(stamp) {
70
+ const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(stamp);
71
+ if (!m)
72
+ return null;
73
+ const d = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
74
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
75
+ }
76
+ /** send 트리거 라인을 단계별 마커로 재작성하는 헬퍼(2단계 내구 마킹). */
77
+ // 스탬프는 id 뒤에 둔다 — 재개 파서가 sending 다음 첫 토큰을 id 로 읽는다.
78
+ export function sendingLine(id, stamp) {
79
+ return `- [x] ⏳ sending ${id} ${stamp}`;
80
+ }
81
+ // 위키링크 텍스트 = out 노트 basename(스탬프+id) — 노트 생성 시 링크가 해소된다.
82
+ export function sentLine(id, stamp) {
83
+ return `- [x] ✅ sent [[${outNoteBase(stamp, id)}]]`;
84
+ }
85
+ export function emptyLine() {
86
+ return "- [x] ⚠️ empty (no message)";
87
+ }
88
+ /**
89
+ * 인박스 본문을 파싱해 액션 목록을 만든다(파일은 쓰지 않음, id 부여도 main 책임).
90
+ * - 경계 라인: send 트리거(라벨=send)·`sending <id>`·종단(`sent`/`empty`) 체크박스.
91
+ * - 그 외 체크박스(사용자 todo 등)는 메시지 본문으로 취급(경계 아님).
92
+ * - 체크된 send 트리거 직전 세그먼트가 메시지(빈 세그먼트는 empty 액션).
93
+ * - `sending <id>` 는 크래시 재개 후보(resume) — main 이 hasId 로 존재검사 후 보정.
94
+ */
95
+ export function parseInbox(content) {
96
+ const trailingNewline = content.endsWith("\n");
97
+ const lines = content.split("\n");
98
+ const actions = [];
99
+ let segmentStart = 0;
100
+ const segment = (end) => lines.slice(segmentStart, end).join("\n").trim();
101
+ for (let i = 0; i < lines.length; i++) {
102
+ const cb = CHECKBOX.exec(lines[i]);
103
+ if (!cb)
104
+ continue; // 일반 텍스트 — 세그먼트 본문
105
+ const checked = cb[1].toLowerCase() === "x";
106
+ const label = cb[2].trim();
107
+ const core = labelCore(label);
108
+ if (core.startsWith("sending")) {
109
+ const m = /sending\s+(\S+)(?:\s+(\S+))?/i.exec(label);
110
+ if (m) {
111
+ const action = { kind: "resume", id: m[1], text: segment(i), lineIndex: i };
112
+ if (m[2])
113
+ action.stamp = m[2];
114
+ actions.push(action);
115
+ }
116
+ segmentStart = i + 1;
117
+ continue;
118
+ }
119
+ if (core.startsWith("sent") || core.startsWith("empty")) {
120
+ segmentStart = i + 1; // 종단 마커 — 경계
121
+ continue;
122
+ }
123
+ if (isSendLabel(label)) {
124
+ if (checked) {
125
+ const text = segment(i);
126
+ actions.push(text.length > 0
127
+ ? { kind: "fresh", text, lineIndex: i }
128
+ : { kind: "empty", text: "", lineIndex: i });
129
+ }
130
+ segmentStart = i + 1; // 체크/미체크 무관 경계
131
+ continue;
132
+ }
133
+ // 세션 제어 라벨(send 와 동일 계약: 정확 일치·앞 이모지 허용·체크 시 트리거·항상 경계).
134
+ // resume 은 인자 허용: `resume 2`(목록 번호)·`resume <세션id>`. 무인자 resume = 목록 조회.
135
+ if (core === "clear" || core === "compact") {
136
+ if (checked) {
137
+ actions.push({ kind: "control", controlKind: core, text: "", lineIndex: i });
138
+ }
139
+ segmentStart = i + 1;
140
+ continue;
141
+ }
142
+ // 인자는 소문자 core 가 아니라 본문에서 추출 — 세션 id 의 대문자를 보존해야 장부와 일치.
143
+ const rm = /^resume(?:\s+(\S+))?$/i.exec(labelBody(label));
144
+ if (rm) {
145
+ if (checked) {
146
+ const action = {
147
+ kind: "control",
148
+ controlKind: rm[1] ? "resume" : "sessions",
149
+ text: "",
150
+ lineIndex: i,
151
+ };
152
+ if (rm[1])
153
+ action.controlArg = rm[1];
154
+ actions.push(action);
155
+ }
156
+ segmentStart = i + 1;
157
+ continue;
158
+ }
159
+ // send/sent/sending/empty/제어 가 아닌 체크박스 → 본문(경계 아님, segmentStart 유지)
160
+ }
161
+ return { actions, lines, trailingNewline };
162
+ }
163
+ const PERM_MARKER = /<!--\s*adde:perm\s+id=(\S+)\s+status=(\S+)\s*-->/;
164
+ const ALLOW_CHECKED = /^\s*-\s*\[x\]\s+.*\ballow\b/i;
165
+ const DENY_CHECKED = /^\s*-\s*\[x\]\s+.*\bdeny\b/i;
166
+ /** 권한 요청 1건을 approvals 노트 블록 문자열로 렌더(append 용, 말미 개행 포함). */
167
+ // now 주입: 요청 시각·자동 deny 기한 표기(테스트 결정론 확보). 기한은 게이트 타임아웃과 동일 기준.
168
+ export function renderApprovalBlock(req, tl = t, now = new Date()) {
169
+ const detail = req.detail.replace(/\s+/g, " ").trim();
170
+ const deadline = new Date(now.getTime() + DEFAULT_GATE_TIMEOUT_MS);
171
+ return [
172
+ `### ⏳ req ${req.id} · ${req.tool}`,
173
+ `> ${detail} (cwd: ${req.cwd})`,
174
+ `> ${tl("markdown.approvalMeta", { requested: formatStamp(now), deadline: formatStamp(deadline) })}`,
175
+ `- [ ] allow`,
176
+ `- [ ] deny`,
177
+ `<!-- adde:perm id=${req.id} status=pending -->`,
178
+ "",
179
+ "",
180
+ ].join("\n");
181
+ }
182
+ /**
183
+ * approvals 노트에서 결정된 권한 블록을 추출하고 종단 재작성한다.
184
+ * - status=pending 블록에서 allow/deny 중 정확히 하나 체크 → 결정.
185
+ * - 0개/2개 체크 = 모호 → pending 유지.
186
+ * - 결정 블록은 marker status 와 헤딩을 종단 상태로 재작성(멱등 가드).
187
+ */
188
+ export function parseApprovals(content) {
189
+ const trailingNewline = content.endsWith("\n");
190
+ const lines = content.split("\n");
191
+ const decisions = [];
192
+ let changed = false;
193
+ // marker 라인 인덱스 기준으로 블록 경계 분할.
194
+ let blockStart = 0;
195
+ for (let i = 0; i < lines.length; i++) {
196
+ const m = PERM_MARKER.exec(lines[i]);
197
+ if (!m)
198
+ continue;
199
+ const id = m[1];
200
+ const status = m[2];
201
+ const blockLines = lines.slice(blockStart, i);
202
+ if (status === "pending") {
203
+ const allow = blockLines.some((l) => ALLOW_CHECKED.test(l));
204
+ const deny = blockLines.some((l) => DENY_CHECKED.test(l));
205
+ if (allow !== deny) {
206
+ const decision = allow ? "allow" : "deny";
207
+ decisions.push({ reqId: id, decision });
208
+ lines[i] = `<!-- adde:perm id=${id} status=${decision} -->`;
209
+ // 헤딩 종단 표기(블록 내 첫 ### 라인).
210
+ for (let j = blockStart; j < i; j++) {
211
+ if (/^###\s/.test(lines[j])) {
212
+ lines[j] = lines[j].replace(/^###\s+⏳/, `### ${decision === "allow" ? "✅" : "⛔"}`).replace(/\breq\b/, `req(${decision})`);
213
+ break;
214
+ }
215
+ }
216
+ changed = true;
217
+ }
218
+ }
219
+ blockStart = i + 1;
220
+ }
221
+ let newContent = lines.join("\n");
222
+ if (trailingNewline && !newContent.endsWith("\n"))
223
+ newContent += "\n";
224
+ return { decisions, newContent, changed };
225
+ }
226
+ /** 타임아웃·강제 종단 시 pending 블록을 deny 로 재작성. 변경 없으면 changed=false. */
227
+ export function finalizeApprovalDeny(content, reqId, reason) {
228
+ const trailingNewline = content.endsWith("\n");
229
+ const lines = content.split("\n");
230
+ let changed = false;
231
+ for (let i = 0; i < lines.length; i++) {
232
+ const m = PERM_MARKER.exec(lines[i]);
233
+ if (m && m[1] === reqId && m[2] === "pending") {
234
+ lines[i] = `<!-- adde:perm id=${reqId} status=deny reason=${reason} -->`;
235
+ changed = true;
236
+ }
237
+ }
238
+ let newContent = lines.join("\n");
239
+ if (trailingNewline && !newContent.endsWith("\n"))
240
+ newContent += "\n";
241
+ return { decisions: [], newContent, changed };
242
+ }
243
+ // --- 어댑터 ------------------------------------------------------------------
244
+ /** root 상대 경로를 절대경로로 해소한다. 필수 키 누락 시 throw(fail-closed). */
245
+ function resolvePaths(conf) {
246
+ if (!conf.root)
247
+ throw new Error(t("markdown.confRootMissing"));
248
+ if (!conf.inbox)
249
+ throw new Error(t("markdown.confInboxMissing"));
250
+ const rootDir = conf.root;
251
+ const inboxPath = join(rootDir, conf.inbox);
252
+ const inboxDir = dirname(inboxPath);
253
+ // 승인은 요청당 파일 디렉터리(D, 백로그 B3) — conf.approvals 는 디렉터리(미지정 시 inbox 형제 approvals/).
254
+ const approvalsDir = conf.approvals ? join(rootDir, conf.approvals) : join(inboxDir, "approvals");
255
+ const outboxDir = conf.outbox ? join(rootDir, conf.outbox) : join(inboxDir, "out");
256
+ const quarantineDir = join(inboxDir, ".conflicts");
257
+ return { rootDir, inboxPath, approvalsDir, outboxDir, quarantineDir };
258
+ }
259
+ export function createMarkdownSource(cfg) {
260
+ const tl = tFor(cfg.conf.lang);
261
+ const { rootDir, inboxPath, approvalsDir, outboxDir, quarantineDir } = resolvePaths(cfg.conf);
262
+ const decisionHandlers = [];
263
+ const watchers = [];
264
+ const debounceTimers = new Map();
265
+ const permTimers = new Map();
266
+ const lastSelfWrite = new Map();
267
+ // 폴링 백스톱(B2): 파일별 마지막 관측 시그니처(mtimeMs:size)와 인터벌·in-flight 추적.
268
+ const lastFileSig = new Map();
269
+ let pollTimer = null;
270
+ let pollBusy = false;
271
+ let pollOp = Promise.resolve();
272
+ let inboxBusy = false;
273
+ let running = false;
274
+ // enqueue 연속 실패 추적 — 임계 도달 시 outbox 알림 1회, 성공 시 리셋.
275
+ let consecutiveEnqueueFailures = 0;
276
+ let enqueueAlertSent = false;
277
+ // approvals 파일 변경을 직렬화(append·결정 재작성·타임아웃 경합 방지).
278
+ let approvalsLock = Promise.resolve();
279
+ // watch 발 격리(fire-and-forget)도 체인으로 추적 — stop() 이 in-flight 격리를 대기
280
+ // (teardown 뒤 살아남은 mkdir 이 정리된 임시 경로를 재생성하는 것 방지).
281
+ let quarantineOp = Promise.resolve();
282
+ // in-flight inbox/approvals 처리 추적 — stop() 이 정리 완료를 대기.
283
+ let inboxOp = Promise.resolve();
284
+ let approvalsOp = Promise.resolve();
285
+ /** 원자 기록(shared 위임) + 자기쓰기 echo 가드 등록. */
286
+ async function atomicWrite(filePath, content) {
287
+ await atomicWriteFile(filePath, content);
288
+ lastSelfWrite.set(filePath, content);
289
+ }
290
+ function withApprovalsLock(fn) {
291
+ const run = approvalsLock.then(fn, fn);
292
+ approvalsLock = run.then(() => undefined, () => undefined);
293
+ return run;
294
+ }
295
+ // ts 는 전송 스탬프의 원본(SoT) — 호출자가 스탬프와 같은 순간의 값을 넘긴다.
296
+ function normalize(id, text, ts, control) {
297
+ return {
298
+ v: 1,
299
+ id,
300
+ lane: cfg.lane,
301
+ source: "markdown",
302
+ backend: "acp",
303
+ engine: cfg.engine,
304
+ project: cfg.proj,
305
+ ts,
306
+ text,
307
+ reply_ref: { channel_msg_id: id },
308
+ ...(control ? { control } : {}),
309
+ };
310
+ }
311
+ /** 제어 라벨 → ControlRequest 해석. resume 인자 해석은 채널 공통(resolveResumeControl). */
312
+ async function resolveControl(action) {
313
+ const kind = action.controlKind;
314
+ if (kind !== "resume")
315
+ return { kind };
316
+ return resolveResumeControl(action.controlArg, await readLedger(cfg.paths));
317
+ }
318
+ async function readMaybe(filePath) {
319
+ try {
320
+ return await readFile(filePath, "utf8");
321
+ }
322
+ catch {
323
+ return null;
324
+ }
325
+ }
326
+ /**
327
+ * 내용 안정화 후 읽기(B1) — 짧은 간격 2회 read 가 동일할 때만 반환. 동기 중 절반만
328
+ * 기록된 파일을 읽어 잘린 메시지를 보내는 것을 막는다. 변경 진행 중이면 null(다음
329
+ * watch/폴 이벤트가 안정된 상태로 재시도). atomic-rename 저장은 즉시 안정이라 지연 없음.
330
+ */
331
+ async function readStable(filePath) {
332
+ const first = await readMaybe(filePath);
333
+ if (first === null)
334
+ return null;
335
+ await new Promise((r) => setTimeout(r, READ_SETTLE_MS));
336
+ const second = await readMaybe(filePath);
337
+ if (second === null || second !== first)
338
+ return null;
339
+ return second;
340
+ }
341
+ function joinLines(lines, trailingNewline) {
342
+ return lines.join("\n") + (trailingNewline ? "\n" : "");
343
+ }
344
+ async function handleInbox() {
345
+ if (inboxBusy)
346
+ return;
347
+ inboxBusy = true;
348
+ try {
349
+ const content = await readStable(inboxPath);
350
+ if (content === null)
351
+ return; // 부재 또는 변경 진행 중(B1) — 다음 이벤트 재시도
352
+ if (lastSelfWrite.get(inboxPath) === content)
353
+ return; // 자기쓰기 echo
354
+ const { actions, lines, trailingNewline } = parseInbox(content);
355
+ if (actions.length === 0)
356
+ return;
357
+ // Phase A: fresh→id 부여+sending 마킹, empty→마킹 (내구 기록 후 enqueue).
358
+ // control 은 단일 단계(마킹 없이 enqueue→sent 종단) — 재구성 불가한 제어 정보라 sending
359
+ // 재개 대상에서 제외하고, 크래시 시 라벨이 남아 재트리거되는 쪽을 택한다(멱등에 가까움).
360
+ const pending = [];
361
+ let dirtyA = false;
362
+ for (const a of actions) {
363
+ if (a.kind === "empty") {
364
+ lines[a.lineIndex] = emptyLine();
365
+ dirtyA = true;
366
+ }
367
+ else if (a.kind === "control") {
368
+ const id = randomUUID();
369
+ const d = new Date();
370
+ pending.push({
371
+ id,
372
+ text: `/${a.controlKind}`,
373
+ lineIndex: a.lineIndex,
374
+ resume: false,
375
+ stamp: formatStamp(d),
376
+ ts: d.toISOString(),
377
+ control: await resolveControl(a),
378
+ });
379
+ }
380
+ else if (a.kind === "fresh") {
381
+ const id = randomUUID();
382
+ const d = new Date();
383
+ const stamp = formatStamp(d);
384
+ lines[a.lineIndex] = sendingLine(id, stamp);
385
+ dirtyA = true;
386
+ pending.push({
387
+ id,
388
+ text: a.text,
389
+ lineIndex: a.lineIndex,
390
+ resume: false,
391
+ stamp,
392
+ ts: d.toISOString(),
393
+ });
394
+ }
395
+ else {
396
+ // resume: 라인은 이미 `sending <id> [<stamp>]` — 스탬프를 라인에서 복원해
397
+ // sent 링크·envelope.ts 와 일치시킨다. 구버전 라인(스탬프 없음)은 now 폴백.
398
+ const d = new Date();
399
+ const stamp = a.stamp ?? formatStamp(d);
400
+ const ts = (a.stamp ? isoFromStamp(a.stamp) : null) ?? d.toISOString();
401
+ pending.push({
402
+ id: a.id,
403
+ text: a.text,
404
+ lineIndex: a.lineIndex,
405
+ resume: true,
406
+ stamp,
407
+ ts,
408
+ });
409
+ }
410
+ }
411
+ if (dirtyA)
412
+ await atomicWrite(inboxPath, joinLines(lines, trailingNewline));
413
+ // enqueue (resume 이고 이미 존재하면 스킵) → 성공분만 종단 후보.
414
+ const finalize = [];
415
+ for (const p of pending) {
416
+ try {
417
+ if (p.resume && (await hasId(cfg.paths, p.id))) {
418
+ finalize.push(p); // 이미 enqueue 됨 — 종단만
419
+ continue;
420
+ }
421
+ await enqueue(cfg.paths, normalize(p.id, p.text, p.ts, p.control));
422
+ finalize.push(p);
423
+ consecutiveEnqueueFailures = 0; // 성공 → 연속 실패 리셋
424
+ enqueueAlertSent = false;
425
+ }
426
+ catch (err) {
427
+ // 필수 동작 실패 — 흡수 금지: 로그 후 sending 유지(재기동/다음 이벤트 재개).
428
+ consecutiveEnqueueFailures++;
429
+ console.error(t("log.markdown.enqueueError", {
430
+ count: consecutiveEnqueueFailures,
431
+ lane: cfg.lane,
432
+ id: p.id,
433
+ error: errMsg(err),
434
+ }));
435
+ // 임계 도달 시 1회 운영자 알림 — telegram 패턴과 일관.
436
+ if (consecutiveEnqueueFailures >= ENQUEUE_FAIL_THRESHOLD && !enqueueAlertSent) {
437
+ enqueueAlertSent = true;
438
+ await alertEnqueueFailure(consecutiveEnqueueFailures);
439
+ }
440
+ }
441
+ }
442
+ // Phase B: enqueue 확정분을 sent 로 종단.
443
+ if (finalize.length > 0) {
444
+ for (const f of finalize)
445
+ lines[f.lineIndex] = sentLine(f.id, f.stamp);
446
+ await atomicWrite(inboxPath, joinLines(lines, trailingNewline));
447
+ cfg.onInbound?.(); // injector 깨우기(in-process)
448
+ }
449
+ }
450
+ finally {
451
+ inboxBusy = false;
452
+ }
453
+ }
454
+ async function handleApprovals() {
455
+ await withApprovalsLock(async () => {
456
+ let entries;
457
+ try {
458
+ entries = await readdir(approvalsDir);
459
+ }
460
+ catch {
461
+ return; // 디렉터리 부재 — 아직 요청 없음
462
+ }
463
+ for (const fn of entries) {
464
+ if (!fn.endsWith(".md"))
465
+ continue;
466
+ if (isConflictFile(fn)) {
467
+ await quarantine(fn, approvalsDir);
468
+ continue;
469
+ }
470
+ const file = join(approvalsDir, fn);
471
+ const content = await readStable(file);
472
+ if (content === null)
473
+ continue; // 부재 또는 변경 진행 중(B1)
474
+ if (lastSelfWrite.get(file) === content)
475
+ continue; // 자기쓰기 echo
476
+ const parsed = parseApprovals(content);
477
+ for (const d of parsed.decisions) {
478
+ const timer = permTimers.get(d.reqId);
479
+ if (timer) {
480
+ clearTimeout(timer);
481
+ permTimers.delete(d.reqId);
482
+ }
483
+ for (const cb of decisionHandlers)
484
+ cb(d.reqId, d.decision);
485
+ }
486
+ if (parsed.changed)
487
+ await atomicWrite(file, parsed.newContent);
488
+ }
489
+ });
490
+ }
491
+ async function quarantine(filename, srcDir) {
492
+ try {
493
+ await mkdir(quarantineDir, { recursive: true });
494
+ await rename(join(srcDir, filename), join(quarantineDir, filename));
495
+ }
496
+ catch (err) {
497
+ // ENOENT = watch 경로와 폴링 백스톱이 같은 파일을 겹쳐 시도(한쪽이 이미 격리) — 정상 경합, 무음.
498
+ if (errCode(err) === "ENOENT")
499
+ return;
500
+ console.error(t("log.markdown.quarantineFail", {
501
+ filename,
502
+ error: errMsg(err),
503
+ }));
504
+ }
505
+ }
506
+ /** out/<id>.out (+ sidecar) → 출력 노트. injector 가 writeOut 직후 in-process 호출. */
507
+ /** enqueue 연속 실패 임계 도달 시 outbox 에 1회 액션형 알림 노트. 채널이 파일이라 outbox 로 표면화. */
508
+ async function alertEnqueueFailure(count) {
509
+ const note = formatException({
510
+ situation: tl("markdown.enqueueFail.situation", { count }),
511
+ action: tl("markdown.enqueueFail.action"),
512
+ }, tl);
513
+ await atomicWrite(join(outboxDir, "_enqueue-alert.md"), note).catch((e) => console.error(t("log.markdown.alertWriteError", { error: errMsg(e) })));
514
+ }
515
+ async function renderOut(id) {
516
+ const text = await readFile(join(cfg.paths.outDir, `${id}.out`), "utf8");
517
+ // sidecar 읽기는 queue.readSidecar 로 일원화(부재·파손 → null = 메타 없이 진행).
518
+ const sidecar = await readSidecar(cfg.paths, id);
519
+ // 파일명 스탬프는 전송 시각(origin_ts) 유래 — 재렌더에도 결정론적.
520
+ // origin_ts 부재(구버전 sidecar)는 종전 `<id>.md` 유지.
521
+ const stamp = sidecar?.origin_ts ? stampFromIso(sidecar.origin_ts) : null;
522
+ const noteName = stamp ? `${outNoteBase(stamp, id)}.md` : `${id}.md`;
523
+ const headerLines = [];
524
+ const replyRef = sidecar?.reply_ref?.channel_msg_id;
525
+ if (replyRef)
526
+ headerLines.push(`> ↩ ${replyRef}`);
527
+ if (sidecar?.question)
528
+ headerLines.push(`> ❓ ${sidecar.question}`);
529
+ if (stamp && sidecar?.ts) {
530
+ headerLines.push(`> ${tl("markdown.outMeta", { sent: stamp, done: stampFromIso(sidecar.ts) })}`);
531
+ }
532
+ const header = headerLines.length > 0 ? `${headerLines.join("\n")}\n\n` : "";
533
+ await atomicWrite(join(outboxDir, noteName), `${header}${text}`);
534
+ }
535
+ /** handleInbox 를 추적 가능한 형태로 기동(fire-and-forget + .catch, stop 대기 대상). */
536
+ function runInbox() {
537
+ inboxOp = handleInbox().catch((err) => console.error(t("log.markdown.inboxError", { error: errMsg(err) })));
538
+ }
539
+ /** handleApprovals 를 추적 가능한 형태로 기동. */
540
+ function runApprovals() {
541
+ approvalsOp = handleApprovals().catch((err) => console.error(t("log.markdown.approvalsError", {
542
+ error: errMsg(err),
543
+ })));
544
+ }
545
+ function debounce(key, fn) {
546
+ const existing = debounceTimers.get(key);
547
+ if (existing)
548
+ clearTimeout(existing);
549
+ debounceTimers.set(key, setTimeout(() => {
550
+ debounceTimers.delete(key);
551
+ fn();
552
+ }, DEBOUNCE_MS));
553
+ }
554
+ function watchDir(dir, onFile) {
555
+ mkdirSync(dir, { recursive: true });
556
+ const w = watch(dir, (_event, filename) => {
557
+ if (!running || !filename)
558
+ return;
559
+ onFile(filename);
560
+ });
561
+ watchers.push(w);
562
+ }
563
+ /** 파일 시그니처(mtimeMs:size) — 부재 시 null. mtime 1초 granularity 보완 위해 size 동반. */
564
+ async function fileSig(filePath) {
565
+ try {
566
+ const s = await stat(filePath);
567
+ return `${s.mtimeMs}:${s.size}`;
568
+ }
569
+ catch {
570
+ return null;
571
+ }
572
+ }
573
+ /**
574
+ * 디렉터리 시그니처 — `.md` 파일별 mtime:size 집계(내용 변경 감지). 디렉터리 mtime 만으로는
575
+ * 파일 내용 변경(체크박스 토글)을 못 잡으므로 항목별 stat 으로 구성. 부재 시 null.
576
+ */
577
+ async function dirSig(dir) {
578
+ let files;
579
+ try {
580
+ files = (await readdir(dir)).filter((f) => f.endsWith(".md")).sort();
581
+ }
582
+ catch {
583
+ return null;
584
+ }
585
+ const parts = [];
586
+ for (const f of files) {
587
+ const s = await fileSig(join(dir, f));
588
+ if (s !== null)
589
+ parts.push(`${f}:${s}`);
590
+ }
591
+ return parts.join("|");
592
+ }
593
+ /** 기동 시 inbox baseline 시그니처 seed(첫 폴의 불필요 재트리거 방지). 부재면 다음 폴이 생성 감지. */
594
+ function seedSig(filePath) {
595
+ try {
596
+ const s = statSync(filePath);
597
+ lastFileSig.set(filePath, `${s.mtimeMs}:${s.size}`);
598
+ }
599
+ catch {
600
+ // 부재 — seed 생략
601
+ }
602
+ }
603
+ /**
604
+ * 폴링 백스톱 1회(B2): inbox 파일·approvals 디렉터리 시그니처 변화 시 watch 와 동일한 debounce
605
+ * 핸들러 트리거. watch 가 놓친 이벤트를 보정. 핸들러의 self-write·busy 가드가 중복을 멱등 흡수.
606
+ */
607
+ async function pollOnce() {
608
+ if (!running || pollBusy)
609
+ return;
610
+ pollBusy = true;
611
+ try {
612
+ const inboxSig = await fileSig(inboxPath);
613
+ if (inboxSig !== null && lastFileSig.get(inboxPath) !== inboxSig) {
614
+ lastFileSig.set(inboxPath, inboxSig);
615
+ debounce(inboxPath, runInbox);
616
+ }
617
+ const apprSig = await dirSig(approvalsDir);
618
+ if (apprSig !== null && lastFileSig.get(approvalsDir) !== apprSig) {
619
+ lastFileSig.set(approvalsDir, apprSig);
620
+ debounce(approvalsDir, runApprovals);
621
+ }
622
+ // 충돌 파일 격리 백스톱: inbox 파일 시그니처·approvals 시그니처는 "충돌 파일 생성" 을
623
+ // 포착하지 못하므로(watch 가 생성 이벤트를 놓치면 영구 방치) 인박스 디렉터리를 직접 스캔.
624
+ try {
625
+ const entries = await readdir(dirname(inboxPath));
626
+ for (const fn of entries) {
627
+ if (isConflictFile(fn))
628
+ await quarantine(fn, dirname(inboxPath));
629
+ }
630
+ }
631
+ catch {
632
+ // 디렉터리 부재 — 다음 폴에서 재시도
633
+ }
634
+ }
635
+ finally {
636
+ pollBusy = false;
637
+ }
638
+ }
639
+ function start() {
640
+ if (!existsSync(rootDir)) {
641
+ throw new Error(t("markdown.rootNotFound", { path: rootDir }));
642
+ }
643
+ // 입력 검증(C): 상대 경로(inbox/approvals/outbox)는 root 안에 머물러야 한다 — '..'·절대경로로
644
+ // root 를 탈출하면 임의 위치 읽기/쓰기 위험 → fail-closed 기동 거부.
645
+ for (const [name, rel] of [
646
+ ["inbox", cfg.conf.inbox],
647
+ ["approvals", cfg.conf.approvals],
648
+ ["outbox", cfg.conf.outbox],
649
+ ]) {
650
+ if (rel === undefined)
651
+ continue;
652
+ if (isAbsolute(rel) || rel.split(/[\\/]/).includes("..")) {
653
+ throw new Error(t("markdown.pathNotRelative", { name, rel }));
654
+ }
655
+ }
656
+ // 제어 노트가 AI 작업폴더(cwd) 내부면 자기승인 위험 → fail-closed 기동 거부.
657
+ const effectiveCwd = cfg.conf.cwd && cfg.conf.cwd.length > 0 ? resolve(cfg.conf.cwd) : process.cwd();
658
+ for (const [name, p] of [
659
+ ["inbox", inboxPath],
660
+ ["approvals", approvalsDir],
661
+ ["outbox", outboxDir],
662
+ ]) {
663
+ if (isPathInside(p, effectiveCwd)) {
664
+ throw new Error(t("markdown.controlNoteInCwd", { name, path: p, cwd: effectiveCwd }));
665
+ }
666
+ }
667
+ // 상호 배타: 승인/출력/입력/격리 경로가 같거나 포함 관계면 자기쓰기 재발화·승인
668
+ // 오파싱 위험(출력·알림 노트가 승인 감시에 잡힘) → fail-closed 기동 거부.
669
+ // 판정 규칙은 lane-config 의 생성 시 사전 경고와 동일해야 한다 — shared/paths 가 SSOT.
670
+ const rApprovals = resolve(approvalsDir);
671
+ const rOutbox = resolve(outboxDir);
672
+ const rInbox = resolve(inboxPath);
673
+ const rQuarantine = resolve(quarantineDir);
674
+ for (const [nameA, a, nameB, b] of [
675
+ ["approvals", rApprovals, "outbox", rOutbox],
676
+ ["approvals", rApprovals, "quarantine(.conflicts)", rQuarantine],
677
+ ["outbox", rOutbox, "quarantine(.conflicts)", rQuarantine],
678
+ ]) {
679
+ if (pathsOverlap(a, b)) {
680
+ throw new Error(t("markdown.pathsOverlap", { nameA, a, nameB, b }));
681
+ }
682
+ }
683
+ for (const [name, dir] of [
684
+ ["approvals", rApprovals],
685
+ ["outbox", rOutbox],
686
+ ]) {
687
+ if (isPathInside(normCasePath(rInbox), normCasePath(dir))) {
688
+ throw new Error(t("markdown.inboxInsideDir", { inbox: rInbox, name, dir }));
689
+ }
690
+ }
691
+ running = true;
692
+ const inboxDir = dirname(inboxPath);
693
+ const dispatch = (srcDir, filename) => {
694
+ if (isConflictFile(filename)) {
695
+ quarantineOp = quarantineOp.then(() => quarantine(filename, srcDir));
696
+ return;
697
+ }
698
+ const full = join(srcDir, filename);
699
+ if (full === inboxPath)
700
+ debounce(inboxPath, () => runInbox());
701
+ else if (srcDir === approvalsDir && filename.endsWith(".md")) {
702
+ debounce(approvalsDir, () => runApprovals());
703
+ }
704
+ };
705
+ // inbox 디렉터리 + approvals 요청당-파일 디렉터리 감시.
706
+ const dirs = new Set([inboxDir, approvalsDir]);
707
+ for (const dir of dirs)
708
+ watchDir(dir, (filename) => dispatch(dir, filename));
709
+ // out 렌더는 injector 가 renderOut() 으로 in-process 호출(out/ watch 제거).
710
+ mkdirSync(cfg.paths.outDir, { recursive: true });
711
+ mkdirSync(outboxDir, { recursive: true });
712
+ // 기동 시 기존 인박스/승인 노트 1회 처리(능동 세션 재개).
713
+ runInbox();
714
+ runApprovals();
715
+ // 폴링 백스톱(B2): watch 가 이벤트를 놓쳐도 주기적으로 보정. inbox baseline seed 후 인터벌 시작.
716
+ // approvals 는 디렉터리라 첫 폴에서 시그니처를 seed(첫 폴 1회 스캔은 멱등이라 무해).
717
+ seedSig(inboxPath);
718
+ pollTimer = setInterval(() => {
719
+ pollOp = pollOnce().catch((err) => console.error(t("log.markdown.pollError", { error: errMsg(err) })));
720
+ }, POLL_INTERVAL_MS);
721
+ }
722
+ async function stop() {
723
+ running = false;
724
+ if (pollTimer) {
725
+ clearInterval(pollTimer);
726
+ pollTimer = null;
727
+ }
728
+ for (const w of watchers)
729
+ w.close();
730
+ watchers.length = 0;
731
+ for (const t of debounceTimers.values())
732
+ clearTimeout(t);
733
+ debounceTimers.clear();
734
+ for (const t of permTimers.values())
735
+ clearTimeout(t);
736
+ permTimers.clear();
737
+ // in-flight 처리(폴 + approvals 락 체인 + inbox/approvals/격리 op) settle 대기 —
738
+ // 임시 디렉터리 정리 뒤 살아남은 쓰기가 ENOENT 를 내지 않도록.
739
+ await pollOp.catch(() => { });
740
+ await approvalsLock.catch(() => { });
741
+ await inboxOp.catch(() => { });
742
+ await approvalsOp.catch(() => { });
743
+ await quarantineOp.catch(() => { });
744
+ }
745
+ /**
746
+ * 요청당 승인 파일 경로(D). reqId 는 엔진이 통제하는 sessionId 이므로(client.ts) 경로 탈출 차단:
747
+ * 승인 파일은 approvalsDir 의 *직속 자식* 이어야 한다 — `..`·`/` 등이 섞이면 fail-closed throw
748
+ * (게이트가 sendPermPrompt throw 를 deny 로 처리). AI 가 승인 노트를 임의 경로에 위조하는 것을 막는다.
749
+ */
750
+ function approvalFile(reqId) {
751
+ const file = resolve(approvalsDir, `${reqId}.md`);
752
+ if (dirname(file) !== resolve(approvalsDir)) {
753
+ throw new Error(t("markdown.badApprovalId", { reqId }));
754
+ }
755
+ return file;
756
+ }
757
+ async function requestPermission(req) {
758
+ // 요청당 파일(D, 백로그 B3) — 단일 파일 append 대신 격리해 동시 편집 충돌면 축소.
759
+ await withApprovalsLock(async () => {
760
+ await atomicWrite(approvalFile(req.id), renderApprovalBlock(req, tl));
761
+ });
762
+ // 어댑터-로컬 타임아웃 — 무응답 시 해당 요청 파일을 deny 로 종단(게이트도 독립 deny).
763
+ const timer = setTimeout(() => {
764
+ permTimers.delete(req.id);
765
+ void withApprovalsLock(async () => {
766
+ const file = approvalFile(req.id);
767
+ const content = await readMaybe(file);
768
+ if (content === null)
769
+ return;
770
+ const parsed = finalizeApprovalDeny(content, req.id, "timeout");
771
+ if (parsed.changed)
772
+ await atomicWrite(file, parsed.newContent);
773
+ });
774
+ for (const cb of decisionHandlers)
775
+ cb(req.id, "deny");
776
+ }, DEFAULT_GATE_TIMEOUT_MS);
777
+ permTimers.set(req.id, timer);
778
+ }
779
+ function onDecision(cb) {
780
+ decisionHandlers.push(cb);
781
+ }
782
+ /**
783
+ * Source 계약: 운영 알림 — outbox 의 _adde-notice.md 에 시각과 함께 append
784
+ * (채널이 파일이라 노트로 표면화. outbox 는 인바운드 감시 밖이라 자기쓰기 루프 없음).
785
+ */
786
+ async function notify(text) {
787
+ const file = join(outboxDir, "_adde-notice.md");
788
+ const existing = (await readMaybe(file)) ?? "";
789
+ const stamp = new Date().toISOString();
790
+ await atomicWrite(file, `${existing}${existing ? "\n" : ""}> ${stamp}\n\n${text}\n`);
791
+ }
792
+ return { start, stop, requestPermission, onDecision, renderOut, notify };
793
+ }
794
+ //# sourceMappingURL=markdown.js.map