@asc-agent/runtime 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude-code/guard.d.ts +38 -0
  3. package/dist/adapters/claude-code/guard.js +162 -30
  4. package/dist/adapters/claude-code/skill.js +29 -0
  5. package/dist/adapters/github/scm.d.ts +2 -0
  6. package/dist/adapters/github/scm.js +5 -1
  7. package/dist/adapters/gitlab/scm.d.ts +43 -0
  8. package/dist/adapters/gitlab/scm.js +201 -0
  9. package/dist/adapters/markdown/layout.js +0 -2
  10. package/dist/adapters/markdown/serialize.js +0 -7
  11. package/dist/adapters/memory/runtime-binding.d.ts +27 -2
  12. package/dist/adapters/memory/runtime-binding.js +72 -1
  13. package/dist/cli/asc.js +189 -20
  14. package/dist/composition/runtime.d.ts +2 -0
  15. package/dist/composition/runtime.js +10 -0
  16. package/dist/core/attach/init.js +13 -5
  17. package/dist/core/distribution/release.d.ts +3 -3
  18. package/dist/core/distribution/release.js +1 -1
  19. package/dist/core/execution/executor.js +6 -2
  20. package/dist/core/execution/grant.d.ts +44 -1
  21. package/dist/core/execution/grant.js +59 -0
  22. package/dist/core/model/entities.d.ts +53 -27
  23. package/dist/core/model/entities.js +21 -14
  24. package/dist/core/model/transitions.d.ts +1 -3
  25. package/dist/core/model/transitions.js +0 -13
  26. package/dist/core/operator/local-operator.js +9 -1
  27. package/dist/core/operator/proceed.js +13 -3
  28. package/dist/core/operator/work-state.js +52 -14
  29. package/dist/core/runtime/background.d.ts +33 -0
  30. package/dist/core/runtime/background.js +68 -2
  31. package/dist/core/runtime/controller.d.ts +9 -1
  32. package/dist/core/runtime/controller.js +8 -7
  33. package/dist/core/runtime/workspaces.d.ts +1 -0
  34. package/dist/core/runtime/workspaces.js +1 -1
  35. package/dist/ports/scm.d.ts +8 -0
  36. package/dist/ports/state-store.d.ts +1 -2
  37. package/dist/ports/state-store.js +0 -1
  38. package/package.json +1 -1
package/README.md CHANGED
@@ -7,7 +7,7 @@ proceed-by-default, escalation, audit, the external-write guard, host integratio
7
7
  here.
8
8
 
9
9
  ```bash
10
- npm install -g @asc-agent/runtime@0.6.1
10
+ npm install -g @asc-agent/runtime@0.7.0
11
11
  ```
12
12
 
13
13
  npm owns the executable link (on Windows, npm's own `asc.cmd`). This package never edits
@@ -27,6 +27,44 @@ export declare const PERMISSION_DENY_RULES: readonly string[];
27
27
  * worker에게 주는 설정 파일에 살고, 파일은 .asc/ 안(untracked)에 있다.
28
28
  */
29
29
  export declare function workerSettings(): string;
30
+ /**
31
+ * 한 줄의 명령을 **실제로 실행되는 조각들**로 가른다 (0.7.0).
32
+ *
33
+ * 이 함수가 있는 이유는 실측이다. 예전에는 명령 문자열 전체에 정규식을 걸었고,
34
+ * `git commit -m "docs: push 관련"` 이 `git push` 로 읽혔다. 따옴표 안은 인자이지
35
+ * 실행이 아니다.
36
+ *
37
+ * 가르는 기준은 **따옴표 밖의** 제어 연산자와 개행뿐이다: `;` `&&` `||` `|` `\n`.
38
+ * 각 조각은 두 부분으로 나온다 —
39
+ *
40
+ * ```text
41
+ * bare 따옴표를 걷어낸 나머지. 판정은 여기서만 한다.
42
+ * quoted 따옴표 안에 있던 것들. 보통은 인자이지만, `sh -c` 처럼 그 자체가 명령이
43
+ * 되는 자리가 있어 호출자가 다시 볼 수 있게 함께 준다.
44
+ * ```
45
+ *
46
+ * **범용 shell 파서가 아니다.** 치환·here-doc·중첩 따옴표의 모든 경우를 풀지 않는다.
47
+ * hook 이 받는 것이 문자열 하나뿐이라는 플랫폼 제약 위에서, 인용부호를 구분하는 데까지가
48
+ * 이 함수의 몫이다.
49
+ *
50
+ * **hook 스크립트가 이 함수의 소스를 그대로 실어 나른다** — 그래서 자기 완결적이어야 하고,
51
+ * 바깥 식별자를 참조하면 안 된다.
52
+ */
53
+ export declare function segmentsOf(command: string): {
54
+ bare: string;
55
+ quoted: string[];
56
+ }[];
57
+ /**
58
+ * 금지된 외부 write 가 이 명령 안에 있는가.
59
+ *
60
+ * 따옴표 밖에서만 판정하고, `sh -c '…'` · `eval '…'` 처럼 따옴표 안이 곧 명령인 자리에서는
61
+ * 그 안을 한 번 더 본다. 그 밖의 우회(치환·파일 경유 실행 등)는 이 층이 잡지 못한다 —
62
+ * 그것은 알려진 한계이며 C-03 §5.3 의 3층 방어가 그 자리를 나눠 진다.
63
+ */
64
+ export declare function forbiddenIn(command: string, patterns: readonly {
65
+ pattern: RegExp;
66
+ label: string;
67
+ }[]): string | null;
30
68
  export declare function isForbiddenCommand(command: string): {
31
69
  forbidden: boolean;
32
70
  label?: string;
@@ -76,12 +76,116 @@ export function workerSettings() {
76
76
  permissions: { deny: [...PERMISSION_DENY_RULES] },
77
77
  }, null, 2)}\n`;
78
78
  }
79
- export function isForbiddenCommand(command) {
80
- for (const { pattern, label } of FORBIDDEN_COMMAND_PATTERNS) {
81
- if (pattern.test(command))
82
- return { forbidden: true, label };
79
+ /**
80
+ * 줄의 명령을 **실제로 실행되는 조각들**로 가른다 (0.7.0).
81
+ *
82
+ * 함수가 있는 이유는 실측이다. 예전에는 명령 문자열 전체에 정규식을 걸었고,
83
+ * `git commit -m "docs: push 관련"` 이 `git push` 로 읽혔다. 따옴표 안은 인자이지
84
+ * 실행이 아니다.
85
+ *
86
+ * 가르는 기준은 **따옴표 밖의** 제어 연산자와 개행뿐이다: `;` `&&` `||` `|` `\n`.
87
+ * 각 조각은 두 부분으로 나온다 —
88
+ *
89
+ * ```text
90
+ * bare 따옴표를 걷어낸 나머지. 판정은 여기서만 한다.
91
+ * quoted 따옴표 안에 있던 것들. 보통은 인자이지만, `sh -c` 처럼 그 자체가 명령이
92
+ * 되는 자리가 있어 호출자가 다시 볼 수 있게 함께 준다.
93
+ * ```
94
+ *
95
+ * **범용 shell 파서가 아니다.** 치환·here-doc·중첩 따옴표의 모든 경우를 풀지 않는다.
96
+ * hook 이 받는 것이 문자열 하나뿐이라는 플랫폼 제약 위에서, 인용부호를 구분하는 데까지가
97
+ * 이 함수의 몫이다.
98
+ *
99
+ * **hook 스크립트가 이 함수의 소스를 그대로 실어 나른다** — 그래서 자기 완결적이어야 하고,
100
+ * 바깥 식별자를 참조하면 안 된다.
101
+ */
102
+ export function segmentsOf(command) {
103
+ const segments = [];
104
+ let bare = '';
105
+ let quoted = [];
106
+ let buffer = '';
107
+ let quote = null;
108
+ const flush = () => {
109
+ if (bare.trim().length > 0 || quoted.length > 0)
110
+ segments.push({ bare, quoted });
111
+ bare = '';
112
+ quoted = [];
113
+ };
114
+ for (let i = 0; i < command.length; i += 1) {
115
+ const char = command[i];
116
+ if (quote) {
117
+ if (char === '\\' && quote === '"' && i + 1 < command.length) {
118
+ buffer += command[i + 1];
119
+ i += 1;
120
+ continue;
121
+ }
122
+ if (char === quote) {
123
+ quoted.push(buffer);
124
+ buffer = '';
125
+ quote = null;
126
+ // 따옴표가 있던 자리는 공백으로 남긴다 — 앞뒤 토큰이 붙어 버리면 안 된다.
127
+ bare += ' ';
128
+ continue;
129
+ }
130
+ buffer += char;
131
+ continue;
132
+ }
133
+ if (char === '"' || char === "'") {
134
+ quote = char;
135
+ continue;
136
+ }
137
+ if (char === '\\' && i + 1 < command.length) {
138
+ // 이스케이프된 문자는 그대로 인자다. 연산자로 읽히지 않게 한다.
139
+ bare += command[i + 1] === '\n' ? ' ' : command[i + 1];
140
+ i += 1;
141
+ continue;
142
+ }
143
+ if (char === '\n' || char === ';' || char === '|' || char === '&') {
144
+ // `&&` `||` 는 두 글자다 — 한 번만 자른다.
145
+ if ((char === '|' || char === '&') && command[i + 1] === char)
146
+ i += 1;
147
+ flush();
148
+ continue;
149
+ }
150
+ bare += char;
151
+ }
152
+ if (quote) {
153
+ // 닫히지 않은 따옴표. 그 안의 것을 인자로 단정하지 않는다 — 판정 대상에 남긴다.
154
+ bare += ' ' + buffer;
83
155
  }
84
- return { forbidden: false };
156
+ flush();
157
+ return segments;
158
+ }
159
+ /** 이 조각이 다른 명령을 문자열로 받아 실행하는 자리인가. */
160
+ function runsGivenText(bare) {
161
+ return /(^|\s)(?:sh|bash|zsh|dash|ksh)\s+(?:-[a-zA-Z]*\s+)*-c(\s|$)/.test(bare) || /(^|\s)eval(\s|$)/.test(bare);
162
+ }
163
+ /**
164
+ * 금지된 외부 write 가 이 명령 안에 있는가.
165
+ *
166
+ * 따옴표 밖에서만 판정하고, `sh -c '…'` · `eval '…'` 처럼 따옴표 안이 곧 명령인 자리에서는
167
+ * 그 안을 한 번 더 본다. 그 밖의 우회(치환·파일 경유 실행 등)는 이 층이 잡지 못한다 —
168
+ * 그것은 알려진 한계이며 C-03 §5.3 의 3층 방어가 그 자리를 나눠 진다.
169
+ */
170
+ export function forbiddenIn(command, patterns) {
171
+ for (const segment of segmentsOf(command)) {
172
+ for (const { pattern, label } of patterns) {
173
+ if (pattern.test(segment.bare))
174
+ return label;
175
+ }
176
+ if (runsGivenText(segment.bare)) {
177
+ for (const inner of segment.quoted) {
178
+ const nested = forbiddenIn(inner, patterns);
179
+ if (nested)
180
+ return nested;
181
+ }
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+ export function isForbiddenCommand(command) {
187
+ const label = forbiddenIn(command, FORBIDDEN_COMMAND_PATTERNS);
188
+ return label ? { forbidden: true, label } : { forbidden: false };
85
189
  }
86
190
  /** 1층 — worker에게 주입하는 계약문. 지침이지 enforcement가 아니라고 전제한다. */
87
191
  export function workerContract(input) {
@@ -122,6 +226,14 @@ export function hookScript() {
122
226
  const asSource = (list) => list.map((p) => ` { pattern: ${p.pattern.toString()}, label: ${JSON.stringify(p.label)} },`).join('\n');
123
227
  const patterns = asSource(FORBIDDEN_COMMAND_PATTERNS);
124
228
  const offlinePatterns = asSource(OFFLINE_COMMAND_PATTERNS);
229
+ // **판정 로직을 두 번 쓰지 않는다.** 위 함수들의 소스를 그대로 실어 나른다 — 손으로
230
+ // 옮겨 적으면 언젠가 hook 과 단위 검사가 서로 다른 것을 막는다. 그래서 저 함수들은
231
+ // 바깥 식별자를 참조하지 않는다.
232
+ const logic = [segmentsOf, runsGivenText, forbiddenIn]
233
+ .map((fn) => fn.toString())
234
+ .join('\n\n')
235
+ .replace(/`/g, '\\`')
236
+ .replace(/\$\{/g, '\\${');
125
237
  return `#!/usr/bin/env node
126
238
  // ASC external-write guard (PreToolUse) — 설치·갱신은 \`asc host claude install\` 로만.
127
239
  // 관리 대상(ASC RuntimeBinding에 등록된) Claude 세션의 외부 write를 실행 직전에 막는다.
@@ -143,6 +255,8 @@ const OFFLINE_ONLY = [
143
255
  ${offlinePatterns}
144
256
  ]
145
257
 
258
+ ${logic}
259
+
146
260
  /**
147
261
  * 원격이 얼어 있는가. 얼어 있으면 완전 오프라인인지까지 본다.
148
262
  * 읽지 못하면 얼지 않은 것으로 본다 — guard 오작동이 곧 작업 중단이 되면 안 된다.
@@ -246,14 +360,13 @@ const registered = lookupWorkspace(cwd)
246
360
  if (registered === 'MISSING') {
247
361
  // **조건부 fail-closed** (C-11 §4). 이 경로는 ASC가 맡은 곳인데 runtime을 읽지 못했다.
248
362
  // 그대로 통과시키면 관리 대상 세션의 외부 write가 조용히 열린다 — 그게 가장 나쁘다.
249
- for (const { pattern, label } of FORBIDDEN) {
250
- if (pattern.test(command)) {
251
- console.error(
252
- \`[ASC guard] 이 경로는 ASC workspace로 등록돼 있는데 runtime을 읽지 못했다. \` +
253
- \`'\${label}' 를 막는다 — asc setup status 로 확인하라.\`,
254
- )
255
- process.exit(2)
256
- }
363
+ const blocked = forbiddenIn(command, FORBIDDEN)
364
+ if (blocked) {
365
+ console.error(
366
+ \`[ASC guard] 이 경로는 ASC workspace로 등록돼 있는데 runtime을 읽지 못했다. \` +
367
+ \`'\${blocked}' 를 막는다 — asc setup status 로 확인하라.\`,
368
+ )
369
+ process.exit(2)
257
370
  }
258
371
  process.exit(0)
259
372
  }
@@ -281,7 +394,28 @@ if (isMutation && !managed) {
281
394
  process.exit(2)
282
395
  }
283
396
 
284
- if (!managed) process.exit(0)
397
+ // **관리 대상 workspace 인데 이 Run 이 어느 계약에도 들어 있지 않다** (0.7.0 B-1).
398
+ //
399
+ // 지금까지 여기서 통과시켰다. 그래서 결합이 사라지거나 아직 생기지 않은 상태의 세션은
400
+ // 계약 밖에서 밖으로 쓸 수 있었다 — guard 가 있는데 열려 있는 상태이고, 그것이 가장 나쁘다.
401
+ // 읽기는 그대로 통과한다. 막는 것은 밖으로 나가는 쓰기뿐이다.
402
+ if (!managed) {
403
+ const outward = forbiddenIn(command, FORBIDDEN)
404
+ if (outward) {
405
+ const id = observedSessionId || '<this session id>'
406
+ console.error(
407
+ [
408
+ \`[ASC guard] 이 workspace 는 ASC 가 관리한다. '\${outward}' 는 논리 세션 밖에서 나갈 수 없다.\`,
409
+ ' asc proceed --work <WORK-KEY> --json # 작업 항목이 있으면',
410
+ ' asc proceed --json # 이어갈 세션을 고르거나 계약을 제안받는다',
411
+ ' asc host claude bind <S-ID> --physical ' + id,
412
+ '읽기·조회는 막지 않는다 — 막는 것은 밖으로 나가는 쓰기뿐이다.',
413
+ ].join('\\n'),
414
+ )
415
+ process.exit(2)
416
+ }
417
+ process.exit(0)
418
+ }
285
419
 
286
420
  // 관찰은 여기서 끝난다 — 아래 차단 판정은 이 호출의 성패를 보지 않는다
287
421
  try {
@@ -291,27 +425,25 @@ try {
291
425
  // 변경 도구는 여기까지다 — 아래 목록은 Bash 명령에 대한 것이다.
292
426
  if (isMutation) process.exit(0)
293
427
 
294
- for (const { pattern, label } of FORBIDDEN) {
295
- if (pattern.test(command)) {
296
- console.error(
297
- \`[ASC guard] '\${label}' 는 ASC-managed 세션에서 금지다. \` +
298
- \`외부 반영은 승인된 Execution Grant(asc grant run)로만 나간다.\`,
299
- )
300
- process.exit(2) // exit 2 = 도구 실행 차단
301
- }
428
+ const forbidden = forbiddenIn(command, FORBIDDEN)
429
+ if (forbidden) {
430
+ console.error(
431
+ \`[ASC guard] '\${forbidden}' 는 ASC-managed 세션에서 금지다. \` +
432
+ \`외부 반영은 승인된 Execution Grant(asc grant run)로만 나간다.\`,
433
+ )
434
+ process.exit(2) // exit 2 = 도구 실행 차단
302
435
  }
303
436
 
304
437
  // 완전 오프라인 선언이 있을 때만 읽기까지 막는다. 로컬 작업은 얼리지 않는다.
305
438
  const freeze = freezePolicy(ascRoot)
306
439
  if (freeze && freeze.frozen && freeze.denyRemoteRead) {
307
- for (const { pattern, label } of OFFLINE_ONLY) {
308
- if (pattern.test(command)) {
309
- console.error(
310
- \`[ASC guard] 완전 오프라인이다\${freeze.reason ? ' (' + freeze.reason + ')' : ''} — '\${label}' 를 막는다. \` +
311
- \`로컬 작업은 그대로 된다. 녹이려면 asc thaw.\`,
312
- )
313
- process.exit(2)
314
- }
440
+ const offline = forbiddenIn(command, OFFLINE_ONLY)
441
+ if (offline) {
442
+ console.error(
443
+ \`[ASC guard] 완전 오프라인이다\${freeze.reason ? ' (' + freeze.reason + ')' : ''} — '\${offline}' 를 막는다. \` +
444
+ \`로컬 작업은 그대로 된다. 녹이려면 asc thaw.\`,
445
+ )
446
+ process.exit(2)
315
447
  }
316
448
  }
317
449
 
@@ -164,6 +164,35 @@ report as one.
164
164
  Finishing: \`asc session done <ID> --verified "<what the self-check covered>" --next "<next>"\`.
165
165
  Updating state is the Controller's job — point the person at \`asc controller collect\`.
166
166
 
167
+ ## "Publish it" — what a session produced, going out
168
+
169
+ The person says *publish it* · *open the MR* · *get it onto develop*. That sentence is the
170
+ approval, and it is not asked for twice. It is also not wider than itself: **"open the MR"
171
+ is not "approve the merge"**.
172
+
173
+ Nothing reaches an external system except through a grant. That is one command, then the
174
+ act:
175
+
176
+ \`\`\`text
177
+ asc grant issue --session <S-ID> --action <key> --target <ref> --body-file <path> --as <actor>
178
+ asc grant run <G-ID>
179
+ \`\`\`
180
+
181
+ The action key is the provider's (\`gitlab.mr.create\`, \`gitlab.note.create\`, \`git.push\`,
182
+ \`coordination.publish\`, \`github.issue_comment.create\`). If nothing bound to this
183
+ workspace can carry out that action, issuing **fails there** rather than after the person
184
+ approved — read the message and fix the binding, do not look for another way out.
185
+
186
+ The body comes from a file because it has to be the thing the person is agreeing to. Do
187
+ not compose it after the fact and do not widen \`--target\`.
188
+
189
+ Publishing a coordination question is the same shape — \`asc coordination publish --grant
190
+ <G-ID> --query <X-ID> …\`. Reading (\`coordination status\`, \`coordination observe\`) needs
191
+ no grant.
192
+
193
+ **Never** reach for \`git push\`, \`glab\`, or \`gh\` directly. The guard stops those, and
194
+ being stopped is not a puzzle to solve — it means the act belongs in a grant.
195
+
167
196
  ## Progress reporting
168
197
 
169
198
  From outside, a person can see nothing while work runs. Leave one line at each of the
@@ -46,5 +46,7 @@ export declare class GitHubScm implements ScmPort {
46
46
  * 승인된 단일 행위. 아는 action만 수행하며, 그 외에는 아무것도 하지 않는다 —
47
47
  * Grant의 allowedWrites 검사(Executor)에 더해 Adapter도 자기 몫으로 닫아 둔다.
48
48
  */
49
+ /** 이 통로가 아는 행위. execute 의 분기와 같은 목록이어야 한다. */
50
+ supports(action: string): boolean;
49
51
  execute(action: ExternalAction): Promise<ExternalActionResult>;
50
52
  }
@@ -81,8 +81,12 @@ export class GitHubScm {
81
81
  * 승인된 단일 행위. 아는 action만 수행하며, 그 외에는 아무것도 하지 않는다 —
82
82
  * Grant의 allowedWrites 검사(Executor)에 더해 Adapter도 자기 몫으로 닫아 둔다.
83
83
  */
84
+ /** 이 통로가 아는 행위. execute 의 분기와 같은 목록이어야 한다. */
85
+ supports(action) {
86
+ return action === 'github.issue_comment.create';
87
+ }
84
88
  async execute(action) {
85
- if (action.action !== 'github.issue_comment.create') {
89
+ if (!this.supports(action.action)) {
86
90
  return { ok: false, error: `unsupported action: ${action.action}` };
87
91
  }
88
92
  const ref = parseThreadRef(this.#expand(action.target));
@@ -0,0 +1,43 @@
1
+ import type { CanonicalSnapshot } from '../../core/model/entities.ts';
2
+ import type { BaselineQuery, ExternalAction, ExternalActionResult, ScmPort, ThreadSnapshot } from '../../ports/scm.ts';
3
+ import { type GitLabReader, type GitLabWriter } from './client.ts';
4
+ /** 이 adapter 가 수행할 수 있는 행위. 목록에 없는 것은 하지 않는다. */
5
+ export declare const GITLAB_ACTIONS: readonly ["coordination.publish", "gitlab.note.create", "gitlab.mr.create", "gitlab.mr.merge", "gitlab.issue.update", "git.push"];
6
+ export type GitLabScmDeps = {
7
+ reader: GitLabReader;
8
+ writer: GitLabWriter;
9
+ /** 이 결합이 가리키는 프로젝트. `#7` 처럼 짧게 온 참조를 여기에 붙인다. */
10
+ defaultProject?: string;
11
+ /** canonical source id → ref. Profile 이 정한다 — adapter 가 추측하지 않는다. */
12
+ sourceRefs?: Readonly<Record<string, {
13
+ ref: string;
14
+ }>>;
15
+ /** `git.push` 를 수행할 자리. 없으면 push 는 할 수 없다고 답한다. */
16
+ repoRoot?: string;
17
+ /** 테스트가 실제 git 을 부르지 않게 하는 통로. */
18
+ git?: (args: readonly string[], cwd: string) => Promise<{
19
+ ok: boolean;
20
+ detail: string;
21
+ }>;
22
+ };
23
+ export declare class GitLabScm implements ScmPort {
24
+ #private;
25
+ readonly id = "gitlab";
26
+ constructor(deps: GitLabScmDeps);
27
+ /**
28
+ * 스레드의 지금 상태. Drift Guard 가 승인 시점과 대조하는 값이다.
29
+ *
30
+ * 마지막 사건은 note 하나로 본다 — 목록의 마지막 id 를 쓰고, 못 읽으면 `missing` 이다.
31
+ * 모르는 것을 "변화 없음" 으로 적으면 오래된 초안이 그대로 나간다.
32
+ */
33
+ getThread(reference: string): Promise<ThreadSnapshot>;
34
+ /** 정본의 지금 baseline. Profile 이 정한 ref 만 묻는다. */
35
+ getBaselines(queries: readonly BaselineQuery[]): Promise<CanonicalSnapshot[]>;
36
+ /**
37
+ * 승인된 단일 행위. **아는 것만 한다** — Grant 의 allowedWrites 검사(Executor)에 더해
38
+ * adapter 도 자기 몫으로 닫아 둔다.
39
+ */
40
+ /** 이 통로가 아는 행위. execute 의 분기와 같은 목록이어야 한다. */
41
+ supports(action: string): boolean;
42
+ execute(action: ExternalAction): Promise<ExternalActionResult>;
43
+ }
@@ -0,0 +1,201 @@
1
+ // GitLab SCM — 승인된 단일 외부 행위의 GitLab 인스턴스 (OM §11.5, C-09).
2
+ //
3
+ // 이 파일이 있는 이유는 실측이다. Grant 실행 경로는 CLI 안에서 GitHub 하나로 못 박혀
4
+ // 있었고, 그래서 코드가 GitLab 에 있는 프로젝트에서는 승인이 끝난 뒤에야 "실행할 통로가
5
+ // 없다" 가 드러났다. 관측 경로는 진작 provider-neutral 이었는데 실행 경로만 남아 있었다.
6
+ //
7
+ // **쓰기는 `execute` 하나뿐이고, 그 함수는 Grant 를 쥔 Executor 만 부른다.** Port 자체는
8
+ // 권한을 판단하지 않으므로 호출 지점이 좁게 유지되는 것이 계약이다.
9
+ //
10
+ // `git.push` 가 여기 있는 것이 어색해 보일 수 있다. 그러나 원격에 쓰는 일이고, 그 원격은
11
+ // 이 결합이 가리키는 바로 그 프로젝트다 — 승인된 외부 write 하나를 수행한다는 점에서
12
+ // MR 생성과 같은 종류의 행위다. Core 는 여전히 어느 것도 해석하지 않는다.
13
+ import { execFile } from 'node:child_process';
14
+ import { promisify } from 'node:util';
15
+ import { encodeProject, parseRef } from "./client.js";
16
+ const run = promisify(execFile);
17
+ /** 이 adapter 가 수행할 수 있는 행위. 목록에 없는 것은 하지 않는다. */
18
+ export const GITLAB_ACTIONS = [
19
+ // 조율 게시. 실행은 CoordinationSurface 가 하고, 이 통로는 **그 행위가 승인될 수
20
+ // 있다는 것**만 안다 — 발급 시점에 "할 수 없는 일을 승인시키지 않는다" 가 성립하려면
21
+ // 이 목록이 그 사실을 담아야 한다.
22
+ 'coordination.publish',
23
+ 'gitlab.note.create',
24
+ 'gitlab.mr.create',
25
+ 'gitlab.mr.merge',
26
+ 'gitlab.issue.update',
27
+ 'git.push',
28
+ ];
29
+ export class GitLabScm {
30
+ id = 'gitlab';
31
+ #reader;
32
+ #writer;
33
+ #project;
34
+ #sourceRefs;
35
+ #repoRoot;
36
+ #git;
37
+ constructor(deps) {
38
+ this.#reader = deps.reader;
39
+ this.#writer = deps.writer;
40
+ this.#project = deps.defaultProject;
41
+ this.#sourceRefs = deps.sourceRefs ?? {};
42
+ this.#repoRoot = deps.repoRoot;
43
+ this.#git =
44
+ deps.git ??
45
+ (async (args, cwd) => {
46
+ try {
47
+ const { stdout, stderr } = await run('git', [...args], { cwd });
48
+ return { ok: true, detail: (stdout || stderr).trim() };
49
+ }
50
+ catch (error) {
51
+ const failure = error;
52
+ return { ok: false, detail: (failure.stderr || failure.message || String(error)).trim() };
53
+ }
54
+ });
55
+ }
56
+ /**
57
+ * 스레드의 지금 상태. Drift Guard 가 승인 시점과 대조하는 값이다.
58
+ *
59
+ * 마지막 사건은 note 하나로 본다 — 목록의 마지막 id 를 쓰고, 못 읽으면 `missing` 이다.
60
+ * 모르는 것을 "변화 없음" 으로 적으면 오래된 초안이 그대로 나간다.
61
+ */
62
+ async getThread(reference) {
63
+ const ref = parseRef(this.#expand(reference));
64
+ if (!ref)
65
+ return { reference, lastEventId: '', missing: true };
66
+ const path = ref.kind === 'change' ? 'merge_requests' : 'issues';
67
+ const response = await this.#reader.get(`/projects/${encodeProject(ref.project)}/${path}/${ref.iid}/notes?per_page=1&sort=desc`);
68
+ if (!response.ok || !response.data)
69
+ return { reference, lastEventId: '', missing: true };
70
+ const latest = response.data[0];
71
+ return { reference, lastEventId: latest ? String(latest.id) : '(none)' };
72
+ }
73
+ /** 정본의 지금 baseline. Profile 이 정한 ref 만 묻는다. */
74
+ async getBaselines(queries) {
75
+ const out = [];
76
+ for (const query of queries) {
77
+ const ref = query.ref ?? this.#sourceRefs[query.sourceId]?.ref;
78
+ if (!ref || !this.#project) {
79
+ out.push({ sourceId: query.sourceId, baseline: '(unknown)' });
80
+ continue;
81
+ }
82
+ const response = await this.#reader.get(`/projects/${encodeProject(this.#project)}/repository/branches/${encodeURIComponent(ref)}`);
83
+ const baseline = response.ok ? (response.data?.commit?.id ?? '(unknown)') : '(unknown)';
84
+ out.push({ sourceId: query.sourceId, baseline });
85
+ }
86
+ return out;
87
+ }
88
+ /**
89
+ * 승인된 단일 행위. **아는 것만 한다** — Grant 의 allowedWrites 검사(Executor)에 더해
90
+ * adapter 도 자기 몫으로 닫아 둔다.
91
+ */
92
+ /** 이 통로가 아는 행위. execute 의 분기와 같은 목록이어야 한다. */
93
+ supports(action) {
94
+ return GITLAB_ACTIONS.includes(action);
95
+ }
96
+ async execute(action) {
97
+ switch (action.action) {
98
+ case 'gitlab.note.create':
99
+ return this.#note(action);
100
+ case 'gitlab.mr.create':
101
+ return this.#createChange(action);
102
+ case 'gitlab.mr.merge':
103
+ return this.#mergeChange(action);
104
+ case 'gitlab.issue.update':
105
+ return this.#updateIssue(action);
106
+ case 'git.push':
107
+ return this.#push(action);
108
+ case 'coordination.publish':
109
+ // 이 행위는 CoordinationSurface 가 수행한다. 계약은 여기서 승인될 수 있지만
110
+ // 실행은 그쪽 통로다 — 두 곳이 같은 글을 올리지 않게 여기서는 하지 않는다.
111
+ return { ok: false, error: 'coordination.publish runs through the coordination surface' };
112
+ default:
113
+ return { ok: false, error: `unsupported action: ${action.action}` };
114
+ }
115
+ }
116
+ async #note(action) {
117
+ const ref = parseRef(this.#expand(action.target));
118
+ if (!ref)
119
+ return { ok: false, error: `unrecognized target: ${action.target}` };
120
+ const path = ref.kind === 'change' ? 'merge_requests' : 'issues';
121
+ const response = await this.#writer.post(`/projects/${encodeProject(ref.project)}/${path}/${ref.iid}/notes`, { body: action.payload });
122
+ if (!response.ok || !response.data)
123
+ return { ok: false, error: response.error ?? `HTTP ${response.status}` };
124
+ return { ok: true, resultRef: `${ref.project}${ref.kind === 'change' ? '!' : '#'}${ref.iid}#note_${response.data.id}` };
125
+ }
126
+ /**
127
+ * 변경요청 생성. payload 는 승인된 내용 그대로이므로 여기서 다시 쓰지 않는다 —
128
+ * 필드는 JSON 으로 온다.
129
+ */
130
+ async #createChange(action) {
131
+ const project = action.target || this.#project;
132
+ if (!project)
133
+ return { ok: false, error: 'no project for gitlab.mr.create' };
134
+ let body;
135
+ try {
136
+ body = JSON.parse(action.payload);
137
+ }
138
+ catch (error) {
139
+ return { ok: false, error: `payload is not JSON: ${error instanceof Error ? error.message : String(error)}` };
140
+ }
141
+ for (const required of ['source_branch', 'target_branch', 'title']) {
142
+ if (typeof body[required] !== 'string')
143
+ return { ok: false, error: `payload is missing ${required}` };
144
+ }
145
+ const response = await this.#writer.post(`/projects/${encodeProject(project)}/merge_requests`, body);
146
+ if (!response.ok || !response.data)
147
+ return { ok: false, error: response.error ?? `HTTP ${response.status}` };
148
+ return { ok: true, resultRef: response.data.web_url ?? `${project}!${response.data.iid}` };
149
+ }
150
+ async #mergeChange(action) {
151
+ const ref = parseRef(this.#expand(action.target));
152
+ if (!ref || ref.kind !== 'change')
153
+ return { ok: false, error: `unrecognized change: ${action.target}` };
154
+ // GitLab 의 merge 는 PUT 이다. 이 adapter 의 통로는 post 하나이므로, 통로가 넓어지기
155
+ // 전까지는 할 수 없다고 **말한다** — 못 하는 것을 하는 척하지 않는다.
156
+ const response = await this.#writer.post(`/projects/${encodeProject(ref.project)}/merge_requests/${ref.iid}/merge`, action.payload ? JSON.parse(action.payload) : {});
157
+ if (!response.ok || !response.data)
158
+ return { ok: false, error: response.error ?? `HTTP ${response.status}` };
159
+ return { ok: true, resultRef: response.data.web_url ?? `${ref.project}!${ref.iid}` };
160
+ }
161
+ async #updateIssue(action) {
162
+ const ref = parseRef(this.#expand(action.target));
163
+ if (!ref || ref.kind !== 'issue')
164
+ return { ok: false, error: `unrecognized issue: ${action.target}` };
165
+ let body;
166
+ try {
167
+ body = JSON.parse(action.payload);
168
+ }
169
+ catch (error) {
170
+ return { ok: false, error: `payload is not JSON: ${error instanceof Error ? error.message : String(error)}` };
171
+ }
172
+ const response = await this.#writer.post(`/projects/${encodeProject(ref.project)}/issues/${ref.iid}`, body);
173
+ if (!response.ok || !response.data)
174
+ return { ok: false, error: response.error ?? `HTTP ${response.status}` };
175
+ return { ok: true, resultRef: response.data.web_url ?? `${ref.project}#${ref.iid}` };
176
+ }
177
+ /**
178
+ * 원격에 가지를 올린다. target 은 `<remote> <branch>` 또는 `<branch>` 다.
179
+ *
180
+ * `--force` 계열은 받지 않는다 — 되돌릴 수 없는 형태를 승인 한 번으로 열지 않는다.
181
+ */
182
+ async #push(action) {
183
+ if (!this.#repoRoot)
184
+ return { ok: false, error: 'no repository root for git.push' };
185
+ const parts = action.target.trim().split(/\s+/).filter(Boolean);
186
+ if (parts.length === 0)
187
+ return { ok: false, error: 'git.push needs a branch' };
188
+ if (parts.some((part) => part.startsWith('-')))
189
+ return { ok: false, error: `git.push takes no flags: ${action.target}` };
190
+ const [remote, branch] = parts.length === 1 ? ['origin', parts[0]] : [parts[0], parts[1]];
191
+ const result = await this.#git(['push', remote, branch], this.#repoRoot);
192
+ return result.ok
193
+ ? { ok: true, resultRef: `${remote}/${branch}` }
194
+ : { ok: false, error: result.detail };
195
+ }
196
+ #expand(reference) {
197
+ if (!this.#project)
198
+ return reference;
199
+ return /^[!#]\d+$/.test(reference.trim()) ? `${this.#project}${reference.trim()}` : reference;
200
+ }
201
+ }
@@ -7,7 +7,6 @@ const ENTITY_DIR = {
7
7
  session: 'sessions/active',
8
8
  request: 'monitor/inbox',
9
9
  grant: 'monitor/grants',
10
- queueItem: 'monitor/queue',
11
10
  event: 'monitor/events',
12
11
  };
13
12
  export const CONTROL_STATE_FILE = 'state.md';
@@ -32,7 +31,6 @@ const ARCHIVE_DIR = {
32
31
  session: 'sessions/archive',
33
32
  request: 'monitor/archive/inbox',
34
33
  grant: 'monitor/archive/grants',
35
- queueItem: 'monitor/archive/queue',
36
34
  event: 'monitor/archive/events',
37
35
  };
38
36
  export function archiveDir(root, kind) {
@@ -85,13 +85,6 @@ function renderBody(kind, entity) {
85
85
  lines.push(`Result: ${g.resultRef}`);
86
86
  break;
87
87
  }
88
- case 'queueItem': {
89
- const q = entity;
90
- lines.push(`# ${q.id} — ${q.title}`, '', `State: ${q.state} (v${q.version})`);
91
- if (q.sessionId)
92
- lines.push(`Session: ${q.sessionId}`);
93
- break;
94
- }
95
88
  case 'event': {
96
89
  const e = entity;
97
90
  lines.push(`# ${e.eventKey}`, '', `${e.type} · ${e.suggestedPriority} · ${e.processing} (v${e.version})`, `Detected: ${e.detectedAt}`, `Inbox 후보: ${e.inboxCandidate ? 'yes' : 'no'}`);