@asc-agent/runtime 0.4.0 → 0.5.1

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 (48) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude-code/guard.js +26 -1
  3. package/dist/adapters/claude-code/install.d.ts +27 -0
  4. package/dist/adapters/claude-code/install.js +3 -1
  5. package/dist/adapters/fixture-surface/index.d.ts +33 -0
  6. package/dist/adapters/fixture-surface/index.js +97 -0
  7. package/dist/adapters/gitlab/adapter.js +1 -0
  8. package/dist/adapters/gitlab/client.d.ts +16 -2
  9. package/dist/adapters/gitlab/client.js +27 -0
  10. package/dist/adapters/gitlab/coordination.d.ts +19 -0
  11. package/dist/adapters/gitlab/coordination.js +111 -0
  12. package/dist/adapters/gitlab/ports.d.ts +6 -0
  13. package/dist/adapters/gitlab/ports.js +29 -2
  14. package/dist/adapters/local/canonical.d.ts +20 -0
  15. package/dist/adapters/local/canonical.js +58 -0
  16. package/dist/adapters/markdown/state-store.js +2 -1
  17. package/dist/adapters/service/launchd.js +18 -1
  18. package/dist/adapters/service/systemd-user.js +4 -1
  19. package/dist/adapters/webhook/ingress.d.ts +2 -2
  20. package/dist/cli/asc.js +569 -17
  21. package/dist/composition/runtime.d.ts +20 -0
  22. package/dist/composition/runtime.js +32 -1
  23. package/dist/core/attach/setup-plan.d.ts +107 -6
  24. package/dist/core/attach/setup-plan.js +104 -6
  25. package/dist/core/attach/setup.d.ts +3 -1
  26. package/dist/core/attach/setup.js +25 -1
  27. package/dist/core/binding/types.d.ts +1 -1
  28. package/dist/core/binding/types.js +1 -0
  29. package/dist/core/distribution/external-command.d.ts +26 -0
  30. package/dist/core/distribution/external-command.js +58 -1
  31. package/dist/core/distribution/persistent-runtime.d.ts +9 -0
  32. package/dist/core/distribution/release.d.ts +3 -3
  33. package/dist/core/distribution/release.js +1 -1
  34. package/dist/core/distribution/service-runtime.d.ts +40 -0
  35. package/dist/core/distribution/service-runtime.js +76 -0
  36. package/dist/core/runtime/coordination.d.ts +312 -0
  37. package/dist/core/runtime/coordination.js +247 -0
  38. package/dist/core/runtime/front.d.ts +10 -0
  39. package/dist/core/runtime/front.js +16 -1
  40. package/dist/core/runtime/publish.d.ts +107 -0
  41. package/dist/core/runtime/publish.js +153 -0
  42. package/dist/core/runtime/workspaces.d.ts +22 -0
  43. package/dist/core/runtime/workspaces.js +18 -0
  44. package/dist/ports/coordination-surface.d.ts +59 -0
  45. package/dist/ports/coordination-surface.js +16 -0
  46. package/dist/ports/resource-context.d.ts +5 -0
  47. package/dist/schemas/profile.d.ts +2 -2
  48. 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.4.0
10
+ npm install -g @asc-agent/runtime@0.5.1
11
11
  ```
12
12
 
13
13
  npm owns the executable link (on Windows, npm's own `asc.cmd`). This package never edits
@@ -231,7 +231,11 @@ try {
231
231
  process.exit(0) // 입력을 못 읽으면 판단하지 않는다 — guard 오작동으로 전부 막는 것이 더 나쁘다
232
232
  }
233
233
 
234
- if (input.tool_name !== 'Bash') process.exit(0)
234
+ // 파일을 바꾸는 도구는 **일을 시작한다는 신호**다 (F6). 읽기는 여기 없다 — 상태를 보는
235
+ // 세션까지 관리 대상으로 끌어들이면 그것은 자동화가 아니라 방해다.
236
+ const MUTATORS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
237
+ const isMutation = MUTATORS.has(String(input.tool_name ?? ''))
238
+ if (input.tool_name !== 'Bash' && !isMutation) process.exit(0)
235
239
  const command = String(input.tool_input?.command ?? '')
236
240
 
237
241
  const cwd = input.cwd ?? process.cwd()
@@ -259,6 +263,24 @@ const ascRoot = registered ? registered.root : findAscRoot(cwd)
259
263
  if (!ascRoot) process.exit(0)
260
264
 
261
265
  const managed = findManaged(ascRoot, observedSessionId)
266
+
267
+ // **일이 시작되는데 논리 세션이 없다** (F6). 사람이 "ASC 적용해" 라고 말해야 했던 자리다.
268
+ // 여기서 막고 다음 한 걸음을 그대로 준다 — 그 명령을 실행하는 것은 agent 이고, 사람이
269
+ // 아니다. 세션에 들어간 뒤에는 이 문이 다시 열린다.
270
+ if (isMutation && !managed) {
271
+ const id = observedSessionId || '<this session id>'
272
+ console.error(
273
+ [
274
+ '[ASC] 이 workspace 는 ASC 가 관리한다. 파일을 바꾸기 전에 논리 세션 안에 들어가라.',
275
+ ' asc proceed --work <WORK-KEY> --json # 작업 항목이 있으면',
276
+ ' asc proceed --json # 이어갈 세션을 고르거나 계약을 제안받는다',
277
+ ' asc host claude bind <S-ID> --physical ' + id,
278
+ '읽기·조회는 막지 않는다 — 막는 것은 관리 밖의 변경뿐이다.',
279
+ ].join('\\n'),
280
+ )
281
+ process.exit(2)
282
+ }
283
+
262
284
  if (!managed) process.exit(0)
263
285
 
264
286
  // 관찰은 여기서 끝난다 — 아래 차단 판정은 이 호출의 성패를 보지 않는다
@@ -266,6 +288,9 @@ try {
266
288
  recordActivity(ascRoot, managed, observedSessionId, String(input.tool_name ?? ''))
267
289
  } catch {}
268
290
 
291
+ // 변경 도구는 여기까지다 — 아래 목록은 Bash 명령에 대한 것이다.
292
+ if (isMutation) process.exit(0)
293
+
269
294
  for (const { pattern, label } of FORBIDDEN) {
270
295
  if (pattern.test(command)) {
271
296
  console.error(
@@ -10,6 +10,32 @@ export type InstallPaths = {
10
10
  entry?: string;
11
11
  };
12
12
  export declare const defaultPaths: () => InstallPaths;
13
+ /**
14
+ * ASC가 등록하는 hook들. 이벤트마다 **하나**씩이며 중복 등록은 그 자체가 결함이다.
15
+ *
16
+ * `_asc` 표식이 소유권의 근거다 — 이것이 붙은 항목만 우리가 고치고 지운다.
17
+ */
18
+ type HookSpec = {
19
+ event: string;
20
+ marker: string;
21
+ /** 이 이벤트가 matcher를 쓰는가. SessionStart는 도구 이름으로 거르지 않는다. */
22
+ matcher?: string;
23
+ script: string;
24
+ };
25
+ export declare function locate(paths: InstallPaths): {
26
+ /** Bundle 전체. 파일이 늘어도 아래 계약(manifest·digest·멱등)은 그대로다 (C-05 §5). */
27
+ skills: {
28
+ name: string;
29
+ path: string;
30
+ text: string;
31
+ }[];
32
+ /** hook은 **하나**로 둔다. guard는 안전 층이고 중복 등록은 그 자체가 위험이다. */
33
+ hook: string;
34
+ front: string;
35
+ settings: string;
36
+ manifest: string;
37
+ hooks: HookSpec[];
38
+ };
13
39
  export type InstallOutcome = {
14
40
  written: string[];
15
41
  skipped: {
@@ -72,3 +98,4 @@ export type UninstallOutcome = {
72
98
  };
73
99
  /** manifest로 증명되는 것만 제거한다. 사용자가 고친 파일은 남기고 이유를 말한다. */
74
100
  export declare function uninstall(paths: InstallPaths): Promise<UninstallOutcome>;
101
+ export {};
@@ -20,7 +20,7 @@ const sha = (text) => createHash('sha256').update(text).digest('hex').slice(0, 1
20
20
  export const defaultPaths = () => ({ claudeHome: join(homedir(), '.claude') });
21
21
  const HOOK_MARKER = 'asc-external-write-guard';
22
22
  const FRONT_MARKER = 'asc-front-binding';
23
- function locate(paths) {
23
+ export function locate(paths) {
24
24
  const guard = join(paths.claudeHome, 'asc', 'guard-hook.mjs');
25
25
  const front = join(paths.claudeHome, 'asc', 'front-hook.mjs');
26
26
  return {
@@ -38,6 +38,8 @@ function locate(paths) {
38
38
  hooks: (() => {
39
39
  const specs = [
40
40
  { event: 'PreToolUse', marker: HOOK_MARKER, matcher: 'Bash', script: guard },
41
+ // 파일을 바꾸는 도구도 같은 문을 지난다 — 일이 시작되는 신호이기 때문이다 (F6).
42
+ { event: 'PreToolUse', marker: HOOK_MARKER, matcher: 'Edit|Write|MultiEdit|NotebookEdit', script: guard },
41
43
  ];
42
44
  // 부를 곳을 모르면 심지 않는다 (§InstallPaths.entry)
43
45
  if (paths.entry)
@@ -0,0 +1,33 @@
1
+ import type { CoordinationSurfacePort, PublicPayload, SurfaceCandidate, SurfaceQuery, SurfaceSnapshot } from '../../ports/coordination-surface.ts';
2
+ import type { RemoteIdentity } from '../../core/runtime/coordination.ts';
3
+ type Stored = {
4
+ objectId: string;
5
+ payload: PublicPayload;
6
+ correlation: string;
7
+ workReference?: string;
8
+ closed?: boolean;
9
+ };
10
+ export type FixtureSurfaceDeps = {
11
+ /** 이 횟수만큼은 만들고 나서 응답을 잃는다. 객체는 남는다 — 그것이 이 사고의 요점이다. */
12
+ loseNextResponses?: number;
13
+ /** 주소를 어떻게 짓는가. 판정이 주소에 기대고 있으면 이 값을 바꿔 보면 드러난다. */
14
+ locatorShape?: (objectId: string) => string;
15
+ /** 찾기가 실패하는가. 못 찾은 것과 없는 것은 다른 사실이다. */
16
+ findFails?: boolean;
17
+ };
18
+ export declare class FixtureSurfaceAdapter implements CoordinationSurfacePort {
19
+ #private;
20
+ readonly id = "fixture-surface";
21
+ readonly objects: Stored[];
22
+ /** 실제로 만들기가 몇 번 일어났는가. 중복 생성 0 을 세는 자리다. */
23
+ createCalls: number;
24
+ constructor(deps?: FixtureSurfaceDeps);
25
+ find(query: SurfaceQuery): Promise<SurfaceCandidate[]>;
26
+ create(payload: PublicPayload, query: SurfaceQuery): Promise<RemoteIdentity>;
27
+ read(identity: Pick<RemoteIdentity, 'objectType' | 'objectId'>): Promise<SurfaceSnapshot | null>;
28
+ /** 밖에서 일어난 일을 흉내 낸다 — 시험이 게시물을 닫거나 남의 것을 심을 때 쓴다. */
29
+ seed(stored: Omit<Stored, 'objectId'> & {
30
+ objectId?: string;
31
+ }): RemoteIdentity;
32
+ }
33
+ export {};
@@ -0,0 +1,97 @@
1
+ // Fixture Coordination Surface — 조율 표면 계약을 provider 없이 돌린다 (R7).
2
+ //
3
+ // 왜 있는가: 조율이 특정 회사 도구의 성질이 아니라는 것은, 그 도구가 아닌 것으로도
4
+ // 같은 계약이 성립할 때만 증명된다. 여기 있는 것은 메모리 위의 게시판 하나이며,
5
+ // Core 는 이것과 실제 adapter 를 구분하지 못한다 — 구분하면 그것이 결함이다.
6
+ //
7
+ // 실 사고를 흉내 낼 수 있어야 쓸모가 있다. 그래서 두 개의 손잡이가 있다:
8
+ // `loseNextResponses` — 만들어지긴 하는데 응답을 잃는다 (중복 생성의 실제 원인)
9
+ // `locatorShape` — 주소 모양이 바뀐다 (주소로 판정하면 여기서 깨진다)
10
+ export class FixtureSurfaceAdapter {
11
+ id = 'fixture-surface';
12
+ objects = [];
13
+ /** 실제로 만들기가 몇 번 일어났는가. 중복 생성 0 을 세는 자리다. */
14
+ createCalls = 0;
15
+ #lose;
16
+ #locator;
17
+ #findFails;
18
+ constructor(deps = {}) {
19
+ this.#lose = deps.loseNextResponses ?? 0;
20
+ this.#locator = deps.locatorShape ?? ((objectId) => `fixture://board/${objectId}`);
21
+ this.#findFails = deps.findFails ?? false;
22
+ }
23
+ #identity(stored) {
24
+ return {
25
+ adapter: this.id,
26
+ objectType: 'thread',
27
+ objectId: stored.objectId,
28
+ resource: 'board',
29
+ locator: this.#locator(stored.objectId),
30
+ };
31
+ }
32
+ async find(query) {
33
+ if (this.#findFails)
34
+ throw new Error('surface unreachable');
35
+ const out = [];
36
+ const seen = new Set();
37
+ const push = (stored, matchedBy) => {
38
+ if (seen.has(stored.objectId))
39
+ return;
40
+ seen.add(stored.objectId);
41
+ out.push({
42
+ identity: this.#identity(stored),
43
+ title: stored.payload.title,
44
+ matchedBy,
45
+ ...(stored.closed ? { closed: true } : {}),
46
+ });
47
+ };
48
+ for (const known of query.known ?? []) {
49
+ const hit = this.objects.find((stored) => stored.objectId === known.objectId);
50
+ if (hit)
51
+ push(hit, 'known-identity');
52
+ }
53
+ for (const stored of this.objects) {
54
+ if (stored.correlation === query.correlation)
55
+ push(stored, 'correlation');
56
+ }
57
+ if (query.workReference) {
58
+ for (const stored of this.objects) {
59
+ if (stored.workReference === query.workReference)
60
+ push(stored, 'work-reference');
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ async create(payload, query) {
66
+ this.createCalls += 1;
67
+ const stored = {
68
+ objectId: `OBJ-${this.objects.length + 1}`,
69
+ payload,
70
+ correlation: query.correlation,
71
+ ...(query.workReference ? { workReference: query.workReference } : {}),
72
+ };
73
+ // 먼저 남기고 나서 응답을 잃는다 — 반대로 하면 이 fixture 가 흉내 내는 사고가 아니다.
74
+ this.objects.push(stored);
75
+ if (this.#lose > 0) {
76
+ this.#lose -= 1;
77
+ throw new Error('response lost after the object was created');
78
+ }
79
+ return this.#identity(stored);
80
+ }
81
+ async read(identity) {
82
+ const hit = this.objects.find((stored) => stored.objectId === identity.objectId);
83
+ if (!hit)
84
+ return null;
85
+ return {
86
+ identity: this.#identity(hit),
87
+ title: hit.payload.title,
88
+ ...(hit.closed ? { closed: true } : {}),
89
+ };
90
+ }
91
+ /** 밖에서 일어난 일을 흉내 낸다 — 시험이 게시물을 닫거나 남의 것을 심을 때 쓴다. */
92
+ seed(stored) {
93
+ const full = { objectId: stored.objectId ?? `OBJ-${this.objects.length + 1}`, ...stored };
94
+ this.objects.push(full);
95
+ return this.#identity(full);
96
+ }
97
+ }
@@ -12,6 +12,7 @@ const PROVIDES = [
12
12
  'context.resource',
13
13
  'context.thread',
14
14
  'context.change',
15
+ 'coordination.surface',
15
16
  ];
16
17
  /**
17
18
  * 이 host는 다른 adapter가 맡는다. 여기서 후보로 잡으면 같은 remote를 둘이 주장한다.
@@ -28,23 +28,37 @@ export declare function glabAvailable(run: ProcessRunner): Promise<boolean>;
28
28
  * `glab api` 를 읽기 통로로 감싼 클라이언트. GET 만 다룬다 — 쓰기는 Grant 를 지나야 하고,
29
29
  * 그 경로를 우회하는 통로를 여기에 만들지 않는다.
30
30
  */
31
- export declare class GlabApiClient implements GitLabReader {
31
+ export declare class GlabApiClient implements GitLabReader, GitLabWriter {
32
32
  #private;
33
33
  constructor(run: ProcessRunner);
34
34
  get<T>(path: string): Promise<GitLabResponse<T>>;
35
+ post<T>(path: string, body: Record<string, unknown>): Promise<GitLabResponse<T>>;
35
36
  }
36
37
  /**
37
38
  * 읽기 통로. GitLabClient(토큰)와 GlabApiClient(로그인된 도구) 둘 다 이것이다 —
38
39
  * Port 들은 어느 쪽인지 몰라야 한다. 통로가 바뀌었다고 조회 코드가 바뀌면, 통로를 늘릴
39
40
  * 때마다 같은 코드가 갈라진다.
40
41
  */
42
+ /**
43
+ * 쓰기 통로. **읽기와 일부러 갈라 둔다** — 조회 코드가 쓰기를 할 수 있으면 어디서 무엇이
44
+ * 나가는지 아무도 세지 못한다. 이것을 쥔 곳은 조율 표면 하나뿐이고, 거기서 나가는 것은
45
+ * 공개 payload 로 제한돼 있다.
46
+ *
47
+ * 승인(Grant)을 지나는 외부 Action 과는 다른 경로다. 그쪽은 사람이 승인한 단일 행동을
48
+ * 그대로 내보내는 통로이고, 이쪽은 물어본 것이 밖에 실제로 있게 하는 조율 행위다.
49
+ * 둘을 한 통로로 합치면 승인의 의미가 흐려진다.
50
+ */
51
+ export interface GitLabWriter {
52
+ post<T>(path: string, body: Record<string, unknown>): Promise<GitLabResponse<T>>;
53
+ }
41
54
  export interface GitLabReader {
42
55
  get<T>(path: string): Promise<GitLabResponse<T>>;
43
56
  }
44
- export declare class GitLabClient implements GitLabReader {
57
+ export declare class GitLabClient implements GitLabReader, GitLabWriter {
45
58
  #private;
46
59
  constructor(deps: GitLabClientDeps);
47
60
  get<T>(path: string): Promise<GitLabResponse<T>>;
61
+ post<T>(path: string, body: Record<string, unknown>): Promise<GitLabResponse<T>>;
48
62
  }
49
63
  /** `group/sub/project!19` 또는 `group/project#7` 을 쪼갠다. */
50
64
  export declare function parseRef(reference: string): {
@@ -39,6 +39,18 @@ export class GlabApiClient {
39
39
  return { ok: false, status: 0, data: null, error: String(error.message ?? error).slice(0, 200) };
40
40
  }
41
41
  }
42
+ async post(path, body) {
43
+ // 값 하나가 한 필드다. 문자열을 만들어 붙이지 않는다 — 그렇게 하면 본문에 개행이나
44
+ // 따옴표가 있을 때 조용히 다른 것이 나간다.
45
+ const fields = Object.entries(body).flatMap(([key, value]) => value === undefined ? [] : ['-f', `${key}=${String(value)}`]);
46
+ try {
47
+ const stdout = await this.#run('glab', ['api', '--method', 'POST', path.replace(/^\//, ''), ...fields]);
48
+ return { ok: true, status: 201, data: JSON.parse(stdout) };
49
+ }
50
+ catch (error) {
51
+ return { ok: false, status: 0, data: null, error: String(error.message ?? error).slice(0, 200) };
52
+ }
53
+ }
42
54
  }
43
55
  export class GitLabClient {
44
56
  #token;
@@ -65,6 +77,21 @@ export class GitLabClient {
65
77
  ...(nextPage ? { nextPage } : {}),
66
78
  };
67
79
  }
80
+ async post(path, body) {
81
+ const response = await this.#fetch(`${this.#baseUrl}${path}`, {
82
+ method: 'POST',
83
+ headers: {
84
+ accept: 'application/json',
85
+ 'content-type': 'application/json',
86
+ 'private-token': this.#token,
87
+ },
88
+ body: JSON.stringify(body),
89
+ });
90
+ if (!response.ok) {
91
+ return { ok: false, status: response.status, data: null, error: `HTTP ${response.status}` };
92
+ }
93
+ return { ok: true, status: response.status, data: (await response.json()) };
94
+ }
68
95
  }
69
96
  /** `group/sub/project!19` 또는 `group/project#7` 을 쪼갠다. */
70
97
  export function parseRef(reference) {
@@ -0,0 +1,19 @@
1
+ import type { CoordinationSurfacePort, PublicPayload, SurfaceCandidate, SurfaceQuery, SurfaceSnapshot } from '../../ports/coordination-surface.ts';
2
+ import type { RemoteIdentity } from '../../core/runtime/coordination.ts';
3
+ import { type GitLabReader, type GitLabWriter } from './client.ts';
4
+ /** 상관 관계를 심는 방법. 라벨 하나이고, 그 안에 들어가는 것은 기대 id 뿐이다. */
5
+ export declare const CORRELATION_LABEL = "asc-coordination";
6
+ export type GitLabCoordinationDeps = {
7
+ reader: GitLabReader;
8
+ /** 없으면 만들 수 없다. **없는 것을 있는 척하지 않는다** — create 가 그렇게 답한다. */
9
+ writer?: GitLabWriter;
10
+ project: string;
11
+ };
12
+ export declare class GitLabCoordinationSurface implements CoordinationSurfacePort {
13
+ #private;
14
+ readonly id = "gitlab";
15
+ constructor(deps: GitLabCoordinationDeps);
16
+ find(query: SurfaceQuery): Promise<SurfaceCandidate[]>;
17
+ create(payload: PublicPayload, query: SurfaceQuery): Promise<RemoteIdentity>;
18
+ read(identity: Pick<RemoteIdentity, 'objectType' | 'objectId'>): Promise<SurfaceSnapshot | null>;
19
+ }
@@ -0,0 +1,111 @@
1
+ // GitLab 조율 표면 — 밖에 있는 durable 한 조율 게시물 하나를 찾고, 만들고, 되읽는다.
2
+ //
3
+ // 이 파일은 provider 를 아는 자리다. Core 는 `CoordinationSurfacePort` 만 알고 여기 이름을
4
+ // 모른다 (C-09 §6). 그래서 여기서 GitLab 어휘를 쓰는 것은 계약대로이고, Core 쪽에 같은
5
+ // 어휘가 새는 것은 결함이다.
6
+ //
7
+ // 신원을 무엇으로 삼는가가 이 adapter 의 핵심 결정이다. 주소(web_url)는 쓰지 않는다 —
8
+ // 호스트가 바뀌고, 프로젝트가 옮겨 다니고, 같은 게시물이 다른 주소로 보인다. 대신
9
+ // `group/project#iid` 를 쓴다. 이 값은 그 시스템 안에서 안정적이고, 스스로 어느 프로젝트인지
10
+ // 말하므로 되읽기가 다른 정보를 필요로 하지 않는다.
11
+ import { encodeProject, parseRef } from "./client.js";
12
+ /** 상관 관계를 심는 방법. 라벨 하나이고, 그 안에 들어가는 것은 기대 id 뿐이다. */
13
+ export const CORRELATION_LABEL = 'asc-coordination';
14
+ const correlationLabel = (correlation) => `${CORRELATION_LABEL}:${correlation}`;
15
+ export class GitLabCoordinationSurface {
16
+ id = 'gitlab';
17
+ #reader;
18
+ #writer;
19
+ #project;
20
+ constructor(deps) {
21
+ this.#reader = deps.reader;
22
+ this.#writer = deps.writer;
23
+ this.#project = deps.project;
24
+ }
25
+ #identity(issue, project = this.#project) {
26
+ return {
27
+ adapter: this.id,
28
+ objectType: 'issue',
29
+ // 주소가 아니라 이 값이 정본이다.
30
+ objectId: `${project}#${issue.iid}`,
31
+ resource: project,
32
+ ...(issue.web_url ? { locator: issue.web_url } : {}),
33
+ ...(issue.updated_at ? { revisionMarker: issue.updated_at } : {}),
34
+ };
35
+ }
36
+ #candidate(issue, matchedBy) {
37
+ return {
38
+ identity: this.#identity(issue),
39
+ title: issue.title,
40
+ matchedBy,
41
+ ...(issue.state === 'closed' ? { closed: true } : {}),
42
+ };
43
+ }
44
+ async find(query) {
45
+ const out = [];
46
+ const seen = new Set();
47
+ const add = (candidate) => {
48
+ if (seen.has(candidate.identity.objectId))
49
+ return;
50
+ seen.add(candidate.identity.objectId);
51
+ out.push(candidate);
52
+ };
53
+ // 1) 이미 아는 게시물. 가장 강한 근거이고, 여기서 걸리면 나머지를 볼 이유가 없다.
54
+ for (const known of query.known ?? []) {
55
+ const snapshot = await this.read(known);
56
+ if (snapshot) {
57
+ add({
58
+ identity: snapshot.identity,
59
+ title: snapshot.title,
60
+ matchedBy: 'known-identity',
61
+ ...(snapshot.closed ? { closed: true } : {}),
62
+ });
63
+ }
64
+ }
65
+ // 2) 우리가 심어 둔 상관 관계. 라벨은 그 시스템이 색인하는 값이라 제목처럼 흔들리지 않는다.
66
+ const labelled = await this.#reader.get(`/projects/${encodeProject(this.#project)}/issues?labels=${encodeURIComponent(correlationLabel(query.correlation))}&state=all&per_page=20`);
67
+ if (!labelled.ok) {
68
+ // 못 찾은 것을 없는 것으로 넘기지 않는다. 호출자가 이 차이로 판단한다.
69
+ throw new Error(labelled.error ?? `issue search failed (${labelled.status})`);
70
+ }
71
+ for (const issue of labelled.data ?? [])
72
+ add(this.#candidate(issue, 'correlation'));
73
+ // 3) 작업 항목으로 훑는다. **약한 근거다** — 같은 작업에 여러 조율이 붙을 수 있다.
74
+ if (query.workReference) {
75
+ const searched = await this.#reader.get(`/projects/${encodeProject(this.#project)}/issues?search=${encodeURIComponent(query.workReference)}&in=title,description&state=all&per_page=20`);
76
+ if (searched.ok)
77
+ for (const issue of searched.data ?? [])
78
+ add(this.#candidate(issue, 'work-reference'));
79
+ }
80
+ return out;
81
+ }
82
+ async create(payload, query) {
83
+ if (!this.#writer)
84
+ throw new Error('this binding has no write channel — nothing was created');
85
+ // **여기를 지나는 것이 밖으로 나가는 전부다.** payload 밖의 값은 이 함수에 오지 않고,
86
+ // 라벨 하나만 우리가 덧붙인다 — 다음 회차가 이 게시물을 다시 찾는 근거다.
87
+ const labels = [...(payload.labels ?? []), correlationLabel(query.correlation)].join(',');
88
+ const created = await this.#writer.post(`/projects/${encodeProject(this.#project)}/issues`, {
89
+ title: payload.title,
90
+ description: payload.body,
91
+ labels,
92
+ });
93
+ if (!created.ok || !created.data) {
94
+ throw new Error(created.error ?? `issue create failed (${created.status})`);
95
+ }
96
+ return this.#identity(created.data);
97
+ }
98
+ async read(identity) {
99
+ const parsed = parseRef(identity.objectId);
100
+ if (!parsed || parsed.kind !== 'issue')
101
+ return null;
102
+ const response = await this.#reader.get(`/projects/${encodeProject(parsed.project)}/issues/${parsed.iid}`);
103
+ if (!response.ok || !response.data)
104
+ return null;
105
+ return {
106
+ identity: this.#identity(response.data, parsed.project),
107
+ title: response.data.title,
108
+ ...(response.data.state === 'closed' ? { closed: true } : {}),
109
+ };
110
+ }
111
+ }
@@ -21,6 +21,12 @@ declare abstract class GitLabBase {
21
21
  * 할 일 목록(todo)으로 증분을 받는다. GitHub의 알림과 같은 자리이고, 같은 한계를 갖는다 —
22
22
  * 지정이 빠지면 오지 않는다. 그래서 Inventory가 따로 있다.
23
23
  */
24
+ /**
25
+ * 두 시각 중 늦은 것. ISO 문자열이지만 **문자열 비교를 하지 않는다** — 이 API 는 계정 설정에
26
+ * 따라 `+09:00` 과 `Z` 를 섞어 돌려주고, 그 둘은 사전순과 시간순이 다르다. 해석이 안 되는
27
+ * 값은 비교하지 않고 있는 쪽을 택한다.
28
+ */
29
+ export declare function laterOf(a: string | undefined, b: string | undefined): string | undefined;
24
30
  export declare class GitLabEventSource extends GitLabBase implements EventSource {
25
31
  readonly id = "gitlab-todo";
26
32
  cursorFrom(since: string): Cursor;
@@ -33,6 +33,22 @@ class GitLabBase {
33
33
  * 할 일 목록(todo)으로 증분을 받는다. GitHub의 알림과 같은 자리이고, 같은 한계를 갖는다 —
34
34
  * 지정이 빠지면 오지 않는다. 그래서 Inventory가 따로 있다.
35
35
  */
36
+ /**
37
+ * 두 시각 중 늦은 것. ISO 문자열이지만 **문자열 비교를 하지 않는다** — 이 API 는 계정 설정에
38
+ * 따라 `+09:00` 과 `Z` 를 섞어 돌려주고, 그 둘은 사전순과 시간순이 다르다. 해석이 안 되는
39
+ * 값은 비교하지 않고 있는 쪽을 택한다.
40
+ */
41
+ export function laterOf(a, b) {
42
+ if (!a)
43
+ return b;
44
+ if (!b)
45
+ return a;
46
+ const ta = Date.parse(a);
47
+ const tb = Date.parse(b);
48
+ if (Number.isNaN(ta) || Number.isNaN(tb))
49
+ return a >= b ? a : b;
50
+ return tb > ta ? b : a;
51
+ }
36
52
  export class GitLabEventSource extends GitLabBase {
37
53
  id = 'gitlab-todo';
38
54
  cursorFrom(since) {
@@ -46,8 +62,13 @@ export class GitLabEventSource extends GitLabBase {
46
62
  const response = await this.client.get(`/todos?${params}`);
47
63
  if (!response.ok || !response.data)
48
64
  return { events: [], cursor };
65
+ // 이번 회차(페이지 전체)에서 본 가장 늦은 시각. **반환 순서를 믿지 않는다** — 이 목록은
66
+ // 최신순으로 오므로 마지막 항목이 가장 오래된 것이었고, 그것을 다음 기준선으로 삼자
67
+ // 커서가 옛 시각에 고정돼 같은 것을 매 회차 다시 읽었다. 기준선은 뒤로 가지 않는다.
68
+ let highWater = parsed.highWater ?? parsed.since;
49
69
  const events = [];
50
70
  for (const todo of response.data) {
71
+ highWater = laterOf(highWater, todo.updated_at);
51
72
  // 기준선 이전 것은 버린다. provider가 since를 지원하지 않아 여기서 거른다 —
52
73
  // 겹쳐 읽고 key로 중복을 거르는 편이 놓치는 것보다 싸다 (OM §10.5).
53
74
  if (parsed.since && todo.updated_at < parsed.since)
@@ -69,8 +90,13 @@ export class GitLabEventSource extends GitLabBase {
69
90
  });
70
91
  }
71
92
  const next = response.nextPage
72
- ? JSON.stringify({ ...(parsed.since ? { since: parsed.since } : {}), page: response.nextPage })
73
- : JSON.stringify({ since: events.at(-1)?.detectedAt ?? parsed.since });
93
+ ? JSON.stringify({
94
+ ...(parsed.since ? { since: parsed.since } : {}),
95
+ page: response.nextPage,
96
+ ...(highWater ? { highWater } : {}),
97
+ })
98
+ : // 페이지를 다 돌았으면 기준선은 지금까지 본 최대값이다 — 이전 기준선보다 앞서지 않는다.
99
+ JSON.stringify(highWater ? { since: highWater } : {});
74
100
  return { events, cursor: next, ...(response.nextPage ? { hasMore: true } : {}) };
75
101
  }
76
102
  }
@@ -149,6 +175,7 @@ export class GitLabResourceContext extends GitLabBase {
149
175
  body: note.body ?? '',
150
176
  // 여기는 resolvable 여부를 알려준다 — 아는 것만 적는다.
151
177
  ...(note.resolvable ? { unresolved: note.resolved !== true } : {}),
178
+ ...(note.system ? { system: true } : {}),
152
179
  }));
153
180
  }
154
181
  }
@@ -0,0 +1,20 @@
1
+ import type { CanonicalSnapshot } from '../../core/model/entities.ts';
2
+ import type { BaselineQuery, ExternalAction, ExternalActionResult, ScmPort, ThreadSnapshot } from '../../ports/scm.ts';
3
+ export type LocalCanonicalDeps = {
4
+ cwd: string;
5
+ /** sourceId → 어느 ref 를 읽는가. Profile 이 준다. */
6
+ sourceRefs: Readonly<Record<string, {
7
+ ref: string;
8
+ remote?: string;
9
+ }>>;
10
+ exec?: (args: readonly string[], cwd: string) => Promise<string>;
11
+ };
12
+ export declare class LocalCanonicalReader implements ScmPort {
13
+ #private;
14
+ readonly id = "git";
15
+ constructor(deps: LocalCanonicalDeps);
16
+ getBaselines(queries: readonly BaselineQuery[]): Promise<CanonicalSnapshot[]>;
17
+ /** 스레드는 이 통로의 것이 아니다. 모르는 것을 아는 척하지 않는다. */
18
+ getThread(reference: string): Promise<ThreadSnapshot>;
19
+ execute(action: ExternalAction): Promise<ExternalActionResult>;
20
+ }
@@ -0,0 +1,58 @@
1
+ // 정본 baseline 을 **선언된 대로** 읽는다.
2
+ //
3
+ // Profile 은 정본 갈래마다 provider 를 적는다. `git` 이라고 적힌 갈래는 이 checkout 이
4
+ // 이미 들고 있는 사실이다 — 원격 API 로 갈 이유가 없고, 갈 수 있다는 보장도 없다.
5
+ // 실제로 그 자리에서 한 provider 의 API 로만 읽으려다, 다른 host 를 쓰는 프로젝트에서
6
+ // 세션 발급이 통째로 막혔다. 정본을 못 읽으면 무엇을 딛고 시작하는지 적을 수 없기 때문이다.
7
+ //
8
+ // 여기는 **읽기만** 한다. 쓰기 통로가 아니고, 그 사실을 execute 가 그대로 말한다.
9
+ import { execFile } from 'node:child_process';
10
+ import { promisify } from 'node:util';
11
+ const run = promisify(execFile);
12
+ export class LocalCanonicalReader {
13
+ id = 'git';
14
+ #cwd;
15
+ #refs;
16
+ #exec;
17
+ constructor(deps) {
18
+ this.#cwd = deps.cwd;
19
+ this.#refs = deps.sourceRefs;
20
+ this.#exec =
21
+ deps.exec ??
22
+ (async (args, cwd) => {
23
+ const { stdout } = await run('git', [...args], { cwd });
24
+ return stdout.trim();
25
+ });
26
+ }
27
+ async getBaselines(queries) {
28
+ const out = [];
29
+ for (const query of queries) {
30
+ const configured = this.#refs[query.sourceId];
31
+ const ref = query.ref ?? configured?.ref;
32
+ if (!ref) {
33
+ out.push({ sourceId: query.sourceId, baseline: 'unknown' });
34
+ continue;
35
+ }
36
+ // 원격 추적 ref 를 먼저 본다. 로컬 브랜치를 정본처럼 읽으면 내 작업이 정본이 된다.
37
+ const remote = configured?.remote;
38
+ const candidates = remote ? [`${remote}/${ref}`, ref] : [ref];
39
+ let baseline = 'unknown';
40
+ for (const candidate of candidates) {
41
+ const sha = await this.#exec(['rev-parse', '--verify', `${candidate}^{commit}`], this.#cwd).catch(() => '');
42
+ if (sha) {
43
+ baseline = sha;
44
+ break;
45
+ }
46
+ }
47
+ out.push({ sourceId: query.sourceId, baseline });
48
+ }
49
+ return out;
50
+ }
51
+ /** 스레드는 이 통로의 것이 아니다. 모르는 것을 아는 척하지 않는다. */
52
+ async getThread(reference) {
53
+ return { reference, lastEventId: 'unknown', missing: true };
54
+ }
55
+ async execute(action) {
56
+ return { ok: false, error: `this canonical reader performs no external action: ${action.action}` };
57
+ }
58
+ }
@@ -314,7 +314,8 @@ function renderControlState(state) {
314
314
  '# Execution',
315
315
  `활성 Block: ${state.activeBlock ?? '없음'}`,
316
316
  `활성 세션: ${state.activeSessions.join(', ') || '없음'}`,
317
- `Write Boundary 점유: ${state.writeBoundaryOccupancy.join(', ') || '없음'}`,
317
+ // 객체를 그대로 문자열에 넣으면 "[object Object]" 가 남는다 — 실제 state.md 에서 그렇게 보였다.
318
+ `Write Boundary 점유: ${state.writeBoundaryOccupancy.map((o) => `${o.sessionId} → ${o.paths.join(' ')}`).join(', ') || '없음'}`,
318
319
  `승인 대기: ${state.awaitingController.join(', ') || '없음'}`,
319
320
  '',
320
321
  '# Monitoring',