@asc-agent/runtime 0.3.1 → 0.3.2

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.3.1
10
+ npm install -g @asc-agent/runtime@0.3.2
11
11
  ```
12
12
 
13
13
  npm owns the executable link (on Windows, npm's own `asc.cmd`). This package never edits
@@ -7,6 +7,7 @@
7
7
  // 호스트 세션이 자기 도구 목록을 보고 채우는 것은 별도 경로(host-report)다.
8
8
  import { execFile } from 'node:child_process';
9
9
  import { promisify } from 'node:util';
10
+ import { resolveExternalCommand } from "../../core/distribution/external-command.js";
10
11
  const run = promisify(execFile);
11
12
  export const CAPABILITIES = [
12
13
  'cross_session_message',
@@ -26,8 +27,12 @@ export const CAPABILITIES = [
26
27
  /** 안전에 필수인 capability — 없으면 degrade가 아니라 STOP이다 (C-03 §5.2). */
27
28
  export const SAFETY_CRITICAL = ['external_write_guard'];
28
29
  const tryRun = async (command, args) => {
30
+ // Windows에서 `claude` 는 npm이 만든 `.cmd` shim이라 bare 이름 execFile은
31
+ // 실패한다 — PATH에 실재하는데 "못 찾았다"가 되는 오판이 실측에서 나왔다.
32
+ // 실행 형태를 먼저 해석해 세 OS에서 같은 의미로 부른다.
33
+ const resolved = resolveExternalCommand(command, args);
29
34
  try {
30
- const { stdout } = await run(command, args, { timeout: 15_000 });
35
+ const { stdout } = await run(resolved.command, resolved.args, { timeout: 15_000 });
31
36
  return { ok: true, stdout };
32
37
  }
33
38
  catch {
@@ -10,6 +10,7 @@
10
10
  // stderr를 프로토콜로 읽지 않는다 — 진단용으로만 쓰고, 그것도 길이를 제한한다
11
11
  // 자격 값을 절대 기록하지 않는다 — 이 파일은 토큰을 받지도, 보지도, 남기지도 않는다
12
12
  import { spawn } from 'node:child_process';
13
+ import { resolveExternalCommand } from "../../core/distribution/external-command.js";
13
14
  const PROTOCOL_VERSION = '2024-11-05';
14
15
  /** 진단으로 남길 stderr 최대 길이. 무한히 모으면 그 자체가 새는 곳이 된다. */
15
16
  const STDERR_KEEP = 2000;
@@ -30,9 +31,12 @@ export class JamMcpClient {
30
31
  if (this.#ready && this.#child)
31
32
  return { ok: true, value: { name: '', version: '' } };
32
33
  try {
34
+ // Windows에서 `jam` 은 npm `.cmd` shim이다 — bare 이름 spawn은 ENOENT가 나고
35
+ // (asc init 실측: "spawn jam ENOENT"), 실행 형태를 해석해야 세 OS에서 같다.
36
+ const resolved = resolveExternalCommand(this.#deps.command, this.#deps.args ?? []);
33
37
  this.#child = this.#deps.spawnProcess
34
38
  ? this.#deps.spawnProcess()
35
- : spawn(this.#deps.command, [...(this.#deps.args ?? [])], {
39
+ : spawn(resolved.command, resolved.args, {
36
40
  stdio: ['pipe', 'pipe', 'pipe'],
37
41
  ...(this.#deps.cwd ? { cwd: this.#deps.cwd } : {}),
38
42
  });
package/dist/cli/asc.js CHANGED
@@ -64,7 +64,7 @@ import { preflight } from "../core/operator/preflight.js";
64
64
  import { DraftProvenance, issueArgs, planSessionContract, } from "../core/operator/contract-draft.js";
65
65
  import { lookupAuthority } from "../core/policy/ownership.js";
66
66
  import { renderProgress } from "../core/operator/render.js";
67
- import { AuditLedger, decisionLines, delegationLine, executionLines, reclaimLine, validationLines, } from "../core/runtime/audit.js";
67
+ import { AuditLedger, decisionLines, delegationLine, executionLines, reclaimLine, validationLines, DecisionClass, } from "../core/runtime/audit.js";
68
68
  import { ClosureLedger } from "../core/runtime/closure.js";
69
69
  import { Orchestrator, renderTick } from "../core/runtime/orchestrator.js";
70
70
  import { QueryLedger } from "../core/runtime/query.js";
@@ -159,7 +159,7 @@ const USAGE = `asc — Agent Session Control
159
159
  asc session pause <ID> --position <text> --next <text> [--done <task>...]
160
160
  asc session resume <ID>
161
161
  asc session done <ID> --verified <text> --next <text> [--done <task>...]
162
- [--changed <path>...] [--unresolved <text>...]
162
+ [--changed <path>...] [--unresolved <text>...] [--physical <id>]
163
163
  asc session list
164
164
 
165
165
  asc controller collect
@@ -406,10 +406,32 @@ function reportNodeRuntime(check, asJson) {
406
406
  console.error(` ${action.node.version} at ${action.node.path}\n ${action.display}`);
407
407
  }
408
408
  export async function runAscCommand(argv, entry = 'runtime') {
409
- const { values, positionals } = parseArgs({
409
+ let parsed;
410
+ try {
411
+ parsed = parseArgsOrThrow(argv);
412
+ }
413
+ catch (error) {
414
+ // 모르는 옵션은 사용자 입력 오류다 — Node 스택을 던지면 그때부터 도구를 의심하게 된다.
415
+ const message = error instanceof Error ? error.message : String(error);
416
+ console.error(`Could not read the arguments: ${message.split('\n')[0]}`);
417
+ console.error('Run `asc --help` for the commands and flags this build understands.');
418
+ return 2;
419
+ }
420
+ const { values, positionals } = parsed;
421
+ // 실행 중인 버전을 묻는 유일한 공식 통로. 설치 안내가 버전을 핀으로 고정하는데
422
+ // 정작 지금 도는 것이 무엇인지 물을 방법이 없었다 (SSAFESTA Windows 실측 ASC-1).
423
+ if (values.version || positionals[0] === 'version') {
424
+ console.log(RELEASE_VERSION);
425
+ return 0;
426
+ }
427
+ return runParsedCommand(values, positionals, entry, argv);
428
+ }
429
+ function parseArgsOrThrow(argv) {
430
+ return parseArgs({
410
431
  args: argv,
411
432
  allowPositionals: true,
412
433
  options: {
434
+ version: { type: 'boolean', default: false },
413
435
  root: { type: 'string' },
414
436
  parent: { type: 'string' },
415
437
  'issued-by': { type: 'string' },
@@ -509,6 +531,8 @@ export async function runAscCommand(argv, entry = 'runtime') {
509
531
  help: { type: 'boolean', short: 'h', default: false },
510
532
  },
511
533
  });
534
+ }
535
+ async function runParsedCommand(values, positionals, entry, argv) {
512
536
  const [group, command, target, extra] = positionals;
513
537
  if (values.help || group === undefined) {
514
538
  console.log(USAGE);
@@ -612,7 +636,7 @@ export async function runAscCommand(argv, entry = 'runtime') {
612
636
  return runFreeze(group, command, values, store);
613
637
  switch (command) {
614
638
  case 'list': {
615
- const items = await operator.list({ all: values.all, ...(priority ? { priority } : {}) });
639
+ const items = await operator.list({ all: Boolean(values.all), ...(priority ? { priority } : {}) });
616
640
  console.log(values.json ? JSON.stringify(items, null, 2) : renderer.renderList(items).text);
617
641
  return 0;
618
642
  }
@@ -1150,6 +1174,9 @@ async function detectSetupState(values, entry) {
1150
1174
  const { root: projectRoot, git } = await discoverProjectRoot(process.cwd());
1151
1175
  const resolution = await resolveRoot(process.cwd(), values.root);
1152
1176
  const ascRoot = resolution.kind === 'UNRESOLVED' ? undefined : resolution.root;
1177
+ // 디렉터리가 있다고 붙은 것이 아니다 — profile.lock까지 서야 붙은 것이다. 빈 skeleton을
1178
+ // "붙어 있음"으로 넘기면 plan이 applied를 답하며 실패할 proceed를 준다 (실측 ASC-2).
1179
+ const attachmentBroken = ascRoot ? (await inspectSetup(ascRoot)).attachment === 'BROKEN' : false;
1153
1180
  const scope = values.scope === 'project' ? 'project' : 'local';
1154
1181
  const hostReport = await verifyInstall(defaultPaths());
1155
1182
  return {
@@ -1157,6 +1184,7 @@ async function detectSetupState(values, entry) {
1157
1184
  projectRoot,
1158
1185
  git,
1159
1186
  ...(ascRoot ? { ascRoot } : {}),
1187
+ ...(attachmentBroken ? { attachmentBroken } : {}),
1160
1188
  ...(values.profile ? { requestedProfile: values.profile } : {}),
1161
1189
  profileCandidates: await availableProfiles(installRoot(), externalProfileRoot()),
1162
1190
  scope,
@@ -2345,9 +2373,16 @@ async function runSession(command, target, values, store, resolved) {
2345
2373
  const at = new Date().toISOString();
2346
2374
  switch (command) {
2347
2375
  case 'issue': {
2376
+ // 누락과 오값은 다른 실수다 — "필수"라고 답하면 이미 준 사람은 자기가 무엇을
2377
+ // 틀렸는지 모른다 (SSAFESTA Windows 실측 ASC-3).
2378
+ const roleChoices = SessionRole.options.join('|');
2379
+ if (values.role === undefined || !values.goal) {
2380
+ console.error(`--role and --goal are required (role: ${roleChoices})`);
2381
+ return 2;
2382
+ }
2348
2383
  const role = SessionRole.safeParse(values.role);
2349
- if (!role.success || !values.goal) {
2350
- console.error('--role and --goal are required (role: planner|researcher|implementer|verifier)');
2384
+ if (!role.success) {
2385
+ console.error(`'${String(values.role)}' is not a role this build knows — choose one of: ${roleChoices}`);
2351
2386
  return 2;
2352
2387
  }
2353
2388
  const authority = parseAuthority(values.authority);
@@ -2407,12 +2442,19 @@ async function runSession(command, target, values, store, resolved) {
2407
2442
  console.error('At least one --evidence and one --why are required (C-13 §4).');
2408
2443
  return 2;
2409
2444
  }
2445
+ // 오값을 Core까지 흘리면 ZodError 원문이 사람에게 떨어진다 (실측 ASC-5).
2446
+ // 사용자 입력 검증은 Surface의 몫이다 — 여기서 고를 수 있는 값을 그대로 준다.
2447
+ const parsedClass = DecisionClass.safeParse(decisionClass);
2448
+ if (!parsedClass.success) {
2449
+ console.error(`'${decisionClass}' is not a decision class — choose one of: ${DecisionClass.options.join(', ')}`);
2450
+ return 2;
2451
+ }
2410
2452
  const audit = auditLedger(store);
2411
2453
  const recorded = await audit.decide({
2412
2454
  sessionId: target,
2413
2455
  actor: values.as ?? values.principal ?? '(미상)',
2414
2456
  ownership: values.ownership ?? [],
2415
- class: decisionClass,
2457
+ class: parsedClass.data,
2416
2458
  evidenceRefs: [evidence[0], ...evidence.slice(1)],
2417
2459
  selectedOption: selected,
2418
2460
  alternatives: values.alternative ?? [],
@@ -14,6 +14,14 @@ export type SetupState = {
14
14
  git: boolean;
15
15
  /** 이미 붙어 있으면 그 runtime 뿌리. 없으면 안 붙은 것이다. */
16
16
  ascRoot?: string;
17
+ /**
18
+ * runtime 디렉터리는 있는데 profile.lock을 읽지 못하는 상태 — 붙이다 만 것이다.
19
+ *
20
+ * 이것을 "붙어 있음"으로 읽으면 plan은 `applied`를 답하면서 실패할 `asc proceed`를
21
+ * 다음 행동으로 준다 (SSAFESTA Windows 실측 ASC-2: 파일 잠금이 빈 skeleton만 남긴
22
+ * 경우). 붙이다 만 상태는 붙일 것이 남은 상태다 — repair가 plan에 드러나야 한다.
23
+ */
24
+ attachmentBroken?: boolean;
17
25
  /** 붙어 있다면 무엇으로 붙었는가. */
18
26
  attachedProfile?: string;
19
27
  /** 사람이 `--profile` 로 지정한 것. */
@@ -17,7 +17,9 @@ export function computeSetupPlan(state) {
17
17
  const evidence = [
18
18
  `project=${state.projectRoot}`,
19
19
  state.git ? 'git=yes' : 'git=no',
20
- state.ascRoot ? `attached=${state.ascRoot}` : 'attached=no',
20
+ state.ascRoot
21
+ ? `attached=${state.ascRoot}${state.attachmentBroken ? ' (BROKEN — profile.lock unreadable)' : ''}`
22
+ : 'attached=no',
21
23
  `scope=${state.scope}`,
22
24
  ];
23
25
  // 지금 명령이 어디서 도는가. 설치된 `asc` 가 없으면 bootstrap이고, 그때 agent에게
@@ -76,7 +78,7 @@ export function computeSetupPlan(state) {
76
78
  changes.push({ target: 'host-install', host: host.id, from: host.status });
77
79
  }
78
80
  }
79
- if (state.ascRoot) {
81
+ if (state.ascRoot && !state.attachmentBroken) {
80
82
  // 붙어 있어도 **무엇을 고를 수 있었는지**는 사실이다. 사용자 소유 Profile을 새로 놓고
81
83
  // 계획을 물었을 때 그것이 어디에도 안 보이면, 놓은 사람은 경로를 의심하게 된다.
82
84
  if (state.profileCandidates.length > 0) {
@@ -84,7 +86,8 @@ export function computeSetupPlan(state) {
84
86
  }
85
87
  return finish(changes, evidence, state, mode, command);
86
88
  }
87
- // 아직 안 붙었다. 무엇으로 붙을지는 사람이 정한다.
89
+ // 아직 안 붙었거나, 붙이다 말았다(BROKEN). 무엇으로 붙을지는 사람이 정한다 —
90
+ // BROKEN이면 같은 선택으로 다시 붙이는 것이 repair다.
88
91
  const profile = state.requestedProfile ?? soleCandidate(state.profileCandidates);
89
92
  if (!profile) {
90
93
  evidence.push(`profile candidates=${state.profileCandidates.join(', ') || '(none)'}`);
@@ -0,0 +1,22 @@
1
+ export type ResolvedInvocation = {
2
+ command: string;
3
+ args: string[];
4
+ };
5
+ export type ResolveDeps = {
6
+ platform?: NodeJS.Platform;
7
+ env?: NodeJS.ProcessEnv;
8
+ /** 테스트 주입용 — 실제 파일시스템을 보지 않게 한다. */
9
+ exists?: (path: string) => boolean;
10
+ readText?: (path: string) => string | null;
11
+ nodePath?: string;
12
+ };
13
+ /**
14
+ * npm `.cmd` shim이 가리키는 JS 진입점.
15
+ *
16
+ * npm이 쓰는 shim은 두 세대가 있고 둘 다 `"%dp0%\<상대경로>" %*` 형태로 JS를 부른다:
17
+ * "%_prog%" "%dp0%\node_modules\<pkg>\<bin>.js" %*
18
+ * "%dp0%\node.exe" "%dp0%\node_modules\<pkg>\<bin>.js" %*
19
+ * 형태가 다르면 null — 아는 척하지 않고 cmd.exe 경로로 넘어간다.
20
+ */
21
+ export declare function shimTarget(shimText: string): string | null;
22
+ export declare function resolveExternalCommand(command: string, args: readonly string[], deps?: ResolveDeps): ResolvedInvocation;
@@ -0,0 +1,79 @@
1
+ // 바깥 CLI를 Windows에서도 실제로 찾아 부른다 (C-14 §11의 연장).
2
+ //
3
+ // Node는 보안 수정 이후 shell 없이 `.cmd` 를 실행하지 않는다. 그런데 npm이 전역 설치로
4
+ // 만들어 주는 명령은 Windows에서 전부 `.cmd` shim이다 — bare 이름을 Unix 방식으로만
5
+ // spawn하면 ENOENT/EINVAL이 나고, 호출자는 "설치돼 있지 않다"고 오판한다
6
+ // (SSAFESTA Windows 실측: shim이 PATH에 실재하는데 host probe가 not found →
7
+ // external_write_guard STOP까지 이어졌다).
8
+ //
9
+ // shell을 켜는 것은 답이 아니다 — 인자가 escape 없이 이어붙는다(DEP0190). 대신:
10
+ // ① PATH에서 `.exe` 를 찾으면 그대로 부른다 (shell 불필요).
11
+ // ② `.cmd` shim이면 그 안이 가리키는 JS 진입점을 읽어 지금 도는 node로 직접 부른다 —
12
+ // cli/asc.ts의 npm 해석(resolveCommand)과 같은 태도다.
13
+ // ③ shim을 못 읽으면 cmd.exe /d /c 로 그 .cmd 를 부른다 — cmd.exe는 진짜 실행 파일이라
14
+ // shell 옵션이 필요 없다.
15
+ // 셋 다 실패하면 이름 그대로 돌려준다 — PATH에 진짜 실행 파일이 있는 환경이 그 경우다.
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { delimiter as winDelimiter, dirname, extname, isAbsolute, join } from 'node:path/win32';
18
+ const defaultRead = (path) => {
19
+ try {
20
+ return readFileSync(path, 'utf8');
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ };
26
+ /**
27
+ * npm `.cmd` shim이 가리키는 JS 진입점.
28
+ *
29
+ * npm이 쓰는 shim은 두 세대가 있고 둘 다 `"%dp0%\<상대경로>" %*` 형태로 JS를 부른다:
30
+ * "%_prog%" "%dp0%\node_modules\<pkg>\<bin>.js" %*
31
+ * "%dp0%\node.exe" "%dp0%\node_modules\<pkg>\<bin>.js" %*
32
+ * 형태가 다르면 null — 아는 척하지 않고 cmd.exe 경로로 넘어간다.
33
+ */
34
+ export function shimTarget(shimText) {
35
+ const match = /"%dp0%\\([^"%]+\.(?:js|mjs|cjs))"/i.exec(shimText);
36
+ return match ? match[1] : null;
37
+ }
38
+ export function resolveExternalCommand(command, args, deps = {}) {
39
+ const platform = deps.platform ?? process.platform;
40
+ if (platform !== 'win32')
41
+ return { command, args: [...args] };
42
+ // 경로나 확장자를 이미 갖췄으면 호출자가 알고 부르는 것이다 — 손대지 않는다.
43
+ if (isAbsolute(command) || command.includes('/') || command.includes('\\') || extname(command) !== '') {
44
+ return { command, args: [...args] };
45
+ }
46
+ const env = deps.env ?? process.env;
47
+ const exists = deps.exists ?? existsSync;
48
+ const readText = deps.readText ?? defaultRead;
49
+ const nodePath = deps.nodePath ?? process.execPath;
50
+ const pathValue = env.PATH ?? env.Path ?? '';
51
+ let firstShim = null;
52
+ for (const dir of pathValue.split(winDelimiter)) {
53
+ if (!dir)
54
+ continue;
55
+ const exe = join(dir, `${command}.exe`);
56
+ if (exists(exe))
57
+ return { command: exe, args: [...args] };
58
+ if (!firstShim) {
59
+ for (const ext of ['.cmd', '.bat']) {
60
+ const shim = join(dir, `${command}${ext}`);
61
+ if (exists(shim)) {
62
+ firstShim = shim;
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ }
68
+ if (firstShim) {
69
+ const text = readText(firstShim);
70
+ const target = text ? shimTarget(text) : null;
71
+ if (target) {
72
+ const script = join(dirname(firstShim), target);
73
+ if (exists(script))
74
+ return { command: nodePath, args: [script, ...args] };
75
+ }
76
+ return { command: 'cmd.exe', args: ['/d', '/c', firstShim, ...args] };
77
+ }
78
+ return { command, args: [...args] };
79
+ }
@@ -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.3.1";
5
- export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.3.1";
6
- export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.3.1";
4
+ export declare const RELEASE_VERSION = "0.3.2";
5
+ export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.3.2";
6
+ export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.3.2";
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.3.1';
12
+ export const RELEASE_VERSION = '0.3.2';
13
13
  export const RUNTIME_SPEC = `${RUNTIME_PACKAGE}@${RELEASE_VERSION}`;
14
14
  export const BOOTSTRAP_SPEC = `${BOOTSTRAP_PACKAGE}@${RELEASE_VERSION}`;
15
15
  /**
@@ -76,7 +76,9 @@ export class ProgressService {
76
76
  return {
77
77
  ok: false,
78
78
  reason: 'NOT_OWNER',
79
- detail: `${logicalSessionId} Runtime이 붙어 있지 않다 먼저 소유권을 주장하라`,
79
+ // "주장하라"만으로는 다음 명령을 모른다 복구 명령까지가 오류 메시지다 (실측 ASC-4).
80
+ // Core는 host 이름을 모른다(B-17) — 자리만 비워 두면 Surface가 채운다.
81
+ detail: `${logicalSessionId} 에 Runtime이 붙어 있지 않다 — 먼저 소유권을 주장하라: asc host <host> bind ${logicalSessionId} --physical <id>`,
80
82
  };
81
83
  }
82
84
  if (binding.physicalSessionId !== physicalSessionId) {
@@ -70,7 +70,12 @@ export class SessionRuntime {
70
70
  return {
71
71
  ok: false,
72
72
  reason: 'NOT_OWNER',
73
- detail: `${id} 에는 Runtime이 붙어 있다 owner(${binding.physicalSessionId})만 기록할 있다`,
73
+ // owner가 누구인지만 말하면 owner 본인도 다음 명령을 모른다 pause/done은
74
+ // `--physical <owner>` 로 자격을 대고, 아니면 소유권을 먼저 놓는다 (실측 ASC-6).
75
+ // Core는 host 이름을 모른다(B-17) — `<host>` 자리는 Surface의 어휘다.
76
+ detail: `${id} 에는 Runtime이 붙어 있다 — owner(${binding.physicalSessionId})만 기록할 수 있다. ` +
77
+ `owner라면 --physical ${binding.physicalSessionId} 을 붙여 다시 실행하고, ` +
78
+ `그 세션이 끝났다면 asc host <host> release ${id} --physical ${binding.physicalSessionId} 로 소유권을 놓아라`,
74
79
  };
75
80
  }
76
81
  if (binding.physicalSessionId !== physicalSessionId) {
@@ -66,7 +66,13 @@ async function findProjectLocal(start, stopAt, exists) {
66
66
  // 정지선 자체는 보지 않는다 — 홈의 `.asc` 는 user runtime이지 프로젝트 상태가 아니다
67
67
  if (boundary && normalized === boundary)
68
68
  return null;
69
- if (await exists(join(dir, ASC_DIR)))
69
+ const candidate = join(dir, ASC_DIR);
70
+ // 정지선이 홈이어도 **다른 홈**의 `.asc` 는 걸러지지 않는다. Windows는 temp
71
+ // 디렉터리가 사용자 프로필 아래라, temp의 프로젝트에서 위로 걷다 실사용자
72
+ // `~/.asc` 를 프로젝트 상태로 오인했다 (SSAFESTA 실측 — setup status가
73
+ // UNATTACHED 대신 BROKEN을 답한 원인). user runtime은 내용으로 알아본다:
74
+ // workspaces/·profiles/·runtime.json 은 홈에만 생긴다.
75
+ if ((await exists(candidate)) && !(await looksLikeUserRuntime(candidate, exists)))
70
76
  return dir;
71
77
  const parent = dirname(dir);
72
78
  if (parent === dir)
@@ -74,6 +80,14 @@ async function findProjectLocal(start, stopAt, exists) {
74
80
  dir = parent;
75
81
  }
76
82
  }
83
+ /** `~/.asc` 꼴인가 — 프로젝트 부착이 아니라 이 기계의 user runtime 홈인가. */
84
+ async function looksLikeUserRuntime(ascDir, exists) {
85
+ for (const marker of ['workspaces', 'profiles', 'runtime.json']) {
86
+ if (await exists(join(ascDir, marker)))
87
+ return true;
88
+ }
89
+ return false;
90
+ }
77
91
  /** 사람이 읽는 한 줄. 왜 그 뿌리인지가 함께 와야 사람이 틀린 결합을 알아챈다. */
78
92
  export function resolutionLine(resolution) {
79
93
  switch (resolution.kind) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asc-agent/runtime",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",