@asc-agent/runtime 0.3.2 → 0.4.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 (37) 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/jam/adapter.d.ts +46 -9
  7. package/dist/adapters/jam/adapter.js +88 -22
  8. package/dist/adapters/jam/setup.d.ts +62 -0
  9. package/dist/adapters/jam/setup.js +85 -0
  10. package/dist/adapters/service/launchd.d.ts +12 -0
  11. package/dist/adapters/service/launchd.js +80 -0
  12. package/dist/adapters/service/schtasks.d.ts +16 -0
  13. package/dist/adapters/service/schtasks.js +67 -0
  14. package/dist/adapters/service/systemd-user.d.ts +15 -0
  15. package/dist/adapters/service/systemd-user.js +95 -0
  16. package/dist/cli/asc.js +840 -143
  17. package/dist/composition/registry.js +11 -3
  18. package/dist/composition/runtime.d.ts +33 -0
  19. package/dist/composition/runtime.js +74 -0
  20. package/dist/core/attach/setup-plan.d.ts +66 -2
  21. package/dist/core/attach/setup-plan.js +50 -0
  22. package/dist/core/distribution/external-command.js +1 -1
  23. package/dist/core/distribution/persistent-runtime.d.ts +73 -0
  24. package/dist/core/distribution/persistent-runtime.js +49 -0
  25. package/dist/core/distribution/release.d.ts +3 -3
  26. package/dist/core/distribution/release.js +1 -1
  27. package/dist/core/runtime/background.d.ts +104 -0
  28. package/dist/core/runtime/background.js +225 -0
  29. package/dist/core/runtime/front.d.ts +48 -0
  30. package/dist/core/runtime/front.js +34 -0
  31. package/dist/core/runtime/workspaces.d.ts +39 -0
  32. package/dist/core/runtime/workspaces.js +64 -0
  33. package/dist/core/workspace/resolve.d.ts +36 -0
  34. package/dist/core/workspace/resolve.js +110 -3
  35. package/dist/ports/adapter.d.ts +12 -0
  36. package/dist/schemas/profile.d.ts +6 -6
  37. 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.3.2
10
+ npm install -g @asc-agent/runtime@0.4.0
11
11
  ```
12
12
 
13
13
  npm owns the executable link (on Windows, npm's own `asc.cmd`). This package never edits
@@ -1,6 +1,13 @@
1
1
  export type InstallPaths = {
2
2
  /** 보통 ~/.claude — 테스트가 격리 디렉터리를 넘긴다. */
3
3
  claudeHome: string;
4
+ /**
5
+ * 이 hook들을 부를 ASC CLI의 경로. SessionStart hook이 상태를 물어볼 곳이다.
6
+ *
7
+ * 없으면 SessionStart는 설치되지 않는다 — 어디를 부를지 모르는 hook을 심느니
8
+ * 그 기능이 없는 편이 낫다.
9
+ */
10
+ entry?: string;
4
11
  };
5
12
  export declare const defaultPaths: () => InstallPaths;
6
13
  export type InstallOutcome = {
@@ -14,12 +14,15 @@ import { mkdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
14
14
  import { homedir } from 'node:os';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { hookScript } from "./guard.js";
17
+ import { sessionStartScript } from "./session-start.js";
17
18
  import { skillBundle } from "./skill.js";
18
19
  const sha = (text) => createHash('sha256').update(text).digest('hex').slice(0, 16);
19
20
  export const defaultPaths = () => ({ claudeHome: join(homedir(), '.claude') });
20
- const HOOK_MATCHER = 'Bash';
21
21
  const HOOK_MARKER = 'asc-external-write-guard';
22
+ const FRONT_MARKER = 'asc-front-binding';
22
23
  function locate(paths) {
24
+ const guard = join(paths.claudeHome, 'asc', 'guard-hook.mjs');
25
+ const front = join(paths.claudeHome, 'asc', 'front-hook.mjs');
23
26
  return {
24
27
  /** Bundle 전체. 파일이 늘어도 아래 계약(manifest·digest·멱등)은 그대로다 (C-05 §5). */
25
28
  skills: skillBundle().map((skill) => ({
@@ -28,11 +31,84 @@ function locate(paths) {
28
31
  text: skill.text,
29
32
  })),
30
33
  /** hook은 **하나**로 둔다. guard는 안전 층이고 중복 등록은 그 자체가 위험이다. */
31
- hook: join(paths.claudeHome, 'asc', 'guard-hook.mjs'),
34
+ hook: guard,
35
+ front,
32
36
  settings: join(paths.claudeHome, 'settings.json'),
33
37
  manifest: join(paths.claudeHome, 'asc', 'install-manifest.json'),
38
+ hooks: (() => {
39
+ const specs = [
40
+ { event: 'PreToolUse', marker: HOOK_MARKER, matcher: 'Bash', script: guard },
41
+ ];
42
+ // 부를 곳을 모르면 심지 않는다 (§InstallPaths.entry)
43
+ if (paths.entry)
44
+ specs.push({ event: 'SessionStart', marker: FRONT_MARKER, script: front });
45
+ return specs;
46
+ })(),
34
47
  };
35
48
  }
49
+ /**
50
+ * settings.json 의 hook 목록에서 **우리 항목만** 손본다 (C-03 §5.1).
51
+ *
52
+ * 표식이 없는 남의 항목은 읽지도 고치지도 않는다. 여기가 "사용자의 host integration을
53
+ * 보존한다"가 실제로 지켜지는 자리다 — 사람이 넣어 둔 SessionStart hook 옆에 우리 것을
54
+ * **더할** 뿐이다.
55
+ *
56
+ * 표식 없이 우리 스크립트를 가리키는 항목은 **옛 설치본이다.** 그것을 남으로 보면 재설치가
57
+ * 같은 guard를 하나 더 등록해 버린다 (실측: 표식 이전 버전으로 설치한 기계에서 그렇게
58
+ * 됐다). 그래서 명령이 우리 스크립트를 가리키면 그 항목을 우리 것으로 **입양한다**.
59
+ */
60
+ function reconcileHooks(settings, specs) {
61
+ const hooks = { ...(settings.hooks ?? {}) };
62
+ const changed = [];
63
+ for (const spec of specs) {
64
+ const command = hookCommand(spec.script);
65
+ const entries = [...(hooks[spec.event] ?? [])];
66
+ let touched = false;
67
+ // 표식 없이 우리 스크립트를 가리키는 옛 항목을 입양한다
68
+ for (const entry of entries) {
69
+ for (const hook of entry.hooks ?? []) {
70
+ if (hook._asc === undefined && hook.command === command) {
71
+ hook._asc = spec.marker;
72
+ touched = true;
73
+ }
74
+ }
75
+ }
76
+ const ours = entries.filter((entry) => entry.hooks?.some((h) => h._asc === spec.marker));
77
+ if (ours.length === 0) {
78
+ entries.push({
79
+ ...(spec.matcher ? { matcher: spec.matcher } : {}),
80
+ hooks: [{ type: 'command', command, _asc: spec.marker }],
81
+ });
82
+ touched = true;
83
+ }
84
+ else {
85
+ // 등록은 돼 있는데 **다른 곳을 가리키는** 경우가 있다 — 그 상태에서 "설치됨"이라고
86
+ // 하면 hook이 없는데 있다고 믿는다. 우리 항목만 지금 경로로 고친다.
87
+ for (const entry of ours) {
88
+ for (const hook of entry.hooks ?? []) {
89
+ if (hook._asc === spec.marker && hook.command !== command) {
90
+ hook.command = command;
91
+ touched = true;
92
+ }
93
+ }
94
+ }
95
+ // 같은 표식이 여럿이면 하나만 남긴다 — 중복 등록은 그 자체가 결함이다
96
+ if (ours.length > 1) {
97
+ for (const extra of ours.slice(1)) {
98
+ const at = entries.indexOf(extra);
99
+ if (at >= 0)
100
+ entries.splice(at, 1);
101
+ }
102
+ touched = true;
103
+ }
104
+ }
105
+ if (touched) {
106
+ hooks[spec.event] = entries;
107
+ changed.push(spec.event);
108
+ }
109
+ }
110
+ return { settings: changed.length > 0 ? { ...settings, hooks } : settings, changed };
111
+ }
36
112
  async function readJson(path) {
37
113
  try {
38
114
  return JSON.parse(await readFile(path, 'utf8'));
@@ -60,6 +136,8 @@ export async function install(paths, now = () => new Date().toISOString(), opts
60
136
  for (const [path, content] of [
61
137
  ...where.skills.map((skill) => [skill.path, skill.text]),
62
138
  [where.hook, hookScript()],
139
+ // 부를 CLI를 모르면 SessionStart hook 자체를 만들지 않는다
140
+ ...(paths.entry ? [[where.front, sessionStartScript(paths.entry)]] : []),
63
141
  ]) {
64
142
  const existing = await readFile(path, 'utf8').catch(() => null);
65
143
  const state = fileState(existing, content, manifest.files[path]);
@@ -81,30 +159,12 @@ export async function install(paths, now = () => new Date().toISOString(), opts
81
159
  manifest.files[path] = sha(content);
82
160
  written.push(path);
83
161
  }
84
- // settings.json PreToolUse hook 등록 — ASC 항목만 다루고 나머지는 손대지 않는다
85
- const settings = (await readJson(where.settings)) ?? {};
86
- const hooks = (settings.hooks ?? {});
87
- const preToolUse = (hooks.PreToolUse ?? []);
88
- const command = hookCommand(where.hook);
89
- const ours = preToolUse.filter((entry) => entry.hooks?.some((h) => h._asc === HOOK_MARKER));
90
- // 등록은 돼 있는데 **다른 곳을 가리키는** 경우가 있다 — 그 상태에서 "설치됨"이라고
91
- // 하면 guard가 없는데 있다고 믿는다. 우리 항목만 지금 경로로 고친다.
92
- const misdirected = ours.some((entry) => entry.hooks?.some((h) => h._asc === HOOK_MARKER && h.command !== command));
93
- if (ours.length === 0) {
94
- preToolUse.push({ matcher: HOOK_MATCHER, hooks: [{ type: 'command', command, _asc: HOOK_MARKER }] });
95
- }
96
- else if (misdirected) {
97
- for (const entry of ours) {
98
- for (const h of entry.hooks ?? [])
99
- if (h._asc === HOOK_MARKER)
100
- h.command = command;
101
- }
102
- }
103
- if (ours.length === 0 || misdirected) {
104
- settings.hooks = { ...hooks, PreToolUse: preToolUse };
162
+ // settings.json hook 등록 — **ASC 항목만** 다루고 나머지는 글자도 건드리지 않는다
163
+ const reconciled = reconcileHooks((await readJson(where.settings)) ?? {}, where.hooks);
164
+ if (reconciled.changed.length > 0) {
105
165
  await mkdir(dirname(where.settings), { recursive: true });
106
- await writeFile(where.settings, JSON.stringify(settings, null, 2) + '\n', 'utf8');
107
- written.push(`${where.settings} (PreToolUse hook)`);
166
+ await writeFile(where.settings, JSON.stringify(reconciled.settings, null, 2) + '\n', 'utf8');
167
+ written.push(`${where.settings} (${reconciled.changed.join(', ')} hook)`);
108
168
  }
109
169
  manifest.settingsHook = true;
110
170
  await mkdir(dirname(where.manifest), { recursive: true });
@@ -131,6 +191,7 @@ export async function verifyInstall(paths) {
131
191
  const expected = [
132
192
  ...where.skills.map((skill) => [skill.path, skill.text]),
133
193
  [where.hook, hookScript()],
194
+ ...(paths.entry ? [[where.front, sessionStartScript(paths.entry)]] : []),
134
195
  ];
135
196
  const files = [];
136
197
  for (const [path, content] of expected) {
@@ -138,11 +199,16 @@ export async function verifyInstall(paths) {
138
199
  files.push({ path, state: fileState(existing, content, manifest?.files[path]) });
139
200
  }
140
201
  const settings = await readJson(where.settings);
141
- const preToolUse = (settings?.hooks?.PreToolUse ?? []);
142
- const ourHooks = preToolUse.flatMap((entry) => (entry.hooks ?? []).filter((h) => h._asc === HOOK_MARKER));
143
- const hookRegistered = ourHooks.length > 0;
202
+ // 이벤트마다 우리 항목이 있는가. 하나라도 없으면 등록이 성립하지 않은 것으로 본다 —
203
+ // 반쯤 등록된 상태를 "설치됨"이라 부르면 없는 hook을 있다고 믿게 된다.
204
+ const registrations = where.hooks.map((spec) => {
205
+ const entries = (settings?.hooks?.[spec.event] ?? []);
206
+ const mine = entries.flatMap((entry) => (entry.hooks ?? []).filter((h) => h._asc === spec.marker));
207
+ return { present: mine.length > 0, pointsHere: mine.some((h) => h.command === hookCommand(spec.script)) };
208
+ });
209
+ const hookRegistered = registrations.every((r) => r.present);
144
210
  // 등록은 있는데 다른 곳을 가리키면 설치본이 뒤처진 것이다 — 없는 것으로 치지 않고 stale로 본다
145
- const hookMisdirected = hookRegistered && !ourHooks.some((h) => h.command === hookCommand(where.hook));
211
+ const hookMisdirected = registrations.some((r) => r.present && !r.pointsHere);
146
212
  const status = (() => {
147
213
  if (!manifest && files.every((f) => f.state === 'missing') && !hookRegistered)
148
214
  return 'NOT_INSTALLED';
@@ -179,7 +245,7 @@ export function installReportLines(report) {
179
245
  lines.push(` [${file.state}] ${file.path}`);
180
246
  }
181
247
  if (!report.hookRegistered)
182
- lines.push(' [missing] PreToolUse hook registration in settings.json');
248
+ lines.push(' [missing] an ASC hook registration in settings.json');
183
249
  return lines;
184
250
  }
185
251
  /** manifest로 증명되는 것만 제거한다. 사용자가 고친 파일은 남기고 이유를 말한다. */
@@ -201,22 +267,32 @@ export async function uninstall(paths) {
201
267
  await rm(path);
202
268
  removed.push(path);
203
269
  }
204
- // settings에서 ASC hook 항목만 걷어낸다 — 무관한 설정은 그대로
270
+ // settings에서 ASC hook 항목만 걷어낸다 — 무관한 설정은 그대로.
271
+ // 표식(`_asc`)이 소유권의 근거다: 사람이 넣은 SessionStart hook은 그 자리에 남는다.
205
272
  const settings = await readJson(where.settings);
206
273
  if (settings?.hooks) {
207
274
  const hooks = settings.hooks;
208
- const preToolUse = (hooks.PreToolUse ?? []);
209
- const filtered = preToolUse.filter((entry) => !entry.hooks?.some((h) => h._asc === HOOK_MARKER));
210
- if (filtered.length !== preToolUse.length) {
211
- if (filtered.length > 0)
212
- hooks.PreToolUse = filtered;
275
+ // 지금 설치가 SessionStart를 심었더라도 옛 설치가 남긴 것은 걷는다 —
276
+ // 우리 표식이 붙은 것은 전부 우리 것이다.
277
+ const markers = new Set([HOOK_MARKER, FRONT_MARKER]);
278
+ const dropped = [];
279
+ for (const [event, value] of Object.entries(hooks)) {
280
+ const entries = (value ?? []);
281
+ const kept = entries.filter((entry) => !entry.hooks?.some((h) => h._asc && markers.has(h._asc)));
282
+ if (kept.length === entries.length)
283
+ continue;
284
+ if (kept.length > 0)
285
+ hooks[event] = kept;
213
286
  else
214
- delete hooks.PreToolUse;
287
+ delete hooks[event];
288
+ dropped.push(event);
289
+ }
290
+ if (dropped.length > 0) {
215
291
  // 우리가 만든 hooks 컨테이너가 비면 키째 걷는다 — 빈 {}도 원래 없던 흔적이다
216
292
  if (Object.keys(hooks).length === 0)
217
293
  delete settings.hooks;
218
294
  await writeFile(where.settings, JSON.stringify(settings, null, 2) + '\n', 'utf8');
219
- removed.push(`${where.settings} (PreToolUse hook entry)`);
295
+ removed.push(`${where.settings} (${dropped.join(', ')} hook entry)`);
220
296
  }
221
297
  }
222
298
  await rm(where.manifest, { force: true });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * SessionStart hook이 stdout으로 내는 봉투.
3
+ *
4
+ * 보여 줄 것이 없으면 **아무것도 내지 않는다** (`null`). 빈 봉투를 내면 ASC와 무관한
5
+ * 프로젝트의 모든 세션 첫 화면에 빈 블록이 붙는다 — 남의 도구를 방해하지 않는다
6
+ * (C-11 불변식 ⑪).
7
+ */
8
+ export declare function sessionStartPayload(lines: readonly string[]): string | null;
9
+ /**
10
+ * SessionStart hook 본문.
11
+ *
12
+ * `entry` 는 이 hook을 설치한 CLI의 경로다. 그 CLI가 다시 선택된 build로 넘긴다
13
+ * (`runtime use development` 도 그래서 그대로 먹는다) — hook이 build를 고르지 않는다.
14
+ *
15
+ * 계약 셋:
16
+ * 세션을 절대 막지 않는다 — 무슨 일이 나도 exit 0, stdout은 비거나 봉투 하나
17
+ * ASC 무관 프로젝트에서 조용하다 — index에 없으면 CLI를 부르지도 않는다
18
+ * 기다리게 하지 않는다 — 상한을 두고, 넘으면 그냥 지나간다
19
+ */
20
+ export declare function sessionStartScript(entry: string): string;
@@ -0,0 +1,111 @@
1
+ // Claude Host — 세션이 열리면 지금 상태를 되찾는다 (C-12 §4·C-03 §5.6).
2
+ //
3
+ // Core는 "여기서 Front가 열렸다"까지만 안다 (`openFront`). 이 파일이 하는 일은 그 판정을
4
+ // **Claude Code의 형식으로 옮기는 것**뿐이다:
5
+ //
6
+ // Host lifecycle (SessionStart) → asc front open → additionalContext
7
+ //
8
+ // hook은 관찰이지 전이 권한이 아니다 (C-03 §5.6). 여기서 세션을 만들지 않고, 상태를
9
+ // 옮기지 않으며, 승인 대기를 소비하지 않는다 — 읽고 보여 주는 것이 전부다.
10
+ //
11
+ // **다른 Host가 생겨도 Core는 그대로다.** 바뀌는 것은 이 파일 같은 adapter 하나다.
12
+ /**
13
+ * SessionStart hook이 stdout으로 내는 봉투.
14
+ *
15
+ * 보여 줄 것이 없으면 **아무것도 내지 않는다** (`null`). 빈 봉투를 내면 ASC와 무관한
16
+ * 프로젝트의 모든 세션 첫 화면에 빈 블록이 붙는다 — 남의 도구를 방해하지 않는다
17
+ * (C-11 불변식 ⑪).
18
+ */
19
+ export function sessionStartPayload(lines) {
20
+ if (lines.length === 0)
21
+ return null;
22
+ return JSON.stringify({
23
+ hookSpecificOutput: {
24
+ hookEventName: 'SessionStart',
25
+ additionalContext: ['ASC — what is open in this workspace right now:', ...lines].join('\n'),
26
+ },
27
+ });
28
+ }
29
+ /**
30
+ * SessionStart hook 본문.
31
+ *
32
+ * `entry` 는 이 hook을 설치한 CLI의 경로다. 그 CLI가 다시 선택된 build로 넘긴다
33
+ * (`runtime use development` 도 그래서 그대로 먹는다) — hook이 build를 고르지 않는다.
34
+ *
35
+ * 계약 셋:
36
+ * 세션을 절대 막지 않는다 — 무슨 일이 나도 exit 0, stdout은 비거나 봉투 하나
37
+ * ASC 무관 프로젝트에서 조용하다 — index에 없으면 CLI를 부르지도 않는다
38
+ * 기다리게 하지 않는다 — 상한을 두고, 넘으면 그냥 지나간다
39
+ */
40
+ export function sessionStartScript(entry) {
41
+ return `#!/usr/bin/env node
42
+ // ASC front binding (SessionStart) — 설치·갱신은 \`asc host claude install\` 로만.
43
+ // 이 자리에 붙은 ASC workspace가 있으면 지금 무엇이 걸려 있는지 세션 첫 화면에 얹는다.
44
+ // ASC와 무관한 프로젝트에서는 아무것도 하지 않는다.
45
+ //
46
+ // **이 hook은 무엇도 막지 않는다.** 어떤 실패도 exit 0 이고, 그때 stdout은 비어 있다 —
47
+ // 상태를 못 읽었다고 사람의 세션이 안 열리면 그것이 더 큰 고장이다.
48
+ //
49
+ // **workspace 신원을 여기서 판정하지 않는다.** 예전에는 index를 직접 뒤져 걸리지 않으면
50
+ // 빠져나갔는데, 그러면 아직 등록되지 않은 linked worktree에서 Host를 처음 여는 경우가
51
+ // 통째로 빠진다 — 그 자리를 풀 수 있는 것은 공용 resolver뿐이다 (C-11 §1.3).
52
+ // 여기서 보는 것은 "이 기계가 ASC를 쓰기는 하는가" 한 가지이고, 그것은 신원이 아니다.
53
+ import { spawnSync } from 'node:child_process'
54
+ import { existsSync } from 'node:fs'
55
+ import { readFileSync } from 'node:fs'
56
+ import { homedir } from 'node:os'
57
+ import { join } from 'node:path'
58
+
59
+ const ENTRY = ${JSON.stringify(entry)}
60
+ /** 이 안에 못 끝내면 지나간다. 세션 시작을 기다리게 하지 않는다. */
61
+ const BUDGET_MS = 10_000
62
+
63
+ /**
64
+ * 이 기계에 ASC runtime state가 있는가 — 파일 존재 확인 하나.
65
+ *
66
+ * 없으면 어느 경로도 ASC 자리일 수 없으므로 CLI를 부르지 않는다. 이것은 workspace
67
+ * 판정이 아니라 "부를 이유가 있는가"이며, 그 판정은 아래 \`front open\` 이 한다.
68
+ */
69
+ function ascUsedHere() {
70
+ const home = process.env.ASC_HOME || join(homedir(), '.asc')
71
+ return existsSync(join(home, 'workspace-index.json')) || existsSync(join(home, 'workspaces'))
72
+ }
73
+
74
+ function main() {
75
+ let cwd = process.cwd()
76
+ try {
77
+ // SessionStart 입력에 cwd가 실린다. 없으면 프로세스의 cwd가 곧 그 자리다.
78
+ const raw = readFileSync(0, 'utf8')
79
+ if (raw.trim()) cwd = JSON.parse(raw).cwd || cwd
80
+ } catch {
81
+ // 읽지 못해도 계속한다 — 입력 형식 하나 때문에 복원을 통째로 버리지 않는다
82
+ }
83
+
84
+ if (!ascUsedHere()) return
85
+
86
+ // 붙지 않은 자리에서도 이 명령은 성공하고 조용하다. 판정은 전부 저쪽이 한다.
87
+ const run = spawnSync(process.execPath, [ENTRY, 'front', 'open', '--json'], {
88
+ cwd,
89
+ encoding: 'utf8',
90
+ timeout: BUDGET_MS,
91
+ env: process.env,
92
+ })
93
+ if (run.status !== 0 || !run.stdout) return
94
+
95
+ let payload
96
+ try {
97
+ payload = JSON.parse(run.stdout).payload
98
+ } catch {
99
+ return
100
+ }
101
+ if (typeof payload === 'string' && payload) process.stdout.write(payload)
102
+ }
103
+
104
+ try {
105
+ main()
106
+ } catch {
107
+ // 세션을 막지 않는다
108
+ }
109
+ process.exit(0)
110
+ `;
111
+ }
@@ -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');