@asc-agent/runtime 0.2.0 → 0.3.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 +25 -25
- package/dist/adapters/claude-code/skill.js +249 -235
- package/dist/adapters/gitlab/adapter.d.ts +3 -0
- package/dist/adapters/gitlab/adapter.js +22 -3
- package/dist/adapters/gitlab/client.d.ts +27 -1
- package/dist/adapters/gitlab/client.js +30 -0
- package/dist/adapters/gitlab/ports.d.ts +3 -3
- package/dist/adapters/jam/adapter.d.ts +14 -0
- package/dist/adapters/jam/adapter.js +84 -6
- package/dist/adapters/jam/ports.d.ts +9 -0
- package/dist/adapters/jam/ports.js +49 -2
- package/dist/adapters/local/repo.d.ts +15 -0
- package/dist/adapters/local/repo.js +179 -0
- package/dist/adapters/markdown/state-store.js +13 -1
- package/dist/adapters/memory/state-store.js +4 -0
- package/dist/cli/asc.d.ts +6 -0
- package/dist/cli/asc.js +525 -8
- package/dist/composition/propose.d.ts +19 -0
- package/dist/composition/propose.js +35 -0
- package/dist/composition/runtime.d.ts +2 -0
- package/dist/composition/runtime.js +41 -5
- package/dist/core/attach/init.d.ts +17 -0
- package/dist/core/attach/init.js +28 -0
- package/dist/core/attach/setup-plan.d.ts +9 -0
- package/dist/core/attach/setup-plan.js +16 -5
- package/dist/core/distribution/node-runtime.d.ts +44 -0
- package/dist/core/distribution/node-runtime.js +75 -0
- package/dist/core/distribution/release.d.ts +3 -3
- package/dist/core/distribution/release.js +1 -1
- package/dist/core/monitor/investigation.d.ts +16 -0
- package/dist/core/monitor/investigation.js +18 -9
- package/dist/core/operator/contract-draft.d.ts +124 -0
- package/dist/core/operator/contract-draft.js +234 -0
- package/dist/core/operator/derive-draft.d.ts +37 -0
- package/dist/core/operator/derive-draft.js +216 -0
- package/dist/core/operator/proceed.d.ts +79 -0
- package/dist/core/operator/proceed.js +123 -1
- package/dist/core/operator/work-state.d.ts +54 -0
- package/dist/core/operator/work-state.js +145 -0
- package/dist/core/runtime/closure.d.ts +2 -2
- package/dist/ports/local-repo.d.ts +61 -0
- package/dist/ports/local-repo.js +9 -0
- package/dist/ports/resource-context.d.ts +7 -0
- package/dist/schemas/profile.d.ts +2 -2
- package/package.json +2 -2
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { BindingPlan, Capability } from '../core/binding/types.ts';
|
|
2
|
+
export type BindingProposal = {
|
|
3
|
+
/**
|
|
4
|
+
* capability → 이 통로를 맡을 adapter.
|
|
5
|
+
*
|
|
6
|
+
* **조립에 밀어 넣는 값이 아니다.** 후보가 유일하면 capability 해석은 스스로 풀리므로,
|
|
7
|
+
* 여기서 역할을 박으면 선언과 제안이 같은 자리에 섞인다. 이 표는 "무엇이 무엇을 맡게
|
|
8
|
+
* 됐는지"를 사람에게 말하기 위한 것이다.
|
|
9
|
+
*/
|
|
10
|
+
roles: Partial<Record<Capability, string>>;
|
|
11
|
+
/** 사람이 읽는 근거. 제안이라는 사실을 문장 안에 남긴다. */
|
|
12
|
+
reasons: string[];
|
|
13
|
+
/** 후보가 갈려 제안하지 않은 것들. */
|
|
14
|
+
conflicts: string[];
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* 선언이 하나도 없을 때만 부른다. 선언이 있으면 그것이 답이고, 여기서 다시 정하지 않는다.
|
|
18
|
+
*/
|
|
19
|
+
export declare function proposeBindings(plan: BindingPlan): BindingProposal;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Binding 자동 제안 (P1-G).
|
|
2
|
+
//
|
|
3
|
+
// Profile 은 **팀이 정한 것**을 담는다. 그런데 지금까지는 발견하면 알 수 있는 사실까지
|
|
4
|
+
// 거기 적어야 했다 — 원격이 하나뿐이고 그 원격을 다룰 adapter 도 하나뿐인데도, 누가
|
|
5
|
+
// bindings 에 적어 주기 전에는 아무 통로가 서지 않았다.
|
|
6
|
+
//
|
|
7
|
+
// 여기서 하는 일은 그 한 가지뿐이다: **후보가 유일할 때만** 역할을 제안한다. 저장하지
|
|
8
|
+
// 않고, Profile 을 고치지 않으며, 둘 이상이면 고르지 않는다 — 고르는 것은 사람의 일이고,
|
|
9
|
+
// 틀린 연결은 없는 연결보다 나쁘다.
|
|
10
|
+
const USABLE = new Set(['AVAILABLE', 'DEGRADED']);
|
|
11
|
+
/**
|
|
12
|
+
* 선언이 하나도 없을 때만 부른다. 선언이 있으면 그것이 답이고, 여기서 다시 정하지 않는다.
|
|
13
|
+
*/
|
|
14
|
+
export function proposeBindings(plan) {
|
|
15
|
+
const proposal = { roles: {}, reasons: [], conflicts: [] };
|
|
16
|
+
const usable = plan.bindings.filter((binding) => USABLE.has(binding.state));
|
|
17
|
+
const byCapability = new Map();
|
|
18
|
+
for (const binding of usable) {
|
|
19
|
+
for (const capability of binding.provides) {
|
|
20
|
+
byCapability.set(capability, [...(byCapability.get(capability) ?? []), binding]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
for (const [capability, candidates] of byCapability) {
|
|
24
|
+
const distinct = new Map(candidates.map((c) => [`${c.adapterId}:${c.resource}`, c]));
|
|
25
|
+
if (distinct.size !== 1) {
|
|
26
|
+
proposal.conflicts.push(`${capability}: 후보가 ${distinct.size}개다 (${[...distinct.keys()].join(', ')}) — 어느 쪽인지는 사람이 정한다`);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const only = [...distinct.values()][0];
|
|
30
|
+
const role = only.role ?? only.adapterId;
|
|
31
|
+
proposal.roles[capability] = role;
|
|
32
|
+
proposal.reasons.push(`${capability} ← ${only.adapterId}:${only.resource} (유일한 후보라 제안한다 — Profile 에 저장하지 않는다)`);
|
|
33
|
+
}
|
|
34
|
+
return proposal;
|
|
35
|
+
}
|
|
@@ -42,6 +42,8 @@ export type BuildInput = {
|
|
|
42
42
|
/** 이 binding이 어느 주소를 가리키는지. 발견 단계가 알아낸 값을 그대로 잇는다. */
|
|
43
43
|
endpointFor?: (binding: ResolvedBinding) => string | undefined;
|
|
44
44
|
};
|
|
45
|
+
/** 이 프로세스가 띄운 도구 자식들을 정리한다. 여러 번 불러도 안전하다. */
|
|
46
|
+
export declare function closeToolClients(): Promise<void>;
|
|
45
47
|
/**
|
|
46
48
|
* capability가 필요한 자리마다 어느 binding이 맡을지 정해 Port를 만든다.
|
|
47
49
|
*
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
// 변경으로 끝난다 — 그것이 "provider 교체는 Binding 교체" 의 실제 모습이다.
|
|
6
6
|
//
|
|
7
7
|
// **Core는 이 파일을 import하지 않는다.** 방향은 언제나 Composition → Core다.
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
8
10
|
import { resolveCapability } from "../core/binding/types.js";
|
|
9
11
|
import { GitHubClient, discoverToken } from "../adapters/github/client.js";
|
|
10
12
|
import { GitHubChangeContext, GitHubInventory, GitHubResourceContext } from "../adapters/github/context.js";
|
|
11
13
|
import { GitHubEventSource } from "../adapters/github/event-source.js";
|
|
12
14
|
import { GitHubScm } from "../adapters/github/scm.js";
|
|
13
|
-
import { GitLabClient, discoverToken as discoverGitLabToken } from "../adapters/gitlab/client.js";
|
|
15
|
+
import { GitLabClient, GlabApiClient, discoverToken as discoverGitLabToken, glabAvailable, } from "../adapters/gitlab/client.js";
|
|
14
16
|
import { GitLabChangeContext, GitLabEventSource, GitLabInventory, GitLabResourceContext, } from "../adapters/gitlab/ports.js";
|
|
15
17
|
import { JamMcpClient } from "../adapters/jam/mcp-client.js";
|
|
16
18
|
import { JamEventSource } from "../adapters/jam/event-source.js";
|
|
@@ -19,7 +21,9 @@ const FACTORIES = {
|
|
|
19
21
|
gitlab(binding, input, token) {
|
|
20
22
|
// 자체 호스팅이 흔하다. 어디를 가리키는지는 발견 단계가 이미 알아냈으므로 같은 값을 쓴다.
|
|
21
23
|
const baseUrl = input.endpointFor?.(binding);
|
|
22
|
-
|
|
24
|
+
// 토큰이 빈 문자열이면 자격이 도구 안에 있다는 뜻이다 (P1-H). 값을 꺼내 오지 않고
|
|
25
|
+
// 그 도구에게 요청을 대신 보내 달라고 한다 — 읽기 전용이다.
|
|
26
|
+
const client = token === '' ? new GlabApiClient(defaultGlabRun) : new GitLabClient({ token, ...(baseUrl ? { baseUrl } : {}) });
|
|
23
27
|
const project = binding.resource;
|
|
24
28
|
return {
|
|
25
29
|
eventSource: new GitLabEventSource({ client, project, perPage: input.perPage ?? 30 }),
|
|
@@ -44,11 +48,11 @@ const FACTORIES = {
|
|
|
44
48
|
// JAM은 토큰을 받지 않는다 — 자격은 도구가 자기 안에서 관리하고 ASC는 상태만 읽는다.
|
|
45
49
|
if (!input.jam)
|
|
46
50
|
return {};
|
|
47
|
-
const client = new JamMcpClient({
|
|
51
|
+
const client = registerToolClient(new JamMcpClient({
|
|
48
52
|
command: input.jam.command,
|
|
49
53
|
...(input.jam.args ? { args: input.jam.args } : {}),
|
|
50
54
|
...(input.jam.cwd ? { cwd: input.jam.cwd } : {}),
|
|
51
|
-
});
|
|
55
|
+
}));
|
|
52
56
|
const projectKey = binding.resource;
|
|
53
57
|
const timezone = input.jam.timezone;
|
|
54
58
|
const inventory = new JamInventory({ client, projectKey, ...(timezone ? { timezone } : {}) });
|
|
@@ -64,8 +68,40 @@ const FACTORIES = {
|
|
|
64
68
|
};
|
|
65
69
|
},
|
|
66
70
|
};
|
|
71
|
+
const execFileAsync = promisify(execFile);
|
|
72
|
+
/** glab 실행 통로. 실패는 예외로 올라가고, 호출측이 "없다"로 접는다. */
|
|
73
|
+
const defaultGlabRun = async (command, args) => {
|
|
74
|
+
const { stdout } = await execFileAsync(command, [...args]);
|
|
75
|
+
return stdout;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* gitlab 자격 찾기. env 토큰이 먼저고, 없으면 로그인된 `glab` 을 통로로 인정해 빈 문자열을
|
|
79
|
+
* 돌려준다 — **값이 아니라 "통로가 있다"는 사실이다.** 둘 다 없으면 null 이고, 그때만
|
|
80
|
+
* 조립하지 않는다.
|
|
81
|
+
*/
|
|
82
|
+
const discoverGitLabAccess = async () => {
|
|
83
|
+
const token = discoverGitLabToken();
|
|
84
|
+
if (token)
|
|
85
|
+
return token;
|
|
86
|
+
return (await glabAvailable(defaultGlabRun)) ? '' : null;
|
|
87
|
+
};
|
|
67
88
|
/** 이 adapter는 토큰 없이 조립된다. 자격은 도구가 자기 안에서 진다. */
|
|
68
89
|
const TOKENLESS = new Set(['jam']);
|
|
90
|
+
/**
|
|
91
|
+
* 자식 프로세스를 띄우는 도구 클라이언트들. 명령이 끝나면 닫아야 한다 — 안 닫으면
|
|
92
|
+
* CLI 가 할 일을 다 하고도 종료하지 못하고 서버 프로세스가 남는다(실제로 그렇게 됐다).
|
|
93
|
+
*/
|
|
94
|
+
const toolClients = new Set();
|
|
95
|
+
function registerToolClient(client) {
|
|
96
|
+
toolClients.add(client);
|
|
97
|
+
return client;
|
|
98
|
+
}
|
|
99
|
+
/** 이 프로세스가 띄운 도구 자식들을 정리한다. 여러 번 불러도 안전하다. */
|
|
100
|
+
export async function closeToolClients() {
|
|
101
|
+
const clients = [...toolClients];
|
|
102
|
+
toolClients.clear();
|
|
103
|
+
await Promise.all(clients.map((client) => client.stop().catch(() => undefined)));
|
|
104
|
+
}
|
|
69
105
|
/**
|
|
70
106
|
* capability와 Port의 대응. **이 표가 없으면 조립이 덮어쓰기가 된다** —
|
|
71
107
|
* 두 binding이 각각 다른 capability를 맡았는데 나중 것이 앞 것의 Port까지 밀어낸다.
|
|
@@ -108,7 +144,7 @@ export async function buildRuntimePorts(input) {
|
|
|
108
144
|
const ports = { unavailable: [] };
|
|
109
145
|
// 자격은 adapter마다 다른 곳에 있다. Core는 이 사실을 모르고, 여기서만 안다.
|
|
110
146
|
const findToken = input.findToken ??
|
|
111
|
-
(async (adapterId) => adapterId === 'gitlab' ?
|
|
147
|
+
(async (adapterId) => adapterId === 'gitlab' ? await discoverGitLabAccess() : await discoverToken());
|
|
112
148
|
// capability마다 따로 푼다. 한 binding이 여럿을 제공해도, 서로 다른 binding이 나눠
|
|
113
149
|
// 맡아도 같은 경로로 조립된다 — 어느 갈래가 어디서 왔는지가 Port마다 정확해야 한다.
|
|
114
150
|
const wanted = Object.keys(PORT_OF);
|
|
@@ -27,3 +27,20 @@ export declare function overrideTemplate(): string;
|
|
|
27
27
|
export declare function identitiesTemplate(): string;
|
|
28
28
|
/** 이미 있으면 건드리지 않는다. 사람이 채운 것을 덮지 않기 위해서다. */
|
|
29
29
|
export declare function writeIfAbsent(path: string, content: string): Promise<boolean>;
|
|
30
|
+
/**
|
|
31
|
+
* 지금 이 사람을 승인 권한자·감시 대상으로 세운 두 파일의 내용 (P1-F).
|
|
32
|
+
*
|
|
33
|
+
* 순수 함수인 이유: 이 결정은 두 파일에 서로 다른 형식으로 적히고, 한쪽만 채워 두면
|
|
34
|
+
* 게이트가 왜 안 열리는지 알 수 없는 상태가 된다. 그 짝을 코드 한 곳에서 만든다.
|
|
35
|
+
*
|
|
36
|
+
* **비밀은 다루지 않는다** — 이름과 채널만 적힌다.
|
|
37
|
+
*/
|
|
38
|
+
export declare function withIdentity(identities: Record<string, unknown>, override: Record<string, unknown>, input: {
|
|
39
|
+
name: string;
|
|
40
|
+
actor: string;
|
|
41
|
+
controller: boolean;
|
|
42
|
+
monitor: boolean;
|
|
43
|
+
}): {
|
|
44
|
+
identities: Record<string, unknown>;
|
|
45
|
+
override: Record<string, unknown>;
|
|
46
|
+
};
|
package/dist/core/attach/init.js
CHANGED
|
@@ -98,3 +98,31 @@ export async function writeIfAbsent(path, content) {
|
|
|
98
98
|
throw error;
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* 지금 이 사람을 승인 권한자·감시 대상으로 세운 두 파일의 내용 (P1-F).
|
|
103
|
+
*
|
|
104
|
+
* 순수 함수인 이유: 이 결정은 두 파일에 서로 다른 형식으로 적히고, 한쪽만 채워 두면
|
|
105
|
+
* 게이트가 왜 안 열리는지 알 수 없는 상태가 된다. 그 짝을 코드 한 곳에서 만든다.
|
|
106
|
+
*
|
|
107
|
+
* **비밀은 다루지 않는다** — 이름과 채널만 적힌다.
|
|
108
|
+
*/
|
|
109
|
+
export function withIdentity(identities, override, input) {
|
|
110
|
+
const nextIdentities = { ...identities };
|
|
111
|
+
const nextOverride = { ...override };
|
|
112
|
+
if (input.controller) {
|
|
113
|
+
nextIdentities[input.name] = [input.actor];
|
|
114
|
+
const controller = { ...(nextOverride.controller ?? {}) };
|
|
115
|
+
controller.identities = {
|
|
116
|
+
...(controller.identities ?? {}),
|
|
117
|
+
[input.name]: [input.actor],
|
|
118
|
+
};
|
|
119
|
+
nextOverride.controller = controller;
|
|
120
|
+
}
|
|
121
|
+
if (input.monitor) {
|
|
122
|
+
const existing = Array.isArray(nextOverride.monitorIdentities)
|
|
123
|
+
? nextOverride.monitorIdentities.filter((v) => typeof v === 'string')
|
|
124
|
+
: [];
|
|
125
|
+
nextOverride.monitorIdentities = [...new Set([...existing, input.name])];
|
|
126
|
+
}
|
|
127
|
+
return { identities: nextIdentities, override: nextOverride };
|
|
128
|
+
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { StableInstallState } from '../distribution/runtime-install.ts';
|
|
2
2
|
/** 세상의 스냅샷. 읽기만 해서 만든다. */
|
|
3
3
|
export type SetupState = {
|
|
4
|
+
/**
|
|
5
|
+
* 이 판단이 **어느 실행물 안에서** 나오는가 (C-14 §3.4).
|
|
6
|
+
*
|
|
7
|
+
* `runtime` 이면 지금 도는 것이 곧 설치된 `asc` 다 — 그 사실은 관측할 것이 아니라
|
|
8
|
+
* 이미 아는 것이다. 예전에는 이 축이 없어 `stableRuntime` 의 부재를 "설치 안 됨"으로
|
|
9
|
+
* 읽었고, 그래서 설치된 `asc` 가 자기를 bootstrap이라고 말하며 agent에게 `npx …` 를
|
|
10
|
+
* 돌려줬다 (v0.2.0 registry 관측). 진입점 자체가 이미 답의 일부다.
|
|
11
|
+
*/
|
|
12
|
+
entry: 'runtime' | 'bootstrap';
|
|
4
13
|
projectRoot: string;
|
|
5
14
|
git: boolean;
|
|
6
15
|
/** 이미 붙어 있으면 그 runtime 뿌리. 없으면 안 붙은 것이다. */
|
|
@@ -22,11 +22,22 @@ export function computeSetupPlan(state) {
|
|
|
22
22
|
];
|
|
23
23
|
// 지금 명령이 어디서 도는가. 설치된 `asc` 가 없으면 bootstrap이고, 그때 agent에게
|
|
24
24
|
// `asc …` 를 실행하라고 주면 안 된다 (C-14 §3.4 · 불변식 ⑯).
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
//
|
|
26
|
+
// **진입점이 먼저다.** `asc` 로 들어왔다면 그 `asc` 는 이미 이 machine에 있다 — 그것을
|
|
27
|
+
// npm에게 물어볼 이유가 없다. bootstrap으로 들어왔을 때만 설치 축이 판정에 쓰인다.
|
|
28
|
+
const mode = state.entry === 'runtime' || state.stableRuntime?.status === 'CURRENT'
|
|
29
|
+
? 'installed-runtime'
|
|
30
|
+
: 'bootstrap';
|
|
31
|
+
const command = (args) => {
|
|
32
|
+
// portable은 **agent가 그대로 실행하는 것**이므로 기계가 읽을 수 있는 형태로 끝나야
|
|
33
|
+
// 한다. `--json` 이 빠져 있으면 agent는 자기가 실행한 명령의 답을 산문으로 받는다 —
|
|
34
|
+
// "산문을 파싱하지 마라"고 적어 놓고 산문을 주는 꼴이었다. display는 사람 형태 그대로.
|
|
35
|
+
const machine = args.includes('--json') ? args : [...args, '--json'];
|
|
36
|
+
return {
|
|
37
|
+
display: shorthandCommand(args),
|
|
38
|
+
portable: mode === 'installed-runtime' ? shorthandCommand(machine) : portableCommand(machine),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
30
41
|
const changes = [];
|
|
31
42
|
// stable runtime은 **프로젝트와도, profile 선택과도 무관하다.** 사람이 profile을
|
|
32
43
|
// 고르는 중이어도 이 설치는 안전한 준비이고, 그것 때문에 통째로 WAIT 하지 않는다
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ProcessRunner } from './runtime-install.ts';
|
|
2
|
+
/** ASC가 요구하는 Node 하한. `engines` 와 같은 값이며 어긋나면 release:check가 잡는다. */
|
|
3
|
+
export declare const MINIMUM_NODE_MAJOR = 24;
|
|
4
|
+
export type NodeCandidate = {
|
|
5
|
+
path: string;
|
|
6
|
+
version: string;
|
|
7
|
+
};
|
|
8
|
+
export type NodeRuntimeCheck = {
|
|
9
|
+
ok: true;
|
|
10
|
+
version: string;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
code: 'NODE_RUNTIME_REQUIRED';
|
|
14
|
+
version: string;
|
|
15
|
+
detail: string;
|
|
16
|
+
/**
|
|
17
|
+
* 이 machine에서 찾은, 하한을 넘는 Node. **비어 있는 것이 실패가 아니다** —
|
|
18
|
+
* 그때는 Node를 놓는 일 자체가 사람의 경계다.
|
|
19
|
+
*/
|
|
20
|
+
candidates: NodeCandidate[];
|
|
21
|
+
};
|
|
22
|
+
export type NodeRuntimeDeps = {
|
|
23
|
+
/** 지금 이 프로세스의 Node 버전 (`process.version` 형태: `v22.23.2`). */
|
|
24
|
+
version: string;
|
|
25
|
+
/** 후보 경로가 실제로 있는가. */
|
|
26
|
+
exists: (path: string) => boolean;
|
|
27
|
+
/** 디렉터리 목록. 없으면 빈 배열 — 없는 것은 오류가 아니다. */
|
|
28
|
+
list: (path: string) => string[];
|
|
29
|
+
/** `<node> -v` 를 돌린다. 실패는 후보 탈락일 뿐이다. */
|
|
30
|
+
run: ProcessRunner;
|
|
31
|
+
/** 사용자 홈. nvm 배치를 찾는 데만 쓴다. */
|
|
32
|
+
home: string;
|
|
33
|
+
/** 경로를 잇는다 — 호출자가 `node:path` 를 준다 (core는 파일시스템을 모른다). */
|
|
34
|
+
join: (...parts: string[]) => string;
|
|
35
|
+
};
|
|
36
|
+
/** `v22.23.2` · `22.23.2` 둘 다 받는다. 못 읽으면 `null` — 추측하지 않는다. */
|
|
37
|
+
export declare function majorOf(version: string): number | null;
|
|
38
|
+
/**
|
|
39
|
+
* 돌릴 수 있는가, 못 돌린다면 무엇이 있는가.
|
|
40
|
+
*
|
|
41
|
+
* 하한을 넘으면 **아무것도 하지 않는다** — 정상 경로에 I/O를 얹지 않는다. 못 넘을 때만
|
|
42
|
+
* 후보를 찾고, 후보에 대해서만 `-v` 를 묻는다.
|
|
43
|
+
*/
|
|
44
|
+
export declare function checkNodeRuntime(deps: NodeRuntimeDeps): Promise<NodeRuntimeCheck>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// 이 프로세스를 돌리고 있는 Node가 ASC를 돌릴 수 있는가 (C-14 §3).
|
|
2
|
+
//
|
|
3
|
+
// **왜 판정이 필요한가**: `engines` 는 npm에게 하는 말이고, npm은 기본값에서 그것을
|
|
4
|
+
// 경고로만 낸다. 그래서 Node 22에서 설치하면 "무서운 경고 한 줄 → 그래도 돌아감"이 되고,
|
|
5
|
+
// 사용자는 자기가 지원 범위 안에 있는지 알 수 없다. 지원 하한은 결정적으로 답해야 한다.
|
|
6
|
+
//
|
|
7
|
+
// **여기서 하지 않는 것**: Node를 설치하지 않고, PATH·shell 설정을 고치지 않으며,
|
|
8
|
+
// version manager를 다루지 않는다. 이미 이 machine에 있는 것을 **best-effort로 찾아
|
|
9
|
+
// 알려 줄** 뿐이다 — 못 찾는 것은 정상이고, 그때는 사람이 답할 일이다.
|
|
10
|
+
import { RELEASE_VERSION } from "./release.js";
|
|
11
|
+
/** ASC가 요구하는 Node 하한. `engines` 와 같은 값이며 어긋나면 release:check가 잡는다. */
|
|
12
|
+
export const MINIMUM_NODE_MAJOR = 24;
|
|
13
|
+
/** `v22.23.2` · `22.23.2` 둘 다 받는다. 못 읽으면 `null` — 추측하지 않는다. */
|
|
14
|
+
export function majorOf(version) {
|
|
15
|
+
const match = /^v?(\d+)\./.exec(version.trim());
|
|
16
|
+
return match ? Number(match[1]) : null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 이미 있는 Node를 찾아본다. **탐색이지 관리가 아니다.**
|
|
20
|
+
*
|
|
21
|
+
* Homebrew와 nvm의 관례적 배치만 본다. 여기에 없다고 해서 Node가 없는 것은 아니고,
|
|
22
|
+
* 그 경우 후보 없이 돌려주는 것이 정직한 답이다 — 없는 것을 지어내지 않는다.
|
|
23
|
+
*/
|
|
24
|
+
function candidatePaths(deps) {
|
|
25
|
+
const found = [];
|
|
26
|
+
// Homebrew: /opt/homebrew/opt/node@26/bin/node · /usr/local/opt/node/bin/node
|
|
27
|
+
for (const prefix of ['/opt/homebrew/opt', '/usr/local/opt']) {
|
|
28
|
+
for (const entry of deps.list(prefix)) {
|
|
29
|
+
if (!/^node(@\d+)?$/.test(entry))
|
|
30
|
+
continue;
|
|
31
|
+
const path = deps.join(prefix, entry, 'bin', 'node');
|
|
32
|
+
if (deps.exists(path))
|
|
33
|
+
found.push(path);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// nvm: ~/.nvm/versions/node/v24.1.0/bin/node
|
|
37
|
+
const nvm = deps.join(deps.home, '.nvm', 'versions', 'node');
|
|
38
|
+
for (const entry of deps.list(nvm)) {
|
|
39
|
+
const path = deps.join(nvm, entry, 'bin', 'node');
|
|
40
|
+
if (deps.exists(path))
|
|
41
|
+
found.push(path);
|
|
42
|
+
}
|
|
43
|
+
return [...new Set(found)];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 돌릴 수 있는가, 못 돌린다면 무엇이 있는가.
|
|
47
|
+
*
|
|
48
|
+
* 하한을 넘으면 **아무것도 하지 않는다** — 정상 경로에 I/O를 얹지 않는다. 못 넘을 때만
|
|
49
|
+
* 후보를 찾고, 후보에 대해서만 `-v` 를 묻는다.
|
|
50
|
+
*/
|
|
51
|
+
export async function checkNodeRuntime(deps) {
|
|
52
|
+
const major = majorOf(deps.version);
|
|
53
|
+
if (major !== null && major >= MINIMUM_NODE_MAJOR)
|
|
54
|
+
return { ok: true, version: deps.version };
|
|
55
|
+
const candidates = [];
|
|
56
|
+
for (const path of candidatePaths(deps)) {
|
|
57
|
+
const probed = await deps.run(path, ['-v']);
|
|
58
|
+
if (!probed.ok)
|
|
59
|
+
continue;
|
|
60
|
+
const version = probed.stdout.trim().split(/\r?\n/)[0] ?? '';
|
|
61
|
+
const found = majorOf(version);
|
|
62
|
+
if (found !== null && found >= MINIMUM_NODE_MAJOR)
|
|
63
|
+
candidates.push({ path, version });
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
code: 'NODE_RUNTIME_REQUIRED',
|
|
68
|
+
version: deps.version,
|
|
69
|
+
detail: `ASC ${RELEASE_VERSION} needs Node ${MINIMUM_NODE_MAJOR} or newer; this process is ${deps.version}.` +
|
|
70
|
+
(candidates.length > 0
|
|
71
|
+
? ' A newer Node is already on this machine — use it for the same command.'
|
|
72
|
+
: ' No newer Node was found in the usual places. Installing one is yours to do.'),
|
|
73
|
+
candidates,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export declare const RUNTIME_PACKAGE = "@asc-agent/runtime";
|
|
2
2
|
export declare const BOOTSTRAP_PACKAGE = "@asc-agent/bootstrap";
|
|
3
3
|
/** runtime과 bootstrap은 초기 release에서 lockstep이다. */
|
|
4
|
-
export declare const RELEASE_VERSION = "0.
|
|
5
|
-
export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.
|
|
6
|
-
export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.
|
|
4
|
+
export declare const RELEASE_VERSION = "0.3.0";
|
|
5
|
+
export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.3.0";
|
|
6
|
+
export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.3.0";
|
|
7
7
|
/**
|
|
8
8
|
* 아직 설치되지 않은 machine에서 그대로 실행되는 형태 (C-14 §3.4).
|
|
9
9
|
*
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const RUNTIME_PACKAGE = '@asc-agent/runtime';
|
|
10
10
|
export const BOOTSTRAP_PACKAGE = '@asc-agent/bootstrap';
|
|
11
11
|
/** runtime과 bootstrap은 초기 release에서 lockstep이다. */
|
|
12
|
-
export const RELEASE_VERSION = '0.
|
|
12
|
+
export const RELEASE_VERSION = '0.3.0';
|
|
13
13
|
export const RUNTIME_SPEC = `${RUNTIME_PACKAGE}@${RELEASE_VERSION}`;
|
|
14
14
|
export const BOOTSTRAP_SPEC = `${BOOTSTRAP_PACKAGE}@${RELEASE_VERSION}`;
|
|
15
15
|
/**
|
|
@@ -5,6 +5,18 @@ import { type OwnershipMap } from '../policy/ownership.ts';
|
|
|
5
5
|
import type { Relevance } from './relevance.ts';
|
|
6
6
|
export declare const STEPS: readonly ["resource", "delta", "responsibility", "work", "thread", "change", "work-context", "canonical", "relevance", "recommendation", "draft"];
|
|
7
7
|
export type StepId = (typeof STEPS)[number];
|
|
8
|
+
/**
|
|
9
|
+
* 확인하지 못한 이유. "안 봤다"와 "볼 수 없다"가 같은 결과로 뭉개지면, 조사 누락이
|
|
10
|
+
* 접근 불가처럼 보이고 그 상태로 추천이 나간다.
|
|
11
|
+
*
|
|
12
|
+
* - `MISSING` — 통로 자체가 없어 **보지 않았다** (Port 미배선)
|
|
13
|
+
* - `UNAVAILABLE` — 보려 했으나 실패했다 (조회 오류·접근 거부·사라짐)
|
|
14
|
+
* - `NOT_APPLICABLE` — 이 사건에 해당하지 않는다
|
|
15
|
+
*
|
|
16
|
+
* 이전 판의 결과가 `done` 으로 되돌아오면 이 필드가 없다. 그때는 MISSING 으로 읽는다 —
|
|
17
|
+
* 모르는 쪽을 "확인했다"로 읽는 것보다 "안 봤다"로 읽는 편이 안전하다.
|
|
18
|
+
*/
|
|
19
|
+
export type StepReason = 'MISSING' | 'UNAVAILABLE' | 'NOT_APPLICABLE';
|
|
8
20
|
export type StepResult = {
|
|
9
21
|
id: StepId;
|
|
10
22
|
kind: 'DONE';
|
|
@@ -15,13 +27,17 @@ export type StepResult = {
|
|
|
15
27
|
id: StepId;
|
|
16
28
|
kind: 'UNDECIDABLE';
|
|
17
29
|
detail: string;
|
|
30
|
+
reason?: Extract<StepReason, 'MISSING' | 'UNAVAILABLE'>;
|
|
18
31
|
}
|
|
19
32
|
/** 이 사건에는 해당하지 않는다 (변경이 없는 사건의 change 단계 등). */
|
|
20
33
|
| {
|
|
21
34
|
id: StepId;
|
|
22
35
|
kind: 'SKIPPED';
|
|
23
36
|
detail: string;
|
|
37
|
+
reason?: Extract<StepReason, 'NOT_APPLICABLE'>;
|
|
24
38
|
};
|
|
39
|
+
/** 단계 결과를 세 상태로 읽는다. reason 이 없는 옛 결과는 보수적으로 읽는다. */
|
|
40
|
+
export declare function stepReason(step: StepResult): 'DONE' | StepReason;
|
|
25
41
|
export type Investigation = {
|
|
26
42
|
steps: StepResult[];
|
|
27
43
|
/** 사람이 읽는 상황. 단계 산출을 이어 붙인 것이며 요약하지 않는다. */
|
|
@@ -21,10 +21,19 @@ export const STEPS = [
|
|
|
21
21
|
'recommendation', // ⑩ 무엇을 하면 되는가
|
|
22
22
|
'draft', // ⑪ 초안 (조건을 만족할 때만)
|
|
23
23
|
];
|
|
24
|
+
/** 단계 결과를 세 상태로 읽는다. reason 이 없는 옛 결과는 보수적으로 읽는다. */
|
|
25
|
+
export function stepReason(step) {
|
|
26
|
+
if (step.kind === 'DONE')
|
|
27
|
+
return 'DONE';
|
|
28
|
+
if (step.kind === 'SKIPPED')
|
|
29
|
+
return step.reason ?? 'NOT_APPLICABLE';
|
|
30
|
+
return step.reason ?? 'MISSING';
|
|
31
|
+
}
|
|
24
32
|
const missingPort = (id, what) => ({
|
|
25
33
|
id,
|
|
26
34
|
kind: 'UNDECIDABLE',
|
|
27
35
|
detail: `${what} 를 제공하는 binding이 없다 — 이 단계는 확인하지 못했다`,
|
|
36
|
+
reason: 'MISSING',
|
|
28
37
|
});
|
|
29
38
|
/**
|
|
30
39
|
* 단계를 순서대로 밟는다. **각 단계는 자기가 필요한 Port만 요청한다** — 어떤 외부 시스템이
|
|
@@ -45,7 +54,7 @@ export async function investigate(input, ports, done = []) {
|
|
|
45
54
|
if (!ports.resource)
|
|
46
55
|
put(missingPort('resource', '리소스 조회'));
|
|
47
56
|
else if (!resource || resource.missing) {
|
|
48
|
-
put({ id: 'resource', kind: 'UNDECIDABLE', detail: '리소스를 읽지 못했다 (사라졌거나 접근 불가)' });
|
|
57
|
+
put({ id: 'resource', kind: 'UNDECIDABLE', detail: '리소스를 읽지 못했다 (사라졌거나 접근 불가)', reason: 'UNAVAILABLE' });
|
|
49
58
|
}
|
|
50
59
|
else {
|
|
51
60
|
put({
|
|
@@ -60,7 +69,7 @@ export async function investigate(input, ports, done = []) {
|
|
|
60
69
|
}
|
|
61
70
|
// ② Delta — 현재 모습만 보면 무엇이 새로운지 알 수 없다.
|
|
62
71
|
if (!input.previous) {
|
|
63
|
-
put({ id: 'delta', kind: 'SKIPPED', detail: '지난 관측이 없다 — 처음 보는 사건이다' });
|
|
72
|
+
put({ id: 'delta', kind: 'SKIPPED', detail: '지난 관측이 없다 — 처음 보는 사건이다', reason: 'NOT_APPLICABLE' });
|
|
64
73
|
}
|
|
65
74
|
else if (!resource) {
|
|
66
75
|
put(missingPort('delta', '리소스 조회'));
|
|
@@ -88,11 +97,11 @@ export async function investigate(input, ports, done = []) {
|
|
|
88
97
|
}
|
|
89
98
|
put(responsibility.length > 0
|
|
90
99
|
? { id: 'responsibility', kind: 'DONE', findings: responsibility }
|
|
91
|
-
: { id: 'responsibility', kind: 'SKIPPED', detail: 'owner·결정 영역이 선언되지 않았다' });
|
|
100
|
+
: { id: 'responsibility', kind: 'SKIPPED', detail: 'owner·결정 영역이 선언되지 않았다', reason: 'NOT_APPLICABLE' });
|
|
92
101
|
// ④ 지금 돌고 있는 것과의 관계.
|
|
93
102
|
put(input.activeSessions?.length
|
|
94
103
|
? { id: 'work', kind: 'DONE', findings: [`활성 세션: ${input.activeSessions.join(', ')}`] }
|
|
95
|
-
: { id: 'work', kind: 'SKIPPED', detail: '지금 돌고 있는 세션이 없다' });
|
|
104
|
+
: { id: 'work', kind: 'SKIPPED', detail: '지금 돌고 있는 세션이 없다', reason: 'NOT_APPLICABLE' });
|
|
96
105
|
// ⑤ Thread — 전부 읽지 않는다. 판단에 필요한 만큼만 (C-05 §3).
|
|
97
106
|
if (!ports.resource)
|
|
98
107
|
put(missingPort('thread', '스레드 조회'));
|
|
@@ -101,7 +110,7 @@ export async function investigate(input, ports, done = []) {
|
|
|
101
110
|
.getComments(input.reference, { limit: input.commentLimit ?? 5 })
|
|
102
111
|
.catch(() => null);
|
|
103
112
|
put(comments === null
|
|
104
|
-
? { id: 'thread', kind: 'UNDECIDABLE', detail: '논의를 읽지 못했다' }
|
|
113
|
+
? { id: 'thread', kind: 'UNDECIDABLE', detail: '논의를 읽지 못했다', reason: 'UNAVAILABLE' }
|
|
105
114
|
: {
|
|
106
115
|
id: 'thread',
|
|
107
116
|
kind: 'DONE',
|
|
@@ -128,7 +137,7 @@ export async function investigate(input, ports, done = []) {
|
|
|
128
137
|
}
|
|
129
138
|
// ⑦ Work Context — 작업 항목의 상태·연결. 코드 쪽과 다른 Binding일 수 있다.
|
|
130
139
|
if (!input.workReference) {
|
|
131
|
-
put({ id: 'work-context', kind: 'SKIPPED', detail: '연결된 작업 항목이 선언되지 않았다' });
|
|
140
|
+
put({ id: 'work-context', kind: 'SKIPPED', detail: '연결된 작업 항목이 선언되지 않았다', reason: 'NOT_APPLICABLE' });
|
|
132
141
|
}
|
|
133
142
|
else if (!ports.work) {
|
|
134
143
|
put(missingPort('work-context', '작업 항목 조회'));
|
|
@@ -136,7 +145,7 @@ export async function investigate(input, ports, done = []) {
|
|
|
136
145
|
else {
|
|
137
146
|
const item = await ports.work.getResource(input.workReference).catch(() => null);
|
|
138
147
|
if (!item || item.missing) {
|
|
139
|
-
put({ id: 'work-context', kind: 'UNDECIDABLE', detail: '작업 항목을 읽지 못했다' });
|
|
148
|
+
put({ id: 'work-context', kind: 'UNDECIDABLE', detail: '작업 항목을 읽지 못했다', reason: 'UNAVAILABLE' });
|
|
140
149
|
}
|
|
141
150
|
else {
|
|
142
151
|
// 경위는 있으면 더한다. 없다고 이 단계 전체가 무너지지는 않는다 —
|
|
@@ -163,7 +172,7 @@ export async function investigate(input, ports, done = []) {
|
|
|
163
172
|
else {
|
|
164
173
|
const snapshots = await ports.baselines().catch(() => null);
|
|
165
174
|
if (!snapshots)
|
|
166
|
-
put({ id: 'canonical', kind: 'UNDECIDABLE', detail: '정본을 읽지 못했다' });
|
|
175
|
+
put({ id: 'canonical', kind: 'UNDECIDABLE', detail: '정본을 읽지 못했다', reason: 'UNAVAILABLE' });
|
|
167
176
|
else {
|
|
168
177
|
const touched = (change?.changedPaths ?? []).filter((path) => input.canonicalPaths?.length && isWithinScopes(path, input.canonicalPaths));
|
|
169
178
|
canonicalConflict = touched.length > 0;
|
|
@@ -184,7 +193,7 @@ export async function investigate(input, ports, done = []) {
|
|
|
184
193
|
kind: 'DONE',
|
|
185
194
|
findings: input.relevance.evidence.map((e) => `${e.supports ? '+' : '-'} ${e.detail}`),
|
|
186
195
|
}
|
|
187
|
-
: { id: 'relevance', kind: 'SKIPPED', detail: '관련성 판정을 받지 못했다' });
|
|
196
|
+
: { id: 'relevance', kind: 'SKIPPED', detail: '관련성 판정을 받지 못했다', reason: 'NOT_APPLICABLE' });
|
|
188
197
|
const undecidable = steps
|
|
189
198
|
.filter((s) => s.kind === 'UNDECIDABLE')
|
|
190
199
|
.map((s) => `${s.id}: ${s.detail}`);
|