@asc-agent/runtime 0.3.2 → 0.5.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 (60) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude-code/install.d.ts +7 -0
  3. package/dist/adapters/claude-code/install.js +114 -38
  4. package/dist/adapters/claude-code/session-start.d.ts +20 -0
  5. package/dist/adapters/claude-code/session-start.js +111 -0
  6. package/dist/adapters/fixture-surface/index.d.ts +33 -0
  7. package/dist/adapters/fixture-surface/index.js +97 -0
  8. package/dist/adapters/gitlab/adapter.js +1 -0
  9. package/dist/adapters/gitlab/client.d.ts +16 -2
  10. package/dist/adapters/gitlab/client.js +27 -0
  11. package/dist/adapters/gitlab/coordination.d.ts +19 -0
  12. package/dist/adapters/gitlab/coordination.js +111 -0
  13. package/dist/adapters/gitlab/ports.d.ts +6 -0
  14. package/dist/adapters/gitlab/ports.js +29 -2
  15. package/dist/adapters/jam/adapter.d.ts +46 -9
  16. package/dist/adapters/jam/adapter.js +88 -22
  17. package/dist/adapters/jam/setup.d.ts +62 -0
  18. package/dist/adapters/jam/setup.js +85 -0
  19. package/dist/adapters/local/canonical.d.ts +20 -0
  20. package/dist/adapters/local/canonical.js +58 -0
  21. package/dist/adapters/markdown/state-store.js +2 -1
  22. package/dist/adapters/service/launchd.d.ts +12 -0
  23. package/dist/adapters/service/launchd.js +97 -0
  24. package/dist/adapters/service/schtasks.d.ts +16 -0
  25. package/dist/adapters/service/schtasks.js +67 -0
  26. package/dist/adapters/service/systemd-user.d.ts +15 -0
  27. package/dist/adapters/service/systemd-user.js +98 -0
  28. package/dist/adapters/webhook/ingress.d.ts +2 -2
  29. package/dist/cli/asc.js +1080 -145
  30. package/dist/composition/registry.js +11 -3
  31. package/dist/composition/runtime.d.ts +36 -0
  32. package/dist/composition/runtime.js +80 -1
  33. package/dist/core/attach/setup-plan.d.ts +66 -2
  34. package/dist/core/attach/setup-plan.js +50 -0
  35. package/dist/core/binding/types.d.ts +1 -1
  36. package/dist/core/binding/types.js +1 -0
  37. package/dist/core/distribution/external-command.d.ts +26 -0
  38. package/dist/core/distribution/external-command.js +59 -2
  39. package/dist/core/distribution/persistent-runtime.d.ts +82 -0
  40. package/dist/core/distribution/persistent-runtime.js +49 -0
  41. package/dist/core/distribution/release.d.ts +3 -3
  42. package/dist/core/distribution/release.js +1 -1
  43. package/dist/core/runtime/background.d.ts +104 -0
  44. package/dist/core/runtime/background.js +225 -0
  45. package/dist/core/runtime/coordination.d.ts +312 -0
  46. package/dist/core/runtime/coordination.js +247 -0
  47. package/dist/core/runtime/front.d.ts +58 -0
  48. package/dist/core/runtime/front.js +50 -1
  49. package/dist/core/runtime/publish.d.ts +107 -0
  50. package/dist/core/runtime/publish.js +153 -0
  51. package/dist/core/runtime/workspaces.d.ts +61 -0
  52. package/dist/core/runtime/workspaces.js +82 -0
  53. package/dist/core/workspace/resolve.d.ts +36 -0
  54. package/dist/core/workspace/resolve.js +110 -3
  55. package/dist/ports/adapter.d.ts +12 -0
  56. package/dist/ports/coordination-surface.d.ts +59 -0
  57. package/dist/ports/coordination-surface.js +16 -0
  58. package/dist/ports/resource-context.d.ts +5 -0
  59. package/dist/schemas/profile.d.ts +6 -6
  60. package/package.json +1 -1
@@ -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
  }
@@ -7,8 +7,8 @@ export type JamAdapterDeps = {
7
7
  */
8
8
  command?: string;
9
9
  args?: readonly string[];
10
- /** 상태 조회 통로. 테스트가 실제 프로세스를 띄우지 않기 위한 주입점. */
11
- authStatus?: (context: DiscoveryContext) => Promise<AuthStatus>;
10
+ /** 진단 통로. 테스트가 실제 프로세스를 띄우지 않기 위한 주입점. */
11
+ doctor?: (context: DiscoveryContext) => Promise<JamDoctor>;
12
12
  /** 선언 파일 읽기 통로. */
13
13
  readDeclaration?: (projectRoot: string) => Promise<string | null>;
14
14
  /** 개인 선언 파일 읽기 통로. */
@@ -16,16 +16,51 @@ export type JamAdapterDeps = {
16
16
  /** 이 저장소의 remote 들. 개인 선언과 맞출 때만 쓴다. */
17
17
  listRemotes?: (projectRoot: string) => Promise<string[]>;
18
18
  };
19
- /** `jam auth status --json` 의 응답. **토큰 값은 여기 오지 않는다.** */
20
- export type AuthStatus = {
21
- status?: 'configured' | 'not_configured';
22
- code?: string;
23
- source?: string;
24
- /** 어느 인스턴스인지. 사람이 "맞는 곳인가" 확인하는 쓴다. */
25
- baseUrl?: string;
19
+ /**
20
+ * `doctor --json` 의 응답에서 우리가 읽는 부분. **토큰 값은 여기 오지 않는다.**
21
+ *
22
+ * JAM 이 진단의 정본이다 (설계 §9.2). ASC 는 그 판정을 다시 만들지 않고 번역만 한다 —
23
+ * 예전에는 `auth status` 하나만 보고 준비 상태를 정했는데, 그러면 자격은 멀쩡한데
24
+ * 프로젝트 결합이나 host 등록이 어긋난 상태를 " 있다"로 읽는다.
25
+ */
26
+ export type JamDoctor = {
27
+ status?: 'ready' | 'failed' | string;
28
+ diagnosis?: Record<string, {
29
+ state?: string;
30
+ code?: string;
31
+ detail?: string;
32
+ }>;
33
+ axes?: Record<string, string>;
26
34
  /** 실행 자체가 안 됐을 때의 이유. */
27
35
  error?: string;
28
36
  };
37
+ /** 되살릴 수 있는가, 사람이 해야 하는가 (설계 §9.4). */
38
+ export type JamRemedy =
39
+ /** 사람만 할 수 있다 — 자격 입력. ASC 는 토큰을 받지도 저장하지도 않는다. */
40
+ {
41
+ kind: 'HUMAN';
42
+ code: string;
43
+ detail: string;
44
+ }
45
+ /** ASC 가 JAM 의 공식 setup 으로 스스로 고칠 수 있다. */
46
+ | {
47
+ kind: 'SELF_HEAL';
48
+ code: string;
49
+ detail: string;
50
+ }
51
+ /** 다시 돌려도 달라지지 않는다. */
52
+ | {
53
+ kind: 'HARD';
54
+ code: string;
55
+ detail: string;
56
+ };
57
+ /**
58
+ * doctor 판정을 ASC 가 쓰는 세 갈래로 옮긴다.
59
+ *
60
+ * **코드로만 분기한다** — JAM 이 산문을 바꿔도 판정이 흔들리지 않아야 하고, 우리가 모르는
61
+ * 코드는 사람에게 넘긴다(조용히 자가 치유를 시도하지 않는다).
62
+ */
63
+ export declare function remedyFor(doctor: JamDoctor): JamRemedy | null;
29
64
  /**
30
65
  * 어떻게 실행할 것인가.
31
66
  *
@@ -54,6 +89,8 @@ export declare class JamAdapter implements Adapter {
54
89
  probe(candidate: BindingCandidate, context: DiscoveryContext): Promise<ProbeResult>;
55
90
  /** 프로젝트와 무관하게 "도구가 지금 쓸 수 있는가". binding 유무와 별개 사실이다. */
56
91
  runtime(context: DiscoveryContext): Promise<RuntimeStatus>;
92
+ /** 직전 진단이 무엇을 요구했는가. 없으면 문제 없거나 아직 안 물어봤다. */
93
+ lastRemedy(): JamRemedy | null;
57
94
  }
58
95
  /**
59
96
  * 개인 선언에서 `workspace` 와 `key` 짝만 꺼낸다. YAML 파서를 들이지 않는다 —
@@ -37,6 +37,40 @@ const DECLARATION = join('.jira-agent', 'project.yaml');
37
37
  * 개인 선언은 remote 로 맞춘다(파일의 `path` 는 출처 기록일 뿐이다).
38
38
  */
39
39
  const PERSONAL_BINDINGS = join('.jam', 'projects.yaml');
40
+ /**
41
+ * doctor 판정을 ASC 가 쓰는 세 갈래로 옮긴다.
42
+ *
43
+ * **코드로만 분기한다** — JAM 이 산문을 바꿔도 판정이 흔들리지 않아야 하고, 우리가 모르는
44
+ * 코드는 사람에게 넘긴다(조용히 자가 치유를 시도하지 않는다).
45
+ */
46
+ export function remedyFor(doctor) {
47
+ if (doctor.status === 'ready')
48
+ return null;
49
+ const failed = Object.entries(doctor.diagnosis ?? {}).filter(([, axis]) => axis.state === 'FAILED');
50
+ if (failed.length === 0)
51
+ return null;
52
+ for (const [axis, value] of failed) {
53
+ const code = value.code ?? axis;
54
+ const detail = value.detail ?? axis;
55
+ // 자격은 사람 몫이다. JAM 이 그렇게 설계돼 있고 ASC 가 그것을 우회하지 않는다.
56
+ if (code === 'JAM_AUTH_REQUIRED' || axis === 'credentials' || axis === 'jiraAuthentication') {
57
+ return { kind: 'HUMAN', code, detail };
58
+ }
59
+ }
60
+ const [firstAxis, first] = failed[0];
61
+ const code = first.code ?? firstAxis;
62
+ const detail = first.detail ?? firstAxis;
63
+ // 프로젝트 결합·runtime 설정·host 등록은 JAM 의 공식 setup 이 고친다.
64
+ const healable = new Set([
65
+ 'JAM_PROJECT_SELECTION_REQUIRED',
66
+ 'JAM_RUNTIME_CONFIG_MISSING',
67
+ 'HOST_REGISTRATION_STALE',
68
+ 'HOST_REGISTRATION_MISSING',
69
+ ]);
70
+ if (healable.has(code))
71
+ return { kind: 'SELF_HEAL', code, detail };
72
+ return { kind: 'HARD', code, detail };
73
+ }
40
74
  /**
41
75
  * 어떻게 실행할 것인가.
42
76
  *
@@ -61,14 +95,14 @@ export function parseProjectKey(text) {
61
95
  export class JamAdapter {
62
96
  #command;
63
97
  #args;
64
- #authStatus;
98
+ #doctor;
65
99
  #readDeclaration;
66
100
  #readPersonalBindings;
67
101
  #listRemotes;
68
102
  constructor(deps = {}) {
69
103
  this.#command = deps.command ?? 'jam';
70
104
  this.#args = deps.args ?? [];
71
- this.#authStatus = deps.authStatus;
105
+ this.#doctor = deps.doctor;
72
106
  this.#readDeclaration = deps.readDeclaration ?? defaultRead;
73
107
  this.#readPersonalBindings = deps.readPersonalBindings ?? defaultReadPersonal;
74
108
  this.#listRemotes = deps.listRemotes ?? defaultListRemotes;
@@ -79,10 +113,10 @@ export class JamAdapter {
79
113
  version: '1',
80
114
  provides: PROVIDES,
81
115
  // 자격이 필요하다는 사실과 **사람이 해야 한다는 사실**까지. 값은 오지 않는다.
82
- requiresCredential: ['사람이 직접 `jam auth login` — ASC가 대신 로그인하지 않는다'],
116
+ requiresCredential: ['사람이 직접 JAM 로그인해야 한다 — ASC가 대신 로그인하지 않는다'],
83
117
  prerequisites: [
84
- '프로젝트 루트에 .jira-agent/project.yaml 있어야 한다',
85
- 'JAM 실행 경로를 ASC_JAM_PATH 알려 줘야 한다 (아직 패키지로 배포되지 않았다)',
118
+ // 개인 결합이 기본값이다. 저장소에 파일을 두는 것은 팀이 채택했을 때뿐이다.
119
+ 'JAM workspace Jira 프로젝트에 결합하고 있어야 한다 (개인 결합이 기본, 팀 채택 시 .jira-agent/project.yaml)',
86
120
  ],
87
121
  };
88
122
  }
@@ -99,9 +133,19 @@ export class JamAdapter {
99
133
  return [{ adapterId: 'jam', resource: key, provides: PROVIDES, discoveredBy: DECLARATION }];
100
134
  // 저장소에 선언이 없다고 붙어 있지 않은 것은 아니다 — 개인 자리를 본다.
101
135
  const personal = await this.#personalKey(context);
102
- if (!personal)
103
- return [];
104
- return [{ adapterId: 'jam', resource: personal, provides: PROVIDES, discoveredBy: PERSONAL_BINDINGS }];
136
+ if (personal) {
137
+ return [{ adapterId: 'jam', resource: personal, provides: PROVIDES, discoveredBy: PERSONAL_BINDINGS }];
138
+ }
139
+ // 지역 흔적이 없어도 **Profile 이 이미 정해 둔 것**은 후보다 (설계 §9.3).
140
+ // 이것은 추측이 아니다 — 사람이 적은 결정이고, 실제로 되는지는 probe 가 정한다.
141
+ // 이 갈래가 없으면 "선언은 있는데 아무 일도 일어나지 않는" 상태가 남는다.
142
+ const declared = (context.declared ?? []).filter((entry) => entry.adapterId === 'jam');
143
+ return declared.map((entry) => ({
144
+ adapterId: 'jam',
145
+ resource: entry.resource,
146
+ provides: PROVIDES,
147
+ discoveredBy: 'Profile bindings',
148
+ }));
105
149
  }
106
150
  /** 개인 선언에서 이 저장소의 키를 찾는다. remote 가 일치할 때만 — 이름이 비슷한 것은 근거가 아니다. */
107
151
  async #personalKey(context) {
@@ -129,29 +173,46 @@ export class JamAdapter {
129
173
  async runtime(context) {
130
174
  return this.#status(context);
131
175
  }
176
+ /** 마지막 진단. setup 경로가 자가 치유 여부를 정할 때 읽는다 — 다시 돌리지 않기 위해서다. */
177
+ #lastRemedy = null;
178
+ /** 직전 진단이 무엇을 요구했는가. 없으면 문제 없거나 아직 안 물어봤다. */
179
+ lastRemedy() {
180
+ return this.#lastRemedy;
181
+ }
132
182
  async #status(context) {
133
- const read = this.#authStatus ?? ((ctx) => this.#defaultAuthStatus(ctx));
134
- const status = await read(context).catch((error) => ({ error: String(error) }));
135
- if (status.error) {
136
- return { state: 'UNAVAILABLE', detail: `JAM을 실행하지 못했다 — ${status.error}` };
183
+ const read = this.#doctor ?? ((ctx) => this.#defaultDoctor(ctx));
184
+ const doctor = await read(context).catch((error) => ({ error: String(error) }));
185
+ if (doctor.error) {
186
+ this.#lastRemedy = null;
187
+ return { state: 'UNAVAILABLE', detail: `JAM을 실행하지 못했다 — ${doctor.error}` };
137
188
  }
138
- if (status.status === 'configured') {
139
- return { state: 'AVAILABLE', ...(status.baseUrl ? { detail: `연결 대상 ${status.baseUrl}` } : {}) };
189
+ const remedy = remedyFor(doctor);
190
+ this.#lastRemedy = remedy;
191
+ if (!remedy)
192
+ return { state: 'AVAILABLE', ...(jamTarget(doctor) ? { detail: jamTarget(doctor) } : {}) };
193
+ switch (remedy.kind) {
194
+ case 'HUMAN':
195
+ // 설정이 안 된 것은 고장이 아니다. 사람이 할 일이 남았다는 뜻이고, 그 일을 알려 준다.
196
+ return {
197
+ state: 'UNCONFIGURED',
198
+ detail: `${remedy.code} — 사람이 직접 JAM 에 로그인해야 한다 (ASC가 대신하지 않는다): ${remedy.detail}`,
199
+ };
200
+ case 'SELF_HEAL':
201
+ // 고칠 수 있는 것을 "못 쓴다"로 적지 않는다 — 그 판단이 setup 을 멈추게 만든다.
202
+ return { state: 'UNCONFIGURED', detail: `${remedy.code} — JAM 공식 setup 으로 고칠 수 있다: ${remedy.detail}` };
203
+ case 'HARD':
204
+ return { state: 'UNAVAILABLE', detail: `${remedy.code} — ${remedy.detail}` };
140
205
  }
141
- // 설정이 안 된 것은 고장이 아니다. 사람이 할 일이 남았다는 뜻이고, 그 일을 알려 준다.
142
- return {
143
- state: 'UNCONFIGURED',
144
- detail: '자격이 없다 — 사람이 직접 `jam auth login` 을 실행해야 한다 (ASC가 대신하지 않는다)',
145
- };
146
206
  }
147
- async #defaultAuthStatus(context) {
207
+ async #defaultDoctor(context) {
148
208
  const { command, args } = resolveJamCommand(context.env, this.#command, this.#args);
149
209
  try {
150
- const { stdout } = await run(command, [...args, 'auth', 'status', '--json']);
210
+ const { stdout } = await run(command, [...args, 'doctor', '--json'], { cwd: context.projectRoot });
151
211
  return JSON.parse(stdout);
152
212
  }
153
213
  catch (error) {
154
- // 실행 실패와 "자격 없음"을 합치지 않는다. 전자는 설치·경로 문제다.
214
+ // 실행 실패와 "준비 안 됨"을 합치지 않는다. 전자는 설치·경로 문제다.
215
+ // doctor 는 준비되지 않았을 때 0 이 아닌 코드로 끝나면서도 JSON 을 낸다.
155
216
  const stdout = error.stdout;
156
217
  if (stdout) {
157
218
  try {
@@ -165,6 +226,11 @@ export class JamAdapter {
165
226
  }
166
227
  }
167
228
  }
229
+ /** 어느 인스턴스에 붙었는지. 사람이 "맞는 곳인가"를 확인하는 데 쓴다. */
230
+ function jamTarget(doctor) {
231
+ const detail = doctor.diagnosis?.credentials?.detail ?? doctor.diagnosis?.jiraAuthentication?.detail;
232
+ return detail ? `연결 확인됨 (${detail})` : null;
233
+ }
168
234
  async function defaultRead(projectRoot) {
169
235
  try {
170
236
  return await readFile(join(projectRoot, DECLARATION), 'utf8');
@@ -0,0 +1,62 @@
1
+ /** JAM 의 zero-install 진입. **버전을 여기 박지 않는다** — JAM 이 말한 값을 쓴다. */
2
+ export declare const JAM_BOOTSTRAP = "@jam-mcp/bootstrap";
3
+ /**
4
+ * 정확한 버전으로 고정한 공식 명령.
5
+ *
6
+ * JAM 문서가 `@latest` 를 금지하고 정확한 핀을 요구한다. 그 버전을 ASC 가 정하면 두
7
+ * 제품의 릴리스가 묶이므로, **돌고 있는 JAM 이 스스로 말한 버전**(doctor 의
8
+ * `axes.packageVersion`)을 그대로 쓴다.
9
+ */
10
+ export declare function jamBootstrapCommand(version: string, args: readonly string[]): {
11
+ command: string;
12
+ args: string[];
13
+ };
14
+ /** `setup plan --json` 에서 우리가 읽는 부분. */
15
+ export type JamSetupPlan = {
16
+ status?: string;
17
+ /** 사람이 결정해야 하는 것이 남아 있는가. 남아 있으면 ASC 는 손대지 않는다. */
18
+ requiresUserAction?: boolean;
19
+ changes?: unknown[];
20
+ project?: {
21
+ key?: string;
22
+ keySource?: string;
23
+ };
24
+ code?: string;
25
+ error?: string;
26
+ };
27
+ export type JamHealOutcome =
28
+ /** 고칠 것이 없었다. */
29
+ {
30
+ kind: 'ALREADY_READY';
31
+ }
32
+ /** ASC 가 JAM 공식 setup 으로 고쳤다. */
33
+ | {
34
+ kind: 'HEALED';
35
+ changes: number;
36
+ }
37
+ /** 사람이 해야 한다 — 자격, 또는 진짜 프로젝트 선택. */
38
+ | {
39
+ kind: 'NEEDS_HUMAN';
40
+ detail: string;
41
+ }
42
+ /** 다시 돌려도 달라지지 않는다. */
43
+ | {
44
+ kind: 'FAILED';
45
+ detail: string;
46
+ };
47
+ export type JamSetupDeps = {
48
+ cwd: string;
49
+ /** JAM 이 말한 자기 버전. 없으면 부를 수 없다 — 버전을 지어내지 않는다. */
50
+ version: string;
51
+ /** 프로세스 실행 통로. 테스트가 실제 npx 를 부르지 않기 위한 주입점. */
52
+ exec?: (command: string, args: readonly string[], cwd: string) => Promise<string>;
53
+ };
54
+ /**
55
+ * 준비되지 않은 JAM 을 공식 경로로 되살린다.
56
+ *
57
+ * **계획을 먼저 본다.** 계획이 사람을 요구하면 적용하지 않는다 — 그것이 자격이거나 진짜
58
+ * 프로젝트 선택이고, 둘 다 ASC 가 대신할 수 없는 것이다 (설계 §9.4).
59
+ */
60
+ export declare function healJam(deps: JamSetupDeps): Promise<JamHealOutcome>;
61
+ /** 사람이 읽는 한 줄. 무엇을 했는지·무엇이 남았는지가 여기 있어야 한다. */
62
+ export declare function healLine(outcome: JamHealOutcome): string;
@@ -0,0 +1,85 @@
1
+ // JAM 준비 상태를 JAM 의 공식 경로로 되살린다 (설계 §9.2·§9.3).
2
+ //
3
+ // **진단도 수리도 JAM 이 한다.** ASC 가 하는 일은 셋뿐이다:
4
+ //
5
+ // 언제 부를지 정한다 Profile 이 work binding 을 선언했는데 JAM 이 준비되지 않았을 때
6
+ // 무엇을 부를지 고른다 JAM 이 스스로 말한 버전의 공식 bootstrap
7
+ // 어디서 멈출지 판단한다 사람만 할 수 있는 것은 사람에게 넘긴다
8
+ //
9
+ // ASC 는 Jira 토큰을 받지도 저장하지도 않고, 프로젝트 키를 만들어 내지도 않는다.
10
+ // 키는 Profile 에 이미 사람이 적어 둔 것이고, 그 결정을 다시 묻지 않는다.
11
+ import { execFile } from 'node:child_process';
12
+ import { promisify } from 'node:util';
13
+ const run = promisify(execFile);
14
+ /** JAM 의 zero-install 진입. **버전을 여기 박지 않는다** — JAM 이 말한 값을 쓴다. */
15
+ export const JAM_BOOTSTRAP = '@jam-mcp/bootstrap';
16
+ /**
17
+ * 정확한 버전으로 고정한 공식 명령.
18
+ *
19
+ * JAM 문서가 `@latest` 를 금지하고 정확한 핀을 요구한다. 그 버전을 ASC 가 정하면 두
20
+ * 제품의 릴리스가 묶이므로, **돌고 있는 JAM 이 스스로 말한 버전**(doctor 의
21
+ * `axes.packageVersion`)을 그대로 쓴다.
22
+ */
23
+ export function jamBootstrapCommand(version, args) {
24
+ return { command: 'npx', args: ['--yes', `${JAM_BOOTSTRAP}@${version}`, ...args] };
25
+ }
26
+ const defaultExec = async (command, args, cwd) => {
27
+ try {
28
+ const { stdout } = await run(command, [...args], { cwd, maxBuffer: 8 * 1024 * 1024 });
29
+ return stdout;
30
+ }
31
+ catch (error) {
32
+ // JAM 은 준비되지 않았을 때도 JSON 을 내면서 0 이 아닌 코드로 끝난다.
33
+ const stdout = error.stdout;
34
+ if (stdout)
35
+ return stdout;
36
+ throw error;
37
+ }
38
+ };
39
+ /**
40
+ * 준비되지 않은 JAM 을 공식 경로로 되살린다.
41
+ *
42
+ * **계획을 먼저 본다.** 계획이 사람을 요구하면 적용하지 않는다 — 그것이 자격이거나 진짜
43
+ * 프로젝트 선택이고, 둘 다 ASC 가 대신할 수 없는 것이다 (설계 §9.4).
44
+ */
45
+ export async function healJam(deps) {
46
+ const exec = deps.exec ?? defaultExec;
47
+ const read = async (args) => {
48
+ const { command, args: full } = jamBootstrapCommand(deps.version, args);
49
+ try {
50
+ return JSON.parse(await exec(command, full, deps.cwd));
51
+ }
52
+ catch (error) {
53
+ return { error: String(error.message ?? error).slice(0, 300) };
54
+ }
55
+ };
56
+ const plan = await read(['setup', 'plan', '--json']);
57
+ if (plan.error)
58
+ return { kind: 'FAILED', detail: `JAM setup plan 을 읽지 못했다 — ${plan.error}` };
59
+ if (plan.requiresUserAction) {
60
+ // 사람만 할 수 있는 것이 남았다. 대신 하지 않고, 무엇인지 그대로 전한다.
61
+ return { kind: 'NEEDS_HUMAN', detail: plan.code ?? plan.status ?? 'JAM setup requires a person' };
62
+ }
63
+ if ((plan.changes?.length ?? 0) === 0)
64
+ return { kind: 'ALREADY_READY' };
65
+ const applied = await read(['setup', 'apply', '--non-interactive', '--json']);
66
+ if (applied.error)
67
+ return { kind: 'FAILED', detail: `JAM setup apply 가 실패했다 — ${applied.error}` };
68
+ if (applied.requiresUserAction) {
69
+ return { kind: 'NEEDS_HUMAN', detail: applied.code ?? applied.status ?? 'JAM setup requires a person' };
70
+ }
71
+ return { kind: 'HEALED', changes: plan.changes?.length ?? 0 };
72
+ }
73
+ /** 사람이 읽는 한 줄. 무엇을 했는지·무엇이 남았는지가 여기 있어야 한다. */
74
+ export function healLine(outcome) {
75
+ switch (outcome.kind) {
76
+ case 'ALREADY_READY':
77
+ return 'JAM: already set up for this project';
78
+ case 'HEALED':
79
+ return `JAM: repaired through its own setup (${outcome.changes} change${outcome.changes === 1 ? '' : 's'})`;
80
+ case 'NEEDS_HUMAN':
81
+ return `JAM: needs you — ${outcome.detail}. ASC does not sign in for you.`;
82
+ case 'FAILED':
83
+ return `JAM: ${outcome.detail}`;
84
+ }
85
+ }
@@ -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
+ }