@asc-agent/runtime 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +25 -25
  2. package/dist/adapters/claude-code/skill.js +249 -235
  3. package/dist/adapters/gitlab/adapter.d.ts +3 -0
  4. package/dist/adapters/gitlab/adapter.js +22 -3
  5. package/dist/adapters/gitlab/client.d.ts +27 -1
  6. package/dist/adapters/gitlab/client.js +30 -0
  7. package/dist/adapters/gitlab/ports.d.ts +3 -3
  8. package/dist/adapters/jam/adapter.d.ts +14 -0
  9. package/dist/adapters/jam/adapter.js +84 -6
  10. package/dist/adapters/jam/ports.d.ts +9 -0
  11. package/dist/adapters/jam/ports.js +49 -2
  12. package/dist/adapters/local/repo.d.ts +15 -0
  13. package/dist/adapters/local/repo.js +179 -0
  14. package/dist/adapters/markdown/state-store.js +13 -1
  15. package/dist/adapters/memory/state-store.js +4 -0
  16. package/dist/cli/asc.d.ts +6 -0
  17. package/dist/cli/asc.js +525 -8
  18. package/dist/composition/propose.d.ts +19 -0
  19. package/dist/composition/propose.js +35 -0
  20. package/dist/composition/runtime.d.ts +2 -0
  21. package/dist/composition/runtime.js +41 -5
  22. package/dist/core/attach/init.d.ts +17 -0
  23. package/dist/core/attach/init.js +28 -0
  24. package/dist/core/attach/setup-plan.d.ts +9 -0
  25. package/dist/core/attach/setup-plan.js +16 -5
  26. package/dist/core/distribution/node-runtime.d.ts +44 -0
  27. package/dist/core/distribution/node-runtime.js +75 -0
  28. package/dist/core/distribution/release.d.ts +3 -3
  29. package/dist/core/distribution/release.js +1 -1
  30. package/dist/core/monitor/investigation.d.ts +16 -0
  31. package/dist/core/monitor/investigation.js +18 -9
  32. package/dist/core/operator/contract-draft.d.ts +124 -0
  33. package/dist/core/operator/contract-draft.js +234 -0
  34. package/dist/core/operator/derive-draft.d.ts +37 -0
  35. package/dist/core/operator/derive-draft.js +216 -0
  36. package/dist/core/operator/proceed.d.ts +79 -0
  37. package/dist/core/operator/proceed.js +123 -1
  38. package/dist/core/operator/work-state.d.ts +54 -0
  39. package/dist/core/operator/work-state.js +145 -0
  40. package/dist/core/runtime/closure.d.ts +2 -2
  41. package/dist/ports/local-repo.d.ts +61 -0
  42. package/dist/ports/local-repo.js +9 -0
  43. package/dist/ports/resource-context.d.ts +7 -0
  44. package/dist/schemas/profile.d.ts +2 -2
  45. package/package.json +2 -2
package/dist/cli/asc.js CHANGED
@@ -8,17 +8,19 @@
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';
12
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
11
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
12
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
13
13
  import { fileURLToPath } from 'node:url';
14
- import { homedir } from 'node:os';
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
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";
19
20
  import { GitHubScm } from "../adapters/github/scm.js";
20
21
  import { MarkdownStateStore } from "../adapters/markdown/state-store.js";
21
22
  import { LocalIdentityBinding } from "../adapters/local/identity.js";
23
+ import { IDENTITY_FILE } from "./identity-config.js";
22
24
  import { TextRenderer } from "../adapters/text/renderer.js";
23
25
  import { ApprovalService } from "../core/approval/service.js";
24
26
  import { Executor } from "../core/execution/executor.js";
@@ -31,6 +33,7 @@ import { adoptionLine, judgeAdoption, migrate } from "../core/workspace/migrate.
31
33
  import { newWorkspaceId, normalizeRemote, recoverCandidates, recoverLines } from "../core/workspace/identity.js";
32
34
  import { resolveWorkspace, resolutionLine } from "../core/workspace/resolve.js";
33
35
  import { assessSetup, renderSetup } from "../core/attach/setup.js";
36
+ import { withIdentity } from "../core/attach/init.js";
34
37
  import { applySetupPlan, computeSetupPlan, renderSetupPlan, } from "../core/attach/setup-plan.js";
35
38
  import { CLAUDE_PROVIDER, CLAUDE_SCOPE, claudeBindings } from "../adapters/claude-code/binding.js";
36
39
  import { readHeartbeat } from "../adapters/claude-code/observer.js";
@@ -41,15 +44,23 @@ import { MonitorEngine } from "../core/monitor/engine.js";
41
44
  import { CoverageLedger, renderHealth } from "../core/monitor/coverage.js";
42
45
  import { evaluateHealth, healthAlertLines } from "../core/monitor/health-alerts.js";
43
46
  import { Operator } from "../core/operator/proceed.js";
47
+ import { deriveSessionContractDraft } from "../core/operator/derive-draft.js";
48
+ import { LocalRepoAdapter } from "../adapters/local/repo.js";
49
+ import { GitHubAdapter } from "../adapters/github/adapter.js";
50
+ import { GitLabAdapter } from "../adapters/gitlab/adapter.js";
51
+ import { JamAdapter } from "../adapters/jam/adapter.js";
52
+ import { statusIndicatesDone } from "../adapters/jam/ports.js";
44
53
  import { ProgressService } from "../core/operator/progress.js";
45
54
  import { composeBindings, defaultAdapters } from "../composition/registry.js";
46
- import { buildRuntimePorts, rolesFor } from "../composition/runtime.js";
55
+ import { buildRuntimePorts, closeToolClients, rolesFor } from "../composition/runtime.js";
56
+ import { proposeBindings } from "../composition/propose.js";
47
57
  import { buildEventObservation } from "../composition/observe.js";
48
58
  import { availableProfiles, planBootstrap, renderPlan } from "../core/attach/bootstrap.js";
49
59
  import { detectStableInstall, installStableRuntime, verifyStableInstall, } from "../core/distribution/runtime-install.js";
50
60
  import { readRuntimeSelection, remediationAction, remediationLines, resolveRuntimeTarget, runtimeSelectionLine, selectionPath, writeRuntimeSelection, } from "../core/distribution/runtime-select.js";
51
61
  import { portableCommand, shorthandCommand } from "../core/distribution/release.js";
52
62
  import { preflight } from "../core/operator/preflight.js";
63
+ import { DraftProvenance, issueArgs, planSessionContract, } from "../core/operator/contract-draft.js";
53
64
  import { lookupAuthority } from "../core/policy/ownership.js";
54
65
  import { renderProgress } from "../core/operator/render.js";
55
66
  import { AuditLedger, decisionLines, delegationLine, executionLines, reclaimLine, validationLines, } from "../core/runtime/audit.js";
@@ -75,7 +86,7 @@ import { LocalOperator } from "../core/operator/local-operator.js";
75
86
  import { loadIdentityMap } from "./identity-config.js";
76
87
  const USAGE = `asc — Agent Session Control
77
88
 
78
- asc proceed [--session <id>] [--goal <text>] [--json]
89
+ asc proceed [--session <id>] [--work <WORK-ID>] [--goal <text>] [--json]
79
90
 
80
91
  asc inbox list [--all] [--priority P0|P1|P2] [--json]
81
92
  asc inbox show <REQUEST_ID> [--json]
@@ -120,11 +131,16 @@ const USAGE = `asc — Agent Session Control
120
131
  # without --profile: report what was detected, then stop
121
132
 
122
133
  asc setup status [--json]
134
+ asc setup identity [--role controller|monitor|both] [--actor <channel:actor>]
135
+ # 지금 이 사람을 승인 권한자로 세우고 재고정까지 한다
123
136
  asc setup plan [--profile <id>] [--scope local|project] [--json]
124
137
  # says what it would change — changes nothing
125
138
  asc setup apply [--profile <id>] [--scope local|project] [--json]
126
- asc setup --agent # non-interactive apply. stdout is a single JSON document
139
+ asc setup apply --json # non-interactive apply. stdout is a single JSON document
127
140
 
141
+ asc session plan [--id <S-ID>] [--role <role>] [--goal <text>] [--boundary <glob>...]
142
+ [--criteria <text>...] [--owner <role>] [--provenance <f>=<STATUS>[:<src>]...]
143
+ [--json] # is this draft issuable? changes nothing
128
144
  asc session issue <ID> --role <role> --goal <text> [--block <id>]
129
145
  [--parent <S-ID>] [--issued-by <principal>]
130
146
  asc session pause <S-ID> --position <t> --next <t> [--physical <id>]
@@ -188,6 +204,8 @@ Options
188
204
  --write actually write the artefacts (default: preview)
189
205
  --role planner|researcher|implementer|verifier
190
206
  --goal the single goal of this session
207
+ --work work item to investigate before proposing a contract (asc proceed)
208
+ --actor who you are, as <channel>:<actor> (asc setup identity)
191
209
  --boundary write scope (must be narrower than the Profile's)
192
210
  --exception SOFT DENY item allowed for this session only
193
211
  --criteria a verifiable done-criterion (repeatable)
@@ -297,6 +315,12 @@ const DECISION_ERROR = {
297
315
  ALREADY_DECIDED: '이미 결정된 요청이다.',
298
316
  STALE: '읽은 뒤 요청이 바뀌었다. 다시 확인하고 결정하라.',
299
317
  };
318
+ /**
319
+ * bootstrap이 자기 USAGE에 적을 값. **거기서 손으로 적지 않게 하려고 내보낸다** —
320
+ * 두 패키지가 각자 버전 문자열을 들면 릴리스마다 한쪽이 뒤처지고, 그 지연은 곧
321
+ * 사용자가 실행하는 명령이 된다 (0.2.0 회차의 skill.ts가 그랬다).
322
+ */
323
+ export { BOOTSTRAP_SPEC } from "../core/distribution/release.js";
300
324
  /**
301
325
  * CLI 한 번의 실행. **다른 진입도 이 함수를 부른다** (C-14 불변식 ①).
302
326
  *
@@ -332,6 +356,54 @@ function explainConfigError(error) {
332
356
  return `That profile is not valid JSON — ${error.message}`;
333
357
  return null;
334
358
  }
359
+ /** 실제 파일시스템·프로세스를 물린다. Core는 이 중 아무것도 직접 하지 않는다. */
360
+ function nodeRuntimeDeps() {
361
+ return {
362
+ version: process.version,
363
+ exists: (path) => existsSync(path),
364
+ list: (path) => {
365
+ try {
366
+ return readdirSync(path);
367
+ }
368
+ catch {
369
+ // 없는 디렉터리는 "후보 없음"이다. 이 machine에 그 배치가 없을 뿐이다.
370
+ return [];
371
+ }
372
+ },
373
+ run: nodeProcessRunner,
374
+ home: homedir(),
375
+ join,
376
+ };
377
+ }
378
+ /**
379
+ * 못 돌린다는 사실과, 이 machine에서 실제로 쓸 수 있는 것을 함께 준다.
380
+ *
381
+ * 후보가 있으면 **같은 canonical 명령을 그 Node로 돌리는 형태**를 낸다 — 이것은 per-invocation
382
+ * 환경변수이지 PATH·profile 수정이 아니다 (불변식 ⑰). 이 형태마저 host가 실행을 거부하면
383
+ * 그때는 ASC의 문제가 아니라 host 경계이며, AGENTS.md가 그 자리를 정의한다.
384
+ */
385
+ function reportNodeRuntime(check, asJson) {
386
+ const actions = check.candidates.map((candidate) => ({
387
+ type: 'use_node_runtime',
388
+ display: `PATH="${dirname(candidate.path)}:$PATH" ${shorthandCommand(['setup', 'apply', '--json'])}`,
389
+ portable: `PATH="${dirname(candidate.path)}:$PATH" ${portableCommand(['setup', 'apply', '--json'])}`,
390
+ node: candidate,
391
+ }));
392
+ if (asJson) {
393
+ console.log(JSON.stringify({
394
+ code: check.code,
395
+ detail: check.detail,
396
+ runtime: { node: process.execPath, version: check.version, required: `>=${MINIMUM_NODE_MAJOR}` },
397
+ candidates: check.candidates,
398
+ nextActions: actions.map((action) => action.portable),
399
+ actions,
400
+ }, null, 2));
401
+ return;
402
+ }
403
+ console.error(check.detail);
404
+ for (const action of actions)
405
+ console.error(` ${action.node.version} at ${action.node.path}\n ${action.display}`);
406
+ }
335
407
  export async function runAscCommand(argv, entry = 'runtime') {
336
408
  const { values, positionals } = parseArgs({
337
409
  args: argv,
@@ -355,6 +427,8 @@ export async function runAscCommand(argv, entry = 'runtime') {
355
427
  ownership: { type: 'string', multiple: true },
356
428
  verification: { type: 'string', multiple: true },
357
429
  why: { type: 'string', multiple: true },
430
+ /** 초안의 출처 — `<field>=<FACT|PROPOSAL|DECISION_REQUIRED>[:<source>]` (session plan). */
431
+ provenance: { type: 'string', multiple: true },
358
432
  offline: { type: 'boolean', default: false },
359
433
  id: { type: 'string' },
360
434
  intent: { type: 'string' },
@@ -403,6 +477,8 @@ export async function runAscCommand(argv, entry = 'runtime') {
403
477
  body: { type: 'string' },
404
478
  to: { type: 'string' },
405
479
  session: { type: 'string' },
480
+ work: { type: 'string' },
481
+ actor: { type: 'string' },
406
482
  position: { type: 'string' },
407
483
  next: { type: 'string' },
408
484
  done: { type: 'string', multiple: true },
@@ -437,6 +513,17 @@ export async function runAscCommand(argv, entry = 'runtime') {
437
513
  console.log(USAGE);
438
514
  return 0;
439
515
  }
516
+ // **지원 하한을 먼저 답한다** (C-14 §3). `engines` 는 npm에게 하는 말이라 기본값에서
517
+ // 경고로만 나가고, 그러면 "경고 뒤에 그래도 돌아감"이 된다 — 사용자는 자기가 지원
518
+ // 범위 안인지 끝내 모른다. 여기서 한 번, 결정적으로 답한다.
519
+ //
520
+ // 이 자리인 이유: 설치된 `asc` 와 bootstrap이 **같은 문으로 들어온다**. bootstrap에
521
+ // 두면 그쪽에 정책이 생기고(C-14 불변식 ⑦), 그러면 두 진입의 답이 갈릴 수 있다.
522
+ const runnable = await checkNodeRuntime(nodeRuntimeDeps());
523
+ if (!runnable.ok) {
524
+ reportNodeRuntime(runnable, Boolean(values.json) || Boolean(values.agent));
525
+ return 1;
526
+ }
440
527
  // 선택된 build로 넘길 것이 있으면 여기서 넘긴다. **선택 자체를 다루는 명령은 넘기지
441
528
  // 않는다** — 잘못 가리키는 선택을 고치거나 들여다보는 명령이 그 선택 때문에 못 돌면
442
529
  // 사람이 갇힌다.
@@ -920,7 +1007,7 @@ function declaredPolicies(resolved) {
920
1007
  }
921
1008
  return declared;
922
1009
  }
923
- const ASC_VERSION = '0.2.0';
1010
+ const ASC_VERSION = '0.2.1';
924
1011
  const CAPABILITIES = ['scm.github', 'state.markdown', 'approval.local'];
925
1012
  const ADAPTER_VERSIONS = { 'scm.github': ASC_VERSION, 'state.markdown': ASC_VERSION };
926
1013
  /**
@@ -1009,6 +1096,8 @@ async function runSetup(command, values, entry = 'runtime') {
1009
1096
  // plan/apply는 같은 판단을 나눠 쓴다 (C-14 §6). `--agent` 는 apply의 비대화 형태다.
1010
1097
  if (command === 'plan' || command === 'apply')
1011
1098
  return runSetupLifecycle(command, values, entry);
1099
+ if (command === 'identity')
1100
+ return runSetupIdentity(values);
1012
1101
  if (values.agent)
1013
1102
  return runSetupLifecycle('apply', values, entry);
1014
1103
  if (command !== undefined && command !== 'status') {
@@ -1050,6 +1139,7 @@ async function detectSetupState(values, entry) {
1050
1139
  const scope = values.scope === 'project' ? 'project' : 'local';
1051
1140
  const hostReport = await verifyInstall(defaultPaths());
1052
1141
  return {
1142
+ entry,
1053
1143
  projectRoot,
1054
1144
  git,
1055
1145
  ...(ascRoot ? { ascRoot } : {}),
@@ -1106,6 +1196,104 @@ const nodeProcessRunner = async (command, args) => {
1106
1196
  * 사람이 보는 줄과 agent가 파싱하는 JSON은 **같은 plan**에서 나온다 (C-14 불변식 ①).
1107
1197
  * agent 경로의 stdout은 JSON 문서 하나뿐이고, 그 밖의 말은 stderr로 간다 (§7).
1108
1198
  */
1199
+ /**
1200
+ * `asc setup identity` — 지금 이 사람을 이 workspace 의 승인 권한자로 세운다 (P1-F).
1201
+ *
1202
+ * 결정은 사람이 하고(이 명령을 실행하는 것이 그 결정이다), **파일 편집은 ASC 가 한다.**
1203
+ * 그 전까지는 identities.json 과 override.json 을 손으로 고치는 것이 유일한 길이었고,
1204
+ * 그 둘은 서로 다른 형식이라 한쪽만 채워 놓고 왜 안 열리는지 모르는 상태가 흔했다.
1205
+ *
1206
+ * 비밀은 읽지도 쓰지도 않는다. 여기서 다루는 것은 이름과 채널뿐이다.
1207
+ */
1208
+ async function runSetupIdentity(values) {
1209
+ const resolution = await resolveRoot(process.cwd(), values.root);
1210
+ if (resolution.kind === 'UNRESOLVED') {
1211
+ console.error('Not attached yet — run `asc init --profile <id>` first.');
1212
+ return 2;
1213
+ }
1214
+ const root = resolution.root;
1215
+ const explicit = values.actor;
1216
+ const candidate = explicit ?? (await detectSelf());
1217
+ if (!candidate) {
1218
+ console.error('Could not tell who you are here. Pass one: --actor local:<name>');
1219
+ return 2;
1220
+ }
1221
+ const actor = candidate.includes(':') ? candidate : `local:${candidate}`;
1222
+ const name = actor.slice(actor.indexOf(':') + 1);
1223
+ const roles = values.role ?? 'both';
1224
+ if (!['controller', 'monitor', 'both'].includes(roles)) {
1225
+ console.error("--role is controller|monitor|both");
1226
+ return 2;
1227
+ }
1228
+ const asController = roles !== 'monitor';
1229
+ const asMonitor = roles !== 'controller';
1230
+ // 어느 Profile 로 재고정할지는 lock 파일에서 읽는다. attachment 판정을 쓰면 안 되는 이유는
1231
+ // 이 명령 자신이 drift 를 만들기 때문이다 — 한 번 실패하면 그 다음부터는 자기가 닫아야 할
1232
+ // drift 때문에 profile 을 못 읽어 영영 못 닫는다.
1233
+ const attachedProfile = values.profile ?? (await lockedProfileId(root));
1234
+ if (!attachedProfile) {
1235
+ console.error('붙어 있는 Profile 을 알 수 없다 — `asc setup status` 를 보고, 필요하면 --profile 로 지목하라.');
1236
+ return 1;
1237
+ }
1238
+ const identitiesPath = join(root, IDENTITY_FILE);
1239
+ const overridePath = join(root, 'override.json');
1240
+ const merged = withIdentity(await readJson(identitiesPath), await readJson(overridePath), {
1241
+ name,
1242
+ actor,
1243
+ controller: asController,
1244
+ monitor: asMonitor,
1245
+ });
1246
+ await writeJson(identitiesPath, merged.identities);
1247
+ await writeJson(overridePath, merged.override);
1248
+ console.log(`${name} — ${actor}`);
1249
+ console.log(` identities.json ${asController ? 'approver 등록' : '건드리지 않음'}`);
1250
+ console.log(` override.json ${asController ? 'controller.identities' : ''}${asController && asMonitor ? ' · ' : ''}${asMonitor ? 'monitorIdentities' : ''}`);
1251
+ // override 는 lock digest 에 들어간다 — 고친 뒤 재고정하지 않으면 다음 명령이 멈춘다.
1252
+ // 어느 Profile 로 재고정할지는 지금 붙어 있는 것이 답이다 — 사람에게 다시 묻지 않는다.
1253
+ const relocked = await runProfile('resolve', { ...values, profile: attachedProfile, write: true }, root);
1254
+ if (relocked !== 0)
1255
+ return relocked;
1256
+ console.log('');
1257
+ console.log(renderSetup(await inspectSetup(root)));
1258
+ return 0;
1259
+ }
1260
+ /** 지금 붙어 있는 Profile id. lock 이 어긋나 있어도 읽힌다 — 파일에 그대로 남아 있다. */
1261
+ async function lockedProfileId(root) {
1262
+ try {
1263
+ const lock = JSON.parse(await readFile(join(root, 'profile.lock'), 'utf8'));
1264
+ return lock.profile?.id;
1265
+ }
1266
+ catch {
1267
+ return undefined;
1268
+ }
1269
+ }
1270
+ /** git·계정에서 "지금 이 사람"의 이름만 읽는다. 자격 값은 읽지 않는다. */
1271
+ async function detectSelf() {
1272
+ const fromGit = await execText('git', ['config', 'user.name']);
1273
+ if (fromGit)
1274
+ return fromGit;
1275
+ const user = userInfo().username;
1276
+ return user || null;
1277
+ }
1278
+ async function execText(command, args) {
1279
+ try {
1280
+ const { stdout } = await execFileAsync(command, args);
1281
+ return stdout.trim() || null;
1282
+ }
1283
+ catch {
1284
+ return null;
1285
+ }
1286
+ }
1287
+ async function readJson(path) {
1288
+ try {
1289
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
1290
+ return parsed && typeof parsed === 'object' ? parsed : {};
1291
+ }
1292
+ catch {
1293
+ return {};
1294
+ }
1295
+ }
1296
+ const writeJson = (path, value) => writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
1109
1297
  async function runSetupLifecycle(command, values, entry) {
1110
1298
  const asJson = Boolean(values.json) || Boolean(values.agent);
1111
1299
  const plan = computeSetupPlan(await detectSetupState(values, entry));
@@ -1293,10 +1481,12 @@ async function runProfileAdopt(values, entry) {
1293
1481
  // 다음 한 걸음은 두 형태로 준다 — agent는 portable, 사람은 display (C-14 불변식 ⑯).
1294
1482
  // 여기서는 설치 상태를 다시 관측하지 않는다: 이 명령이 도는 방식이 곧 그 답이다.
1295
1483
  const args = ['setup', 'apply', '--profile', adopted.id];
1484
+ // portable은 agent가 그대로 실행한다 — 기계가 읽는 형태로 끝난다 (setup-plan.ts와 같은 규칙).
1485
+ const machine = [...args, '--json'];
1296
1486
  const action = {
1297
1487
  type: 'apply_setup',
1298
1488
  display: shorthandCommand(args),
1299
- portable: entry === 'bootstrap' ? portableCommand(args) : shorthandCommand(args),
1489
+ portable: entry === 'bootstrap' ? portableCommand(machine) : shorthandCommand(machine),
1300
1490
  };
1301
1491
  if (asJson) {
1302
1492
  console.log(JSON.stringify({ id: adopted.id, path, project: parsed.data.project, warnings: adopted.warnings, nextActions: [action.portable], actions: [action] }, null, 2));
@@ -1644,11 +1834,18 @@ async function runProceed(values, store, root, resolved) {
1644
1834
  canonicalSources: (resolved?.canonicalSources ?? []).map((sourceId) => ({ sourceId })),
1645
1835
  ...(resolved?.ownership ? { ownership: resolved.ownership } : {}),
1646
1836
  });
1837
+ const workRef = values.work ?? undefined;
1838
+ const ingress = workRef ? await buildWorkIngress(store, root, sessions, resolved) : undefined;
1839
+ if (workRef && !ingress) {
1840
+ console.error(`작업 항목 '${workRef}' 을 읽을 통로가 없다 — Profile bindings 에 작업 항목 provider 를 선언하라.`);
1841
+ return 2;
1842
+ }
1647
1843
  const operator = new Operator({
1648
1844
  store,
1649
1845
  sessions,
1650
1846
  // 막힌 node만 보고 판단한다 — checkpoint를 발행했다는 이유로 멈추지 않는다 (C-13 §3.1)
1651
1847
  escalations: escalationLedger(store),
1848
+ ...(ingress ? { ingress } : {}),
1652
1849
  // main()의 checkBootstrap과 같은 원천(bootstrapGuard)이다 — 중복 판단이 아니라 같은 문
1653
1850
  guard: async () => {
1654
1851
  const outcome = await checkBootstrap(root);
@@ -1658,7 +1855,10 @@ async function runProceed(values, store, root, resolved) {
1658
1855
  const outcome = await operator.proceed({
1659
1856
  ...(values.session ? { sessionId: values.session } : {}),
1660
1857
  ...(values.goal ? { goal: values.goal } : {}),
1858
+ ...(workRef ? { workRef } : {}),
1661
1859
  });
1860
+ // 도구 자식(JAM MCP 서버 등)을 여기서 닫는다 — 안 닫으면 출력까지 끝내고도 종료하지 못한다.
1861
+ await closeToolClients();
1662
1862
  if (values.json) {
1663
1863
  console.log(JSON.stringify(outcome, null, 2));
1664
1864
  return outcome.kind.startsWith('BLOCKED') || outcome.kind === 'FAILED' ? 1 : 0;
@@ -1712,7 +1912,39 @@ async function runProceed(values, store, root, resolved) {
1712
1912
  console.log(` ${c.id} ${c.status.padEnd(7)} ${c.wouldDo.padEnd(8)} ${c.goal}`);
1713
1913
  }
1714
1914
  return 1;
1915
+ case 'WORK_STATE': {
1916
+ const shown = outcome.result.leaning
1917
+ ? `${outcome.result.state} (${outcome.result.leaning})`
1918
+ : outcome.result.state;
1919
+ console.log(`${outcome.workRef}: ${shown}`);
1920
+ for (const line of outcome.result.evidence)
1921
+ console.log(` 근거 ${line}`);
1922
+ for (const line of outcome.result.limitations)
1923
+ console.log(` 한계 ${line}`);
1924
+ for (const line of outcome.result.missing)
1925
+ console.log(` 미확인 ${line}`);
1926
+ console.log(`\n다음 행동: ${outcome.nextAction}`);
1927
+ return outcome.result.state === 'UNDECIDABLE' ? 1 : 0;
1928
+ }
1715
1929
  case 'PROPOSE_CONTRACT':
1930
+ if (outcome.plan) {
1931
+ console.log(`${outcome.plan.status}${outcome.full?.id ? ` — ${outcome.full.id}` : ''}`);
1932
+ for (const fact of outcome.plan.facts)
1933
+ console.log(` fact ${fact.field} (${fact.source})`);
1934
+ for (const proposal of outcome.plan.proposals) {
1935
+ console.log(` proposal ${proposal.field} — ${proposal.reason ?? proposal.source}`);
1936
+ }
1937
+ for (const item of outcome.plan.invalid)
1938
+ console.log(` invalid ${item.field}: ${item.detail}`);
1939
+ for (const item of outcome.plan.unresolved) {
1940
+ console.log(` decide ${item.field} [${item.reason}]: ${item.detail}`);
1941
+ }
1942
+ if (outcome.forController) {
1943
+ console.log(`\n계약은 성립한다. 발급은 Controller 의 것이다 — ${outcome.plan.issuance.detail}:`);
1944
+ console.log(` ${shorthandCommand(outcome.forController.slice(1))}`);
1945
+ }
1946
+ return outcome.plan.status === 'READY_TO_ISSUE' ? 0 : 1;
1947
+ }
1716
1948
  console.log('No runnable session. If a new contract is needed, the Controller issues it:');
1717
1949
  console.log(` asc session issue S-<date>-<n> --role ${outcome.draft.role} --goal "${outcome.draft.goal || '<goal>'}"`);
1718
1950
  return 1;
@@ -1737,6 +1969,199 @@ async function runProceed(values, store, root, resolved) {
1737
1969
  return 1;
1738
1970
  }
1739
1971
  }
1972
+ /**
1973
+ * 이미 쓴 세션 id. **회수돼 보관된 것까지 센다** — 그 번호를 다시 쓰면 앞의 계약 기록
1974
+ * 위에 다른 계약이 앉는다. 실제로 그렇게 기록 하나를 잃었다.
1975
+ */
1976
+ async function usedSessionIds(store, root) {
1977
+ const active = (await store.list('session')).map((session) => session.id);
1978
+ let archived = [];
1979
+ try {
1980
+ archived = (await readdir(join(root, 'sessions', 'archive')))
1981
+ .filter((name) => name.endsWith('.md'))
1982
+ .map((name) => name.slice(0, -3));
1983
+ }
1984
+ catch {
1985
+ archived = [];
1986
+ }
1987
+ return [...new Set([...active, ...archived])];
1988
+ }
1989
+ /**
1990
+ * 저장소의 최상위 자리와 그 아래 `src`. 분류 이름이 어느 모듈에 맞는지 재는 재료다 —
1991
+ * 목록일 뿐이고, 이것이 쓰기 범위가 되지는 않는다 (derive 가 최상위는 범위로 쓰지 않는다).
1992
+ */
1993
+ async function topLevelModules(projectRoot) {
1994
+ try {
1995
+ const entries = await readdir(projectRoot, { withFileTypes: true });
1996
+ return entries
1997
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules')
1998
+ .flatMap((entry) => [entry.name, `${entry.name}/src`]);
1999
+ }
2000
+ catch {
2001
+ return [];
2002
+ }
2003
+ }
2004
+ /** 선행·의존으로 볼 후보. 링크가 먼저고, 본문이 말한 키가 그다음이다. 상한은 5. */
2005
+ const DEPENDENCY_CAP = 5;
2006
+ function dependencyCandidates(workItem) {
2007
+ if (!workItem)
2008
+ return [];
2009
+ // 본문이 "BLOCKED BY: KEY-1" 처럼 말한 것은 링크가 없어도 의존이다 — 링크가 없다는
2010
+ // 사실이 의존이 없다는 뜻은 아니다.
2011
+ const fromBody = new Set();
2012
+ const project = workItem.reference.split('-')[0];
2013
+ if (project) {
2014
+ const pattern = new RegExp(`(?:blocked\\s*by|선행|의존)[^\\n]*?(${project}-\\d+)`, 'gi');
2015
+ for (const [, key] of `${workItem.body ?? ''}`.matchAll(pattern))
2016
+ if (key)
2017
+ fromBody.add(key);
2018
+ }
2019
+ // 부모·하위 작업은 포함 관계이지 선행이 아니다 — 그것까지 세면 거의 모든 작업이 막힌다.
2020
+ const ordered = [...(workItem.blockedBy ?? []), ...fromBody].filter((key) => key !== workItem.reference);
2021
+ return [...new Set(ordered)].slice(0, DEPENDENCY_CAP);
2022
+ }
2023
+ /**
2024
+ * 작업 항목 하나를 조사해 계약까지 잇는 통로 (P0-D).
2025
+ *
2026
+ * 여기서 판정하지 않는다. 모으고(gather·observeRepo), 도출하고(derive), **기존 판정기에
2027
+ * 넘긴다**(plan = planSessionContract, issue = SessionRuntime). 범위·책임·발급 권한은
2028
+ * 그것들이 답하는 것을 그대로 쓴다.
2029
+ *
2030
+ * buildMonitorEngine 을 거치지 않는 이유: 그 함수는 GitHub 토큰이 없으면 멈춘다. 감시에는
2031
+ * 맞는 문이지만, GitLab·JAM 프로젝트에서 저장소 조사까지 막아 버린다 — 그것이 "원격이
2032
+ * 막혔으니 저장소도 못 본다"는 잘못된 결론을 만든 구조다.
2033
+ */
2034
+ async function buildWorkIngress(store, root, runtime, resolved) {
2035
+ const { root: projectRoot } = await discoverProjectRoot(process.cwd());
2036
+ // JAM 은 아직 `jam` 바이너리를 깔지 않는 설치가 기본이다 — 그 경우 launcher 를 통해 부른다.
2037
+ const jamCommand = process.env.ASC_JAM_PATH ? undefined : { command: 'npx', args: ['--yes', '@jam-mcp/launcher'] };
2038
+ const adapters = [
2039
+ new GitHubAdapter(),
2040
+ new GitLabAdapter(),
2041
+ new JamAdapter(jamCommand ?? {}),
2042
+ ];
2043
+ const declared = resolved?.layers.profile.bindings ?? [];
2044
+ const plan = await composeBindings({
2045
+ context: { projectRoot, env: process.env },
2046
+ adapters,
2047
+ roles: declared.map((b) => ({ adapterId: b.adapter, resource: b.resource, role: b.role })),
2048
+ });
2049
+ // 선언이 없으면 발견된 사실로 제안한다 (P1-G). 저장하지 않고, 갈리면 고르지 않는다.
2050
+ const proposed = declared.length === 0 ? proposeBindings(plan) : undefined;
2051
+ if (proposed) {
2052
+ for (const reason of proposed.reasons)
2053
+ console.error(` 제안 ${reason}`);
2054
+ for (const conflict of proposed.conflicts)
2055
+ console.error(` 보류 ${conflict}`);
2056
+ }
2057
+ const ports = await buildRuntimePorts({
2058
+ plan,
2059
+ // 제안은 **말하는 것**이지 정하는 것이 아니다. 역할을 박아 넣으면 선언과 구분되지 않고,
2060
+ // capability 해석은 후보가 유일할 때 이미 스스로 풀린다.
2061
+ roles: rolesFor(plan, declared),
2062
+ perPage: 30,
2063
+ jam: { command: jamCommand?.command ?? 'jam', args: [...(jamCommand?.args ?? []), 'serve'], cwd: projectRoot },
2064
+ endpointFor: (binding) => endpointOf(adapters, binding),
2065
+ });
2066
+ const work = ports.resourceContext;
2067
+ if (!work)
2068
+ return undefined;
2069
+ // 저장소는 원격 provider 와 무관하게 본다. 이 한 줄이 P0-E 의 요점이다.
2070
+ const repo = new LocalRepoAdapter({ cwd: projectRoot });
2071
+ const canonicalRef = resolved?.layers.profile.canonical.sources[0]?.ref;
2072
+ const canonicalPaths = resolved?.layers.profile.canonical.sources.flatMap((source) => source.paths) ?? [];
2073
+ const changeContext = ports.changeContext;
2074
+ return {
2075
+ gather: async (workRef) => {
2076
+ const workItem = await work.getResource(workRef).catch(() => undefined);
2077
+ const comments = await work
2078
+ .getComments(workRef, { limit: 20 })
2079
+ .then((list) => list)
2080
+ .catch(() => 'UNAVAILABLE');
2081
+ const change = changeContext
2082
+ ? await changeContext
2083
+ .getChange(workRef)
2084
+ .then((summary) => summary)
2085
+ .catch(() => 'UNAVAILABLE')
2086
+ : 'UNAVAILABLE';
2087
+ // 선행 작업이 열려 있는지는 **조회해야** 안다. 키만 넘기면 판정이 늘 "모른다"가 되고,
2088
+ // 그러면 막힌 작업이 착수 가능으로 보인다. adapter 가 막는 것을 앞에 실어 주므로
2089
+ // 상한에 걸려도 blocker 가 먼저 확인된다.
2090
+ const candidates = dependencyCandidates(workItem);
2091
+ const dependencies = await Promise.all(candidates.map(async (reference) => {
2092
+ const item = await work.getResource(reference).catch(() => undefined);
2093
+ const done = item && !item.missing ? statusIndicatesDone(item.state) : undefined;
2094
+ return {
2095
+ reference,
2096
+ ...(item?.state ? { state: item.state } : {}),
2097
+ ...(done === undefined ? {} : { open: !done }),
2098
+ };
2099
+ }));
2100
+ return {
2101
+ ...(workItem ? { workItem } : {}),
2102
+ ...(workItem ? { trackerDone: statusIndicatesDone(workItem.state) } : {}),
2103
+ comments,
2104
+ change,
2105
+ dependencies,
2106
+ };
2107
+ },
2108
+ observeRepo: async (query) => {
2109
+ // 조회할 경로는 **작업 항목이 지목한 것**이 먼저다. 그것을 확인해야 좁은 범위를
2110
+ // 만들 수 있고, 확인하지 않으면 넓은 범위밖에 남지 않는다. 저장소의 최상위 자리도
2111
+ // 함께 확인한다 — 분류 이름이 어느 모듈 하나에만 맞는지 재려면 그 목록이 있어야 한다.
2112
+ const modules = await topLevelModules(projectRoot);
2113
+ const paths = [...new Set([...(query.paths ?? []), ...canonicalPaths])];
2114
+ return repo.observe({
2115
+ refHint: query.refHint,
2116
+ ...(canonicalRef ? { canonicalRef } : {}),
2117
+ ...(paths.length > 0 ? { paths } : {}),
2118
+ ...(modules.length > 0 ? { modulePaths: modules } : {}),
2119
+ });
2120
+ },
2121
+ usedIds: () => usedSessionIds(store, root),
2122
+ derive: (input) => deriveSessionContractDraft({
2123
+ existingIds: input.existingIds,
2124
+ intent: { workRef: input.workRef, ...(input.goal ? { goal: input.goal } : {}) },
2125
+ workItem: input.workItem,
2126
+ workState: input.workState,
2127
+ repo: input.repo,
2128
+ // 상한이지 출처가 아니다 — 도출한 후보가 이 밖으로 나가지 않는지 재는 데만 쓴다.
2129
+ maxScopes: resolved?.resolved.policy.roleScopes.implementer ?? [],
2130
+ ...(resolved?.ownership ? { ownership: resolved.ownership } : {}),
2131
+ today: new Date().toISOString().slice(0, 10).replace(/-/g, ''),
2132
+ }),
2133
+ plan: async (draft) => planSessionContract({
2134
+ draft,
2135
+ ...(resolved?.resolved.policy ? { policy: resolved.resolved.policy } : {}),
2136
+ ...(resolved?.ownership ? { ownership: resolved.ownership } : {}),
2137
+ existingIds: await usedSessionIds(store, root),
2138
+ }),
2139
+ issue: async (draft) => {
2140
+ // 발급 경로는 하나뿐이다 — `asc session issue` 와 같은 SessionRuntime.issue 를 부른다.
2141
+ const issued = await runtime.issue({
2142
+ id: draft.id,
2143
+ role: SessionRole.parse(draft.role ?? 'implementer'),
2144
+ goal: draft.goal ?? '',
2145
+ ...(draft.criteria ? { doneCriteria: [...draft.criteria] } : {}),
2146
+ ...(draft.boundary ? { writeBoundary: [...draft.boundary] } : {}),
2147
+ ...(draft.owner ? { owner: draft.owner } : {}),
2148
+ });
2149
+ if (!issued.ok) {
2150
+ return { ok: false, detail: issued.failures.map((f) => `${f.kind}: ${f.detail}`).join('; ') };
2151
+ }
2152
+ await auditLedger(store).delegate({
2153
+ childSessionId: issued.session.id,
2154
+ role: issued.session.role,
2155
+ goal: issued.session.goal,
2156
+ scope: issued.session.writeBoundary,
2157
+ doneCriteria: issued.session.doneCriteria,
2158
+ issuedBy: issued.session.owner ?? '(위임 범위 내 자동 발급)',
2159
+ issuedAt: new Date().toISOString(),
2160
+ });
2161
+ return { ok: true, sessionId: issued.session.id };
2162
+ },
2163
+ };
2164
+ }
1740
2165
  /** 세션 lifecycle. 계약을 발급하고, 중단·재개하고, Handoff까지 남긴다 (OM §6.2). */
1741
2166
  /**
1742
2167
  * 이 세션이 걸린 결정마다 실제 결정권자. 세션 계약이 먼저고, 없으면 Profile 지도에서 푼다.
@@ -1771,6 +2196,94 @@ function parseAuthority(pairs) {
1771
2196
  }
1772
2197
  return { ok: true, map };
1773
2198
  }
2199
+ /**
2200
+ * 초안을 재 본다. **아무것도 발급하지 않는다** (C-14 §6의 plan/apply 분리와 같은 자세).
2201
+ *
2202
+ * agent가 사용자 요청·work item·Profile·저장소를 읽어 계약 초안을 만들고, 여기서 그것이
2203
+ * 구조·경계로 성립하는지 확인한다. 판정은 셋뿐이다 — 발급해도 된다 / 사람이 정할 것이
2204
+ * 남았다 / 이 초안으로는 계약이 안 된다.
2205
+ */
2206
+ async function runSessionPlan(values, store, resolved) {
2207
+ const authority = parseAuthority(values.authority);
2208
+ if (!authority.ok) {
2209
+ console.error(authority.detail);
2210
+ return 2;
2211
+ }
2212
+ // 출처는 `<field>=<status>[:<source>]` 로 받는다 — 초안을 만든 쪽이 무엇을 확인했고
2213
+ // 무엇을 제안했는지 스스로 적게 한다. 적지 않으면 제안으로 셈한다(사실로 올리지 않는다).
2214
+ const provenance = [];
2215
+ for (const raw of values.provenance ?? []) {
2216
+ const [field, rest] = raw.split('=', 2);
2217
+ const [status, source] = (rest ?? '').split(':', 2);
2218
+ const parsed = DraftProvenance.safeParse({
2219
+ field,
2220
+ status,
2221
+ source: source ?? 'agent_proposal',
2222
+ ...(values.why ? { reason: values.why[0] } : {}),
2223
+ });
2224
+ if (!parsed.success) {
2225
+ console.error(`--provenance 는 <field>=<FACT|PROPOSAL|DECISION_REQUIRED>[:<source>] 형식이다: '${raw}'`);
2226
+ return 2;
2227
+ }
2228
+ provenance.push(parsed.data);
2229
+ }
2230
+ const draft = {
2231
+ ...(values.id ? { id: values.id } : {}),
2232
+ ...(values.role ? { role: values.role } : {}),
2233
+ ...(values.goal ? { goal: values.goal } : {}),
2234
+ ...(values.boundary ? { boundary: values.boundary } : {}),
2235
+ ...(values.criteria ? { criteria: values.criteria } : {}),
2236
+ ...(values.owner ? { owner: values.owner } : {}),
2237
+ ...(values.domain ? { decisionDomains: values.domain } : {}),
2238
+ ...(Object.keys(authority.map).length > 0 ? { decisionAuthority: authority.map } : {}),
2239
+ ...(provenance.length > 0 ? { provenance } : {}),
2240
+ };
2241
+ const plan = planSessionContract({
2242
+ draft,
2243
+ ...(resolved?.resolved.policy ? { policy: resolved.resolved.policy } : {}),
2244
+ ...(resolved?.ownership ? { ownership: resolved.ownership } : {}),
2245
+ existingIds: (await store.list('session')).map((session) => session.id),
2246
+ });
2247
+ // 통과했을 때만 실행 가능한 명령을 준다. 통과하지 않은 초안의 명령을 함께 주면
2248
+ // agent는 그것을 "고치고 나서 쓸 것"이 아니라 "지금 쓸 것"으로 읽는다.
2249
+ //
2250
+ // 그리고 **완성된 계약이라고 해서 발급해도 되는 것은 아니다** (OM §450). 위임이 없으면
2251
+ // 명령은 `forController` 로 간다 — actions에 넣으면 "portable을 실행하라"는 지시를 따르는
2252
+ // agent가 사람의 권한을 대신 쓰게 된다.
2253
+ const issuable = plan.status === 'READY_TO_ISSUE';
2254
+ const command = {
2255
+ display: shorthandCommand(issueArgs(draft)),
2256
+ portable: shorthandCommand([...issueArgs(draft), '--json']),
2257
+ };
2258
+ const actions = issuable && plan.issuance.authority === 'delegated' ? [{ type: 'issue_session', ...command }] : [];
2259
+ const forController = issuable && plan.issuance.authority === 'controller' ? command : undefined;
2260
+ if (values.json || values.agent) {
2261
+ console.log(JSON.stringify({ ...plan, nextActions: actions.map((action) => action.portable), actions, ...(forController ? { forController } : {}) }, null, 2));
2262
+ }
2263
+ else {
2264
+ console.log(`${plan.status}${plan.draft.id ? ` — ${plan.draft.id}` : ''}`);
2265
+ for (const fact of plan.facts)
2266
+ console.log(` fact ${fact.field} (${fact.source})`);
2267
+ for (const proposal of plan.proposals)
2268
+ console.log(` proposal ${proposal.field} — ${proposal.reason ?? proposal.source}`);
2269
+ for (const item of plan.invalid)
2270
+ console.log(` invalid ${item.field}: ${item.detail}`);
2271
+ for (const item of plan.unresolved) {
2272
+ console.log(` decide ${item.field} [${item.reason}]: ${item.detail}`);
2273
+ for (const [index, option] of (item.options ?? []).entries()) {
2274
+ console.log(` ${index + 1}. ${option}${item.recommended === index ? ' ← recommended' : ''}`);
2275
+ }
2276
+ }
2277
+ for (const action of actions)
2278
+ console.log(`\nIssue it: ${action.display}`);
2279
+ if (forController) {
2280
+ console.log(`\nThe contract holds. Issuing it is the Controller's — ${plan.issuance.detail}:`);
2281
+ console.log(` ${forController.display}`);
2282
+ }
2283
+ }
2284
+ // 사람이 정할 것이 남았거나 초안이 성립하지 않으면 1이다 — setup plan과 같은 규칙.
2285
+ return plan.status === 'READY_TO_ISSUE' ? 0 : 1;
2286
+ }
1774
2287
  async function runSession(command, target, values, store, resolved) {
1775
2288
  // Profile이 정한 Role 범위와 금지가 여기까지 와야 실제로 강제된다.
1776
2289
  // 정책 없이 만들면 붙어 있는 프로젝트에서도 계약 검사가 통째로 비어버린다.
@@ -1794,6 +2307,10 @@ async function runSession(command, target, values, store, resolved) {
1794
2307
  }
1795
2308
  return 0;
1796
2309
  }
2310
+ // `plan` 은 **아직 세션이 없을 때** 부르는 것이므로 id를 요구하지 않는다. 초안에 id가
2311
+ // 없다는 사실 자체가 판정 대상이다 (없으면 그것이 unresolved로 나온다).
2312
+ if (command === 'plan')
2313
+ return runSessionPlan(values, store, resolved);
1797
2314
  if (!target) {
1798
2315
  console.error(`Usage: asc session ${command ?? '<command>'} <SESSION_ID>`);
1799
2316
  return 2;