@asc-agent/runtime 0.3.1 → 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.
- package/README.md +1 -1
- package/dist/adapters/claude-code/install.d.ts +7 -0
- package/dist/adapters/claude-code/install.js +114 -38
- package/dist/adapters/claude-code/probe.js +6 -1
- package/dist/adapters/claude-code/session-start.d.ts +20 -0
- package/dist/adapters/claude-code/session-start.js +111 -0
- package/dist/adapters/jam/adapter.d.ts +46 -9
- package/dist/adapters/jam/adapter.js +88 -22
- package/dist/adapters/jam/mcp-client.js +5 -1
- package/dist/adapters/jam/setup.d.ts +62 -0
- package/dist/adapters/jam/setup.js +85 -0
- package/dist/adapters/service/launchd.d.ts +12 -0
- package/dist/adapters/service/launchd.js +80 -0
- package/dist/adapters/service/schtasks.d.ts +16 -0
- package/dist/adapters/service/schtasks.js +67 -0
- package/dist/adapters/service/systemd-user.d.ts +15 -0
- package/dist/adapters/service/systemd-user.js +95 -0
- package/dist/cli/asc.js +886 -147
- package/dist/composition/registry.js +11 -3
- package/dist/composition/runtime.d.ts +33 -0
- package/dist/composition/runtime.js +74 -0
- package/dist/core/attach/setup-plan.d.ts +73 -1
- package/dist/core/attach/setup-plan.js +56 -3
- package/dist/core/distribution/external-command.d.ts +22 -0
- package/dist/core/distribution/external-command.js +79 -0
- package/dist/core/distribution/persistent-runtime.d.ts +73 -0
- package/dist/core/distribution/persistent-runtime.js +49 -0
- package/dist/core/distribution/release.d.ts +3 -3
- package/dist/core/distribution/release.js +1 -1
- package/dist/core/operator/progress.js +3 -1
- package/dist/core/runtime/background.d.ts +104 -0
- package/dist/core/runtime/background.js +225 -0
- package/dist/core/runtime/front.d.ts +48 -0
- package/dist/core/runtime/front.js +34 -0
- package/dist/core/runtime/session.js +6 -1
- package/dist/core/runtime/workspaces.d.ts +39 -0
- package/dist/core/runtime/workspaces.js +64 -0
- package/dist/core/workspace/resolve.d.ts +36 -0
- package/dist/core/workspace/resolve.js +124 -3
- package/dist/ports/adapter.d.ts +12 -0
- package/dist/schemas/profile.d.ts +6 -6
- 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.
|
|
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:
|
|
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
|
|
85
|
-
const
|
|
86
|
-
|
|
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} (
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
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 =
|
|
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]
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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} (
|
|
295
|
+
removed.push(`${where.settings} (${dropped.join(', ')} hook entry)`);
|
|
220
296
|
}
|
|
221
297
|
}
|
|
222
298
|
await rm(where.manifest, { force: true });
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// 호스트 세션이 자기 도구 목록을 보고 채우는 것은 별도 경로(host-report)다.
|
|
8
8
|
import { execFile } from 'node:child_process';
|
|
9
9
|
import { promisify } from 'node:util';
|
|
10
|
+
import { resolveExternalCommand } from "../../core/distribution/external-command.js";
|
|
10
11
|
const run = promisify(execFile);
|
|
11
12
|
export const CAPABILITIES = [
|
|
12
13
|
'cross_session_message',
|
|
@@ -26,8 +27,12 @@ export const CAPABILITIES = [
|
|
|
26
27
|
/** 안전에 필수인 capability — 없으면 degrade가 아니라 STOP이다 (C-03 §5.2). */
|
|
27
28
|
export const SAFETY_CRITICAL = ['external_write_guard'];
|
|
28
29
|
const tryRun = async (command, args) => {
|
|
30
|
+
// Windows에서 `claude` 는 npm이 만든 `.cmd` shim이라 bare 이름 execFile은
|
|
31
|
+
// 실패한다 — PATH에 실재하는데 "못 찾았다"가 되는 오판이 실측에서 나왔다.
|
|
32
|
+
// 실행 형태를 먼저 해석해 세 OS에서 같은 의미로 부른다.
|
|
33
|
+
const resolved = resolveExternalCommand(command, args);
|
|
29
34
|
try {
|
|
30
|
-
const { stdout } = await run(command, args, { timeout: 15_000 });
|
|
35
|
+
const { stdout } = await run(resolved.command, resolved.args, { timeout: 15_000 });
|
|
31
36
|
return { ok: true, stdout };
|
|
32
37
|
}
|
|
33
38
|
catch {
|
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
#
|
|
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.#
|
|
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: ['사람이 직접
|
|
116
|
+
requiresCredential: ['사람이 직접 JAM 에 로그인해야 한다 — ASC가 대신 로그인하지 않는다'],
|
|
83
117
|
prerequisites: [
|
|
84
|
-
|
|
85
|
-
'JAM
|
|
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 (
|
|
103
|
-
return [];
|
|
104
|
-
|
|
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.#
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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 #
|
|
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, '
|
|
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');
|