@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
@@ -0,0 +1,225 @@
1
+ // Background Runtime — 대화가 닫혀 있어도 도는 자리 (C-12 §0·§1.1).
2
+ //
3
+ // Orchestrator는 "언제 무엇을 부를지"를 이미 안다. 없던 것은 그 회차를 **누가 계속
4
+ // 돌리는가**와, 돌고 있는지를 **사람이 어떻게 보는가**였다:
5
+ //
6
+ // asc runtime start 터미널을 잡고 있어야 했다 — 창을 닫으면 감시가 죽는다
7
+ // asc runtime tick 한 회차만 돈다. 부를 사람이 없으면 아무 일도 안 일어난다
8
+ // asc runtime status 어느 빌드를 쓰는지만 답했다 (C-14 §4) — 감시가 사는지는 몰랐다
9
+ //
10
+ // 여기는 그 셋을 잇는 **상태**만 다룬다. 판정도, 감시도, 승인도 하지 않는다
11
+ // (C-12 불변식 ②). scheduler 제품도 모른다 (불변식 ④) — cron이 tick을 부르든, 떨어져
12
+ // 나간 프로세스가 스스로 돌든 같은 lease를 지난다.
13
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
14
+ import { dirname } from 'node:path';
15
+ /** 이 lease가 사는 열쇠. Monitor의 scan-lease와 다른 층이다 — 저쪽은 한 회차, 이쪽은 루프. */
16
+ export const RUNTIME_LEASE_KEY = 'runtime-lease';
17
+ /** 마지막 회차 시각이 사는 열쇠. Orchestrator가 쓰고 status가 읽는다. */
18
+ export const LAST_RUN_KEY = 'last-run';
19
+ /**
20
+ * lease가 죽은 것으로 보이기까지의 최소 시간.
21
+ *
22
+ * **주기보다 길어야 한다.** 10분마다 도는 루프에 5분 만료를 걸면 회차 사이에 자기 lease가
23
+ * 죽은 것으로 보이고, 두 번째 프로세스가 조용히 끼어든다. 그래서 회수 기준은 주기의
24
+ * 3배이며, 이 값은 그 아래로 내려가지 않게 막는 바닥이다.
25
+ */
26
+ export const MIN_STALE_MS = 5 * 60_000;
27
+ /** 주기에서 회수 기준을 정한다. 상수를 Core에 박지 않는다 (C-12 불변식 ③). */
28
+ export const staleAfter = (intervalMs) => Math.max(MIN_STALE_MS, intervalMs * 3);
29
+ /**
30
+ * 루프 하나만 돌게 하는 lease.
31
+ *
32
+ * **이중 기동은 오류가 아니다** (C-12 불변식 ⑥). 늦게 온 쪽은 조용히 물러나고, 이미
33
+ * 도는 쪽이 계속한다. 비정상 종료로 남은 lease는 시간이 지나면 회수된다 — 한 번 죽었다고
34
+ * 영영 못 켜지면 그것이 더 나쁜 고장이다.
35
+ */
36
+ export class RuntimeLease {
37
+ #scope;
38
+ #owner;
39
+ #pid;
40
+ #staleMs;
41
+ #now;
42
+ #startedAt;
43
+ constructor(deps) {
44
+ this.#scope = deps.scope;
45
+ this.#owner = deps.owner;
46
+ this.#pid = deps.pid ?? process.pid;
47
+ this.#staleMs = deps.staleMs ?? MIN_STALE_MS;
48
+ this.#now = deps.now ?? (() => new Date().toISOString());
49
+ }
50
+ /** 지금 누가 잡고 있는가. 읽기만 한다 — status가 이것을 그린다. */
51
+ async read() {
52
+ const raw = await this.#scope.get(RUNTIME_LEASE_KEY);
53
+ if (!raw)
54
+ return { kind: 'FREE' };
55
+ let record;
56
+ try {
57
+ record = JSON.parse(raw);
58
+ }
59
+ catch {
60
+ // 읽을 수 없는 lease는 죽은 것으로 본다. 다만 무엇이 있었는지는 남긴다.
61
+ return { kind: 'STALE', record: { owner: '(unreadable)', pid: 0, at: '', startedAt: '' }, silentFor: Infinity };
62
+ }
63
+ const silentFor = new Date(this.#now()).getTime() - new Date(record.at).getTime();
64
+ // 시계가 뒤로 갔거나 기록이 깨졌으면 살아 있는 쪽으로 읽는다 — 남의 lease를 뺏는
65
+ // 것보다 한 회차 쉬는 편이 싸다.
66
+ if (Number.isNaN(silentFor))
67
+ return { kind: 'HELD', record };
68
+ return silentFor >= this.#staleMs ? { kind: 'STALE', record, silentFor } : { kind: 'HELD', record };
69
+ }
70
+ /**
71
+ * 잡는다. 이미 살아 있는 주인이 있으면 `false` — 조용히 물러나는 것이 계약이다.
72
+ *
73
+ * Monitor scan-lease와 같은 모양이다: `setIfAbsent` 로 원자적으로 걸고, 오래된 것만
74
+ * 지우고 다시 건다. 확인과 쓰기 사이의 틈은 Adapter가 막는다.
75
+ */
76
+ async acquire() {
77
+ const at = this.#now();
78
+ this.#startedAt = at;
79
+ const mine = JSON.stringify({ owner: this.#owner, pid: this.#pid, at, startedAt: at });
80
+ if (await this.#scope.setIfAbsent(RUNTIME_LEASE_KEY, mine))
81
+ return true;
82
+ const state = await this.read();
83
+ if (state.kind === 'HELD')
84
+ return false;
85
+ await this.#scope.delete(RUNTIME_LEASE_KEY);
86
+ return this.#scope.setIfAbsent(RUNTIME_LEASE_KEY, mine);
87
+ }
88
+ /**
89
+ * 살아 있다고 말한다. 회차마다 부른다 — 이것이 없으면 주기가 만료보다 긴 순간
90
+ * 자기 lease가 죽은 것으로 보인다.
91
+ *
92
+ * **남의 lease를 갱신하지 않는다.** 주인이 바뀌어 있으면 `false` 를 돌려주고, 호출자는
93
+ * 그것을 "내가 밀려났다"로 읽는다.
94
+ */
95
+ async renew() {
96
+ const raw = await this.#scope.get(RUNTIME_LEASE_KEY);
97
+ if (raw) {
98
+ try {
99
+ const held = JSON.parse(raw);
100
+ if (held.owner !== this.#owner)
101
+ return false;
102
+ this.#startedAt ??= held.startedAt;
103
+ }
104
+ catch {
105
+ // 못 읽는 lease 위에 내 것을 덮어쓴다 — 아래에서 다시 쓴다
106
+ }
107
+ }
108
+ const at = this.#now();
109
+ this.#startedAt ??= at;
110
+ await this.#scope.set(RUNTIME_LEASE_KEY, JSON.stringify({ owner: this.#owner, pid: this.#pid, at, startedAt: this.#startedAt }));
111
+ return true;
112
+ }
113
+ /** 내 것일 때만 놓는다. 남이 이미 잡았으면 건드리지 않는다. */
114
+ async release() {
115
+ const raw = await this.#scope.get(RUNTIME_LEASE_KEY);
116
+ if (!raw)
117
+ return;
118
+ try {
119
+ if (JSON.parse(raw).owner !== this.#owner)
120
+ return;
121
+ }
122
+ catch {
123
+ // 못 읽는 것은 내가 남긴 것으로 보고 치운다
124
+ }
125
+ await this.#scope.delete(RUNTIME_LEASE_KEY);
126
+ }
127
+ }
128
+ /** 상태를 모은다. **읽기만 한다** — 보는 것이 상태를 바꾸면 아무도 못 본다. */
129
+ export async function readBackground(scope, staleMs, now = () => new Date().toISOString()) {
130
+ const lease = await new RuntimeLease({ scope, owner: '(reader)', staleMs, now }).read();
131
+ const raw = await scope.get(LAST_RUN_KEY);
132
+ let lastRun = {};
133
+ if (raw) {
134
+ try {
135
+ lastRun = JSON.parse(raw);
136
+ }
137
+ catch {
138
+ // 깨진 기록은 없는 것으로 본다 — 아래 렌더가 "한 번도 돌지 않았다"로 말한다
139
+ }
140
+ }
141
+ return { lease, lastRun, staleMs };
142
+ }
143
+ const ORDER = ['delta', 'reconcile', 'census', 'digest'];
144
+ /**
145
+ * 사람이 읽는 줄.
146
+ *
147
+ * **"안 돌고 있다"와 "돌았는데 변화가 없다"를 합치지 않는다** (C-12 불변식 ⑫과 같은 태도).
148
+ * 그래서 lease 상태와 마지막 회차 시각을 각각 말한다 — 둘 중 하나만 보면 오해한다.
149
+ */
150
+ export function renderBackground(status) {
151
+ const lines = [];
152
+ switch (status.lease.kind) {
153
+ case 'FREE':
154
+ lines.push('Background runtime: not running — `asc runtime start --detach` keeps it observing');
155
+ break;
156
+ case 'HELD':
157
+ lines.push(`Background runtime: running (pid ${status.lease.record.pid}, since ${status.lease.record.startedAt}, last heartbeat ${status.lease.record.at})`);
158
+ break;
159
+ case 'STALE':
160
+ lines.push(`Background runtime: not running — a lease from pid ${status.lease.record.pid} has been silent for ` +
161
+ `${Math.round(status.lease.silentFor / 60_000)} min and will be reclaimed on the next start`);
162
+ break;
163
+ }
164
+ const ran = ORDER.filter((kind) => status.lastRun[kind]);
165
+ if (ran.length === 0) {
166
+ // 설정만 하고 켜지 않은 상태다. 조용한 것과 구분해서 말한다.
167
+ lines.push(' no pass has run yet');
168
+ return lines;
169
+ }
170
+ for (const kind of ORDER) {
171
+ lines.push(` ${kind}: ${status.lastRun[kind] ?? 'never run'}`);
172
+ }
173
+ return lines;
174
+ }
175
+ /**
176
+ * 파일 하나짜리 ScopedStore (설계 §5.1).
177
+ *
178
+ * **서비스 lease 와 workspace lease 는 다른 소유 영역이다.** 저쪽은 "이 workspace 의
179
+ * 이번 회차를 누가 잡았는가"이고, 이쪽은 "이 기계의 runtime 을 지금 누가 도는가"다.
180
+ * 둘을 합치면 workspace 가 늘 때마다 기계 수준 직렬화가 무너진다.
181
+ *
182
+ * workspace 상태 저장소를 기계 뿌리에 열지 않는 이유: 그러면 ~/.asc 에 workspace 용
183
+ * 파일들이 생기고, 그것은 그 자리에 없어야 할 것들이다.
184
+ */
185
+ export function fileScope(path) {
186
+ const read = async () => {
187
+ try {
188
+ return JSON.parse(await readFile(path, 'utf8'));
189
+ }
190
+ catch {
191
+ // 없거나 깨진 파일은 빈 것으로 본다 — lease 는 시간으로 회수되므로 안전하다
192
+ return {};
193
+ }
194
+ };
195
+ // tmp+rename. 반쯤 쓰인 lease 를 다른 프로세스가 읽으면 판정이 흔들린다 (C-11 불변식 ⑨).
196
+ const write = async (data) => {
197
+ await mkdir(dirname(path), { recursive: true });
198
+ const tmp = `${path}.tmp-${process.pid}`;
199
+ await writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
200
+ await rename(tmp, path);
201
+ };
202
+ return {
203
+ async get(key) {
204
+ return (await read())[key] ?? null;
205
+ },
206
+ async set(key, value) {
207
+ await write({ ...(await read()), [key]: value });
208
+ },
209
+ async delete(key) {
210
+ const data = await read();
211
+ delete data[key];
212
+ await write(data);
213
+ },
214
+ async keys(prefix) {
215
+ return Object.keys(await read()).filter((key) => (prefix ? key.startsWith(prefix) : true));
216
+ },
217
+ async setIfAbsent(key, value) {
218
+ const data = await read();
219
+ if (key in data)
220
+ return false;
221
+ await write({ ...data, [key]: value });
222
+ return true;
223
+ },
224
+ };
225
+ }
@@ -85,6 +85,54 @@ export type RestoreInput = {
85
85
  * 새로 계산하지 않는다. 여기서 파생을 만들면 그 파생이 곧 두 번째 정본이 된다.
86
86
  */
87
87
  export declare function restoreFront(input: RestoreInput): Promise<FrontState>;
88
+ /**
89
+ * 새 Front Session이 열렸을 때의 판정 (C-12 §4).
90
+ *
91
+ * **Host를 모른다.** Claude Code든 다른 무엇이든 "여기서 세션이 열렸다"는 사실 하나를
92
+ * 받고, 붙을 수 있는지와 무엇이 걸려 있는지를 답한다. Host adapter는 이 답을 자기
93
+ * 호스트의 형식으로 옮기기만 한다 — Core에 `if (host === 'claude')` 가 생기면 그 순간
94
+ * 두 번째 호스트는 Core를 고쳐야 들어온다.
95
+ *
96
+ * 세 갈래를 **구분한다**:
97
+ *
98
+ * NOT_ASC 이 경로는 ASC 소관이 아니다. 조용히 지나간다 — 남의 도구를 방해하지
99
+ * 않는다 (C-11 불변식 ⑪).
100
+ * UNAVAILABLE 붙어야 하는데 못 붙었다. **빈 화면을 주지 않고 왜인지 말한다**
101
+ * (C-12 불변식 ⑰).
102
+ * BOUND 붙었다. 지금 무엇이 걸려 있는지가 함께 온다.
103
+ */
104
+ export type FrontOpening = {
105
+ kind: 'NOT_ASC';
106
+ } | {
107
+ kind: 'UNAVAILABLE';
108
+ detail: string;
109
+ } | {
110
+ kind: 'BOUND';
111
+ state: FrontState;
112
+ };
113
+ /**
114
+ * 세션이 열렸다 — 붙을 수 있는가.
115
+ *
116
+ * **읽기만 한다.** 세션을 만들지 않는다: 실제 작업 요청이 없는데 Session Contract를
117
+ * 만들어 내는 것은 없던 사실을 제조하는 일이다 (AGENTS.md "Do not create a session to
118
+ * demonstrate that setup worked" 와 같은 선).
119
+ */
120
+ export declare function openFront(input: {
121
+ /** 이 경로가 어느 workspace인가. `null` 이면 ASC 소관이 아니다. */
122
+ workspace: {
123
+ workspaceId: string;
124
+ locator: string;
125
+ } | null;
126
+ /** 상태를 읽는다. 던지면 UNAVAILABLE 로 접힌다 — 못 읽은 것을 "없음"으로 그리지 않는다. */
127
+ restore: () => Promise<FrontState>;
128
+ }): Promise<FrontOpening>;
129
+ /**
130
+ * Host가 사람에게 보여 줄 줄. **Host별 포장은 여기 없다** — 문자열 목록까지가 Core다.
131
+ *
132
+ * `NOT_ASC` 는 빈 목록이다. 할 말이 없는 것과 "아무 일도 없다"는 다르고, 전자는
133
+ * 아무것도 띄우지 않는 것이 맞다.
134
+ */
135
+ export declare function frontOpeningLines(opening: FrontOpening): string[];
88
136
  /**
89
137
  * 사람이 읽는 복원 화면.
90
138
  *
@@ -63,6 +63,40 @@ export async function restoreFront(input) {
63
63
  ...(input.workspace ? { workspace: input.workspace } : {}),
64
64
  };
65
65
  }
66
+ /**
67
+ * 세션이 열렸다 — 붙을 수 있는가.
68
+ *
69
+ * **읽기만 한다.** 세션을 만들지 않는다: 실제 작업 요청이 없는데 Session Contract를
70
+ * 만들어 내는 것은 없던 사실을 제조하는 일이다 (AGENTS.md "Do not create a session to
71
+ * demonstrate that setup worked" 와 같은 선).
72
+ */
73
+ export async function openFront(input) {
74
+ if (!input.workspace)
75
+ return { kind: 'NOT_ASC' };
76
+ try {
77
+ return { kind: 'BOUND', state: await input.restore() };
78
+ }
79
+ catch (error) {
80
+ return { kind: 'UNAVAILABLE', detail: error instanceof Error ? error.message : String(error) };
81
+ }
82
+ }
83
+ /**
84
+ * Host가 사람에게 보여 줄 줄. **Host별 포장은 여기 없다** — 문자열 목록까지가 Core다.
85
+ *
86
+ * `NOT_ASC` 는 빈 목록이다. 할 말이 없는 것과 "아무 일도 없다"는 다르고, 전자는
87
+ * 아무것도 띄우지 않는 것이 맞다.
88
+ */
89
+ export function frontOpeningLines(opening) {
90
+ switch (opening.kind) {
91
+ case 'NOT_ASC':
92
+ return [];
93
+ case 'UNAVAILABLE':
94
+ // 조용히 빈 화면을 주지 않는다 (C-12 불변식 ⑰)
95
+ return [`ASC is attached here but its state could not be read — ${opening.detail}`];
96
+ case 'BOUND':
97
+ return renderFront(opening.state);
98
+ }
99
+ }
66
100
  /**
67
101
  * 사람이 읽는 복원 화면.
68
102
  *
@@ -0,0 +1,39 @@
1
+ /** 운영 상태 — 별도 state machine 이 아니라 읽기 모델이다. */
2
+ export type WorkspaceHealth = 'ACTIVE' | 'DEGRADED' | 'DORMANT';
3
+ export type WorkspaceView = {
4
+ workspaceId: string;
5
+ /** 이 workspace 의 runtime 뿌리. */
6
+ root: string;
7
+ /** 지금 살아 있는 checkout 들. 비어 있으면 DORMANT 다. */
8
+ liveLocators: string[];
9
+ /** 등록돼 있지만 지금 없는 checkout 들. 지우지 않는다 — 돌아올 수 있다. */
10
+ missingLocators: string[];
11
+ health: WorkspaceHealth;
12
+ aliases: readonly string[];
13
+ };
14
+ export type WorkspaceInput = {
15
+ workspaceId: string;
16
+ root: string;
17
+ aliases: readonly string[];
18
+ locators: readonly string[];
19
+ };
20
+ /**
21
+ * 지금 이 기계의 상태를 계산한다. **판정도 삭제도 하지 않는다.**
22
+ *
23
+ * `rootExists` 가 거짓이면 runtime 자체가 사라진 것이다 — locator 가 살아 있어도
24
+ * 관측할 수 없으므로 DEGRADED 로 든다. 사라진 것을 조용히 지우지 않는 이유는 위와 같다.
25
+ */
26
+ export declare function viewWorkspaces(workspaces: readonly WorkspaceInput[], exists: (path: string) => boolean): WorkspaceView[];
27
+ /**
28
+ * 이번 회차에 실제로 돌 곳.
29
+ *
30
+ * DORMANT 는 **밖을 치지 않는다** (설계 §19). 살아 있는 checkout 이 없으면 그 저장소를
31
+ * 대신해 무엇을 물어볼 자리도 없고, 없는 자리를 대신해 외부에 질문하면 그것은 관측이
32
+ * 아니라 잡음이다.
33
+ */
34
+ export declare function dueWorkspaces(views: readonly WorkspaceView[]): {
35
+ workspaceId: string;
36
+ cwd: string;
37
+ }[];
38
+ /** 사람이 읽는 기계 전체 화면. `cd` 없이 지금 무엇이 도는지 보여야 한다 (설계 §13.2). */
39
+ export declare function renderWorkspaces(views: readonly WorkspaceView[]): string[];
@@ -0,0 +1,64 @@
1
+ // 이 기계가 돌보는 workspace 들 (설계 §6·§7).
2
+ //
3
+ // **두 번째 등록부를 만들지 않는다.** `monitorEnabled=true` 같은 플래그를 새로 두면 그것이
4
+ // 곧 두 번째 정본이 되고, 붙어 있는데 감시되지 않는(또는 그 반대의) 상태가 생긴다.
5
+ // 운영 상태는 **이미 있는 사실에서 계산한다**:
6
+ //
7
+ // workspace 가 index 에 있다 → 이 기계가 아는 workspace 다
8
+ // 살아 있는 locator 가 하나라도 있다 → 관측할 수 있다
9
+ // 하나도 없다 → DORMANT — 상태는 남기고 밖을 치지 않는다
10
+ //
11
+ // DORMANT 는 삭제 대상이 아니다 (설계 §3.9). checkout 을 잠시 지웠다고 몇 달치 기록을
12
+ // 버리면, 그 삭제를 되돌릴 방법이 없다. 새 locator 가 나타나면 다시 ACTIVE 가 된다.
13
+ /**
14
+ * 지금 이 기계의 상태를 계산한다. **판정도 삭제도 하지 않는다.**
15
+ *
16
+ * `rootExists` 가 거짓이면 runtime 자체가 사라진 것이다 — locator 가 살아 있어도
17
+ * 관측할 수 없으므로 DEGRADED 로 든다. 사라진 것을 조용히 지우지 않는 이유는 위와 같다.
18
+ */
19
+ export function viewWorkspaces(workspaces, exists) {
20
+ return workspaces.map((workspace) => {
21
+ const liveLocators = workspace.locators.filter((locator) => exists(locator));
22
+ const missingLocators = workspace.locators.filter((locator) => !exists(locator));
23
+ const rootExists = exists(workspace.root);
24
+ const health = liveLocators.length === 0 ? 'DORMANT' : rootExists ? 'ACTIVE' : 'DEGRADED';
25
+ return {
26
+ workspaceId: workspace.workspaceId,
27
+ root: workspace.root,
28
+ liveLocators,
29
+ missingLocators,
30
+ health,
31
+ aliases: workspace.aliases,
32
+ };
33
+ });
34
+ }
35
+ /**
36
+ * 이번 회차에 실제로 돌 곳.
37
+ *
38
+ * DORMANT 는 **밖을 치지 않는다** (설계 §19). 살아 있는 checkout 이 없으면 그 저장소를
39
+ * 대신해 무엇을 물어볼 자리도 없고, 없는 자리를 대신해 외부에 질문하면 그것은 관측이
40
+ * 아니라 잡음이다.
41
+ */
42
+ export function dueWorkspaces(views) {
43
+ return views
44
+ .filter((view) => view.health === 'ACTIVE')
45
+ // 여러 checkout 이 있으면 아무 곳에서나 한 번이면 된다 — 관측 대상은 workspace 이지
46
+ // checkout 이 아니다. 첫 번째를 쓰는 것은 안정적인 선택이다(목록 순서가 index 순서다).
47
+ .map((view) => ({ workspaceId: view.workspaceId, cwd: view.liveLocators[0] }));
48
+ }
49
+ /** 사람이 읽는 기계 전체 화면. `cd` 없이 지금 무엇이 도는지 보여야 한다 (설계 §13.2). */
50
+ export function renderWorkspaces(views) {
51
+ if (views.length === 0)
52
+ return ['No workspaces are registered on this machine.'];
53
+ const lines = [];
54
+ for (const view of views) {
55
+ const alias = view.aliases[0] ?? '(no alias)';
56
+ lines.push(`${view.workspaceId} ${view.health} ${alias}`);
57
+ for (const locator of view.liveLocators)
58
+ lines.push(` ${locator}`);
59
+ // 없어진 checkout 도 든다 — 지우지 않았다는 사실이 보여야 사람이 놀라지 않는다
60
+ for (const locator of view.missingLocators)
61
+ lines.push(` ${locator} (gone)`);
62
+ }
63
+ return lines;
64
+ }
@@ -11,6 +11,21 @@ export type Resolution = {
11
11
  workspaceId: string;
12
12
  locator: string;
13
13
  }
14
+ /**
15
+ * 등록된 적은 없지만 **같은 Git repository의 다른 checkout이 등록돼 있다.**
16
+ * 같은 논리 workspace의 다른 execution instance다 (C-11 §1.3) — 쪼개지도 합치지도 않는다.
17
+ */
18
+ | {
19
+ kind: 'LINKED_WORKTREE';
20
+ root: string;
21
+ workspaceId: string;
22
+ /** 지금 이 checkout의 최상위. 호출자가 이 값을 index에 등록해 다음 번을 빠르게 만든다. */
23
+ locator: string;
24
+ /** 근거가 된, 이미 등록돼 있던 형제 checkout. */
25
+ via: string;
26
+ /** main checkout인가 linked worktree인가. */
27
+ kindOfLocator: 'checkout' | 'worktree';
28
+ }
14
29
  /** 저장소 안의 `.asc/` — 팀이 채택했거나 아직 이전하지 않은 개인 legacy다. */
15
30
  | {
16
31
  kind: 'PROJECT_LOCAL';
@@ -28,7 +43,28 @@ export type ResolveInput = {
28
43
  /** 여기를 넘어서는 위로 올라가지 않는다. 보통 사용자 홈. */
29
44
  stopAt?: string;
30
45
  exists?: (path: string) => Promise<boolean>;
46
+ /**
47
+ * 이 경로가 속한 Git repository의 checkout 전부(main + linked). 없으면 이 갈래를 건너뛴다.
48
+ *
49
+ * **주입이 기본값이다** — guard hook처럼 매 호출마다 도는 소비자는 이것을 넘기지 않고,
50
+ * 그러면 예전과 똑같이 index만 본다.
51
+ */
52
+ worktrees?: WorktreeProbe;
31
53
  };
54
+ /**
55
+ * 같은 Git repository에 속한 checkout 경로들. 첫 번째가 main worktree다.
56
+ * git이 없거나 저장소가 아니면 `null` — "없다"와 "못 봤다"를 구분한다.
57
+ */
58
+ export type WorktreeProbe = (cwd: string) => Promise<readonly string[] | null>;
32
59
  export declare function resolveWorkspace(input: ResolveInput): Promise<Resolution>;
60
+ /**
61
+ * 기본 통로 — `git worktree list --porcelain`. 첫 `worktree` 항목이 main checkout이다.
62
+ *
63
+ * git이 없거나 저장소가 아니면 `null`을 돌려준다. 빈 배열이 아니다 — "checkout이 하나도
64
+ * 없다"와 "물어보지 못했다"는 다른 사실이고, 후자를 전자로 적으면 조용한 오판이 된다.
65
+ */
66
+ export declare const gitWorktrees: WorktreeProbe;
67
+ /** porcelain 출력에서 checkout 경로만. 순서를 보존한다 — 첫 줄이 main worktree다. */
68
+ export declare function parseWorktreeList(stdout: string): string[];
33
69
  /** 사람이 읽는 한 줄. 왜 그 뿌리인지가 함께 와야 사람이 틀린 결합을 알아챈다. */
34
70
  export declare function resolutionLine(resolution: Resolution): string;
@@ -9,8 +9,12 @@
9
9
  //
10
10
  // 1. explicit root 사람이 말한 것이 이긴다
11
11
  // 2. workspace index 이 기계에 등록된 locator (경로가 바뀌어도 따라온다)
12
- // 3. project-adopted 저장소 안의 `.asc/` 팀이 채택한 경우 (legacy 개인 사용 포함)
13
- // 4. UNRESOLVED 모르면 모른다고 한다
12
+ // 3. linked worktree 같은 Git repository의 다른 checkout이 등록돼 있다 (C-11 §1.3)
13
+ // 4. project-adopted 저장소 안의 `.asc/` — 팀이 채택한 경우 (legacy 개인 사용 포함)
14
+ // 5. UNRESOLVED 모르면 모른다고 한다
15
+ //
16
+ // **3번은 index가 빗나갔을 때만 돈다.** 등록된 locator로 풀리는 정상 경로는 예전 그대로
17
+ // 파일 접근 하나이며, Git을 부르지 않는다 — guard hook이 매 Bash 호출마다 지나는 길이다.
14
18
  //
15
19
  // **3번의 탐색에는 경계가 있다.** 예전에는 파일시스템 루트까지 올라갔는데, 사용자 홈에
16
20
  // `~/.asc` 가 생기는 순간 홈 아래 아무 저장소나 그 뿌리로 오인 매칭된다. 그래서 홈을
@@ -47,11 +51,112 @@ export async function resolveWorkspace(input) {
47
51
  };
48
52
  }
49
53
  }
54
+ // index가 빗나갔다. 여기서 처음으로 Git에게 **구조**를 묻는다 — remote가 같다는 이유로
55
+ // 이어붙이지 않는다 (C-11 불변식 ③). 같은 linked repository라는 것은 로컬에서 증명된다.
56
+ if (input.index && input.worktrees) {
57
+ const linked = await resolveLinkedWorktree(input.cwd, input.index, input.worktrees, exists);
58
+ if (linked)
59
+ return linked;
60
+ }
50
61
  const local = await findProjectLocal(input.cwd, input.stopAt, exists);
51
62
  if (local)
52
63
  return { kind: 'PROJECT_LOCAL', root: join(local, ASC_DIR), projectRoot: local };
53
64
  return { kind: 'UNRESOLVED', detail: '이 경로에 붙은 ASC workspace가 없다' };
54
65
  }
66
+ /**
67
+ * 이 checkout이 **이미 등록된 checkout과 같은 Git repository**인가.
68
+ *
69
+ * `git worktree list` 는 그 저장소의 checkout만 든다. 그래서 remote가 같은 **독립 clone**은
70
+ * 서로의 경로를 담지 않고, 여기서 절대 이어붙지 않는다 — 그것이 이 판정이 alias 대조가
71
+ * 아니라 worktree 목록을 쓰는 이유다 (C-11 불변식 ③: alias 일치는 recover candidate일 뿐).
72
+ *
73
+ * 후보 workspace가 둘 이상이면 **고르지 않는다.** 고르면 사람이 그 선택을 보지 못한다.
74
+ */
75
+ async function resolveLinkedWorktree(cwd, index, probe, exists) {
76
+ const paths = await probe(cwd);
77
+ if (!paths || paths.length === 0)
78
+ return null;
79
+ const normalized = paths.map(normalizeLocator);
80
+ const here = currentWorktree(normalized, normalizeLocator(cwd));
81
+ if (!here)
82
+ return null;
83
+ const hits = new Map();
84
+ for (const path of normalized) {
85
+ if (path === here)
86
+ continue;
87
+ const found = lookupLocator(index, path);
88
+ if (found)
89
+ hits.set(found.workspaceId, { root: found.root, via: found.locator });
90
+ }
91
+ if (hits.size === 0)
92
+ return null;
93
+ if (hits.size > 1) {
94
+ return {
95
+ kind: 'UNRESOLVED',
96
+ detail: `이 저장소의 다른 checkout들이 서로 다른 workspace에 등록돼 있다 (${[...hits.keys()].join(', ')}) — 고르지 않는다`,
97
+ };
98
+ }
99
+ const [[workspaceId, hit]] = [...hits.entries()];
100
+ // 등록은 돼 있는데 뿌리가 없으면 넘어가지 않는다 — REGISTERED와 같은 태도다.
101
+ if (!(await exists(hit.root))) {
102
+ return {
103
+ kind: 'UNRESOLVED',
104
+ detail: `${workspaceId} 가 등록돼 있으나 runtime(${hit.root})이 없다 — 옮겼거나 지워졌다`,
105
+ };
106
+ }
107
+ return {
108
+ kind: 'LINKED_WORKTREE',
109
+ root: hit.root,
110
+ workspaceId,
111
+ locator: here,
112
+ via: hit.via,
113
+ // 목록의 첫 항목이 main worktree다 (git worktree list 의 계약).
114
+ kindOfLocator: here === normalized[0] ? 'checkout' : 'worktree',
115
+ };
116
+ }
117
+ /**
118
+ * 목록 중 지금 서 있는 checkout. cwd는 그 아래 하위 디렉터리일 수 있으므로 **가장 긴
119
+ * 접두어**를 고른다. 경계는 구분자에서만 인정한다 — `/a/repo` 가 `/a/repo-2` 를 삼키면 안 된다.
120
+ */
121
+ function currentWorktree(paths, cwd) {
122
+ let best = null;
123
+ for (const path of paths) {
124
+ if (cwd !== path && !cwd.startsWith(`${path}/`))
125
+ continue;
126
+ if (best === null || path.length > best.length)
127
+ best = path;
128
+ }
129
+ return best;
130
+ }
131
+ /**
132
+ * 기본 통로 — `git worktree list --porcelain`. 첫 `worktree` 항목이 main checkout이다.
133
+ *
134
+ * git이 없거나 저장소가 아니면 `null`을 돌려준다. 빈 배열이 아니다 — "checkout이 하나도
135
+ * 없다"와 "물어보지 못했다"는 다른 사실이고, 후자를 전자로 적으면 조용한 오판이 된다.
136
+ */
137
+ export const gitWorktrees = async (cwd) => {
138
+ const { execFile } = await import('node:child_process');
139
+ const { promisify } = await import('node:util');
140
+ try {
141
+ const { stdout } = await promisify(execFile)('git', ['worktree', 'list', '--porcelain'], {
142
+ cwd,
143
+ // 목록은 짧다. 오래 걸리면 그것 자체가 "물어보지 못했다"이다 — 명령을 세우지 않는다.
144
+ timeout: 5_000,
145
+ });
146
+ return parseWorktreeList(stdout);
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ };
152
+ /** porcelain 출력에서 checkout 경로만. 순서를 보존한다 — 첫 줄이 main worktree다. */
153
+ export function parseWorktreeList(stdout) {
154
+ return stdout
155
+ .split(/\r?\n/)
156
+ .filter((line) => line.startsWith('worktree '))
157
+ .map((line) => line.slice('worktree '.length).trim())
158
+ .filter(Boolean);
159
+ }
55
160
  /**
56
161
  * 저장소 안의 `.asc/` 를 위로 올라가며 찾되 **경계를 지킨다**.
57
162
  *
@@ -69,7 +174,7 @@ async function findProjectLocal(start, stopAt, exists) {
69
174
  const candidate = join(dir, ASC_DIR);
70
175
  // 정지선이 홈이어도 **다른 홈**의 `.asc` 는 걸러지지 않는다. Windows는 temp
71
176
  // 디렉터리가 사용자 프로필 아래라, temp의 프로젝트에서 위로 걷다 실사용자
72
- // `~/.asc` 를 프로젝트 상태로 오인했다 (SSAFESTA 실측 — setup status가
177
+ // `~/.asc` 를 프로젝트 상태로 오인했다 ( 프로젝트 실측 — setup status가
73
178
  // UNATTACHED 대신 BROKEN을 답한 원인). user runtime은 내용으로 알아본다:
74
179
  // workspaces/·profiles/·runtime.json 은 홈에만 생긴다.
75
180
  if ((await exists(candidate)) && !(await looksLikeUserRuntime(candidate, exists)))
@@ -95,6 +200,8 @@ export function resolutionLine(resolution) {
95
200
  return `runtime: ${resolution.root} (given with --root)`;
96
201
  case 'REGISTERED':
97
202
  return `runtime: ${resolution.root} (workspace ${resolution.workspaceId} · ${resolution.locator})`;
203
+ case 'LINKED_WORKTREE':
204
+ return `runtime: ${resolution.root} (workspace ${resolution.workspaceId} · same git repository as ${resolution.via})`;
98
205
  case 'PROJECT_LOCAL':
99
206
  return `runtime: ${resolution.root} (.asc inside the repository — team-adopted, or personal state not yet migrated)`;
100
207
  case 'UNRESOLVED':
@@ -5,6 +5,18 @@ export type DiscoveryContext = {
5
5
  projectRoot: string;
6
6
  /** 환경변수. 자격 **존재 여부** 판단에만 쓰고 값을 실어 나르지 않는다. */
7
7
  env?: NodeJS.ProcessEnv;
8
+ /**
9
+ * Profile 이 이 adapter 에 대해 선언한 자원들 (C-09 §3.1).
10
+ *
11
+ * **추측의 반대다.** adapter 가 지역 흔적에서 아무것도 못 찾아도, 사람이 Profile 에
12
+ * 적어 둔 것은 이미 내려진 결정이다 — 그것을 못 본 척하면 "선언은 있는데 아무 일도
13
+ * 일어나지 않는" 상태가 된다. adapter 는 이 값을 후보로 올릴 수 있고, 실제로 되는지는
14
+ * 여전히 probe 가 정한다.
15
+ */
16
+ declared?: readonly {
17
+ adapterId: string;
18
+ resource: string;
19
+ }[];
8
20
  };
9
21
  export type ProbeResult = {
10
22
  state: ProbeState;