@asc-agent/runtime 0.1.0 → 0.2.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/dist/cli/asc.js CHANGED
@@ -8,11 +8,12 @@
8
8
  // 읽기 전용이다. 결정 제출은 사람의 명시적 의사표현을 받는 별도 경로로 나간다 (B-06).
9
9
  import { execFile, spawnSync } from 'node:child_process';
10
10
  import { parseArgs, promisify } from 'node:util';
11
- import { existsSync, realpathSync } from 'node:fs';
11
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
12
12
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { homedir } from 'node:os';
15
- import { dirname, join, resolve } from 'node:path';
15
+ import { basename, dirname, join, resolve } from 'node:path';
16
+ import { MINIMUM_NODE_MAJOR, checkNodeRuntime, } from "../core/distribution/node-runtime.js";
16
17
  import { GitHubClient, discoverToken } from "../adapters/github/client.js";
17
18
  import { GitHubChangeContext, GitHubInventory, GitHubResourceContext } from "../adapters/github/context.js";
18
19
  import { GitHubEventSource } from "../adapters/github/event-source.js";
@@ -25,6 +26,7 @@ import { Executor } from "../core/execution/executor.js";
25
26
  import { GrantService } from "../core/execution/grant.js";
26
27
  import { DecisionKind } from "../core/model/entities.js";
27
28
  import { discoverProjectRoot, excludeFromGit, identitiesTemplate, overrideTemplate, writeIfAbsent } from "../core/attach/init.js";
29
+ import { AdoptError, buildAdoptedProfile } from "../core/attach/adopt.js";
28
30
  import { locatorsOf, lookupLocator, readIndex, register, writeIndex } from "../core/workspace/index-store.js";
29
31
  import { adoptionLine, judgeAdoption, migrate } from "../core/workspace/migrate.js";
30
32
  import { newWorkspaceId, normalizeRemote, recoverCandidates, recoverLines } from "../core/workspace/identity.js";
@@ -47,6 +49,7 @@ import { buildEventObservation } from "../composition/observe.js";
47
49
  import { availableProfiles, planBootstrap, renderPlan } from "../core/attach/bootstrap.js";
48
50
  import { detectStableInstall, installStableRuntime, verifyStableInstall, } from "../core/distribution/runtime-install.js";
49
51
  import { readRuntimeSelection, remediationAction, remediationLines, resolveRuntimeTarget, runtimeSelectionLine, selectionPath, writeRuntimeSelection, } from "../core/distribution/runtime-select.js";
52
+ import { portableCommand, shorthandCommand } from "../core/distribution/release.js";
50
53
  import { preflight } from "../core/operator/preflight.js";
51
54
  import { lookupAuthority } from "../core/policy/ownership.js";
52
55
  import { renderProgress } from "../core/operator/render.js";
@@ -66,137 +69,139 @@ import { FreezeLedger, freezeLines, judgeAction } from "../core/policy/remote-fr
66
69
  import { SessionRuntime } from "../core/runtime/session.js";
67
70
  import { Checkpoint, Handoff, SessionRole } from "../core/model/entities.js";
68
71
  import { archiveLock, bootstrapGuard, buildLock, compareLock, loadLayers, resolveRuntime } from "../core/resolver/load.js";
72
+ import { ProfileSourceError } from "../core/resolver/profile-source.js";
69
73
  import { renderAscMd, renderControllerMd } from "../core/resolver/render.js";
70
- import { ProfileLock } from "../schemas/profile.js";
74
+ import { ProfileLock, ProjectProfile } from "../schemas/profile.js";
71
75
  import { LocalOperator } from "../core/operator/local-operator.js";
72
76
  import { loadIdentityMap } from "./identity-config.js";
73
- const USAGE = `asc — Agent Session Control
74
-
75
- asc proceed [--session <id>] [--goal <text>] [--json]
76
-
77
- asc inbox list [--all] [--priority P0|P1|P2] [--json]
78
- asc inbox show <REQUEST_ID> [--json]
79
- asc inbox trace <REQUEST_ID> [--json] # how it got here — an exploratory trace
80
- asc inbox digest [--flush] [--json] # batched view (P0 stays separate)
81
- asc inbox latest [--priority P0|P1|P2] [--json]
82
- asc inbox decide <REQUEST_ID> <approve|revise|defer|dismiss|queue> --as <actor>
83
- [--revision <text>] [--expect <version>]
84
-
85
- asc grant issue <REQUEST_ID> --action <key> --target <ref> --as <actor>
86
- [--grant-id <id>] [--expires <iso>]
87
- asc grant run <GRANT_ID> [--run-id <id>]
88
-
89
- asc monitor scan [--backfill] [--as <controller>] # fast path
90
- asc monitor reconcile [--as <controller>] # recover what was missed — re-list
91
- asc monitor census [--as <controller>] # full reconcile + detect disappearances
92
- asc monitor status [--json] # how far coverage has been confirmed
93
-
94
- asc profile resolve --profile <id> [--preset <id>] [--install <path>] [--write]
95
-
96
- asc runtime start [--interval-min <n>] [--delta-min <n>] [--reconcile-min <n>]
97
- [--census-min <n>] [--digest-min <n>]
98
- asc runtime tick
99
- asc runtime status [--json] # which build is in use (C-14 §4)
100
- asc runtime use package
101
- asc runtime use development <checkout> # run a built checkout instead
102
- asc front [status] [--json]
103
- asc escalate open <S-ID> --predicate <p>... --question <t> --blocked <node>...
104
- --evidence <ref>... [--blocked-scope <path>...] [--previous <ESC-ID> --why <t>]
105
- asc escalate list
106
- asc escalate resolve <ESC-ID>
107
- asc freeze [status]
108
- asc freeze on --reason <text> [--offline]
109
- asc freeze defer --id <id> --intent <text> [--evidence <ref>]
110
- asc freeze release --id <id>
111
- asc thaw
112
- asc workspace list
113
- asc workspace migrate [--force]
114
- asc init [--profile <id>] [--preset <id>] [--install <path>]
115
- [--scope local|project] [--workspace <W-id>]
116
- # without --profile: report what was detected, then stop
117
-
118
- asc setup status [--json]
119
- asc setup plan [--profile <id>] [--scope local|project] [--json]
120
- # says what it would change — changes nothing
121
- asc setup apply [--profile <id>] [--scope local|project] [--json]
122
- asc setup --agent # non-interactive apply. stdout is a single JSON document
123
-
124
- asc session issue <ID> --role <role> --goal <text> [--block <id>]
125
- [--parent <S-ID>] [--issued-by <principal>]
126
- asc session pause <S-ID> --position <t> --next <t> [--physical <id>]
127
- [--judgment <t>] [--blocker <t>] [--risk <t>] [--evidence <ref>]
128
- asc session validate <target S-ID> --validator <validator S-ID> --result PASS|FAIL [--finding <t>]
129
- asc session audit <S-ID>
130
- asc session report <S-ID> [--json]
131
- asc session decision <S-ID> --class <c> --selected <t> --why <t>... --evidence <ref>...
132
- [--alternative <t>...] [--ownership <scope>...] [--verification <t>...]
133
- [--boundary <glob>...] [--exception <item>...]
134
- [--criteria <text>...] [--owner <role>]
135
- [--domain <decision-domain>...] [--authority <domain>=<role>...]
136
- [--dependency <text>...]
137
- asc session start <ID>
138
- asc session pause <ID> --position <text> --next <text> [--done <task>...]
139
- asc session resume <ID>
140
- asc session done <ID> --verified <text> --next <text> [--done <task>...]
141
- [--changed <path>...] [--unresolved <text>...]
142
- asc session list
143
-
144
- asc controller collect
145
-
146
- asc closure list [<S-ID>]
147
- asc closure confirm <S-ID> --item <id>...
148
-
149
- asc preflight --path <p>... (--role <r> | --session <S-ID>) [--json]
150
-
151
- asc query open <X-ID> --session <S-ID> --domain <decision-domain>
152
- --question <text> [--context <text>] [--default <text>]
153
- [--blocking <text>] [--expect-response DECIDE|ANSWER]
154
- [--in-reply-to <X-ID>]
155
- asc query answer <X-ID> --kind DECIDE|ANSWER|ESCALATE --by <role> --body <text>
156
- [--to <authority>]
157
- asc query list [--json]
158
-
159
- asc progress show [<S-ID>]
160
- asc progress report <S-ID> --physical <id> --phase <text>
161
- [--milestone <text>...] [--next <text>] [--unresolved <text>...]
162
- [--decision none|later|now] [--decision-ref <text>]
163
- [--verifier none|running|pass|fail] [--verifier-detail <text>] [--terminal]
164
-
165
- asc host claude install [--force] # --force: overwrite ASC files a person has edited
166
- asc host claude uninstall|probe [--report <cap>=<bool>...]
167
- asc host claude guard
168
- asc host claude bind <S-ID> --physical <id> [--principal <p>] [--worker <id>] [--kind <k>] [--force]
169
- asc host claude release <S-ID> --physical <id>
170
- asc host claude contract <S-ID>
171
-
172
- Options
173
- --root <path> runtime directory (otherwise: registered workspace, then repo-local .asc)
174
- --json machine-readable output
175
- --as <actor> who is deciding. Must be mapped as an approver
176
- --revision what was changed, when approving with revisions
177
- --expect the version you read. Rejected if it changed since
178
- --action action key to emit (e.g. github.issue_comment.create)
179
- --target target reference (e.g. owner/repo#19)
180
- --backfill sweep history on the first run (default: from now on)
181
- --profile Project Profile id
182
- --preset Operational Preset id
183
- --install ASC installation path (default: where this CLI lives)
184
- --write actually write the artefacts (default: preview)
185
- --role planner|researcher|implementer|verifier
186
- --goal the single goal of this session
187
- --boundary write scope (must be narrower than the Profile's)
188
- --exception SOFT DENY item allowed for this session only
189
- --criteria a verifiable done-criterion (repeatable)
190
- --path output path to check (preflight, repeatable)
191
- --item confirmed closure item id (closure confirm, repeatable)
192
- --phase one line on what is happening right now (progress report)
193
- --milestone a meaningfully finished chunk (repeatable)
194
- --decision does a person need to decide: none|later|now
195
- --verifier independent verification state: none|running|pass|fail
196
- --terminal final report stays as the closing screen after collect
197
-
198
- decide assumes a person is operating it, and only checks that the name given with --as
199
- is registered as an approver. Approval is not permission to publish: anything reaching an
77
+ const USAGE = `asc — Agent Session Control
78
+
79
+ asc proceed [--session <id>] [--goal <text>] [--json]
80
+
81
+ asc inbox list [--all] [--priority P0|P1|P2] [--json]
82
+ asc inbox show <REQUEST_ID> [--json]
83
+ asc inbox trace <REQUEST_ID> [--json] # how it got here — an exploratory trace
84
+ asc inbox digest [--flush] [--json] # batched view (P0 stays separate)
85
+ asc inbox latest [--priority P0|P1|P2] [--json]
86
+ asc inbox decide <REQUEST_ID> <approve|revise|defer|dismiss|queue> --as <actor>
87
+ [--revision <text>] [--expect <version>]
88
+
89
+ asc grant issue <REQUEST_ID> --action <key> --target <ref> --as <actor>
90
+ [--grant-id <id>] [--expires <iso>]
91
+ asc grant run <GRANT_ID> [--run-id <id>]
92
+
93
+ asc monitor scan [--backfill] [--as <controller>] # fast path
94
+ asc monitor reconcile [--as <controller>] # recover what was missed — re-list
95
+ asc monitor census [--as <controller>] # full reconcile + detect disappearances
96
+ asc monitor status [--json] # how far coverage has been confirmed
97
+
98
+ asc profile adopt [--id <name>] [--json] # make a profile for this repository
99
+ asc profile resolve --profile <id> [--preset <id>] [--install <path>] [--write]
100
+
101
+ asc runtime start [--interval-min <n>] [--delta-min <n>] [--reconcile-min <n>]
102
+ [--census-min <n>] [--digest-min <n>]
103
+ asc runtime tick
104
+ asc runtime status [--json] # which build is in use (C-14 §4)
105
+ asc runtime use package
106
+ asc runtime use development <checkout> # run a built checkout instead
107
+ asc front [status] [--json]
108
+ asc escalate open <S-ID> --predicate <p>... --question <t> --blocked <node>...
109
+ --evidence <ref>... [--blocked-scope <path>...] [--previous <ESC-ID> --why <t>]
110
+ asc escalate list
111
+ asc escalate resolve <ESC-ID>
112
+ asc freeze [status]
113
+ asc freeze on --reason <text> [--offline]
114
+ asc freeze defer --id <id> --intent <text> [--evidence <ref>]
115
+ asc freeze release --id <id>
116
+ asc thaw
117
+ asc workspace list
118
+ asc workspace migrate [--force]
119
+ asc init [--profile <id>] [--preset <id>] [--install <path>]
120
+ [--scope local|project] [--workspace <W-id>]
121
+ # without --profile: report what was detected, then stop
122
+
123
+ asc setup status [--json]
124
+ asc setup plan [--profile <id>] [--scope local|project] [--json]
125
+ # says what it would change — changes nothing
126
+ asc setup apply [--profile <id>] [--scope local|project] [--json]
127
+ asc setup apply --json # non-interactive apply. stdout is a single JSON document
128
+
129
+ asc session issue <ID> --role <role> --goal <text> [--block <id>]
130
+ [--parent <S-ID>] [--issued-by <principal>]
131
+ asc session pause <S-ID> --position <t> --next <t> [--physical <id>]
132
+ [--judgment <t>] [--blocker <t>] [--risk <t>] [--evidence <ref>]
133
+ asc session validate <target S-ID> --validator <validator S-ID> --result PASS|FAIL [--finding <t>]
134
+ asc session audit <S-ID>
135
+ asc session report <S-ID> [--json]
136
+ asc session decision <S-ID> --class <c> --selected <t> --why <t>... --evidence <ref>...
137
+ [--alternative <t>...] [--ownership <scope>...] [--verification <t>...]
138
+ [--boundary <glob>...] [--exception <item>...]
139
+ [--criteria <text>...] [--owner <role>]
140
+ [--domain <decision-domain>...] [--authority <domain>=<role>...]
141
+ [--dependency <text>...]
142
+ asc session start <ID>
143
+ asc session pause <ID> --position <text> --next <text> [--done <task>...]
144
+ asc session resume <ID>
145
+ asc session done <ID> --verified <text> --next <text> [--done <task>...]
146
+ [--changed <path>...] [--unresolved <text>...]
147
+ asc session list
148
+
149
+ asc controller collect
150
+
151
+ asc closure list [<S-ID>]
152
+ asc closure confirm <S-ID> --item <id>...
153
+
154
+ asc preflight --path <p>... (--role <r> | --session <S-ID>) [--json]
155
+
156
+ asc query open <X-ID> --session <S-ID> --domain <decision-domain>
157
+ --question <text> [--context <text>] [--default <text>]
158
+ [--blocking <text>] [--expect-response DECIDE|ANSWER]
159
+ [--in-reply-to <X-ID>]
160
+ asc query answer <X-ID> --kind DECIDE|ANSWER|ESCALATE --by <role> --body <text>
161
+ [--to <authority>]
162
+ asc query list [--json]
163
+
164
+ asc progress show [<S-ID>]
165
+ asc progress report <S-ID> --physical <id> --phase <text>
166
+ [--milestone <text>...] [--next <text>] [--unresolved <text>...]
167
+ [--decision none|later|now] [--decision-ref <text>]
168
+ [--verifier none|running|pass|fail] [--verifier-detail <text>] [--terminal]
169
+
170
+ asc host claude install [--force] # --force: overwrite ASC files a person has edited
171
+ asc host claude uninstall|probe [--report <cap>=<bool>...]
172
+ asc host claude guard
173
+ asc host claude bind <S-ID> --physical <id> [--principal <p>] [--worker <id>] [--kind <k>] [--force]
174
+ asc host claude release <S-ID> --physical <id>
175
+ asc host claude contract <S-ID>
176
+
177
+ Options
178
+ --root <path> runtime directory (otherwise: registered workspace, then repo-local .asc)
179
+ --json machine-readable output
180
+ --as <actor> who is deciding. Must be mapped as an approver
181
+ --revision what was changed, when approving with revisions
182
+ --expect the version you read. Rejected if it changed since
183
+ --action action key to emit (e.g. github.issue_comment.create)
184
+ --target target reference (e.g. owner/repo#19)
185
+ --backfill sweep history on the first run (default: from now on)
186
+ --profile Project Profile id
187
+ --preset Operational Preset id
188
+ --install ASC installation path (default: where this CLI lives)
189
+ --write actually write the artefacts (default: preview)
190
+ --role planner|researcher|implementer|verifier
191
+ --goal the single goal of this session
192
+ --boundary write scope (must be narrower than the Profile's)
193
+ --exception SOFT DENY item allowed for this session only
194
+ --criteria a verifiable done-criterion (repeatable)
195
+ --path output path to check (preflight, repeatable)
196
+ --item confirmed closure item id (closure confirm, repeatable)
197
+ --phase one line on what is happening right now (progress report)
198
+ --milestone a meaningfully finished chunk (repeatable)
199
+ --decision does a person need to decide: none|later|now
200
+ --verifier independent verification state: none|running|pass|fail
201
+ --terminal final report — stays as the closing screen after collect
202
+
203
+ decide assumes a person is operating it, and only checks that the name given with --as
204
+ is registered as an approver. Approval is not permission to publish: anything reaching an
200
205
  external system goes out through a separate Execution Grant.`;
201
206
  /**
202
207
  * 지금 여기가 어느 ASC runtime인가. **모든 명령이 같은 문을 지난다** (C-11 §3, B-45).
@@ -293,6 +298,12 @@ const DECISION_ERROR = {
293
298
  ALREADY_DECIDED: '이미 결정된 요청이다.',
294
299
  STALE: '읽은 뒤 요청이 바뀌었다. 다시 확인하고 결정하라.',
295
300
  };
301
+ /**
302
+ * bootstrap이 자기 USAGE에 적을 값. **거기서 손으로 적지 않게 하려고 내보낸다** —
303
+ * 두 패키지가 각자 버전 문자열을 들면 릴리스마다 한쪽이 뒤처지고, 그 지연은 곧
304
+ * 사용자가 실행하는 명령이 된다 (0.2.0 회차의 skill.ts가 그랬다).
305
+ */
306
+ export { BOOTSTRAP_SPEC } from "../core/distribution/release.js";
296
307
  /**
297
308
  * CLI 한 번의 실행. **다른 진입도 이 함수를 부른다** (C-14 불변식 ①).
298
309
  *
@@ -300,6 +311,82 @@ const DECISION_ERROR = {
300
311
  * 진입이 둘이어도 판단은 하나다. 그래서 export이고, 그래서 아래 자동 실행은 이 파일이
301
312
  * 진짜 진입점일 때만 돈다.
302
313
  */
314
+ /**
315
+ * 설정을 읽다 실패한 것을 **사람이 읽을 문장**으로 바꾼다.
316
+ *
317
+ * 여기가 없으면 Profile 하나가 잘못됐을 때 사용자가 보는 것은 Node의 stack dump다 —
318
+ * 내부 파일 이름과 프레임이 줄줄이 나오고, 정작 "무엇을 고쳐야 하는지"는 없다.
319
+ * 독립 검증이 다섯 갈래(충돌·깨진 JSON·디렉터리가 아닌 것·EISDIR·긴 id)에서 같은 모양을
320
+ * 관측했다. 예상 못 한 오류는 그대로 던진다 — 삼키면 그게 더 나쁘다.
321
+ */
322
+ function explainConfigError(error) {
323
+ if (error instanceof ProfileSourceError)
324
+ return error.message;
325
+ const failure = error;
326
+ const path = failure?.path ? ` (${failure.path})` : '';
327
+ switch (failure?.code) {
328
+ case 'ENOENT':
329
+ return `That profile is not there${path}. \`asc setup status\` lists what is.`;
330
+ case 'EISDIR':
331
+ return `A profile has to be a file, and that is a directory${path}.`;
332
+ case 'EACCES':
333
+ case 'EPERM':
334
+ return `No permission to read that profile${path}.`;
335
+ default:
336
+ break;
337
+ }
338
+ if (error instanceof SyntaxError)
339
+ return `That profile is not valid JSON — ${error.message}`;
340
+ return null;
341
+ }
342
+ /** 실제 파일시스템·프로세스를 물린다. Core는 이 중 아무것도 직접 하지 않는다. */
343
+ function nodeRuntimeDeps() {
344
+ return {
345
+ version: process.version,
346
+ exists: (path) => existsSync(path),
347
+ list: (path) => {
348
+ try {
349
+ return readdirSync(path);
350
+ }
351
+ catch {
352
+ // 없는 디렉터리는 "후보 없음"이다. 이 machine에 그 배치가 없을 뿐이다.
353
+ return [];
354
+ }
355
+ },
356
+ run: nodeProcessRunner,
357
+ home: homedir(),
358
+ join,
359
+ };
360
+ }
361
+ /**
362
+ * 못 돌린다는 사실과, 이 machine에서 실제로 쓸 수 있는 것을 함께 준다.
363
+ *
364
+ * 후보가 있으면 **같은 canonical 명령을 그 Node로 돌리는 형태**를 낸다 — 이것은 per-invocation
365
+ * 환경변수이지 PATH·profile 수정이 아니다 (불변식 ⑰). 이 형태마저 host가 실행을 거부하면
366
+ * 그때는 ASC의 문제가 아니라 host 경계이며, AGENTS.md가 그 자리를 정의한다.
367
+ */
368
+ function reportNodeRuntime(check, asJson) {
369
+ const actions = check.candidates.map((candidate) => ({
370
+ type: 'use_node_runtime',
371
+ display: `PATH="${dirname(candidate.path)}:$PATH" ${shorthandCommand(['setup', 'apply', '--json'])}`,
372
+ portable: `PATH="${dirname(candidate.path)}:$PATH" ${portableCommand(['setup', 'apply', '--json'])}`,
373
+ node: candidate,
374
+ }));
375
+ if (asJson) {
376
+ console.log(JSON.stringify({
377
+ code: check.code,
378
+ detail: check.detail,
379
+ runtime: { node: process.execPath, version: check.version, required: `>=${MINIMUM_NODE_MAJOR}` },
380
+ candidates: check.candidates,
381
+ nextActions: actions.map((action) => action.portable),
382
+ actions,
383
+ }, null, 2));
384
+ return;
385
+ }
386
+ console.error(check.detail);
387
+ for (const action of actions)
388
+ console.error(` ${action.node.version} at ${action.node.path}\n ${action.display}`);
389
+ }
303
390
  export async function runAscCommand(argv, entry = 'runtime') {
304
391
  const { values, positionals } = parseArgs({
305
392
  args: argv,
@@ -405,6 +492,17 @@ export async function runAscCommand(argv, entry = 'runtime') {
405
492
  console.log(USAGE);
406
493
  return 0;
407
494
  }
495
+ // **지원 하한을 먼저 답한다** (C-14 §3). `engines` 는 npm에게 하는 말이라 기본값에서
496
+ // 경고로만 나가고, 그러면 "경고 뒤에 그래도 돌아감"이 된다 — 사용자는 자기가 지원
497
+ // 범위 안인지 끝내 모른다. 여기서 한 번, 결정적으로 답한다.
498
+ //
499
+ // 이 자리인 이유: 설치된 `asc` 와 bootstrap이 **같은 문으로 들어온다**. bootstrap에
500
+ // 두면 그쪽에 정책이 생기고(C-14 불변식 ⑦), 그러면 두 진입의 답이 갈릴 수 있다.
501
+ const runnable = await checkNodeRuntime(nodeRuntimeDeps());
502
+ if (!runnable.ok) {
503
+ reportNodeRuntime(runnable, Boolean(values.json) || Boolean(values.agent));
504
+ return 1;
505
+ }
408
506
  // 선택된 build로 넘길 것이 있으면 여기서 넘긴다. **선택 자체를 다루는 명령은 넘기지
409
507
  // 않는다** — 잘못 가리키는 선택을 고치거나 들여다보는 명령이 그 선택 때문에 못 돌면
410
508
  // 사람이 갇힌다.
@@ -427,6 +525,10 @@ export async function runAscCommand(argv, entry = 'runtime') {
427
525
  // 그러면 "아직 안 붙었다"를 확인하려고 부른 명령이 안 붙었다는 이유로 죽는다.
428
526
  if (group === 'setup')
429
527
  return runSetup(command, values, entry);
528
+ // adopt는 **붙기 전의 명령이다.** 붙을 Profile을 만드는 것이 일이므로 attach를 요구하면
529
+ // 순서가 뒤집힌다. 나머지 profile 명령은 아래 attach 경로에 그대로 남는다.
530
+ if (group === 'profile' && command === 'adopt')
531
+ return runProfileAdopt(values, entry);
430
532
  if (!['inbox', 'grant', 'monitor', 'runtime', 'front', 'freeze', 'thaw', 'escalate', 'profile', 'session', 'controller', 'proceed', 'progress', 'preflight', 'closure', 'query'].includes(group)) {
431
533
  console.error(`Unknown command: ${group}\n\n${USAGE}`);
432
534
  return 2;
@@ -616,7 +718,7 @@ async function attachLocalWorkspace(projectRoot, git, declaredWorkspace) {
616
718
  return existing.root;
617
719
  }
618
720
  const remotes = git ? await gitRemotes(projectRoot) : [];
619
- const aliases = remotes.map(normalizeRemote).filter((alias) => alias !== null);
721
+ const aliases = remoteAliases(remotes);
620
722
  // 사람이 "이건 그 프로젝트다"라고 말한 경우 — 이어붙인다. 추론이 아니라 선언이다.
621
723
  if (declaredWorkspace) {
622
724
  const known = index.workspaces[declaredWorkspace];
@@ -635,7 +737,7 @@ async function attachLocalWorkspace(projectRoot, git, declaredWorkspace) {
635
737
  console.log(`Registered this location under workspace ${known.workspaceId} — runtime lives at ${root}`);
636
738
  return root;
637
739
  }
638
- const hits = recoverCandidates(Object.values(index.workspaces), remotes);
740
+ const hits = recoverCandidates(Object.values(index.workspaces), aliases);
639
741
  if (hits.length > 0) {
640
742
  // 붙일지는 사람이 정한다. 여기서 이어붙이면 남의 workspace를 조용히 가져올 수 있다.
641
743
  for (const line of recoverLines(hits))
@@ -658,20 +760,33 @@ async function attachLocalWorkspace(projectRoot, git, declaredWorkspace) {
658
760
  console.log(`Recognisable as: ${aliases.join(', ')}`);
659
761
  return root;
660
762
  }
661
- /** 모든 remote를 evidence로 모은다. origin을 primary로 단정하지 않는다 (C-11 불변식 ④). */
763
+ /**
764
+ * 모든 remote를 evidence로 모은다. origin을 primary로 단정하지 않는다 (C-11 불변식 ④).
765
+ *
766
+ * 이름을 함께 든다 — identity alias는 이름이 필요 없지만, `profile adopt` 는 어느 remote가
767
+ * 이 프로젝트를 대표하는지 골라야 하고 `git remote -v` 의 출력 순서는 알파벳순이라
768
+ * "첫 줄이 origin"이 아니다.
769
+ */
662
770
  async function gitRemotes(projectRoot) {
663
771
  try {
664
772
  const { stdout } = await execFileAsync('git', ['-C', projectRoot, 'remote', '-v']);
665
- const urls = stdout
666
- .split(/\r?\n/)
667
- .map((line) => line.split(/\s+/)[1])
668
- .filter((url) => Boolean(url));
669
- return [...new Set(urls)];
773
+ const seen = new Map();
774
+ for (const line of stdout.split(/\r?\n/)) {
775
+ const [name, url] = line.split(/\s+/);
776
+ if (!name || !url || seen.has(`${name}${url}`))
777
+ continue;
778
+ seen.set(`${name}${url}`, { name, url });
779
+ }
780
+ return [...seen.values()];
670
781
  }
671
782
  catch {
672
783
  return [];
673
784
  }
674
785
  }
786
+ /** identity alias는 이름을 쓰지 않는다 — URL만 정규화한다. */
787
+ const remoteAliases = (remotes) => [...new Set(remotes.map((remote) => remote.url))]
788
+ .map(normalizeRemote)
789
+ .filter((alias) => alias !== null);
675
790
  /**
676
791
  * 저장소 안에 있던 `.asc/` 를 사용자 소유 공간으로 옮긴다 (C-11 §6).
677
792
  *
@@ -679,8 +794,8 @@ async function gitRemotes(projectRoot) {
679
794
  */
680
795
  async function runWorkspace(command, values) {
681
796
  if (command !== 'migrate' && command !== 'list') {
682
- console.error(`Unknown workspace command: ${command ?? '(none)'}
683
-
797
+ console.error(`Unknown workspace command: ${command ?? '(none)'}
798
+
684
799
  ${USAGE}`);
685
800
  return 2;
686
801
  }
@@ -726,7 +841,7 @@ ${USAGE}`);
726
841
  workspaceId,
727
842
  root: target,
728
843
  locator: { path: projectRoot, platform: process.platform, observedAt: new Date().toISOString() },
729
- aliases: remotes.map(normalizeRemote).filter((alias) => alias !== null),
844
+ aliases: remoteAliases(remotes),
730
845
  now: new Date().toISOString(),
731
846
  }));
732
847
  console.log(`Copied and verified ${outcome.plan.entries} entries into ${target} (workspace ${workspaceId}).`);
@@ -773,6 +888,7 @@ async function runInit(values) {
773
888
  const plan = await planBootstrap({
774
889
  cwd: process.cwd(),
775
890
  installRoot: installRoot(),
891
+ externalProfileRoot: externalProfileRoot(),
776
892
  ...(attachedRoot ? { ascRoot: attachedRoot } : {}),
777
893
  hosts: [{ id: 'claude', installed: await verifyInstalled(defaultPaths()) }],
778
894
  bindings,
@@ -788,6 +904,24 @@ async function runInit(values) {
788
904
  }
789
905
  const { root: projectRoot, git } = await discoverProjectRoot(process.cwd());
790
906
  console.log(`Project: ${projectRoot}${git ? '' : ' (not a git repository)'}`);
907
+ // **읽을 수 있는 Profile인지 먼저 본다.** 아래부터는 `.git/info/exclude` 를 고치고
908
+ // 템플릿을 만드는 등 세상을 바꾸는 일이고, 그 뒤에 Profile이 잘못된 것을 알면 반쯤 만든
909
+ // `.asc/` 가 남는다 — 독립 검증이 실제로 그 상태를 만들었다. 여기서 멈추면 아무것도 남지 않는다.
910
+ try {
911
+ await loadLayers({
912
+ installRoot: installRoot(),
913
+ externalProfileRoot: externalProfileRoot(),
914
+ profileId: values.profile,
915
+ });
916
+ }
917
+ catch (error) {
918
+ const explained = explainConfigError(error);
919
+ if (explained === null)
920
+ throw error;
921
+ console.error(explained);
922
+ console.error('Nothing was changed.');
923
+ return 2;
924
+ }
791
925
  const ascRoot = scope === 'project'
792
926
  ? join(projectRoot, '.asc')
793
927
  : await attachLocalWorkspace(projectRoot, git, values.workspace);
@@ -824,6 +958,7 @@ async function attachedRuntime(ascRoot) {
824
958
  const outcome = await bootstrapGuard({
825
959
  ascRoot,
826
960
  installRoot: installRoot(),
961
+ externalProfileRoot: externalProfileRoot(),
827
962
  capabilities: CAPABILITIES,
828
963
  adapters: ADAPTER_VERSIONS,
829
964
  ascVersion: ASC_VERSION,
@@ -851,7 +986,7 @@ function declaredPolicies(resolved) {
851
986
  }
852
987
  return declared;
853
988
  }
854
- const ASC_VERSION = '0.1.0';
989
+ const ASC_VERSION = '0.2.1';
855
990
  const CAPABILITIES = ['scm.github', 'state.markdown', 'approval.local'];
856
991
  const ADAPTER_VERSIONS = { 'scm.github': ASC_VERSION, 'state.markdown': ASC_VERSION };
857
992
  /**
@@ -981,11 +1116,12 @@ async function detectSetupState(values, entry) {
981
1116
  const scope = values.scope === 'project' ? 'project' : 'local';
982
1117
  const hostReport = await verifyInstall(defaultPaths());
983
1118
  return {
1119
+ entry,
984
1120
  projectRoot,
985
1121
  git,
986
1122
  ...(ascRoot ? { ascRoot } : {}),
987
1123
  ...(values.profile ? { requestedProfile: values.profile } : {}),
988
- profileCandidates: await availableProfiles(installRoot()),
1124
+ profileCandidates: await availableProfiles(installRoot(), externalProfileRoot()),
989
1125
  scope,
990
1126
  host: [{ id: 'claude', status: hostReport.status }],
991
1127
  // **bootstrap으로 들어왔을 때만 본다.** 설치된 runtime이 자기를 다시 설치할 이유가
@@ -1101,6 +1237,7 @@ async function inspectSetup(root) {
1101
1237
  const outcome = await bootstrapGuard({
1102
1238
  ascRoot: root,
1103
1239
  installRoot: installRoot(),
1240
+ externalProfileRoot: externalProfileRoot(),
1104
1241
  capabilities: CAPABILITIES,
1105
1242
  adapters: ADAPTER_VERSIONS,
1106
1243
  ascVersion: ASC_VERSION,
@@ -1115,6 +1252,9 @@ async function inspectSetup(root) {
1115
1252
  const runtime = outcome.ok ? outcome.runtime : undefined;
1116
1253
  return assessSetup({
1117
1254
  attachment,
1255
+ ...(runtime
1256
+ ? { profile: { id: runtime.layers.profile.id, origin: runtime.layers.profileOrigin } }
1257
+ : {}),
1118
1258
  hasApprovers: Object.keys(await loadIdentityMap(root)).length > 0,
1119
1259
  hasControllerIdentities: Object.keys(runtime?.controllerIdentities ?? {}).length > 0,
1120
1260
  hasMonitorIdentities: (runtime?.monitor.identities?.length ?? 0) > 0,
@@ -1129,10 +1269,18 @@ const hasToken = async () => (await discoverToken()) !== null;
1129
1269
  */
1130
1270
  /** 이 저장소(또는 설치된 패키지)의 뿌리. profiles/ · presets/ 를 여기서 읽는다. */
1131
1271
  const installRoot = () => join(dirname(fileURLToPath(import.meta.url)), '..');
1272
+ /**
1273
+ * 사용자 소유 Profile 디렉터리. 팀이 나눠 갖는 실 Profile이 여기 온다 —
1274
+ * 배포본에는 예시만 있고, 남의 프로젝트 설정은 패키지에 실리지 않는다.
1275
+ *
1276
+ * **경로를 아는 것은 Surface의 몫이다.** Core에 홈을 알려 주지 않는다 (C-11).
1277
+ */
1278
+ const externalProfileRoot = () => join(ascHome(), 'profiles');
1132
1279
  async function checkBootstrap(root) {
1133
1280
  const outcome = await bootstrapGuard({
1134
1281
  ascRoot: root,
1135
1282
  installRoot: installRoot(),
1283
+ externalProfileRoot: externalProfileRoot(),
1136
1284
  capabilities: CAPABILITIES,
1137
1285
  adapters: ADAPTER_VERSIONS,
1138
1286
  ascVersion: ASC_VERSION,
@@ -1160,6 +1308,76 @@ async function checkBootstrap(root) {
1160
1308
  console.error('\nOnce you have checked it, re-lock with `asc profile resolve --write`.');
1161
1309
  return { code: 2 };
1162
1310
  }
1311
+ /**
1312
+ * 지금 이 저장소를 설명하는 Profile을 사용자 소유 공간에 만든다 (P0).
1313
+ *
1314
+ * **이것이 되물음을 없앤다.** 배포본에 담긴 Profile은 예시뿐이고, 그래서 URL만 받은 agent는
1315
+ * `ASC_PROFILE_SELECTION_REQUIRED` 앞에서 고를 것이 없어 사람에게 물었다. 여기서 만드는 것은
1316
+ * git remote가 증명하는 사실뿐이다 — 정본 branch·role 경계·정책은 짓지 않는다 (adopt.ts 주석).
1317
+ *
1318
+ * 쓰는 곳은 `$ASC_HOME/profiles/<id>/` 이고 저장소는 건드리지 않는다. 이미 있으면 덮지 않고
1319
+ * 멈춘다 — 남이 쓰던 Profile을 조용히 갈아 끼우는 것이 이 명령의 일이 아니다.
1320
+ */
1321
+ async function runProfileAdopt(values, entry) {
1322
+ const asJson = Boolean(values.json) || Boolean(values.agent);
1323
+ const { root: projectRoot, git } = await discoverProjectRoot(process.cwd());
1324
+ const remotes = git ? await gitRemotes(projectRoot) : [];
1325
+ let adopted;
1326
+ try {
1327
+ adopted = buildAdoptedProfile({
1328
+ dirName: basename(projectRoot),
1329
+ remotes,
1330
+ // provider를 아는 것은 Adapter를 아는 이 층이다 (C-09 §6.1). host 하나로 단정하는
1331
+ // 것은 여기까지고, 모르는 host는 `git` 이라고만 적는다 — 그것이 사실이다.
1332
+ scmForHost: (host) => (host === 'github.com' ? 'github' : 'git'),
1333
+ ...(values.id ? { requestedId: values.id } : {}),
1334
+ });
1335
+ }
1336
+ catch (error) {
1337
+ if (!(error instanceof AdoptError))
1338
+ throw error;
1339
+ console.error(error.message);
1340
+ return 2;
1341
+ }
1342
+ // 스스로 만든 것이 스키마를 통과하는지 **쓰기 전에** 본다. 통과하지 못하는 파일을 놓고
1343
+ // 나가면 그 다음 명령이 남의 설정 오류처럼 죽는다 (95250da가 닫은 것과 같은 모양).
1344
+ const parsed = ProjectProfile.safeParse(adopted.profile);
1345
+ if (!parsed.success) {
1346
+ console.error(`Built a profile that ASC itself rejects — this is a bug in \`profile adopt\`:`);
1347
+ for (const issue of parsed.error.issues)
1348
+ console.error(` - ${issue.path.join('.')}: ${issue.message}`);
1349
+ return 1;
1350
+ }
1351
+ const dir = join(externalProfileRoot(), adopted.id);
1352
+ const path = join(dir, 'profile.json');
1353
+ if (existsSync(path)) {
1354
+ console.error(`A profile called '${adopted.id}' is already there: ${path}\n` +
1355
+ `Attach with it (\`asc setup apply --profile ${adopted.id}\`), or adopt under another name with --id <name>.`);
1356
+ return 1;
1357
+ }
1358
+ await mkdir(dir, { recursive: true });
1359
+ await writeFile(path, `${JSON.stringify(adopted.profile, null, 2)}\n`, 'utf8');
1360
+ // 다음 한 걸음은 두 형태로 준다 — agent는 portable, 사람은 display (C-14 불변식 ⑯).
1361
+ // 여기서는 설치 상태를 다시 관측하지 않는다: 이 명령이 도는 방식이 곧 그 답이다.
1362
+ const args = ['setup', 'apply', '--profile', adopted.id];
1363
+ // portable은 agent가 그대로 실행한다 — 기계가 읽는 형태로 끝난다 (setup-plan.ts와 같은 규칙).
1364
+ const machine = [...args, '--json'];
1365
+ const action = {
1366
+ type: 'apply_setup',
1367
+ display: shorthandCommand(args),
1368
+ portable: entry === 'bootstrap' ? portableCommand(machine) : shorthandCommand(machine),
1369
+ };
1370
+ if (asJson) {
1371
+ console.log(JSON.stringify({ id: adopted.id, path, project: parsed.data.project, warnings: adopted.warnings, nextActions: [action.portable], actions: [action] }, null, 2));
1372
+ return 0;
1373
+ }
1374
+ console.log(`Adopted ${projectRoot} as profile '${adopted.id}' — ${path}`);
1375
+ console.log(`Project: ${parsed.data.project.scm} ${parsed.data.project.repository}`);
1376
+ for (const warning of adopted.warnings)
1377
+ console.log(` note: ${warning}`);
1378
+ console.log(`\nAttach with it: ${action.display}`);
1379
+ return 0;
1380
+ }
1163
1381
  /**
1164
1382
  * 계층을 합쳐 산출물 셋을 만든다. 기본은 미리보기다 — 무엇이 바뀌는지 보고 나서 쓴다.
1165
1383
  * lock이 어긋나면 알리기만 하고 저절로 맞추지 않는다 (OM §4.9).
@@ -1176,6 +1394,7 @@ async function runProfile(command, values, root) {
1176
1394
  const installPath = values.install ?? installRoot();
1177
1395
  const layers = await loadLayers({
1178
1396
  installRoot: installPath,
1397
+ externalProfileRoot: externalProfileRoot(),
1179
1398
  profileId: values.profile,
1180
1399
  ...(values.preset ? { presetId: values.preset } : {}),
1181
1400
  overridePath: join(root, 'override.json'),
@@ -2724,15 +2943,15 @@ async function runFreeze(group, command, values, store) {
2724
2943
  console.log(removed ? `${id} removed from the list — execution goes through the approval path` : `${id} was not found`);
2725
2944
  return removed ? 0 : 1;
2726
2945
  }
2727
- console.error(`Unknown freeze command: ${command}
2728
-
2946
+ console.error(`Unknown freeze command: ${command}
2947
+
2729
2948
  ${USAGE}`);
2730
2949
  return 2;
2731
2950
  }
2732
2951
  async function runFront(command, values, store, root) {
2733
2952
  if (command !== undefined && command !== 'status') {
2734
- console.error(`Unknown front command: ${command}
2735
-
2953
+ console.error(`Unknown front command: ${command}
2954
+
2736
2955
  ${USAGE}`);
2737
2956
  return 2;
2738
2957
  }
@@ -2910,5 +3129,15 @@ const invokedDirectly = (() => {
2910
3129
  return false;
2911
3130
  }
2912
3131
  })();
2913
- if (invokedDirectly)
2914
- process.exitCode = await runAscCommand(process.argv.slice(2));
3132
+ if (invokedDirectly) {
3133
+ try {
3134
+ process.exitCode = await runAscCommand(process.argv.slice(2));
3135
+ }
3136
+ catch (error) {
3137
+ const explained = explainConfigError(error);
3138
+ if (explained === null)
3139
+ throw error; // 모르는 고장은 감추지 않는다
3140
+ console.error(explained);
3141
+ process.exitCode = 1;
3142
+ }
3143
+ }