@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.
- 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/session-start.d.ts +20 -0
- package/dist/adapters/claude-code/session-start.js +111 -0
- package/dist/adapters/fixture-surface/index.d.ts +33 -0
- package/dist/adapters/fixture-surface/index.js +97 -0
- package/dist/adapters/gitlab/adapter.js +1 -0
- package/dist/adapters/gitlab/client.d.ts +16 -2
- package/dist/adapters/gitlab/client.js +27 -0
- package/dist/adapters/gitlab/coordination.d.ts +19 -0
- package/dist/adapters/gitlab/coordination.js +111 -0
- package/dist/adapters/gitlab/ports.d.ts +6 -0
- package/dist/adapters/gitlab/ports.js +29 -2
- package/dist/adapters/jam/adapter.d.ts +46 -9
- package/dist/adapters/jam/adapter.js +88 -22
- package/dist/adapters/jam/setup.d.ts +62 -0
- package/dist/adapters/jam/setup.js +85 -0
- package/dist/adapters/local/canonical.d.ts +20 -0
- package/dist/adapters/local/canonical.js +58 -0
- package/dist/adapters/markdown/state-store.js +2 -1
- package/dist/adapters/service/launchd.d.ts +12 -0
- package/dist/adapters/service/launchd.js +97 -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 +98 -0
- package/dist/adapters/webhook/ingress.d.ts +2 -2
- package/dist/cli/asc.js +1080 -145
- package/dist/composition/registry.js +11 -3
- package/dist/composition/runtime.d.ts +36 -0
- package/dist/composition/runtime.js +80 -1
- package/dist/core/attach/setup-plan.d.ts +66 -2
- package/dist/core/attach/setup-plan.js +50 -0
- package/dist/core/binding/types.d.ts +1 -1
- package/dist/core/binding/types.js +1 -0
- package/dist/core/distribution/external-command.d.ts +26 -0
- package/dist/core/distribution/external-command.js +59 -2
- package/dist/core/distribution/persistent-runtime.d.ts +82 -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/runtime/background.d.ts +104 -0
- package/dist/core/runtime/background.js +225 -0
- package/dist/core/runtime/coordination.d.ts +312 -0
- package/dist/core/runtime/coordination.js +247 -0
- package/dist/core/runtime/front.d.ts +58 -0
- package/dist/core/runtime/front.js +50 -1
- package/dist/core/runtime/publish.d.ts +107 -0
- package/dist/core/runtime/publish.js +153 -0
- package/dist/core/runtime/workspaces.d.ts +61 -0
- package/dist/core/runtime/workspaces.js +82 -0
- package/dist/core/workspace/resolve.d.ts +36 -0
- package/dist/core/workspace/resolve.js +110 -3
- package/dist/ports/adapter.d.ts +12 -0
- package/dist/ports/coordination-surface.d.ts +59 -0
- package/dist/ports/coordination-surface.js +16 -0
- package/dist/ports/resource-context.d.ts +5 -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.5.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 });
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|