@asc-agent/runtime 0.5.0 → 0.5.1

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 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.5.0
10
+ npm install -g @asc-agent/runtime@0.5.1
11
11
  ```
12
12
 
13
13
  npm owns the executable link (on Windows, npm's own `asc.cmd`). This package never edits
@@ -231,7 +231,11 @@ try {
231
231
  process.exit(0) // 입력을 못 읽으면 판단하지 않는다 — guard 오작동으로 전부 막는 것이 더 나쁘다
232
232
  }
233
233
 
234
- if (input.tool_name !== 'Bash') process.exit(0)
234
+ // 파일을 바꾸는 도구는 **일을 시작한다는 신호**다 (F6). 읽기는 여기 없다 — 상태를 보는
235
+ // 세션까지 관리 대상으로 끌어들이면 그것은 자동화가 아니라 방해다.
236
+ const MUTATORS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
237
+ const isMutation = MUTATORS.has(String(input.tool_name ?? ''))
238
+ if (input.tool_name !== 'Bash' && !isMutation) process.exit(0)
235
239
  const command = String(input.tool_input?.command ?? '')
236
240
 
237
241
  const cwd = input.cwd ?? process.cwd()
@@ -259,6 +263,24 @@ const ascRoot = registered ? registered.root : findAscRoot(cwd)
259
263
  if (!ascRoot) process.exit(0)
260
264
 
261
265
  const managed = findManaged(ascRoot, observedSessionId)
266
+
267
+ // **일이 시작되는데 논리 세션이 없다** (F6). 사람이 "ASC 적용해" 라고 말해야 했던 자리다.
268
+ // 여기서 막고 다음 한 걸음을 그대로 준다 — 그 명령을 실행하는 것은 agent 이고, 사람이
269
+ // 아니다. 세션에 들어간 뒤에는 이 문이 다시 열린다.
270
+ if (isMutation && !managed) {
271
+ const id = observedSessionId || '<this session id>'
272
+ console.error(
273
+ [
274
+ '[ASC] 이 workspace 는 ASC 가 관리한다. 파일을 바꾸기 전에 논리 세션 안에 들어가라.',
275
+ ' asc proceed --work <WORK-KEY> --json # 작업 항목이 있으면',
276
+ ' asc proceed --json # 이어갈 세션을 고르거나 계약을 제안받는다',
277
+ ' asc host claude bind <S-ID> --physical ' + id,
278
+ '읽기·조회는 막지 않는다 — 막는 것은 관리 밖의 변경뿐이다.',
279
+ ].join('\\n'),
280
+ )
281
+ process.exit(2)
282
+ }
283
+
262
284
  if (!managed) process.exit(0)
263
285
 
264
286
  // 관찰은 여기서 끝난다 — 아래 차단 판정은 이 호출의 성패를 보지 않는다
@@ -266,6 +288,9 @@ try {
266
288
  recordActivity(ascRoot, managed, observedSessionId, String(input.tool_name ?? ''))
267
289
  } catch {}
268
290
 
291
+ // 변경 도구는 여기까지다 — 아래 목록은 Bash 명령에 대한 것이다.
292
+ if (isMutation) process.exit(0)
293
+
269
294
  for (const { pattern, label } of FORBIDDEN) {
270
295
  if (pattern.test(command)) {
271
296
  console.error(
@@ -10,6 +10,32 @@ export type InstallPaths = {
10
10
  entry?: string;
11
11
  };
12
12
  export declare const defaultPaths: () => InstallPaths;
13
+ /**
14
+ * ASC가 등록하는 hook들. 이벤트마다 **하나**씩이며 중복 등록은 그 자체가 결함이다.
15
+ *
16
+ * `_asc` 표식이 소유권의 근거다 — 이것이 붙은 항목만 우리가 고치고 지운다.
17
+ */
18
+ type HookSpec = {
19
+ event: string;
20
+ marker: string;
21
+ /** 이 이벤트가 matcher를 쓰는가. SessionStart는 도구 이름으로 거르지 않는다. */
22
+ matcher?: string;
23
+ script: string;
24
+ };
25
+ export declare function locate(paths: InstallPaths): {
26
+ /** Bundle 전체. 파일이 늘어도 아래 계약(manifest·digest·멱등)은 그대로다 (C-05 §5). */
27
+ skills: {
28
+ name: string;
29
+ path: string;
30
+ text: string;
31
+ }[];
32
+ /** hook은 **하나**로 둔다. guard는 안전 층이고 중복 등록은 그 자체가 위험이다. */
33
+ hook: string;
34
+ front: string;
35
+ settings: string;
36
+ manifest: string;
37
+ hooks: HookSpec[];
38
+ };
13
39
  export type InstallOutcome = {
14
40
  written: string[];
15
41
  skipped: {
@@ -72,3 +98,4 @@ export type UninstallOutcome = {
72
98
  };
73
99
  /** manifest로 증명되는 것만 제거한다. 사용자가 고친 파일은 남기고 이유를 말한다. */
74
100
  export declare function uninstall(paths: InstallPaths): Promise<UninstallOutcome>;
101
+ export {};
@@ -20,7 +20,7 @@ const sha = (text) => createHash('sha256').update(text).digest('hex').slice(0, 1
20
20
  export const defaultPaths = () => ({ claudeHome: join(homedir(), '.claude') });
21
21
  const HOOK_MARKER = 'asc-external-write-guard';
22
22
  const FRONT_MARKER = 'asc-front-binding';
23
- function locate(paths) {
23
+ export function locate(paths) {
24
24
  const guard = join(paths.claudeHome, 'asc', 'guard-hook.mjs');
25
25
  const front = join(paths.claudeHome, 'asc', 'front-hook.mjs');
26
26
  return {
@@ -38,6 +38,8 @@ function locate(paths) {
38
38
  hooks: (() => {
39
39
  const specs = [
40
40
  { event: 'PreToolUse', marker: HOOK_MARKER, matcher: 'Bash', script: guard },
41
+ // 파일을 바꾸는 도구도 같은 문을 지난다 — 일이 시작되는 신호이기 때문이다 (F6).
42
+ { event: 'PreToolUse', marker: HOOK_MARKER, matcher: 'Edit|Write|MultiEdit|NotebookEdit', script: guard },
41
43
  ];
42
44
  // 부를 곳을 모르면 심지 않는다 (§InstallPaths.entry)
43
45
  if (paths.entry)
package/dist/cli/asc.js CHANGED
@@ -14,7 +14,7 @@ import { fileURLToPath } from 'node:url';
14
14
  import { homedir, hostname, userInfo } from 'node:os';
15
15
  import { basename, dirname, join, resolve } from 'node:path';
16
16
  import { MINIMUM_NODE_MAJOR, checkNodeRuntime, reexecWithCandidate, } from "../core/distribution/node-runtime.js";
17
- import { RELEASE_VERSION } from "../core/distribution/release.js";
17
+ import { RELEASE_VERSION, RUNTIME_PACKAGE } from "../core/distribution/release.js";
18
18
  import { GitHubClient, discoverToken } from "../adapters/github/client.js";
19
19
  import { GitHubChangeContext, GitHubInventory, GitHubResourceContext } from "../adapters/github/context.js";
20
20
  import { GitHubEventSource } from "../adapters/github/event-source.js";
@@ -47,6 +47,7 @@ import { evaluateHealth, healthAlertLines } from "../core/monitor/health-alerts.
47
47
  import { Operator } from "../core/operator/proceed.js";
48
48
  import { deriveSessionContractDraft } from "../core/operator/derive-draft.js";
49
49
  import { servicePath } from "../core/distribution/external-command.js";
50
+ import { isTransientPath, resolveServiceRuntime, serviceRuntimeLine, } from "../core/distribution/service-runtime.js";
50
51
  import { LocalCanonicalReader } from "../adapters/local/canonical.js";
51
52
  import { LocalRepoAdapter } from "../adapters/local/repo.js";
52
53
  import { GitHubAdapter } from "../adapters/github/adapter.js";
@@ -56,7 +57,7 @@ import { healJam, healLine } from "../adapters/jam/setup.js";
56
57
  import { statusIndicatesDone } from "../adapters/jam/ports.js";
57
58
  import { ProgressService } from "../core/operator/progress.js";
58
59
  import { composeBindings, defaultAdapters } from "../composition/registry.js";
59
- import { buildObservationChannels, buildRuntimePorts, closeToolClients, rolesFor } from "../composition/runtime.js";
60
+ import { buildObservationChannels, buildRuntimePorts, closeToolClients, rolesFor, workItemRoles, } from "../composition/runtime.js";
60
61
  import { proposeBindings } from "../composition/propose.js";
61
62
  import { buildEventObservation } from "../composition/observe.js";
62
63
  import { availableProfiles, planBootstrap, renderPlan } from "../core/attach/bootstrap.js";
@@ -1331,6 +1332,207 @@ async function runSetup(command, values, entry = 'runtime') {
1331
1332
  * Core의 planner는 파일도 network도 모른다 — 사실은 여기서 관측해 넘긴다. 그래야
1332
1333
  * "이 명령이 무엇을 바꿀 것인가"를 아무것도 바꾸지 않고 물어볼 수 있다.
1333
1334
  */
1335
+ /**
1336
+ * 이 저장소가 스스로 증명하는 Profile 이름 (P0 F2).
1337
+ *
1338
+ * **파일을 만들지 않는다** — 이름과 "이미 있는가"만 관측한다. 다른 논리 workspace 를
1339
+ * 찾아 합치는 것이 아니라, 이 checkout 의 remote 하나에서 읽히는 신원이다 (C-11 유지).
1340
+ */
1341
+ async function adoptableState(projectRoot, git) {
1342
+ if (!git)
1343
+ return {};
1344
+ const remotes = await gitRemotes(projectRoot);
1345
+ if (remotes.length === 0)
1346
+ return {};
1347
+ try {
1348
+ const adopted = buildAdoptedProfile({
1349
+ dirName: basename(projectRoot),
1350
+ remotes,
1351
+ scmForHost: (host) => (host === 'github.com' ? 'github' : 'git'),
1352
+ });
1353
+ // **공유 주소가 없으면 후보가 아니다.** remote 가 프로젝트 신원을 증명하지 못하면
1354
+ // 그것은 이 기계 안의 폴더일 뿐이고, 무엇으로 붙을지는 사람이 정한다.
1355
+ const project = adopted.profile.project;
1356
+ if (!project?.repository || project.repository.startsWith('local/'))
1357
+ return {};
1358
+ const exists = existsSync(join(externalProfileRoot(), adopted.id, 'profile.json'));
1359
+ return { adoptable: { id: adopted.id, exists } };
1360
+ }
1361
+ catch {
1362
+ // 이름을 만들 수 없는 저장소가 있다. 그때는 이 축이 없는 것이고, 사람이 고른다.
1363
+ return {};
1364
+ }
1365
+ }
1366
+ /**
1367
+ * 승인 권한자가 서 있는가, 그리고 지금 이 사람을 무엇으로 부를 수 있는가 (P0 F2).
1368
+ *
1369
+ * **인증된 provider 가 말한 이름을 쓴다.** ASC 가 지어내지 않고, 자격 값은 읽지 않는다.
1370
+ */
1371
+ async function identityState(ascRoot, workspaceComing) {
1372
+ const wired = ascRoot ? Object.keys(await loadIdentityMap(ascRoot)).length > 0 : false;
1373
+ if (wired)
1374
+ return { identity: { wired: true } };
1375
+ // **세울 workspace 가 없으면 누구인지 묻지 않는다.** 물으려면 provider CLI 를 불러야
1376
+ // 하고, 그 도구는 자기 설정 파일을 만든다 — 멈출 계획이 남기는 자국이 되면 안 된다.
1377
+ if (!workspaceComing)
1378
+ return { identity: { wired: false } };
1379
+ const actor = await detectActor();
1380
+ return { identity: { wired: false, ...(actor ? { actor } : {}) } };
1381
+ }
1382
+ /**
1383
+ * 지금 이 사람을 `<channel>:<actor>` 로. 인증된 통로가 먼저다 — 그것이 실제로 밖에서
1384
+ * 나를 부르는 이름이고, git 의 표시 이름은 아무 계정과도 이어지지 않는다.
1385
+ */
1386
+ async function detectActor() {
1387
+ const gitlab = await execText('glab', ['api', 'user']);
1388
+ const gitlabName = readJsonField(gitlab, 'username');
1389
+ if (gitlabName)
1390
+ return `gitlab:${gitlabName}`;
1391
+ const github = await execText('gh', ['api', 'user']);
1392
+ const githubName = readJsonField(github, 'login');
1393
+ if (githubName)
1394
+ return `github:${githubName}`;
1395
+ const local = await detectSelf();
1396
+ return local ? `local:${local}` : null;
1397
+ }
1398
+ function readJsonField(text, field) {
1399
+ if (!text)
1400
+ return null;
1401
+ try {
1402
+ const value = JSON.parse(text)[field];
1403
+ return typeof value === 'string' && value.length > 0 ? value : null;
1404
+ }
1405
+ catch {
1406
+ return null;
1407
+ }
1408
+ }
1409
+ /**
1410
+ * 발견이 증명하는 결합 (P0 F2).
1411
+ *
1412
+ * **역할은 capability 로 정한다** — provider 이름으로 정하면 이 자리가 provider 목록이
1413
+ * 된다. 변경을 아는 통로가 code, 작업 항목을 세는 통로가 work 다. 같은 역할에 후보가
1414
+ * 둘이면 origin remote 가 가리키는 쪽을 쓰고, 그것으로도 갈리면 아무것도 제안하지 않는다.
1415
+ */
1416
+ async function bindingProposalState(projectRoot, profileId, willCreate) {
1417
+ if (!profileId)
1418
+ return {};
1419
+ // 이미 선언이 있으면 손대지 않는다 — 사람이 적은 것이 먼저다.
1420
+ // 파일이 아직 없어도 **이번에 만들 것이면** 비어 있는 것과 같다: adopt 는 결합을
1421
+ // 적지 않으므로, 그 사실을 여기서 미리 알아야 한 번의 apply 로 끝난다.
1422
+ const declared = await readProfileBindings(profileId);
1423
+ if (declared === null && !willCreate)
1424
+ return {};
1425
+ if (declared !== null && declared.length > 0)
1426
+ return {};
1427
+ const adapters = monitorAdapters();
1428
+ const plan = await composeBindings({ context: { projectRoot, env: process.env }, adapters, roles: [] });
1429
+ const usable = plan.bindings.filter((binding) => binding.state === 'AVAILABLE' || binding.state === 'DEGRADED');
1430
+ const origin = (await gitRemotes(projectRoot)).find((remote) => remote.name === 'origin');
1431
+ const originAlias = origin ? normalizeRemote(origin.url) : null;
1432
+ const byRole = new Map();
1433
+ for (const binding of usable) {
1434
+ const role = binding.provides.includes('context.change')
1435
+ ? 'code-primary'
1436
+ : binding.provides.includes('inventory.enumerate')
1437
+ ? 'work'
1438
+ : null;
1439
+ if (!role)
1440
+ continue;
1441
+ byRole.set(role, [...(byRole.get(role) ?? []), { role, adapter: binding.adapterId, resource: binding.resource }]);
1442
+ }
1443
+ const proposal = [];
1444
+ for (const [role, candidates] of byRole) {
1445
+ if (candidates.length === 1) {
1446
+ proposal.push(candidates[0]);
1447
+ continue;
1448
+ }
1449
+ // origin 이 가리키는 것 하나면 그것이다 — 이 저장소의 주소가 그 판정의 근거다.
1450
+ const matching = originAlias
1451
+ ? candidates.filter((candidate) => originAlias.endsWith(`/${candidate.resource}`))
1452
+ : [];
1453
+ if (matching.length === 1)
1454
+ proposal.push(matching[0]);
1455
+ else
1456
+ return {};
1457
+ }
1458
+ return proposal.length > 0 ? { bindingProposal: proposal } : {};
1459
+ }
1460
+ /**
1461
+ * 발견이 증명한 결합을 Profile 파일에 적는다.
1462
+ *
1463
+ * **이미 있는 선언은 건드리지 않는다** — 사람이 적은 것이 먼저다. 그 밖의 필드도 그대로
1464
+ * 둔다: 이 함수가 아는 것은 `bindings` 한 칸뿐이다.
1465
+ */
1466
+ async function writeProfileBindings(profileId, bindings) {
1467
+ const path = join(externalProfileRoot(), profileId, 'profile.json');
1468
+ const profile = JSON.parse(await readFile(path, 'utf8'));
1469
+ if (Array.isArray(profile.bindings) && profile.bindings.length > 0)
1470
+ return false;
1471
+ profile.bindings = bindings.map((binding) => ({ ...binding }));
1472
+ await writeFile(path, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
1473
+ console.log(`bindings declared in ${profileId}: ${bindings.map((b) => `${b.role}=${b.adapter}`).join(', ')}`);
1474
+ return true;
1475
+ }
1476
+ /**
1477
+ * 이 checkout 이 증명하는 정본 갈래 (P0 F5).
1478
+ *
1479
+ * **remote 에게 물어본다.** 로컬 `origin/HEAD` 는 clone 시점에 고정돼 낡는다 — 이 저장소에서
1480
+ * 그 값은 `main` 인데 remote 의 기본 branch 는 `develop` 이었다. 낡은 값을 정본으로 적으면
1481
+ * 세션이 엉뚱한 baseline 을 딛는다. 물어보지 못하면 적지 않는다.
1482
+ */
1483
+ async function canonicalProposalState(projectRoot, git, profileId, willCreate) {
1484
+ if (!git || !profileId)
1485
+ return {};
1486
+ const declared = await readProfileCanonical(profileId);
1487
+ if (declared === null && !willCreate)
1488
+ return {};
1489
+ if (declared !== null && declared > 0)
1490
+ return {};
1491
+ const symref = await execText('git', ['-C', projectRoot, 'ls-remote', '--symref', 'origin', 'HEAD']);
1492
+ const match = symref ? /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(symref) : null;
1493
+ const branch = match?.[1];
1494
+ if (!branch)
1495
+ return {};
1496
+ // provider 는 `git` 이다 — 이 값은 checkout 이 이미 들고 있는 사실이고, 그것을 읽는 통로가
1497
+ // 선언된 provider 를 따른다 (canonical baseline 읽기).
1498
+ return { canonicalProposal: { id: branch, provider: 'git', remote: 'origin', ref: branch } };
1499
+ }
1500
+ /** Profile 이 선언한 정본 갈래 수. 파일이 없으면 `null`. */
1501
+ async function readProfileCanonical(profileId) {
1502
+ const path = join(externalProfileRoot(), profileId, 'profile.json');
1503
+ try {
1504
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
1505
+ return Array.isArray(parsed.canonical?.sources) ? parsed.canonical.sources.length : 0;
1506
+ }
1507
+ catch {
1508
+ return null;
1509
+ }
1510
+ }
1511
+ /**
1512
+ * remote 가 말한 정본 갈래를 Profile 에 적는다. 이미 선언이 있으면 손대지 않는다.
1513
+ */
1514
+ async function writeProfileCanonical(profileId, source) {
1515
+ const path = join(externalProfileRoot(), profileId, 'profile.json');
1516
+ const profile = JSON.parse(await readFile(path, 'utf8'));
1517
+ const canonical = (profile.canonical ?? {});
1518
+ if (Array.isArray(canonical.sources) && canonical.sources.length > 0)
1519
+ return false;
1520
+ profile.canonical = { ...canonical, sources: [source] };
1521
+ await writeFile(path, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
1522
+ console.log(`canonical source declared in ${profileId}: ${source.remote}/${source.ref}`);
1523
+ return true;
1524
+ }
1525
+ /** Profile 이 이미 선언한 결합. 파일이 없으면 `null` — 없는 것과 비어 있는 것은 다르다. */
1526
+ async function readProfileBindings(profileId) {
1527
+ const path = join(externalProfileRoot(), profileId, 'profile.json');
1528
+ try {
1529
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
1530
+ return Array.isArray(parsed.bindings) ? parsed.bindings : [];
1531
+ }
1532
+ catch {
1533
+ return null;
1534
+ }
1535
+ }
1334
1536
  async function detectSetupState(values, entry) {
1335
1537
  const { root: projectRoot, git } = await discoverProjectRoot(process.cwd());
1336
1538
  const resolution = await resolveRoot(process.cwd(), values.root);
@@ -1340,6 +1542,10 @@ async function detectSetupState(values, entry) {
1340
1542
  const attachmentBroken = ascRoot ? (await inspectSetup(ascRoot)).attachment === 'BROKEN' : false;
1341
1543
  const scope = values.scope === 'project' ? 'project' : 'local';
1342
1544
  const hostReport = await verifyInstall(hostPaths());
1545
+ const adoptable = await adoptableState(projectRoot, git);
1546
+ const attachedProfile = ascRoot ? await lockedProfileId(ascRoot) : undefined;
1547
+ // 이번 계획이 쓸 Profile 하나 — 그것의 결합 선언이 비어 있을 때만 제안을 관측한다.
1548
+ const targetProfile = values.profile ?? attachedProfile ?? adoptable.adoptable?.id;
1343
1549
  return {
1344
1550
  entry,
1345
1551
  projectRoot,
@@ -1349,6 +1555,12 @@ async function detectSetupState(values, entry) {
1349
1555
  ...(values.profile ? { requestedProfile: values.profile } : {}),
1350
1556
  profileCandidates: await availableProfiles(installRoot(), externalProfileRoot()),
1351
1557
  scope,
1558
+ ...(attachedProfile ? { attachedProfile } : {}),
1559
+ // fresh onboarding 이 사람에게 되묻지 않으려면 이 셋이 관측돼 있어야 한다 (P0 F2).
1560
+ ...adoptable,
1561
+ ...(await identityState(ascRoot, Boolean(ascRoot) || Boolean(targetProfile))),
1562
+ ...(await bindingProposalState(projectRoot, targetProfile, Boolean(adoptable.adoptable && !adoptable.adoptable.exists))),
1563
+ ...(await canonicalProposalState(projectRoot, git, targetProfile, Boolean(adoptable.adoptable && !adoptable.adoptable.exists))),
1352
1564
  host: [{ id: 'claude', status: hostReport.status }],
1353
1565
  // Profile 이 작업 도구를 선언했으면 그 준비 상태까지 본다 (설계 §9.3).
1354
1566
  ...(await workBindingState(ascRoot, projectRoot)),
@@ -1367,7 +1579,10 @@ async function detectSetupState(values, entry) {
1367
1579
  * 스스로 할 때만 의미가 있다.
1368
1580
  */
1369
1581
  function runningFromInstalledPackage() {
1370
- return fileURLToPath(import.meta.url).replace(/\\/g, '/').includes('/node_modules/@asc-agent/runtime/');
1582
+ const here = fileURLToPath(import.meta.url).replace(/\\/g, '/');
1583
+ // **npx 캐시도 node_modules 다.** 경로 모양만 보면 임시 자리가 설치본으로 통과하고,
1584
+ // 실제로 그렇게 통과해 등록물이 npx 캐시를 가리켰다 — 캐시를 지우면 깨진다.
1585
+ return here.includes('/node_modules/@asc-agent/runtime/') && !isTransientPath(here);
1371
1586
  }
1372
1587
  /**
1373
1588
  * 지금 이 실행에서 기계 등록을 다뤄도 되는가.
@@ -1394,7 +1609,13 @@ async function persistentRuntimeState(values) {
1394
1609
  const adapter = serviceAdapter();
1395
1610
  if (!adapter)
1396
1611
  return {};
1397
- const plan = await planPersistentRuntime(adapter, serviceCommand(serviceInterval(values))).catch(() => null);
1612
+ // 등록물이 가리킬 자리가 없으면 축을 그리지 않는다 — 깨진 등록을 남기느니 등록하지
1613
+ // 않고 그 사실을 말한다 (P0 F1).
1614
+ const runtime = await serviceRuntime();
1615
+ if (runtime.kind !== 'STABLE') {
1616
+ return { persistentRuntime: { action: 'unsupported', adapter: adapter.id, detail: runtime.detail } };
1617
+ }
1618
+ const plan = await planPersistentRuntime(adapter, serviceCommand(serviceInterval(values), runtime)).catch(() => null);
1398
1619
  if (!plan)
1399
1620
  return {};
1400
1621
  return {
@@ -1635,6 +1856,14 @@ async function runSetupLifecycle(command, values, entry) {
1635
1856
  const speak = console.log;
1636
1857
  if (asJson)
1637
1858
  console.log = console.error;
1859
+ const relock = async (profile) => {
1860
+ const root = await discoverRoot(process.cwd(), values.root);
1861
+ if (!root)
1862
+ return;
1863
+ const code = await runProfile('resolve', { ...values, profile, write: true }, root);
1864
+ if (code !== 0)
1865
+ throw new Error(`profile re-lock 실패 (exit ${code})`);
1866
+ };
1638
1867
  let outcome;
1639
1868
  try {
1640
1869
  outcome = await applySetupPlan(plan, {
@@ -1657,8 +1886,13 @@ async function runSetupLifecycle(command, values, entry) {
1657
1886
  const adapter = serviceAdapter();
1658
1887
  if (!adapter)
1659
1888
  return;
1889
+ const runtime = await serviceRuntime();
1890
+ if (runtime.kind !== 'STABLE') {
1891
+ console.error(`Persistent runtime not registered — ${runtime.detail}`);
1892
+ return;
1893
+ }
1660
1894
  try {
1661
- await adapter.install(serviceCommand(serviceInterval(values)));
1895
+ await adapter.install(serviceCommand(serviceInterval(values), runtime));
1662
1896
  console.log(`Persistent runtime registered with ${adapter.id}.`);
1663
1897
  }
1664
1898
  catch (error) {
@@ -1680,8 +1914,41 @@ async function runSetupLifecycle(command, values, entry) {
1680
1914
  if (code !== 0)
1681
1915
  throw new Error(`${change.host} 설치 실패 (exit ${code})`);
1682
1916
  },
1917
+ // 이 저장소를 설명하는 Profile 을 만든다. 이름은 plan 이 이미 정했다.
1918
+ adoptProfile: async (change) => {
1919
+ const code = await runProfileAdopt({ ...values, id: change.profile, json: true }, entry);
1920
+ if (code !== 0)
1921
+ throw new Error(`profile adopt 실패 (exit ${code})`);
1922
+ },
1923
+ // 승인 권한자를 세운다. 이름과 채널뿐이고 비밀은 다루지 않는다.
1924
+ wireIdentity: async (change) => {
1925
+ const code = await runSetupIdentity({ ...values, actor: change.actor, role: 'both' });
1926
+ if (code !== 0)
1927
+ throw new Error(`identity 결선 실패 (exit ${code})`);
1928
+ },
1929
+ // 발견이 증명한 결합을 Profile 에 적는다. 갈리는 것은 plan 에 들어오지 않는다.
1930
+ declareCanonical: async (change) => {
1931
+ const written = await writeProfileCanonical(change.profile, change.source);
1932
+ if (written)
1933
+ await relock(change.profile);
1934
+ },
1935
+ declareBindings: async (change) => {
1936
+ const written = await writeProfileBindings(change.profile, change.bindings);
1937
+ // Profile 을 고쳤으면 lock 이 어긋난다 — 다음 명령이 그 drift 앞에서 멈춘다.
1938
+ // 고친 쪽이 닫는다 (setup identity 가 하는 것과 같다).
1939
+ if (written)
1940
+ await relock(change.profile);
1941
+ },
1683
1942
  });
1684
1943
  }
1944
+ catch (error) {
1945
+ // **적용 실패는 stack trace 가 아니라 답이어야 한다.** agent 는 이 문서를 읽고 다음
1946
+ // 행동을 정한다 — 예외가 그대로 나가면 stdout 이 비고 아무것도 판단할 수 없다.
1947
+ console.log = speak;
1948
+ const detail = error instanceof Error ? error.message : String(error);
1949
+ emit({ ...plan, status: 'apply_failed', changesApplied: false, detail });
1950
+ return 1;
1951
+ }
1685
1952
  finally {
1686
1953
  console.log = speak;
1687
1954
  }
@@ -1719,6 +1986,7 @@ async function inspectSetup(root) {
1719
1986
  ...(runtime
1720
1987
  ? { profile: { id: runtime.layers.profile.id, origin: runtime.layers.profileOrigin } }
1721
1988
  : {}),
1989
+ ...(runtime ? { canonicalSources: runtime.layers.profile.canonical.sources.length } : {}),
1722
1990
  hasApprovers: Object.keys(await loadIdentityMap(root)).length > 0,
1723
1991
  hasControllerIdentities: Object.keys(runtime?.controllerIdentities ?? {}).length > 0,
1724
1992
  hasMonitorIdentities: (runtime?.monitor.identities?.length ?? 0) > 0,
@@ -2201,6 +2469,7 @@ async function runProceed(values, store, root, resolved) {
2201
2469
  const workRef = values.work ?? undefined;
2202
2470
  const ingress = workRef ? await buildWorkIngress(store, root, sessions, resolved) : undefined;
2203
2471
  if (workRef && !ingress) {
2472
+ // **정본이 없는 것과 다른 실패다.** 정본은 code 쪽 사실이고 이것은 작업 항목 쪽이다.
2204
2473
  console.error(`작업 항목 '${workRef}' 을 읽을 통로가 없다 — Profile bindings 에 작업 항목 provider 를 선언하라.`);
2205
2474
  return 2;
2206
2475
  }
@@ -2416,7 +2685,11 @@ async function buildWorkIngress(store, root, runtime, resolved) {
2416
2685
  plan,
2417
2686
  // 제안은 **말하는 것**이지 정하는 것이 아니다. 역할을 박아 넣으면 선언과 구분되지 않고,
2418
2687
  // capability 해석은 후보가 유일할 때 이미 스스로 풀린다.
2419
- roles: rolesFor(plan, declared),
2688
+ //
2689
+ // 다만 **작업 항목을 누구에게 물을지는 여기서 정한다** — code 와 work 가 둘 다 자원
2690
+ // 조회를 제공하면 `rolesFor` 는 아무것도 정하지 못하고, 그러면 선언해 둔 work binding 이
2691
+ // 있는데도 "통로가 없다" 가 된다 (P0 F7).
2692
+ roles: { ...rolesFor(plan, declared), ...workItemRoles(plan, declared) },
2420
2693
  perPage: 30,
2421
2694
  ...jamComposition(projectRoot),
2422
2695
  endpointFor: (binding) => endpointOf(adapters, binding),
@@ -3509,7 +3782,11 @@ async function serviceHealth(values) {
3509
3782
  const adapter = serviceAdapter();
3510
3783
  if (!adapter)
3511
3784
  return null;
3512
- const plan = await planPersistentRuntime(adapter, serviceCommand(serviceInterval(values))).catch(() => null);
3785
+ const runtime = await serviceRuntime();
3786
+ if (runtime.kind !== 'STABLE') {
3787
+ return { adapter: adapter.id, action: 'unsupported', line: serviceRuntimeLine(runtime) };
3788
+ }
3789
+ const plan = await planPersistentRuntime(adapter, serviceCommand(serviceInterval(values), runtime)).catch(() => null);
3513
3790
  if (!plan)
3514
3791
  return null;
3515
3792
  return { adapter: adapter.id, action: plan.action, line: persistentRuntimeLine(adapter.id, plan) };
@@ -3564,15 +3841,42 @@ function serviceAdapter() {
3564
3841
  * 지금 도는 실행 파일과 진입점을 그대로 쓴다 — 어느 build 를 쓸지는 그 진입점이 다시
3565
3842
  * 정하므로(C-14), 등록물이 build 를 고르는 일은 없다.
3566
3843
  */
3567
- function serviceCommand(intervalSeconds) {
3844
+ function serviceCommand(intervalSeconds, runtime) {
3568
3845
  return {
3569
- program: process.execPath,
3570
- args: [fileURLToPath(import.meta.url), 'runtime', 'tick', '--all'],
3846
+ program: runtime.node,
3847
+ args: [runtime.entry, 'runtime', 'tick', '--all'],
3571
3848
  intervalSeconds,
3572
- ...serviceEnvironment(),
3849
+ ...serviceEnvironment(runtime.node),
3573
3850
  logPath: join(ascHome(), 'service.log'),
3574
3851
  };
3575
3852
  }
3853
+ /**
3854
+ * 등록물이 가리킬 Node 와 진입점 (P0 — fresh onboarding).
3855
+ *
3856
+ * 지금 이 프로세스가 어디서 도는지와 **다른 질문이다.** bootstrap 은 npx 캐시에서 돌 수
3857
+ * 있고, 그 자리는 지워진다. 전역 설치본이 있으면 그것이 답이고, 없으면 등록하지 않는다.
3858
+ */
3859
+ async function serviceRuntime() {
3860
+ const stable = await globalRuntimeEntry();
3861
+ const check = await checkNodeRuntime(nodeRuntimeDeps());
3862
+ return resolveServiceRuntime({
3863
+ runningEntry: fileURLToPath(import.meta.url),
3864
+ runningNode: process.execPath,
3865
+ runningNodeVersion: process.version,
3866
+ ...(stable ? { stableEntry: stable } : {}),
3867
+ ...(check.ok ? {} : { nodeCandidates: check.candidates }),
3868
+ });
3869
+ }
3870
+ /** 전역 설치본의 진입점. npm 이 말하는 prefix 를 쓴다 — 경로를 지어내지 않는다. */
3871
+ async function globalRuntimeEntry() {
3872
+ const prefix = await execText('npm', ['prefix', '-g']);
3873
+ if (!prefix)
3874
+ return undefined;
3875
+ const entry = process.platform === 'win32'
3876
+ ? join(prefix, 'node_modules', RUNTIME_PACKAGE, 'dist', 'cli', 'asc.js')
3877
+ : join(prefix, 'lib', 'node_modules', RUNTIME_PACKAGE, 'dist', 'cli', 'asc.js');
3878
+ return existsSync(entry) ? entry : undefined;
3879
+ }
3576
3880
  /**
3577
3881
  * 서비스가 외부 통로를 열 수 있게 하는 환경 (P0-R1).
3578
3882
  *
@@ -3580,10 +3884,10 @@ function serviceCommand(intervalSeconds) {
3580
3884
  * node 의 디렉터리를 맨 앞에 둬서 같은 node 의 npx 가 먼저 잡히게 한다. Windows 의 예약
3581
3885
  * 작업은 사용자 환경을 그대로 물려받으므로 환경을 싣지 않는다.
3582
3886
  */
3583
- function serviceEnvironment() {
3887
+ function serviceEnvironment(node) {
3584
3888
  if (process.platform === 'win32')
3585
3889
  return {};
3586
- const tools = [process.execPath, jamLauncher().command, 'glab', 'git'];
3890
+ const tools = [node, jamLauncher().command, 'glab', 'git'];
3587
3891
  const { path } = servicePath(tools);
3588
3892
  return { environment: { PATH: path } };
3589
3893
  }
@@ -3605,7 +3909,17 @@ async function runRuntimeService(command, values) {
3605
3909
  // 못 하는 것을 "했다"로 적지 않는다. 다만 이것이 오류는 아니다.
3606
3910
  return 0;
3607
3911
  }
3608
- const wanted = serviceCommand(serviceInterval(values));
3912
+ const runtime = await serviceRuntime();
3913
+ if (runtime.kind !== 'STABLE') {
3914
+ // 등록물이 가리킬 안정된 자리가 없다. 무엇이 없어서인지 말한다 — 등록하지 않는 것이
3915
+ // 답이고, 깨진 등록을 남기는 것은 답이 아니다.
3916
+ if (values.json)
3917
+ console.log(JSON.stringify({ supported: true, stable: false, ...runtime }, null, 2));
3918
+ else
3919
+ console.error(serviceRuntimeLine(runtime));
3920
+ return command === 'status' || command === undefined ? 0 : 1;
3921
+ }
3922
+ const wanted = serviceCommand(serviceInterval(values), runtime);
3609
3923
  if (command === undefined || command === 'status') {
3610
3924
  const plan = await planPersistentRuntime(adapter, wanted);
3611
3925
  if (values.json) {
@@ -53,6 +53,23 @@ export declare function closeToolClients(): Promise<void>;
53
53
  * 후보가 갈리면 만들지 않는다 — `AMBIGUOUS_BINDING`은 사람이 정할 문제이고, 여기서 하나를
54
54
  * 고르면 그 선택을 아무도 보지 못한다 (C-09 §4.2).
55
55
  */
56
+ /**
57
+ * 작업 항목을 읽는 통로의 역할 이름 (P0 F7).
58
+ *
59
+ * `rolesFor` 는 **선언된 binding 이 하나일 때만** 역할을 정한다. 그래서 code binding 과
60
+ * work binding 이 둘 다 `context.resource` 를 제공하면 아무 역할도 서지 않고, 작업 항목
61
+ * 조회는 "통로가 없다" 로 끝난다 — 실기계에서 감시는 되는데 `proceed --work` 만 실패한
62
+ * 자리가 여기다. 없는 것이 아니라 **누구에게 물을지 정하지 않은 것**이었다.
63
+ *
64
+ * 가르는 기준은 provider 이름이 아니라 capability 모양이다: 작업 항목 통로는 목록과 자원을
65
+ * 알고 **변경을 모른다**. 코드 통로는 변경을 안다. 그 차이가 곧 두 축의 정의다
66
+ * (C-09 §2.1 — Port 를 좁게 나눈 이유가 그것이다).
67
+ */
68
+ export declare function workItemRoles(plan: BindingPlan, declared: readonly {
69
+ role: string;
70
+ adapter: string;
71
+ resource: string;
72
+ }[]): Partial<Record<Capability, string>>;
56
73
  /**
57
74
  * Profile이 선언한 역할 배정을 capability별 역할로 옮긴다 (C-09 §3.1·§4).
58
75
  *
@@ -124,6 +124,32 @@ const PORT_OF = {
124
124
  * 후보가 갈리면 만들지 않는다 — `AMBIGUOUS_BINDING`은 사람이 정할 문제이고, 여기서 하나를
125
125
  * 고르면 그 선택을 아무도 보지 못한다 (C-09 §4.2).
126
126
  */
127
+ /**
128
+ * 작업 항목을 읽는 통로의 역할 이름 (P0 F7).
129
+ *
130
+ * `rolesFor` 는 **선언된 binding 이 하나일 때만** 역할을 정한다. 그래서 code binding 과
131
+ * work binding 이 둘 다 `context.resource` 를 제공하면 아무 역할도 서지 않고, 작업 항목
132
+ * 조회는 "통로가 없다" 로 끝난다 — 실기계에서 감시는 되는데 `proceed --work` 만 실패한
133
+ * 자리가 여기다. 없는 것이 아니라 **누구에게 물을지 정하지 않은 것**이었다.
134
+ *
135
+ * 가르는 기준은 provider 이름이 아니라 capability 모양이다: 작업 항목 통로는 목록과 자원을
136
+ * 알고 **변경을 모른다**. 코드 통로는 변경을 안다. 그 차이가 곧 두 축의 정의다
137
+ * (C-09 §2.1 — Port 를 좁게 나눈 이유가 그것이다).
138
+ */
139
+ export function workItemRoles(plan, declared) {
140
+ const candidates = plan.bindings
141
+ .filter((binding) => binding.role !== undefined)
142
+ .filter((binding) => declared.some((d) => d.adapter === binding.adapterId && d.resource === binding.resource))
143
+ .filter((binding) => binding.provides.includes('context.resource') &&
144
+ binding.provides.includes('inventory.enumerate') &&
145
+ !binding.provides.includes('context.change'));
146
+ const roles = new Set(candidates.map((binding) => binding.role));
147
+ // 갈리면 고르지 않는다 — 그것은 사람이 정할 문제다 (C-09 §4.2).
148
+ if (roles.size !== 1)
149
+ return {};
150
+ const role = [...roles][0];
151
+ return { 'context.resource': role, 'inventory.enumerate': role, 'context.thread': role, 'context.history': role };
152
+ }
127
153
  /**
128
154
  * Profile이 선언한 역할 배정을 capability별 역할로 옮긴다 (C-09 §3.1·§4).
129
155
  *
@@ -61,6 +61,46 @@ export type SetupState = {
61
61
  * 사람에게 "그 도구를 설정할까요?"라고 되물으면, 사람은 자기가 이미 적어 둔 것을 다시
62
62
  * 답하게 된다. 고칠 수 있는 것은 고치고, 사람만 할 수 있는 것에서만 멈춘다.
63
63
  */
64
+ /**
65
+ * 이 저장소 자신의 remote 에서 읽히는 Profile id (P0 fresh onboarding).
66
+ *
67
+ * **다른 논리 workspace 를 추측해 합치는 것이 아니다** (C-11 금지사항). 이 저장소가
68
+ * 스스로 증명하는 신원 하나이고, 그 이름의 Profile 이 이미 있으면 그것을 쓰고 없으면
69
+ * 만든다. 이것이 없어서 fresh 설치가 `ASC_PROFILE_SELECTION_REQUIRED` 앞에 멈췄고,
70
+ * 사람이 `profile adopt` 를 따로 쳐야 했다.
71
+ */
72
+ adoptable?: {
73
+ id: string;
74
+ exists: boolean;
75
+ };
76
+ /**
77
+ * 이 workspace 에 승인 권한자가 서 있는가, 그리고 지금 이 사람을 무엇으로 알아볼 수
78
+ * 있는가. `actor` 는 인증된 provider 가 말한 값이며 ASC 가 지어내지 않는다.
79
+ */
80
+ identity?: {
81
+ wired: boolean;
82
+ actor?: string;
83
+ };
84
+ /**
85
+ * 발견이 **갈리지 않고** 제안하는 결합. Profile 에 결합 선언이 없을 때만 본다.
86
+ * 갈리면 비어 있다 — 고르는 것은 사람이다 (C-09 §4.2).
87
+ */
88
+ bindingProposal?: readonly {
89
+ role: string;
90
+ adapter: string;
91
+ resource: string;
92
+ }[];
93
+ /**
94
+ * 이 checkout 이 증명하는 정본 갈래. remote 가 스스로 말한 기본 branch 하나이며,
95
+ * Profile 에 정본 선언이 비어 있을 때만 관측한다. 알 수 없으면 없다 — 지어내면 세션이
96
+ * 읽지 못하는 정본을 딛고 서게 된다.
97
+ */
98
+ canonicalProposal?: {
99
+ id: string;
100
+ provider: string;
101
+ remote: string;
102
+ ref: string;
103
+ };
64
104
  workBinding?: {
65
105
  adapter: string;
66
106
  resource: string;
@@ -86,12 +126,63 @@ export type SetupChange =
86
126
  strategy: 'npm-global';
87
127
  from: string;
88
128
  }
129
+ /**
130
+ * 이 저장소를 설명하는 Profile 을 만든다 (P0 fresh onboarding).
131
+ *
132
+ * 근거는 이 저장소의 remote 하나뿐이다. 다른 workspace 를 추측하지 않는다.
133
+ */
134
+ | {
135
+ target: 'adopt-profile';
136
+ profile: string;
137
+ }
89
138
  /** 이 checkout에 runtime을 붙인다. local scope면 저장소에는 아무것도 만들지 않는다. */
90
139
  | {
91
140
  target: 'attach-workspace';
92
141
  scope: 'local' | 'project';
93
142
  profile: string;
94
143
  }
144
+ /**
145
+ * 지금 이 사람을 이 workspace 의 승인 권한자로 세운다.
146
+ *
147
+ * **비밀은 다루지 않는다** — 이름과 채널뿐이다. 이것이 계획에 없으면 fresh 설치가
148
+ * 승인자 0 인 채로 READY 가 되고, 사람이 `setup identity` 를 따로 쳐야 했다.
149
+ */
150
+ | {
151
+ target: 'identity';
152
+ actor: string;
153
+ }
154
+ /**
155
+ * 발견이 증명한 결합을 Profile 에 적는다.
156
+ *
157
+ * 갈리는 것은 담지 않는다. 여기 없으면 사람이 Profile 을 손으로 고쳐야 했고, 그것이
158
+ * fresh onboarding 에서 실제로 필요했던 단계다.
159
+ */
160
+ | {
161
+ target: 'profile-bindings';
162
+ profile: string;
163
+ bindings: readonly {
164
+ role: string;
165
+ adapter: string;
166
+ resource: string;
167
+ }[];
168
+ }
169
+ /**
170
+ * Profile 에 정본 갈래를 적는다.
171
+ *
172
+ * 이것이 비어 있으면 판정이 전부 "확인 못 함" 으로 나온다 — relevance 도
173
+ * responsibility 도 정본을 딛고 서기 때문이다. 근거는 remote 가 스스로 말한 기본
174
+ * branch 이고, 그 값을 얻지 못하면 이 변경은 계획에 들지 않는다.
175
+ */
176
+ | {
177
+ target: 'profile-canonical';
178
+ profile: string;
179
+ source: {
180
+ id: string;
181
+ provider: string;
182
+ remote: string;
183
+ ref: string;
184
+ };
185
+ }
95
186
  /** Host 설치물을 지금 source에 맞춘다. 왜 필요한지까지 든다. */
96
187
  | {
97
188
  target: 'host-install';
@@ -190,17 +281,27 @@ export type SetupEffects = {
190
281
  registerPersistentRuntime?(change: Extract<SetupChange, {
191
282
  target: 'persistent-runtime';
192
283
  }>): Promise<void>;
284
+ /** 이 저장소를 설명하는 Profile 을 만든다. */
285
+ adoptProfile?(change: Extract<SetupChange, {
286
+ target: 'adopt-profile';
287
+ }>): Promise<void>;
288
+ /** 승인 권한자를 세운다. 비밀은 다루지 않는다. */
289
+ wireIdentity?(change: Extract<SetupChange, {
290
+ target: 'identity';
291
+ }>): Promise<void>;
292
+ /** 발견이 증명한 결합을 Profile 에 적는다. */
293
+ declareBindings?(change: Extract<SetupChange, {
294
+ target: 'profile-bindings';
295
+ }>): Promise<void>;
296
+ /** remote 가 말한 정본 갈래를 Profile 에 적는다. */
297
+ declareCanonical?(change: Extract<SetupChange, {
298
+ target: 'profile-canonical';
299
+ }>): Promise<void>;
193
300
  };
194
301
  export type ApplyResult = {
195
302
  applied: SetupChange[];
196
303
  changesApplied: boolean;
197
304
  };
198
- /**
199
- * plan에 적힌 것만 실행한다. **다시 판단하지 않는다** (C-14 불변식 ⑩).
200
- *
201
- * 여기서 상태를 다시 보고 마음을 바꾸면, 사람이 승인한 plan과 실제로 일어난 일이
202
- * 달라진다. 그 순간 plan은 아무것도 보장하지 않는 문서가 된다.
203
- */
204
305
  export declare function applySetupPlan(plan: SetupPlan, effects: SetupEffects): Promise<ApplyResult>;
205
306
  /** 사람이 읽는 줄. 같은 plan에서 나온다 — agent가 보는 JSON과 다른 판단이 아니다. */
206
307
  export declare function renderSetupPlan(plan: SetupPlan): string[];
@@ -66,7 +66,7 @@ export function computeSetupPlan(state) {
66
66
  code: 'ASC_HOST_INSTALL_MODIFIED',
67
67
  // 사람이 고친 것을 덮는 것은 사람이 정한다 — plan에 담아 몰래 적용하지 않는다.
68
68
  // 다만 runtime 설치처럼 이 결정과 무관한 준비는 계획에 남는다.
69
- changes,
69
+ changes: orderChanges(changes, false),
70
70
  requiresUserAction: true,
71
71
  ...actions(mode, evidence, [
72
72
  { type: 'force_host_install', ...command(['host', host.id, 'install', '--force']) },
@@ -96,7 +96,7 @@ export function computeSetupPlan(state) {
96
96
  return {
97
97
  status: 'user_action_required',
98
98
  code: 'ASC_WORK_BINDING_NEEDS_USER',
99
- changes,
99
+ changes: orderChanges(changes, false),
100
100
  requiresUserAction: true,
101
101
  ...actions(mode, evidence, [{ type: 'proceed', ...command(['setup', 'status']) }]),
102
102
  };
@@ -112,6 +112,7 @@ export function computeSetupPlan(state) {
112
112
  }
113
113
  }
114
114
  if (state.ascRoot && !state.attachmentBroken) {
115
+ pushWorkspaceChanges(changes, state, evidence);
115
116
  // 붙어 있어도 **무엇을 고를 수 있었는지**는 사실이다. 사용자 소유 Profile을 새로 놓고
116
117
  // 계획을 물었을 때 그것이 어디에도 안 보이면, 놓은 사람은 경로를 의심하게 된다.
117
118
  if (state.profileCandidates.length > 0) {
@@ -121,13 +122,23 @@ export function computeSetupPlan(state) {
121
122
  }
122
123
  // 아직 안 붙었거나, 붙이다 말았다(BROKEN). 무엇으로 붙을지는 사람이 정한다 —
123
124
  // BROKEN이면 같은 선택으로 다시 붙이는 것이 repair다.
124
- const profile = state.requestedProfile ?? soleCandidate(state.profileCandidates);
125
+ // 저장소가 스스로 증명하는 Profile 이 있으면 그것이 답이다 — 이름이 이미 있으면
126
+ // 그것을 쓰고, 없으면 만든다. 둘 다 "다른 workspace 를 추측"하는 것이 아니다.
127
+ const adoptable = state.adoptable;
128
+ const profile = state.requestedProfile ??
129
+ (adoptable && state.profileCandidates.includes(adoptable.id) ? adoptable.id : undefined) ??
130
+ (adoptable ? adoptable.id : undefined) ??
131
+ soleCandidate(state.profileCandidates);
132
+ if (adoptable && !state.requestedProfile && !adoptable.exists) {
133
+ evidence.push(`profile=${adoptable.id} (adopted from this repository)`);
134
+ changes.push({ target: 'adopt-profile', profile: adoptable.id });
135
+ }
125
136
  if (!profile) {
126
137
  evidence.push(`profile candidates=${state.profileCandidates.join(', ') || '(none)'}`);
127
138
  return {
128
139
  status: 'user_action_required',
129
140
  code: 'ASC_PROFILE_SELECTION_REQUIRED',
130
- changes,
141
+ changes: orderChanges(changes, false),
131
142
  requiresUserAction: true,
132
143
  profiles: state.profileCandidates,
133
144
  // 고를 것이 **없을** 수도 있다 — 배포본이 들고 있는 것은 예시뿐이고, 이 프로젝트를
@@ -153,9 +164,36 @@ export function computeSetupPlan(state) {
153
164
  evidence.push('adoption=project (explicit)');
154
165
  }
155
166
  changes.push({ target: 'attach-workspace', scope: state.scope, profile });
156
- evidence.push(`profile=${profile}${state.requestedProfile ? ' (given)' : ' (sole candidate)'}`);
167
+ if (!changes.some((c) => c.target === 'adopt-profile')) {
168
+ evidence.push(`profile=${profile}${state.requestedProfile ? ' (given)' : ' (sole candidate)'}`);
169
+ }
170
+ pushWorkspaceChanges(changes, state, evidence, profile);
157
171
  return finish(changes, evidence, state, mode, command);
158
172
  }
173
+ /**
174
+ * workspace 가 서고 나서 필요한 것들 — 승인자와 결합 선언.
175
+ *
176
+ * 둘 다 **관측된 사실이 있을 때만** 계획에 든다. 승인자는 인증된 provider 가 말한 이름이고,
177
+ * 결합은 발견이 갈리지 않고 제안한 것이다. 없으면 담지 않는다 — 지어내면 그 값이 곧
178
+ * 사람이 겪는 오작동이 된다.
179
+ */
180
+ function pushWorkspaceChanges(changes, state, evidence, attachingProfile) {
181
+ if (state.identity && !state.identity.wired && state.identity.actor) {
182
+ evidence.push(`identity=${state.identity.actor} (not yet recorded)`);
183
+ changes.push({ target: 'identity', actor: state.identity.actor });
184
+ }
185
+ const profile = attachingProfile ?? state.attachedProfile;
186
+ if (!profile)
187
+ return;
188
+ if (state.bindingProposal && state.bindingProposal.length > 0) {
189
+ evidence.push(`bindings proposed=${state.bindingProposal.map((b) => `${b.role}:${b.adapter}`).join(', ')}`);
190
+ changes.push({ target: 'profile-bindings', profile, bindings: state.bindingProposal });
191
+ }
192
+ if (state.canonicalProposal) {
193
+ evidence.push(`canonical proposed=${state.canonicalProposal.remote}/${state.canonicalProposal.ref}`);
194
+ changes.push({ target: 'profile-canonical', profile, source: state.canonicalProposal });
195
+ }
196
+ }
159
197
  /** 후보가 하나뿐이어도 대신 고르지 않는다 — 여기서 돌려주는 것은 "고를 것이 없다"뿐이다. */
160
198
  function soleCandidate(candidates) {
161
199
  return candidates.length === 1 ? candidates[0] : undefined;
@@ -172,7 +210,9 @@ function actions(mode, evidence, list) {
172
210
  evidence,
173
211
  };
174
212
  }
175
- function finish(changes, evidence, state, mode, command) {
213
+ function finish(raw, evidence, state, mode, command) {
214
+ const settled = (Boolean(state.ascRoot) && !state.attachmentBroken) || raw.some((c) => c.target === 'attach-workspace');
215
+ const changes = orderChanges(raw, settled);
176
216
  if (changes.length === 0) {
177
217
  return {
178
218
  status: 'already_configured',
@@ -194,6 +234,34 @@ function finish(changes, evidence, state, mode, command) {
194
234
  * 여기서 상태를 다시 보고 마음을 바꾸면, 사람이 승인한 plan과 실제로 일어난 일이
195
235
  * 달라진다. 그 순간 plan은 아무것도 보장하지 않는 문서가 된다.
196
236
  */
237
+ /**
238
+ * 실행 순서 (설계 §9 — fresh onboarding).
239
+ *
240
+ * **등록이 맨 마지막이다.** 예전에는 등록이 붙이기보다 먼저 계획에 들어가, OS 가 아직
241
+ * identity·binding 이 서지 않은 workspace 를 회차로 돌렸다. 그 회차는 실패했고, 사람은
242
+ * 자기가 setup 을 끝내기도 전에 실패 기록을 봤다.
243
+ */
244
+ const CHANGE_ORDER = {
245
+ 'runtime-install': 0,
246
+ 'adopt-profile': 1,
247
+ 'attach-workspace': 2,
248
+ identity: 3,
249
+ 'profile-bindings': 4,
250
+ 'profile-canonical': 5,
251
+ 'work-binding-setup': 6,
252
+ 'host-install': 7,
253
+ 'persistent-runtime': 8,
254
+ };
255
+ /**
256
+ * 순서를 고정하고, **아직 설 자리가 아닌 등록은 뺀다.**
257
+ *
258
+ * 붙는 것이 이번 계획으로 끝나지 않으면 이 기계에 등록할 이유가 없다 — 등록물은
259
+ * workspace 를 돌보는 것이고, 돌볼 workspace 가 아직 없기 때문이다.
260
+ */
261
+ function orderChanges(changes, attachmentSettled) {
262
+ const kept = attachmentSettled ? [...changes] : changes.filter((change) => change.target !== 'persistent-runtime');
263
+ return kept.sort((a, b) => CHANGE_ORDER[a.target] - CHANGE_ORDER[b.target]);
264
+ }
197
265
  export async function applySetupPlan(plan, effects) {
198
266
  const applied = [];
199
267
  for (const change of plan.changes) {
@@ -220,6 +288,26 @@ export async function applySetupPlan(plan, effects) {
220
288
  continue;
221
289
  await effects.setupWorkBinding(change);
222
290
  break;
291
+ case 'adopt-profile':
292
+ if (!effects.adoptProfile)
293
+ continue;
294
+ await effects.adoptProfile(change);
295
+ break;
296
+ case 'identity':
297
+ if (!effects.wireIdentity)
298
+ continue;
299
+ await effects.wireIdentity(change);
300
+ break;
301
+ case 'profile-bindings':
302
+ if (!effects.declareBindings)
303
+ continue;
304
+ await effects.declareBindings(change);
305
+ break;
306
+ case 'profile-canonical':
307
+ if (!effects.declareCanonical)
308
+ continue;
309
+ await effects.declareCanonical(change);
310
+ break;
223
311
  }
224
312
  applied.push(change);
225
313
  }
@@ -254,5 +342,15 @@ function changeLine(change) {
254
342
  return ` repair ${change.adapter} for ${change.resource} through its own setup (${change.version})`;
255
343
  case 'persistent-runtime':
256
344
  return ` register this machine's ASC runtime with ${change.adapter}`;
345
+ case 'adopt-profile':
346
+ return ` create profile ${change.profile} from this repository's remote`;
347
+ case 'identity':
348
+ return ` record ${change.actor} as this workspace's approver`;
349
+ case 'profile-bindings':
350
+ return ` declare bindings in ${change.profile}: ${change.bindings
351
+ .map((b) => `${b.role}=${b.adapter}:${b.resource}`)
352
+ .join(', ')}`;
353
+ case 'profile-canonical':
354
+ return ` declare canonical source in ${change.profile}: ${change.source.remote}/${change.source.ref}`;
257
355
  }
258
356
  }
@@ -8,7 +8,7 @@ export type AttachmentState = 'READY' | 'UNATTACHED' | 'BROKEN' | 'LOCK_DRIFT';
8
8
  */
9
9
  export type GateState = 'OPEN' | 'BLOCKED' | 'DEGRADED';
10
10
  export type SetupGate = {
11
- id: 'approval' | 'monitor' | 'external-write';
11
+ id: 'approval' | 'monitor' | 'external-write' | 'canonical';
12
12
  label: string;
13
13
  state: GateState;
14
14
  /** 무엇이 없어서 막혔는지. */
@@ -30,6 +30,8 @@ export type SetupStatus = {
30
30
  gates: SetupGate[];
31
31
  };
32
32
  export type SetupInput = {
33
+ /** Profile 이 선언한 정본 갈래 수. 붙지 않았으면 알 수 없으므로 `undefined` 다. */
34
+ canonicalSources?: number;
33
35
  attachment: AttachmentState;
34
36
  /** 붙어 있는 Profile의 id와 출처. **Surface가 읽어 넘긴다** — Core는 경로를 모른다. */
35
37
  profile?: {
@@ -28,7 +28,7 @@ export function assessSetup(input) {
28
28
  attachment: input.attachment,
29
29
  ...(input.profile ? { profile: input.profile } : {}),
30
30
  ready: [...ALWAYS_READY],
31
- gates: [approvalGate(input), monitorGate(input), externalWriteGate(input)],
31
+ gates: [approvalGate(input), canonicalGate(input), monitorGate(input), externalWriteGate(input)],
32
32
  };
33
33
  }
34
34
  /** 승인 결정. identities.json은 lock digest에 없어 재고정이 필요 없다. */
@@ -46,6 +46,30 @@ function approvalGate(input) {
46
46
  howTo: ['open identities.json and add an approver in the $example form (no re-lock needed)'],
47
47
  };
48
48
  }
49
+ /**
50
+ * 정본 조회 (C-08 · C-11).
51
+ *
52
+ * 코드 결합은 선언돼 있는데 정본 갈래가 비어 있으면, 판정은 "확인 못 함" 으로만 나온다 —
53
+ * relevance 도 responsibility 도 정본을 딛고 서기 때문이다. 실기계에서 그 상태가 READY 로
54
+ * 보였고, 사람은 무엇이 빠졌는지 알 방법이 없었다. 없는 것을 없다고 말한다.
55
+ */
56
+ function canonicalGate(input) {
57
+ // 붙지 않았으면 이 축을 판정하지 않는다 — 아직 Profile 이 없다.
58
+ if (input.attachment !== 'READY' || input.canonicalSources === undefined) {
59
+ return { id: 'canonical', label: 'canonical evidence', state: 'OPEN', missing: [], warnings: [], howTo: [] };
60
+ }
61
+ if (input.canonicalSources > 0) {
62
+ return { id: 'canonical', label: 'canonical evidence', state: 'OPEN', missing: [], warnings: [], howTo: [] };
63
+ }
64
+ return {
65
+ id: 'canonical',
66
+ label: 'canonical evidence',
67
+ state: 'BLOCKED',
68
+ missing: ['the profile declares no canonical source, so nothing can be judged against a baseline'],
69
+ warnings: [],
70
+ howTo: [`declare one in the profile's canonical.sources — ${RESOLVE_AGAIN}`],
71
+ };
72
+ }
49
73
  /**
50
74
  * 외부 감시. 두 설정이 서로 다른 것을 막는다 —
51
75
  * controller.identities가 없으면 실행 자체가 안 되고(단 --as로 우회 가능),
@@ -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.0";
5
- export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.5.0";
6
- export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.5.0";
4
+ export declare const RELEASE_VERSION = "0.5.1";
5
+ export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.5.1";
6
+ export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.5.1";
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.5.0';
12
+ export const RELEASE_VERSION = '0.5.1';
13
13
  export const RUNTIME_SPEC = `${RUNTIME_PACKAGE}@${RELEASE_VERSION}`;
14
14
  export const BOOTSTRAP_SPEC = `${BOOTSTRAP_PACKAGE}@${RELEASE_VERSION}`;
15
15
  /**
@@ -0,0 +1,40 @@
1
+ import { type NodeCandidate } from './node-runtime.ts';
2
+ /**
3
+ * 사라질 수 있는 자리인가.
4
+ *
5
+ * npx 캐시가 이 목록의 이유다. 임시 디렉터리도 같은 성질이고, 어느 쪽이든 **등록물이
6
+ * 가리키면 안 되는 자리**다. 목록은 짧게 유지한다 — 길어지면 그건 판정이 아니라 추측이다.
7
+ */
8
+ export declare function isTransientPath(path: string): boolean;
9
+ export type ServiceRuntimeInput = {
10
+ /** 지금 이 프로세스의 진입점 절대 경로. */
11
+ runningEntry: string;
12
+ /** 지금 이 프로세스의 Node 실행 파일. */
13
+ runningNode: string;
14
+ /** 지금 이 프로세스의 Node 버전 (`v24.1.0` 형태). */
15
+ runningNodeVersion: string;
16
+ /** 전역 설치본의 진입점. 있으면 이것이 먼저다 — 지워지지 않는 자리이기 때문이다. */
17
+ stableEntry?: string;
18
+ /** 이 기계에서 찾은 Node 후보들. 지금 Node 가 못 쓸 때만 본다. */
19
+ nodeCandidates?: readonly NodeCandidate[];
20
+ };
21
+ export type ServiceRuntimeResolution = {
22
+ kind: 'STABLE';
23
+ node: string;
24
+ entry: string;
25
+ } | {
26
+ kind: 'UNSTABLE';
27
+ /** 무엇이 없어서 못 하는가. 사람이 읽고 무엇을 할지 알 수 있어야 한다. */
28
+ reason: 'NO_STABLE_ENTRY' | 'NO_COMPATIBLE_NODE';
29
+ detail: string;
30
+ };
31
+ /**
32
+ * 등록물에 박을 Node 와 진입점을 정한다. **아무것도 실행하지 않는다** — 사실은 호출자가
33
+ * 관측해 넘긴다.
34
+ *
35
+ * 진입점: 전역 설치본이 있으면 그것. 없으면 지금 진입점이되 **임시 자리가 아닐 때만.**
36
+ * Node: 지금 Node 가 하한을 넘고 임시 자리가 아니면 그것. 아니면 후보 중 첫 번째.
37
+ */
38
+ export declare function resolveServiceRuntime(input: ServiceRuntimeInput): ServiceRuntimeResolution;
39
+ /** 사람이 읽는 한 줄. 왜 등록하지 않았는지가 여기 있어야 한다. */
40
+ export declare function serviceRuntimeLine(resolution: ServiceRuntimeResolution): string;
@@ -0,0 +1,76 @@
1
+ // 등록물이 무엇을 가리켜야 하는가 (C-12 · C-14 §3).
2
+ //
3
+ // 실기계에서 이런 일이 났다: `npx @asc-agent/bootstrap … setup apply` 로 처음 설치한
4
+ // 사람에게, 등록물이 **npx 캐시 안의 실행물**을 정본으로 박았다.
5
+ //
6
+ // ~/.npm/_npx/<hash>/node_modules/@asc-agent/runtime/dist/cli/asc.js
7
+ //
8
+ // npx 캐시는 지워지는 자리다. 그것을 지우면 등록물은 남고 실행물만 사라진다 — OS 는 계속
9
+ // 부르고 매번 실패한다. 등록은 **오래 남을 설치본**이 스스로 할 때만 뜻이 있다.
10
+ //
11
+ // 그래서 이 파일은 두 가지를 가른다:
12
+ //
13
+ // 지금 이 프로세스가 어디서 도는가 bootstrap 은 임시 자리에서 돌 수 있다
14
+ // 등록물이 무엇을 가리켜야 하는가 임시 자리는 절대 안 된다
15
+ //
16
+ // Node 도 같다. 등록물이 박는 Node 는 실제로 있고, 하한을 넘고, 임시 자리가 아니어야
17
+ // 한다 — 셋 중 하나라도 아니면 **등록하지 않는다.** 깨진 등록을 남기고 나중에 실패하는
18
+ // 것보다, 등록하지 않고 그 사실을 말하는 편이 낫다.
19
+ import { MINIMUM_NODE_MAJOR, majorOf } from "./node-runtime.js";
20
+ /**
21
+ * 사라질 수 있는 자리인가.
22
+ *
23
+ * npx 캐시가 이 목록의 이유다. 임시 디렉터리도 같은 성질이고, 어느 쪽이든 **등록물이
24
+ * 가리키면 안 되는 자리**다. 목록은 짧게 유지한다 — 길어지면 그건 판정이 아니라 추측이다.
25
+ */
26
+ export function isTransientPath(path) {
27
+ const normalized = path.replace(/\\/g, '/');
28
+ return (normalized.includes('/_npx/') ||
29
+ normalized.includes('/_cacache/') ||
30
+ normalized.startsWith('/tmp/') ||
31
+ normalized.startsWith('/private/tmp/') ||
32
+ normalized.startsWith('/var/folders/') ||
33
+ /\/Temp\//i.test(normalized));
34
+ }
35
+ /**
36
+ * 등록물에 박을 Node 와 진입점을 정한다. **아무것도 실행하지 않는다** — 사실은 호출자가
37
+ * 관측해 넘긴다.
38
+ *
39
+ * 진입점: 전역 설치본이 있으면 그것. 없으면 지금 진입점이되 **임시 자리가 아닐 때만.**
40
+ * Node: 지금 Node 가 하한을 넘고 임시 자리가 아니면 그것. 아니면 후보 중 첫 번째.
41
+ */
42
+ export function resolveServiceRuntime(input) {
43
+ const entry = input.stableEntry && !isTransientPath(input.stableEntry)
44
+ ? input.stableEntry
45
+ : !isTransientPath(input.runningEntry)
46
+ ? input.runningEntry
47
+ : null;
48
+ if (!entry) {
49
+ return {
50
+ kind: 'UNSTABLE',
51
+ reason: 'NO_STABLE_ENTRY',
52
+ detail: `${input.runningEntry} is a temporary location — a registration pointing there breaks when it is cleared`,
53
+ };
54
+ }
55
+ const usable = (path, version) => {
56
+ const major = majorOf(version);
57
+ return major !== null && major >= MINIMUM_NODE_MAJOR && !isTransientPath(path);
58
+ };
59
+ const node = usable(input.runningNode, input.runningNodeVersion)
60
+ ? input.runningNode
61
+ : (input.nodeCandidates ?? []).find((candidate) => usable(candidate.path, candidate.version))?.path;
62
+ if (!node) {
63
+ return {
64
+ kind: 'UNSTABLE',
65
+ reason: 'NO_COMPATIBLE_NODE',
66
+ detail: `no Node ${MINIMUM_NODE_MAJOR} or newer at a stable path (this process runs ${input.runningNodeVersion} from ${input.runningNode})`,
67
+ };
68
+ }
69
+ return { kind: 'STABLE', node, entry };
70
+ }
71
+ /** 사람이 읽는 한 줄. 왜 등록하지 않았는지가 여기 있어야 한다. */
72
+ export function serviceRuntimeLine(resolution) {
73
+ return resolution.kind === 'STABLE'
74
+ ? `service runtime: ${resolution.node} ${resolution.entry}`
75
+ : `service runtime unavailable — ${resolution.detail}`;
76
+ }
@@ -493,6 +493,7 @@ export declare const UserOverride: z.ZodPipeline<z.ZodEffects<z.ZodUnknown, unkn
493
493
  };
494
494
  schemaVersion: 1;
495
495
  monitorIdentities: string[];
496
+ identity: Record<string, string>;
496
497
  approval: {
497
498
  preferredChannel?: string | undefined;
498
499
  messenger?: {
@@ -509,7 +510,6 @@ export declare const UserOverride: z.ZodPipeline<z.ZodEffects<z.ZodUnknown, unkn
509
510
  lockedSettings?: string[] | undefined;
510
511
  unionLists?: Record<string, string[]> | undefined;
511
512
  };
512
- identity: Record<string, string>;
513
513
  $comment?: string | undefined;
514
514
  }, {
515
515
  schemaVersion: 1;
@@ -522,6 +522,7 @@ export declare const UserOverride: z.ZodPipeline<z.ZodEffects<z.ZodUnknown, unkn
522
522
  } | undefined;
523
523
  $comment?: string | undefined;
524
524
  monitorIdentities?: string[] | undefined;
525
+ identity?: Record<string, string> | undefined;
525
526
  approval?: {
526
527
  preferredChannel?: string | undefined;
527
528
  messenger?: {
@@ -538,7 +539,6 @@ export declare const UserOverride: z.ZodPipeline<z.ZodEffects<z.ZodUnknown, unkn
538
539
  lockedSettings?: string[] | undefined;
539
540
  unionLists?: Record<string, string[]> | undefined;
540
541
  } | undefined;
541
- identity?: Record<string, string> | undefined;
542
542
  }>>;
543
543
  export type UserOverride = z.infer<typeof UserOverride>;
544
544
  /** attach 재현성 metadata (OM §4.9). Resolver만 쓴다. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asc-agent/runtime",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "ASC (Agent Session Control) \u2014 local-first human-in-the-loop agent control plane: core, CLI, and adapters",
5
5
  "keywords": [
6
6
  "agent",