@asc-agent/runtime 0.3.0 → 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.0
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 {
@@ -32,6 +32,10 @@ description: >-
32
32
  Safely start, resume or continue a Logical Session in a project that has ASC attached.
33
33
  Triggers — "proceed with ASC", "continue the ASC session", "asc proceed", "start work
34
34
  with ASC", "ASC로 진행해", "ASC 세션 이어서", "ASC로 작업 시작", or the explicit /asc.
35
+ Also triggers on work-status questions in an ASC-attached project — "is this
36
+ implemented?", "what's left to do?", "what should I do now?", "현황 파악",
37
+ "이거 구현됐나", "남은 작업", "지금 뭐 해야 하나" — because answering those by
38
+ exploring the host directly skips ASC's freshness and evidence gates.
35
39
  Do not use it in a project where ASC is not attached.
36
40
  ---
37
41
 
@@ -40,6 +44,16 @@ description: >-
40
44
  This skill is a consumer of the ASC Generic Operator. The judgement belongs to the asc
41
45
  CLI — here you call it, act on the typed outcome, and keep the contract.
42
46
 
47
+ ## Work-status questions go through ASC first
48
+
49
+ "Is X implemented?", "what's the status of this work?", "what remains?" — in an
50
+ ASC-attached project, do NOT settle these by direct host exploration (git log, tracker
51
+ reads). Run \`asc proceed --work <KEY>\` first and read its WORK_STATE: it fetches the
52
+ canonical remote, grades the evidence, and refuses to conclude on a stale or key-only
53
+ observation — none of which ad-hoc exploration does. Direct exploration may supplement
54
+ the answer, never replace the judgement. This is mitigation at the model layer; the
55
+ freshness and evidence gates in the CLI remain the final safeguard.
56
+
43
57
  ## Entry — "proceed with ASC"
44
58
 
45
59
  That phrase means: **check the current state, secure readiness, find the runnable work I
@@ -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
  });
@@ -56,10 +56,19 @@ export class LocalRepoAdapter {
56
56
  if (query.refHint) {
57
57
  observation.refs = filterRefs(await this.#git(['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes']), query.refHint);
58
58
  }
59
- const canonicalRef = query.canonicalRef ?? (await this.#defaultCanonicalRef());
59
+ // 신선도가 먼저다. 로컬 브랜치를 정본처럼 읽으면 원격이 전진한 사실을 모른 채
60
+ // "구현 증거가 없다"가 나온다 — 그것이 이 어댑터가 실전에서 낸 사고였다.
61
+ const canonical = await this.#freshCanonical(query);
62
+ const canonicalRef = canonical.ref;
63
+ observation.freshness = canonical.freshness;
60
64
  if (canonicalRef) {
61
65
  observation.canonicalRef = canonicalRef;
62
66
  observation.mergedIntoCanonical = await this.#anyMerged(observation.refs, canonicalRef);
67
+ if (observation.mergedIntoCanonical !== true && observation.refs.length > 0) {
68
+ const equivalent = await this.#contentEquivalent(observation.refs, canonicalRef);
69
+ if (equivalent !== undefined)
70
+ observation.contentEquivalent = equivalent;
71
+ }
63
72
  if (query.refHint) {
64
73
  // 가지가 지워졌어도 이력은 남는다 — 커밋 메시지가 이 작업을 언급하는지 본다.
65
74
  const log = await this.#git(['log', '--format=%h %s', `--grep=${query.refHint}`, '-n', '5', canonicalRef]);
@@ -98,6 +107,56 @@ export class LocalRepoAdapter {
98
107
  }
99
108
  return observation;
100
109
  }
110
+ /**
111
+ * 정본 대조 기준과 그 신선도. Profile 이 remote 를 선언했으면 당겨 온 뒤 원격 추적
112
+ * ref 를 기준으로 삼는다 — fetch 는 읽기다(원격 write 가 아니다). 실패는 흡수하되
113
+ * FETCH_FAILED 로 남긴다: "당기지 못했다"와 "저장소가 없다"는 다른 사실이다.
114
+ */
115
+ async #freshCanonical(query) {
116
+ const declared = query.canonicalRef;
117
+ if (query.remote && declared) {
118
+ const branch = declared.startsWith(`${query.remote}/`)
119
+ ? declared.slice(query.remote.length + 1)
120
+ : declared;
121
+ const fetched = await this.#git(['fetch', query.remote, branch]);
122
+ const tracking = `${query.remote}/${branch}`;
123
+ if (fetched !== null)
124
+ return { ref: tracking, freshness: { state: 'FRESH' } };
125
+ // 당기지 못했어도 원격 추적 ref 가 있으면 그쪽이 로컬 브랜치보다 정본에 가깝다.
126
+ const trackingExists = (await this.#git(['rev-parse', '--verify', '--quiet', tracking])) !== null;
127
+ return {
128
+ ref: trackingExists ? tracking : declared,
129
+ freshness: { state: 'FETCH_FAILED', detail: `git fetch ${query.remote} ${branch} 실패` },
130
+ };
131
+ }
132
+ const ref = declared ?? (await this.#defaultCanonicalRef());
133
+ return { ref, freshness: { state: 'UNKNOWN', detail: '당겨 올 원격이 선언되지 않았다' } };
134
+ }
135
+ /**
136
+ * 조상은 아니지만 내용이 전부 정본에 있는가 (단일 커밋 squash·rebase·cherry-pick 등가).
137
+ * `git cherry` 는 patch-id 로 대조한다 — `-` 만 나오면 전부 반영, `+` 가 있으면 남은
138
+ * 커밋이 있다. 빈 출력은 가지가 정본과 같다는 뜻이라 반영으로 친다.
139
+ *
140
+ * 한계(검증자 실측): **여러 커밋을 하나로 합친 squash 는 못 잡는다** — 합쳐진 patch-id
141
+ * 는 개별 커밋 어느 것과도 일치하지 않는다. 그 경우 커밋 메시지의 키 언급(grep)이
142
+ * 남은 통로이고, 그것마저 없으면 이 관측은 반영 사실을 모른다.
143
+ */
144
+ async #contentEquivalent(refs, canonicalRef) {
145
+ let measured = false;
146
+ for (const ref of refs) {
147
+ const out = await this.#git(['cherry', canonicalRef, ref]);
148
+ if (out === null)
149
+ continue;
150
+ measured = true;
151
+ const lines = out
152
+ .split('\n')
153
+ .map((line) => line.trim())
154
+ .filter((line) => line.length > 0);
155
+ if (lines.every((line) => line.startsWith('-')))
156
+ return true;
157
+ }
158
+ return measured ? false : undefined;
159
+ }
101
160
  /**
102
161
  * Profile 이 정본 ref 를 선언하지 않았을 때, 저장소 자신에게 묻는다 (origin/HEAD).
103
162
  * 추측이 아니라 관측이다 — 없으면 없는 대로 둔다.
package/dist/cli/asc.js CHANGED
@@ -13,7 +13,8 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { homedir, userInfo } from 'node:os';
15
15
  import { basename, dirname, join, resolve } from 'node:path';
16
- import { MINIMUM_NODE_MAJOR, checkNodeRuntime, } from "../core/distribution/node-runtime.js";
16
+ import { MINIMUM_NODE_MAJOR, checkNodeRuntime, reexecWithCandidate, } from "../core/distribution/node-runtime.js";
17
+ import { RELEASE_VERSION } from "../core/distribution/release.js";
17
18
  import { GitHubClient, discoverToken } from "../adapters/github/client.js";
18
19
  import { GitHubChangeContext, GitHubInventory, GitHubResourceContext } from "../adapters/github/context.js";
19
20
  import { GitHubEventSource } from "../adapters/github/event-source.js";
@@ -63,7 +64,7 @@ import { preflight } from "../core/operator/preflight.js";
63
64
  import { DraftProvenance, issueArgs, planSessionContract, } from "../core/operator/contract-draft.js";
64
65
  import { lookupAuthority } from "../core/policy/ownership.js";
65
66
  import { renderProgress } from "../core/operator/render.js";
66
- 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";
67
68
  import { ClosureLedger } from "../core/runtime/closure.js";
68
69
  import { Orchestrator, renderTick } from "../core/runtime/orchestrator.js";
69
70
  import { QueryLedger } from "../core/runtime/query.js";
@@ -158,7 +159,7 @@ const USAGE = `asc — Agent Session Control
158
159
  asc session pause <ID> --position <text> --next <text> [--done <task>...]
159
160
  asc session resume <ID>
160
161
  asc session done <ID> --verified <text> --next <text> [--done <task>...]
161
- [--changed <path>...] [--unresolved <text>...]
162
+ [--changed <path>...] [--unresolved <text>...] [--physical <id>]
162
163
  asc session list
163
164
 
164
165
  asc controller collect
@@ -405,10 +406,32 @@ function reportNodeRuntime(check, asJson) {
405
406
  console.error(` ${action.node.version} at ${action.node.path}\n ${action.display}`);
406
407
  }
407
408
  export async function runAscCommand(argv, entry = 'runtime') {
408
- 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({
409
431
  args: argv,
410
432
  allowPositionals: true,
411
433
  options: {
434
+ version: { type: 'boolean', default: false },
412
435
  root: { type: 'string' },
413
436
  parent: { type: 'string' },
414
437
  'issued-by': { type: 'string' },
@@ -508,6 +531,8 @@ export async function runAscCommand(argv, entry = 'runtime') {
508
531
  help: { type: 'boolean', short: 'h', default: false },
509
532
  },
510
533
  });
534
+ }
535
+ async function runParsedCommand(values, positionals, entry, argv) {
511
536
  const [group, command, target, extra] = positionals;
512
537
  if (values.help || group === undefined) {
513
538
  console.log(USAGE);
@@ -521,6 +546,17 @@ export async function runAscCommand(argv, entry = 'runtime') {
521
546
  // 두면 그쪽에 정책이 생기고(C-14 불변식 ⑦), 그러면 두 진입의 답이 갈릴 수 있다.
522
547
  const runnable = await checkNodeRuntime(nodeRuntimeDeps());
523
548
  if (!runnable.ok) {
549
+ // 후보를 이미 찾았으면 처방 대신 실행한다 — 같은 명령, 같은 argv, 호환 Node (A6).
550
+ const reexec = reexecWithCandidate(runnable, argv, {
551
+ env: process.env,
552
+ entry: fileURLToPath(import.meta.url),
553
+ spawn: (path, args, env) => {
554
+ const child = spawnSync(path, args, { stdio: 'inherit', env: env });
555
+ return { status: child.status, signal: child.signal, ...(child.error ? { error: child.error } : {}) };
556
+ },
557
+ });
558
+ if (reexec !== null)
559
+ return reexec;
524
560
  reportNodeRuntime(runnable, Boolean(values.json) || Boolean(values.agent));
525
561
  return 1;
526
562
  }
@@ -600,7 +636,7 @@ export async function runAscCommand(argv, entry = 'runtime') {
600
636
  return runFreeze(group, command, values, store);
601
637
  switch (command) {
602
638
  case 'list': {
603
- const items = await operator.list({ all: values.all, ...(priority ? { priority } : {}) });
639
+ const items = await operator.list({ all: Boolean(values.all), ...(priority ? { priority } : {}) });
604
640
  console.log(values.json ? JSON.stringify(items, null, 2) : renderer.renderList(items).text);
605
641
  return 0;
606
642
  }
@@ -1007,7 +1043,9 @@ function declaredPolicies(resolved) {
1007
1043
  }
1008
1044
  return declared;
1009
1045
  }
1010
- const ASC_VERSION = '0.2.1';
1046
+ // RELEASE_VERSION 정본이다 — 여기 문자열을 따로 두면 릴리스마다 낡는다 (0.3.0 에서
1047
+ // 0.2.1 로 남아 lock 의 ascVersion 표기가 실제와 어긋났다).
1048
+ const ASC_VERSION = RELEASE_VERSION;
1011
1049
  const CAPABILITIES = ['scm.github', 'state.markdown', 'approval.local'];
1012
1050
  const ADAPTER_VERSIONS = { 'scm.github': ASC_VERSION, 'state.markdown': ASC_VERSION };
1013
1051
  /**
@@ -1136,6 +1174,9 @@ async function detectSetupState(values, entry) {
1136
1174
  const { root: projectRoot, git } = await discoverProjectRoot(process.cwd());
1137
1175
  const resolution = await resolveRoot(process.cwd(), values.root);
1138
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;
1139
1180
  const scope = values.scope === 'project' ? 'project' : 'local';
1140
1181
  const hostReport = await verifyInstall(defaultPaths());
1141
1182
  return {
@@ -1143,6 +1184,7 @@ async function detectSetupState(values, entry) {
1143
1184
  projectRoot,
1144
1185
  git,
1145
1186
  ...(ascRoot ? { ascRoot } : {}),
1187
+ ...(attachmentBroken ? { attachmentBroken } : {}),
1146
1188
  ...(values.profile ? { requestedProfile: values.profile } : {}),
1147
1189
  profileCandidates: await availableProfiles(installRoot(), externalProfileRoot()),
1148
1190
  scope,
@@ -1520,9 +1562,18 @@ async function runProfile(command, values, root) {
1520
1562
  ...(values.preset ? { presetId: values.preset } : {}),
1521
1563
  overridePath: join(root, 'override.json'),
1522
1564
  });
1523
- // 무엇이 실제로 제공되는지는 붙어 있는 Adapter가 정한다
1524
- const capabilities = ['scm.github', 'state.markdown', 'approval.local'];
1525
- const result = resolveRuntime(layers, capabilities, ASC_VERSION);
1565
+ // 무엇이 실제로 제공되는지는 붙어 있는 Adapter가 정한다 — 상수가 아니라 실측이다 (A5).
1566
+ // discover→probe 실제로 돌린다: glab 로그인 같은 상태 변화가 resolve 재실행으로
1567
+ // 반영된다 (re-probe 수단이 따로 필요 없다). 실측 실패는 그 갈래가 빠질 뿐이다.
1568
+ const composed = await composeBindings({ context: { projectRoot: root, env: process.env } }).catch(() => ({ bindings: [] }));
1569
+ for (const binding of composed.bindings) {
1570
+ console.log(`Binding: ${binding.adapterId}/${binding.resource} — ${binding.state}${binding.detail ? ` (${binding.detail})` : ''}`);
1571
+ }
1572
+ // lock 의 capability 표기는 아직 상수다 — 실측을 digest 재료로 쓰면 glab 로그인 여부에
1573
+ // 따라 lock 이 흔들린다. 실측을 lock 체계에 관통시키는 것은 P1 로 넘기고(재설계 영역),
1574
+ // 여기서는 probe 실측을 사람에게 보이는 것까지 한다. 실사용 read 경로(proceed)는
1575
+ // buildWorkIngress 가 이미 composeBindings 실측으로 조립한다.
1576
+ const result = resolveRuntime(layers, CAPABILITIES, ASC_VERSION);
1526
1577
  if (!result.ok) {
1527
1578
  console.error('resolve failed:');
1528
1579
  for (const failure of result.failures) {
@@ -1535,7 +1586,7 @@ async function runProfile(command, values, root) {
1535
1586
  const lock = buildLock({
1536
1587
  runtime,
1537
1588
  ascVersion: ASC_VERSION,
1538
- adapters: { 'scm.github': ASC_VERSION, 'state.markdown': ASC_VERSION },
1589
+ adapters: ADAPTER_VERSIONS,
1539
1590
  generatedAt,
1540
1591
  });
1541
1592
  console.log(`Layers: ${runtime.resolved.policy.layers.join(' → ')}`);
@@ -2068,7 +2119,10 @@ async function buildWorkIngress(store, root, runtime, resolved) {
2068
2119
  return undefined;
2069
2120
  // 저장소는 원격 provider 와 무관하게 본다. 이 한 줄이 P0-E 의 요점이다.
2070
2121
  const repo = new LocalRepoAdapter({ cwd: projectRoot });
2071
- const canonicalRef = resolved?.layers.profile.canonical.sources[0]?.ref;
2122
+ const canonicalSource = resolved?.layers.profile.canonical.sources[0];
2123
+ const canonicalRef = canonicalSource?.ref;
2124
+ // remote 를 버리면 로컬 브랜치를 정본처럼 읽는다 — 실전 오판의 경로였다.
2125
+ const canonicalRemote = canonicalSource?.remote;
2072
2126
  const canonicalPaths = resolved?.layers.profile.canonical.sources.flatMap((source) => source.paths) ?? [];
2073
2127
  const changeContext = ports.changeContext;
2074
2128
  return {
@@ -2114,6 +2168,7 @@ async function buildWorkIngress(store, root, runtime, resolved) {
2114
2168
  return repo.observe({
2115
2169
  refHint: query.refHint,
2116
2170
  ...(canonicalRef ? { canonicalRef } : {}),
2171
+ ...(canonicalRemote ? { remote: canonicalRemote } : {}),
2117
2172
  ...(paths.length > 0 ? { paths } : {}),
2118
2173
  ...(modules.length > 0 ? { modulePaths: modules } : {}),
2119
2174
  });
@@ -2318,9 +2373,16 @@ async function runSession(command, target, values, store, resolved) {
2318
2373
  const at = new Date().toISOString();
2319
2374
  switch (command) {
2320
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
+ }
2321
2383
  const role = SessionRole.safeParse(values.role);
2322
- if (!role.success || !values.goal) {
2323
- 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}`);
2324
2386
  return 2;
2325
2387
  }
2326
2388
  const authority = parseAuthority(values.authority);
@@ -2380,12 +2442,19 @@ async function runSession(command, target, values, store, resolved) {
2380
2442
  console.error('At least one --evidence and one --why are required (C-13 §4).');
2381
2443
  return 2;
2382
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
+ }
2383
2452
  const audit = auditLedger(store);
2384
2453
  const recorded = await audit.decide({
2385
2454
  sessionId: target,
2386
2455
  actor: values.as ?? values.principal ?? '(미상)',
2387
2456
  ownership: values.ownership ?? [],
2388
- class: decisionClass,
2457
+ class: parsedClass.data,
2389
2458
  evidenceRefs: [evidence[0], ...evidence.slice(1)],
2390
2459
  selectedOption: selected,
2391
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
+ }
@@ -42,3 +42,32 @@ export declare function majorOf(version: string): number | null;
42
42
  * 후보를 찾고, 후보에 대해서만 `-v` 를 묻는다.
43
43
  */
44
44
  export declare function checkNodeRuntime(deps: NodeRuntimeDeps): Promise<NodeRuntimeCheck>;
45
+ /**
46
+ * 재실행 감시자. 이 변수가 있으면 이미 후보 Node 로 한 번 건너뛴 프로세스다 —
47
+ * 거기서도 하한 미달이면 후보 자체가 낡은 것이고, 또 건너뛰면 무한 루프다.
48
+ */
49
+ export declare const REEXEC_SENTINEL = "ASC_NODE_REEXEC";
50
+ export type ReexecSpawn = (path: string, args: string[], env: Record<string, string | undefined>) => {
51
+ status: number | null;
52
+ signal?: string | null;
53
+ error?: Error;
54
+ };
55
+ export type ReexecDeps = {
56
+ env: Record<string, string | undefined>;
57
+ spawn: ReexecSpawn;
58
+ /** 지금 이 CLI 의 entry 파일 — 같은 명령을 그대로 다시 돌린다. */
59
+ entry: string;
60
+ };
61
+ /**
62
+ * 호환 Node 를 이미 찾았으면 그 Node 로 **이 명령을 그대로 다시 돌린다** (A6).
63
+ *
64
+ * 진단(NODE_RUNTIME_REQUIRED)은 정상 동작이었지만, 후보를 찾아 놓고도 매 호출
65
+ * PATH prefix 를 처방하는 것은 persistent 사용성 결함이다 — 처방 대신 실행한다.
66
+ * PATH·shell profile 은 여전히 건드리지 않는다(불변식 ⑰): 프로세스 안의 재실행일 뿐이다.
67
+ *
68
+ * null 은 "재실행하지 않았다" — 후보 없음, 이미 재실행된 프로세스(sentinel), 또는
69
+ * spawn 실패. 그때는 기존 안내가 그대로 나간다.
70
+ */
71
+ export declare function reexecWithCandidate(check: Extract<NodeRuntimeCheck, {
72
+ ok: false;
73
+ }>, argv: readonly string[], deps: ReexecDeps): number | null;
@@ -73,3 +73,38 @@ export async function checkNodeRuntime(deps) {
73
73
  candidates,
74
74
  };
75
75
  }
76
+ /**
77
+ * 재실행 감시자. 이 변수가 있으면 이미 후보 Node 로 한 번 건너뛴 프로세스다 —
78
+ * 거기서도 하한 미달이면 후보 자체가 낡은 것이고, 또 건너뛰면 무한 루프다.
79
+ */
80
+ export const REEXEC_SENTINEL = 'ASC_NODE_REEXEC';
81
+ /**
82
+ * 호환 Node 를 이미 찾았으면 그 Node 로 **이 명령을 그대로 다시 돌린다** (A6).
83
+ *
84
+ * 진단(NODE_RUNTIME_REQUIRED)은 정상 동작이었지만, 후보를 찾아 놓고도 매 호출
85
+ * PATH prefix 를 처방하는 것은 persistent 사용성 결함이다 — 처방 대신 실행한다.
86
+ * PATH·shell profile 은 여전히 건드리지 않는다(불변식 ⑰): 프로세스 안의 재실행일 뿐이다.
87
+ *
88
+ * null 은 "재실행하지 않았다" — 후보 없음, 이미 재실행된 프로세스(sentinel), 또는
89
+ * spawn 실패. 그때는 기존 안내가 그대로 나간다.
90
+ */
91
+ export function reexecWithCandidate(check, argv, deps) {
92
+ if (deps.env[REEXEC_SENTINEL])
93
+ return null;
94
+ const candidate = check.candidates.find((c) => {
95
+ const major = majorOf(c.version);
96
+ return major !== null && major >= MINIMUM_NODE_MAJOR;
97
+ });
98
+ if (!candidate)
99
+ return null;
100
+ const child = deps.spawn(candidate.path, [deps.entry, ...argv], {
101
+ ...deps.env,
102
+ [REEXEC_SENTINEL]: '1',
103
+ });
104
+ if (child.error)
105
+ return null;
106
+ if (child.signal)
107
+ return 128 + (REEXEC_SIGNALS[child.signal] ?? 15);
108
+ return child.status ?? 0;
109
+ }
110
+ const REEXEC_SIGNALS = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
@@ -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.0";
5
- export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.3.0";
6
- export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.3.0";
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.0';
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) {
@@ -44,6 +44,12 @@ export type WorkStateInput = {
44
44
  export type WorkStateResult = {
45
45
  state: WorkState;
46
46
  leaning?: DecidedState;
47
+ /**
48
+ * 구현 존재 증거의 등급. direct 는 정본 자체가 말하는 것(조상·정본 경로·내용 등가),
49
+ * proxy 는 키를 경유한 추정(언급 grep·작업 트리 잔재), none 은 아무것도 없다.
50
+ * "이 키 기준으로 못 찾았다"와 "구현이 없다"를 가르는 것이 이 칸이다.
51
+ */
52
+ evidenceGrade: 'direct' | 'proxy' | 'none';
47
53
  /** 판정의 근거. 사람이 그대로 읽는다. */
48
54
  evidence: string[];
49
55
  /** 보려 했으나 못 본 것. 판정을 뒤집지는 않지만 숨기지도 않는다. */
@@ -33,6 +33,7 @@ export function judgeWorkState(input) {
33
33
  evidence,
34
34
  limitations,
35
35
  missing,
36
+ evidenceGrade: 'none',
36
37
  };
37
38
  }
38
39
  const repo = input.repo;
@@ -49,7 +50,8 @@ export function judgeWorkState(input) {
49
50
  // 언급은 그 자체로 증거가 아니다. 되돌리기만 있는 이력도 이 작업을 "언급"하고, 뒤이어
50
51
  // 걷혀 나간 변경도 그렇다. 살아남은 것이 있어야 정본에 있다고 말할 수 있다.
51
52
  const mentionSurvives = mentioned.length > 0 && repo.mentionedOnlyReverts !== true && repo.mentionedArtifactsPresent === true;
52
- const merged = repo.mergedIntoCanonical === true || onCanonical.length > 0 || mentionSurvives;
53
+ const directEvidence = repo.mergedIntoCanonical === true || onCanonical.length > 0 || repo.contentEquivalent === true;
54
+ const merged = directEvidence || mentionSurvives;
53
55
  const hasBranch = repo.refs.length > 0;
54
56
  const artifacts = Object.entries(repo.pathsExist).filter(([, exists]) => exists);
55
57
  if (repo.canonicalRef)
@@ -58,6 +60,9 @@ export function judgeWorkState(input) {
58
60
  evidence.push(`작업 가지: ${repo.refs.join(', ')}`);
59
61
  if (repo.mergedIntoCanonical === true)
60
62
  evidence.push('작업 가지가 정본에 병합돼 있다');
63
+ if (repo.contentEquivalent === true) {
64
+ evidence.push('작업 가지의 내용이 전부 정본에 반영돼 있다 (조상은 아니다 — rebase·squash 등가)');
65
+ }
61
66
  if (onCanonical.length > 0)
62
67
  evidence.push(`정본에 산출물이 있다: ${onCanonical.map(([p]) => p).join(', ')}`);
63
68
  if (artifacts.length > 0)
@@ -85,6 +90,11 @@ export function judgeWorkState(input) {
85
90
  limitations.push(`선행 작업 상태를 확인하지 못했다: ${unknownDependencies.map((d) => d.reference).join(', ')}`);
86
91
  }
87
92
  const implemented = merged || artifacts.length > 0;
93
+ const evidenceGrade = directEvidence
94
+ ? 'direct'
95
+ : mentionSurvives || artifacts.length > 0
96
+ ? 'proxy'
97
+ : 'none';
88
98
  // ① 구현은 정본에 있는데 tracker 가 안 따라왔다. 여기서만 tracker 를 본다 — 그것도
89
99
  // "끝났다고 말하지 않는다"는 사실로만. tracker 가 결론을 만드는 자리는 없다.
90
100
  // 확정하려면 **살아 있는 산출물**이 있어야 한다: 병합 흔적만으로는 부분 병합·스캐폴드·
@@ -94,35 +104,54 @@ export function judgeWorkState(input) {
94
104
  const artifactSurvives = repo.mergedIntoCanonical === true || onCanonical.length > 0 || repo.mentionedArtifactsPresent === true;
95
105
  if (merged && input.trackerDone === false && artifactSurvives && repo.mentionedOnlyReverts !== true) {
96
106
  limitations.push('인수 조건 전체가 지금도 충족되는지는 확인하지 않았다 — 여기서 말하는 것은 구현의 생존까지다');
97
- return decided('IMPLEMENTED_STALE_TRACKER', evidence, limitations, { demote: false });
107
+ // 측정된 반증은 언급-생존보다 무겁다: cherry "가지에 정본 미반영 커밋이 남아
108
+ // 있다"고 말했으면, 언급 grep 만으로 "할 일은 상태 정리"를 확정하지 않는다.
109
+ if (repo.contentEquivalent === false) {
110
+ limitations.push('작업 가지에 정본에 반영되지 않은 커밋이 남아 있다 (patch 대조) — 상태 정리만 남았다고 확정하지 않는다');
111
+ return decided('IMPLEMENTED_STALE_TRACKER', evidence, limitations, { demote: true, grade: evidenceGrade });
112
+ }
113
+ return decided('IMPLEMENTED_STALE_TRACKER', evidence, limitations, { demote: false, grade: evidenceGrade });
98
114
  }
99
115
  if (merged && input.trackerDone === false) {
100
116
  // 병합 흔적은 있는데 생존을 확인하지 못했다 — 새 구현을 시키지도, 끝났다고 하지도 않는다.
101
117
  limitations.push('정본에 병합 흔적은 있으나 구현이 지금도 남아 있는지 확인하지 못했다');
102
- return decided('IMPLEMENTATION_COMPLETE_BLOCKED_VERIFICATION', evidence, limitations, { demote: true });
118
+ return decided('IMPLEMENTATION_COMPLETE_BLOCKED_VERIFICATION', evidence, limitations, { demote: true, grade: evidenceGrade });
103
119
  }
104
120
  // ② 구현 증거는 있는데 남은 검증 경로가 막혔다.
105
121
  if (implemented && input.change === 'UNAVAILABLE' && !merged) {
106
- return decided('IMPLEMENTATION_COMPLETE_BLOCKED_VERIFICATION', evidence, limitations, { demote: true });
122
+ return decided('IMPLEMENTATION_COMPLETE_BLOCKED_VERIFICATION', evidence, limitations, { demote: true, grade: evidenceGrade });
107
123
  }
108
124
  // ③ 가지는 있는데 병합 전이고 선행 작업이 열려 있다.
109
125
  if (hasBranch && !merged && openDependencies.length > 0) {
110
- return decided('BLOCKED_DEPENDENCY', evidence, limitations, { demote: false });
126
+ return decided('BLOCKED_DEPENDENCY', evidence, limitations, { demote: false, grade: evidenceGrade });
111
127
  }
112
128
  if (!implemented && openDependencies.length > 0) {
113
- return decided('BLOCKED_DEPENDENCY', evidence, limitations, { demote: false });
129
+ return decided('BLOCKED_DEPENDENCY', evidence, limitations, { demote: false, grade: evidenceGrade });
114
130
  }
115
131
  // ④ 검토가 답을 기다린다.
116
132
  if (typeof input.change === 'object' && requestsResponse(input.change, input.comments)) {
117
- return decided('REVIEW_RESPONSE_REQUIRED', evidence, limitations, { demote: true });
133
+ return decided('REVIEW_RESPONSE_REQUIRED', evidence, limitations, { demote: true, grade: evidenceGrade });
118
134
  }
119
135
  // ⑤ 구현 증거도 없고 막힌 것도 없다.
136
+ //
137
+ // "없다"는 신선한 정본에서만 성립한다. 당겨 오지 못한 관측 위의 "없음"은 원격이
138
+ // 이미 품고 있는 구현을 못 본 것일 수 있다 — 그때는 착수를 추천하지 않는다.
139
+ // fetch 실패는 저장소 부재가 아니다: missing 은 freshness 를 따로 가리킨다.
120
140
  if (!implemented) {
121
- evidence.push('정본·작업 트리 어디에도 구현 증거가 없다');
122
- return decided('ACTIONABLE', evidence, limitations, { demote: true });
141
+ if (repo.freshness?.state !== 'FRESH') {
142
+ limitations.push(`정본을 원격에서 당겨 오지 못한 관측이다 (${repo.freshness?.state ?? 'UNKNOWN'}${repo.freshness?.detail ? ` — ${repo.freshness.detail}` : ''}) — 이 위에서 "구현이 없다"를 확정하지 않는다`);
143
+ return { state: 'UNDECIDABLE', evidence, limitations, missing: ['canonical-freshness'], evidenceGrade };
144
+ }
145
+ evidence.push('이 작업 키를 직접 가리키는 증거를 확인하지 못했다');
146
+ const result = decided('ACTIONABLE', evidence, limitations, { demote: true, grade: evidenceGrade });
147
+ // 구조적 한계 — 키 대조는 proxy 다. 다른 키의 커밋이 이 작업의 인수 조건을 이미
148
+ // 충족했을 가능성은 여기서 대조하지 않았다. 표기는 하되 이 한 줄로 판정을 되돌리지는
149
+ // 않는다 (모든 관측에 항상 붙는 한계라, demote 재료로 쓰면 ACTIONABLE 이 사라진다).
150
+ result.limitations.push('다른 키·경로로 이미 충족됐을 가능성은 대조하지 않았다 — 키 기준 관측의 구조적 한계');
151
+ return result;
123
152
  }
124
153
  // 구현 증거는 있는데 위 어디에도 안 걸린다 — 남은 것은 검증이고, 무엇이 막혔는지는 모른다.
125
- return decided('IMPLEMENTATION_COMPLETE_BLOCKED_VERIFICATION', evidence, limitations, { demote: true });
154
+ return decided('IMPLEMENTATION_COMPLETE_BLOCKED_VERIFICATION', evidence, limitations, { demote: true, grade: evidenceGrade });
126
155
  }
127
156
  /** 검토가 응답을 요구하는가. provider 어휘를 해석하지 않고 두 가지 표시만 본다. */
128
157
  function requestsResponse(change, comments) {
@@ -139,7 +168,14 @@ function requestsResponse(change, comments) {
139
168
  */
140
169
  function decided(state, evidence, limitations, options) {
141
170
  if (options.demote && limitations.length > 0) {
142
- return { state: 'DECIDABLE_WITH_LIMITATION', leaning: state, evidence, limitations, missing: [] };
171
+ return {
172
+ state: 'DECIDABLE_WITH_LIMITATION',
173
+ leaning: state,
174
+ evidence,
175
+ limitations,
176
+ missing: [],
177
+ evidenceGrade: options.grade,
178
+ };
143
179
  }
144
- return { state, evidence, limitations, missing: [] };
180
+ return { state, evidence, limitations, missing: [], evidenceGrade: options.grade };
145
181
  }
@@ -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) {
@@ -44,6 +44,24 @@ export type RepoObservation = {
44
44
  * 않은 것을 확인했다고 하지 않는다.
45
45
  */
46
46
  mentionedArtifactsPresent?: boolean;
47
+ /**
48
+ * refs 중 정본의 조상은 아니지만 **내용이 전부 정본에 반영된**(rebase·cherry-pick 등가)
49
+ * 가지가 있는가. SHA·이슈 키가 사라져도 내용이 살아 있으면 "미구현"이 아니다.
50
+ * 로컬에 가지가 남아 있을 때만 잴 수 있다 — 못 쟀으면 undefined.
51
+ */
52
+ contentEquivalent?: boolean;
53
+ /**
54
+ * 정본 대조가 얼마나 신선한가. **관측했다 ≠ 신선하다** — 로컬만 읽은 관측은 원격이
55
+ * 전진한 사실을 모르고, 그 위에서 "구현 증거가 없다"는 결론은 성립하지 않는다.
56
+ *
57
+ * FRESH 원격을 당겨 온 뒤의 원격 추적 ref 를 봤다
58
+ * FETCH_FAILED 당기려 했으나 실패했다 — 관측은 낡았을 수 있다
59
+ * UNKNOWN 당길 대상을 몰랐다 (remote 미선언 등)
60
+ */
61
+ freshness?: {
62
+ state: 'FRESH' | 'FETCH_FAILED' | 'UNKNOWN';
63
+ detail?: string;
64
+ };
47
65
  /** git 자체를 쓸 수 없었던 이유. 있으면 이 관측은 비어 있다. */
48
66
  unavailable?: string;
49
67
  };
@@ -51,6 +69,11 @@ export type RepoQuery = {
51
69
  /** ref 이름에서 찾을 조각. 보통 작업 항목 키. */
52
70
  refHint?: string;
53
71
  canonicalRef?: string;
72
+ /**
73
+ * canonicalRef 를 당겨 올 원격 이름 (Profile 의 canonical source 가 선언한 것).
74
+ * 있으면 observe 가 fetch 를 시도하고 원격 추적 ref 를 대조 기준으로 삼는다.
75
+ */
76
+ remote?: string;
54
77
  paths?: readonly string[];
55
78
  /** 범위 재료로만 확인할 자리들. 증거 칸에 섞이지 않는다. */
56
79
  modulePaths?: readonly string[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@asc-agent/runtime",
3
- "version": "0.3.0",
4
- "description": "ASC (Agent Session Control) local-first human-in-the-loop agent control plane: core, CLI, and adapters",
3
+ "version": "0.3.2",
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",
7
7
  "control-plane",