@asc-agent/runtime 0.3.2 → 0.4.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 (37) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude-code/install.d.ts +7 -0
  3. package/dist/adapters/claude-code/install.js +114 -38
  4. package/dist/adapters/claude-code/session-start.d.ts +20 -0
  5. package/dist/adapters/claude-code/session-start.js +111 -0
  6. package/dist/adapters/jam/adapter.d.ts +46 -9
  7. package/dist/adapters/jam/adapter.js +88 -22
  8. package/dist/adapters/jam/setup.d.ts +62 -0
  9. package/dist/adapters/jam/setup.js +85 -0
  10. package/dist/adapters/service/launchd.d.ts +12 -0
  11. package/dist/adapters/service/launchd.js +80 -0
  12. package/dist/adapters/service/schtasks.d.ts +16 -0
  13. package/dist/adapters/service/schtasks.js +67 -0
  14. package/dist/adapters/service/systemd-user.d.ts +15 -0
  15. package/dist/adapters/service/systemd-user.js +95 -0
  16. package/dist/cli/asc.js +840 -143
  17. package/dist/composition/registry.js +11 -3
  18. package/dist/composition/runtime.d.ts +33 -0
  19. package/dist/composition/runtime.js +74 -0
  20. package/dist/core/attach/setup-plan.d.ts +66 -2
  21. package/dist/core/attach/setup-plan.js +50 -0
  22. package/dist/core/distribution/external-command.js +1 -1
  23. package/dist/core/distribution/persistent-runtime.d.ts +73 -0
  24. package/dist/core/distribution/persistent-runtime.js +49 -0
  25. package/dist/core/distribution/release.d.ts +3 -3
  26. package/dist/core/distribution/release.js +1 -1
  27. package/dist/core/runtime/background.d.ts +104 -0
  28. package/dist/core/runtime/background.js +225 -0
  29. package/dist/core/runtime/front.d.ts +48 -0
  30. package/dist/core/runtime/front.js +34 -0
  31. package/dist/core/runtime/workspaces.d.ts +39 -0
  32. package/dist/core/runtime/workspaces.js +64 -0
  33. package/dist/core/workspace/resolve.d.ts +36 -0
  34. package/dist/core/workspace/resolve.js +110 -3
  35. package/dist/ports/adapter.d.ts +12 -0
  36. package/dist/schemas/profile.d.ts +6 -6
  37. package/package.json +1 -1
package/dist/cli/asc.js CHANGED
@@ -6,12 +6,12 @@
6
6
  // 출력만 있고 판단은 한 줄도 없다.
7
7
  //
8
8
  // 읽기 전용이다. 결정 제출은 사람의 명시적 의사표현을 받는 별도 경로로 나간다 (B-06).
9
- import { execFile, spawnSync } from 'node:child_process';
9
+ import { execFile, spawn, spawnSync } from 'node:child_process';
10
10
  import { parseArgs, promisify } from 'node:util';
11
11
  import { existsSync, readdirSync, realpathSync } from 'node:fs';
12
12
  import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
13
13
  import { fileURLToPath } from 'node:url';
14
- import { homedir, userInfo } from 'node:os';
14
+ import { homedir, hostname, userInfo } from 'node:os';
15
15
  import { basename, dirname, join, resolve } from 'node:path';
16
16
  import { MINIMUM_NODE_MAJOR, checkNodeRuntime, reexecWithCandidate, } from "../core/distribution/node-runtime.js";
17
17
  import { RELEASE_VERSION } from "../core/distribution/release.js";
@@ -32,7 +32,7 @@ import { AdoptError, buildAdoptedProfile } from "../core/attach/adopt.js";
32
32
  import { locatorsOf, lookupLocator, readIndex, register, writeIndex } from "../core/workspace/index-store.js";
33
33
  import { adoptionLine, judgeAdoption, migrate } from "../core/workspace/migrate.js";
34
34
  import { newWorkspaceId, normalizeRemote, recoverCandidates, recoverLines } from "../core/workspace/identity.js";
35
- import { resolveWorkspace, resolutionLine } from "../core/workspace/resolve.js";
35
+ import { gitWorktrees, resolveWorkspace, resolutionLine } from "../core/workspace/resolve.js";
36
36
  import { assessSetup, renderSetup } from "../core/attach/setup.js";
37
37
  import { withIdentity } from "../core/attach/init.js";
38
38
  import { applySetupPlan, computeSetupPlan, renderSetupPlan, } from "../core/attach/setup-plan.js";
@@ -49,11 +49,12 @@ import { deriveSessionContractDraft } from "../core/operator/derive-draft.js";
49
49
  import { LocalRepoAdapter } from "../adapters/local/repo.js";
50
50
  import { GitHubAdapter } from "../adapters/github/adapter.js";
51
51
  import { GitLabAdapter } from "../adapters/gitlab/adapter.js";
52
- import { JamAdapter } from "../adapters/jam/adapter.js";
52
+ import { JamAdapter, resolveJamCommand } from "../adapters/jam/adapter.js";
53
+ import { healJam, healLine } from "../adapters/jam/setup.js";
53
54
  import { statusIndicatesDone } from "../adapters/jam/ports.js";
54
55
  import { ProgressService } from "../core/operator/progress.js";
55
56
  import { composeBindings, defaultAdapters } from "../composition/registry.js";
56
- import { buildRuntimePorts, closeToolClients, rolesFor } from "../composition/runtime.js";
57
+ import { buildObservationChannels, buildRuntimePorts, closeToolClients, rolesFor } from "../composition/runtime.js";
57
58
  import { proposeBindings } from "../composition/propose.js";
58
59
  import { buildEventObservation } from "../composition/observe.js";
59
60
  import { availableProfiles, planBootstrap, renderPlan } from "../core/attach/bootstrap.js";
@@ -67,12 +68,19 @@ import { renderProgress } from "../core/operator/render.js";
67
68
  import { AuditLedger, decisionLines, delegationLine, executionLines, reclaimLine, validationLines, DecisionClass, } from "../core/runtime/audit.js";
68
69
  import { ClosureLedger } from "../core/runtime/closure.js";
69
70
  import { Orchestrator, renderTick } from "../core/runtime/orchestrator.js";
71
+ import { LAST_RUN_KEY, RuntimeLease, fileScope, readBackground, renderBackground, staleAfter, } from "../core/runtime/background.js";
72
+ import { dueWorkspaces, renderWorkspaces, viewWorkspaces, } from "../core/runtime/workspaces.js";
73
+ import { persistentRuntimeLine, planPersistentRuntime, } from "../core/distribution/persistent-runtime.js";
74
+ import { launchdAdapter } from "../adapters/service/launchd.js";
75
+ import { schtasksAdapter } from "../adapters/service/schtasks.js";
76
+ import { serviceAdapterFor, systemdUserAdapter } from "../adapters/service/systemd-user.js";
70
77
  import { QueryLedger } from "../core/runtime/query.js";
71
78
  import { ObservationLedger } from "../core/monitor/observation.js";
72
79
  import { DeliveryLedger, deliver, planDigest } from "../core/presentation/digest.js";
73
80
  import { LocalPresentation } from "../adapters/local/presentation.js";
74
81
  import { collectSessions, renderCollect } from "../core/runtime/controller.js";
75
- import { renderFront, restoreFront } from "../core/runtime/front.js";
82
+ import { frontOpeningLines, openFront, renderFront, restoreFront } from "../core/runtime/front.js";
83
+ import { sessionStartPayload } from "../adapters/claude-code/session-start.js";
76
84
  import { EscalationLedger, escalationLines } from "../core/runtime/escalation.js";
77
85
  import { deriveExecutionState, executionLine } from "../core/runtime/execution-state.js";
78
86
  import { buildFinalReport, renderFinalReport } from "../core/runtime/report.js";
@@ -109,13 +117,19 @@ const USAGE = `asc — Agent Session Control
109
117
  asc profile adopt [--id <name>] [--json] # make a profile for this repository
110
118
  asc profile resolve --profile <id> [--preset <id>] [--install <path>] [--write]
111
119
 
112
- asc runtime start [--interval-min <n>] [--delta-min <n>] [--reconcile-min <n>]
113
- [--census-min <n>] [--digest-min <n>]
114
- asc runtime tick
115
- asc runtime status [--json] # which build is in use (C-14 §4)
120
+ asc runtime start [--detach] [--interval-min <n>] [--delta-min <n>]
121
+ [--reconcile-min <n>] [--census-min <n>] [--digest-min <n>]
122
+ # --detach: keep observing after this terminal closes
123
+ asc runtime tick [--all] # --all: every workspace this machine knows
124
+ asc runtime list [--json] # every workspace, without visiting each one
125
+ asc runtime service [status] [--json] # the machine's persistent registration
126
+ asc runtime service install|uninstall [--interval-min <n>]
127
+ asc runtime stop # ask the background runtime to finish its pass
128
+ asc runtime status [--json] # which build is in use, and whether it observes
116
129
  asc runtime use package
117
130
  asc runtime use development <checkout> # run a built checkout instead
118
131
  asc front [status] [--json]
132
+ asc front open [--json] # a host session opened here — what is waiting
119
133
  asc escalate open <S-ID> --predicate <p>... --question <t> --blocked <node>...
120
134
  --evidence <ref>... [--blocked-scope <path>...] [--previous <ESC-ID> --why <t>]
121
135
  asc escalate list
@@ -232,15 +246,58 @@ async function discoverRoot(start, explicitRoot) {
232
246
  return resolution.kind === 'UNRESOLVED' ? null : resolution.root;
233
247
  }
234
248
  async function resolveRoot(start, explicitRoot) {
235
- return resolveWorkspace({
249
+ const home = ascHome();
250
+ const index = await readIndex(home);
251
+ const resolution = await resolveWorkspace({
236
252
  cwd: start,
237
253
  ...(explicitRoot ? { explicitRoot } : {}),
238
- index: await readIndex(ascHome()),
254
+ index,
239
255
  // 홈의 `~/.asc` 는 user runtime이지 프로젝트 상태가 아니다 — 그 위로 올라가지 않는다
240
256
  stopAt: homedir(),
257
+ // 등록이 빗나갔을 때만 Git에게 구조를 묻는다 (C-11 §1.3).
258
+ worktrees: gitWorktrees,
241
259
  });
260
+ // 같은 저장소의 다른 checkout으로 풀렸으면 **이 경로를 등록해 둔다.** 그래야 다음부터는
261
+ // 예전과 같은 index 조회 하나로 끝나고, guard hook도 이 checkout을 관리 대상으로 본다.
262
+ if (resolution.kind === 'LINKED_WORKTREE')
263
+ await rememberWorktree(home, index, resolution);
264
+ return resolution;
265
+ }
266
+ /**
267
+ * 이번에 구조로 풀린 checkout을 index에 적는다. **workspace를 새로 만들지 않는다** —
268
+ * 같은 논리 workspace의 execution instance가 하나 늘었을 뿐이다 (C-11 §1.3).
269
+ *
270
+ * 실패해도 이번 판정을 무르지 않는다. 다음 호출이 Git을 한 번 더 부르는 것으로 끝난다 —
271
+ * 쓰기 하나가 안 됐다고 붙어 있는 workspace를 못 쓰게 만드는 쪽이 나쁘다.
272
+ */
273
+ async function rememberWorktree(home, index, resolution) {
274
+ const now = new Date().toISOString();
275
+ try {
276
+ await writeIndex(home, register(index, {
277
+ workspaceId: resolution.workspaceId,
278
+ root: resolution.root,
279
+ locator: {
280
+ path: resolution.locator,
281
+ kind: resolution.kindOfLocator,
282
+ platform: process.platform,
283
+ observedAt: now,
284
+ },
285
+ now,
286
+ }));
287
+ }
288
+ catch {
289
+ // 등록만 못 했을 뿐이다. 판정은 그대로 선다.
290
+ }
242
291
  }
243
292
  const execFileAsync = promisify(execFile);
293
+ /**
294
+ * Claude Host 설치 경로 + **이 CLI의 위치**.
295
+ *
296
+ * SessionStart hook은 상태를 물어볼 곳이 필요하고, 그곳은 지금 도는 이 실행파일이다.
297
+ * 그 CLI가 다시 선택된 build로 넘기므로(`runtime use development` 포함) hook이 build를
298
+ * 고르는 일은 없다.
299
+ */
300
+ const hostPaths = () => ({ ...defaultPaths(), entry: fileURLToPath(import.meta.url) });
244
301
  /**
245
302
  * 이 binding이 가리키는 주소. 자체 호스팅은 발견 단계가 이미 알아냈으므로 같은 값을 쓴다 —
246
303
  * 여기서 다시 추측하면 두 곳이 다른 주소를 말하게 된다.
@@ -294,17 +351,60 @@ const ACTIVE_SOURCE_KEY = 'active-source';
294
351
  * digest처럼 조립을 하지 않는 명령도 같은 자리를 읽어야 하는데, 그걸 알아내려고 discovery와
295
352
  * probe를 다시 돌리면 화면 하나 그리자고 외부를 친다. 그래서 monitor가 돌 때 적어 둔다.
296
353
  */
297
- async function rememberMonitorSource(store, sourceId) {
298
- await store.scope('monitor').set(ACTIVE_SOURCE_KEY, sourceId);
354
+ async function rememberMonitorSources(store, sourceIds) {
355
+ await store.scope('monitor').set(ACTIVE_SOURCE_KEY, JSON.stringify([...sourceIds]));
299
356
  }
300
357
  /**
301
- * 마지막으로 붙었던 통로의 scope. 기록이 없으면 예전 이름으로 본다 —
302
- * 이전 설치의 shadow 기록이 이름이 바뀌었다는 이유로 사라진 것처럼 보이면 안 된다.
358
+ * 마지막으로 붙었던 통로들.
359
+ *
360
+ * 예전에는 문자열 하나였다 — 통로가 하나뿐이라는 전제였고, 코드와 작업 항목이 다른 곳에
361
+ * 있는 프로젝트에서 그 전제가 깨졌다. 옛 기록(문자열 하나)도 그대로 읽는다: 형식이
362
+ * 바뀌었다는 이유로 이전 설치의 shadow 기록이 사라진 것처럼 보이면 안 된다.
303
363
  */
364
+ async function activeMonitorSources(store) {
365
+ const raw = await store.scope('monitor').get(ACTIVE_SOURCE_KEY);
366
+ if (!raw)
367
+ return ['github-poll'];
368
+ try {
369
+ const parsed = JSON.parse(raw);
370
+ if (Array.isArray(parsed) && parsed.every((id) => typeof id === 'string')) {
371
+ return parsed.length > 0 ? parsed : ['github-poll'];
372
+ }
373
+ }
374
+ catch {
375
+ // 옛 형식이다 — 문자열 하나
376
+ }
377
+ return [raw];
378
+ }
379
+ /** 통로별 scope. 기록이 하나뿐이던 시절의 호출부는 첫 통로를 본다. */
380
+ async function activeMonitorScopes(store) {
381
+ return (await activeMonitorSources(store)).map(monitorScope);
382
+ }
304
383
  async function activeMonitorScope(store) {
305
- const sourceId = await store.scope('monitor').get(ACTIVE_SOURCE_KEY);
306
- return monitorScope(sourceId ?? 'github-poll');
384
+ return (await activeMonitorScopes(store))[0];
307
385
  }
386
+ /**
387
+ * JAM 을 부르는 방법. **한 곳에서만 정한다** — 감시와 작업 조사가 서로 다른 방법으로
388
+ * JAM 을 부르면 한쪽만 되는 상태가 생긴다(실제로 그랬다: 감시 경로는 이 값을 아예 넘기지
389
+ * 않아 선언된 JAM binding 이 통로를 만들지 못했다).
390
+ */
391
+ const jamLauncher = () => process.env.ASC_JAM_PATH
392
+ ? { command: process.env.ASC_JAM_PATH, args: [] }
393
+ : { command: 'npx', args: ['--yes', '@jam-mcp/launcher'] };
394
+ /** 조립 입력에 실을 JAM 갈래. */
395
+ const jamComposition = (projectRoot) => {
396
+ const launcher = jamLauncher();
397
+ return { jam: { command: launcher.command, args: [...launcher.args, 'serve'], cwd: projectRoot } };
398
+ };
399
+ /** 이 빌드가 아는 adapter 들. 감시와 작업 조사가 같은 목록을 본다. */
400
+ const monitorAdapters = () => {
401
+ const launcher = jamLauncher();
402
+ return [
403
+ new GitHubAdapter(),
404
+ new GitLabAdapter(),
405
+ new JamAdapter(process.env.ASC_JAM_PATH ? {} : { command: launcher.command, args: launcher.args }),
406
+ ];
407
+ };
308
408
  /** user-owned ASC home. */
309
409
  const ascHome = () => process.env.ASC_HOME ?? join(homedir(), '.asc');
310
410
  const DECISION_ERROR = {
@@ -419,12 +519,23 @@ export async function runAscCommand(argv, entry = 'runtime') {
419
519
  }
420
520
  const { values, positionals } = parsed;
421
521
  // 실행 중인 버전을 묻는 유일한 공식 통로. 설치 안내가 버전을 핀으로 고정하는데
422
- // 정작 지금 도는 것이 무엇인지 물을 방법이 없었다 (SSAFESTA Windows 실측 ASC-1).
522
+ // 정작 지금 도는 것이 무엇인지 물을 방법이 없었다 (Windows 실전 실측 ASC-1).
423
523
  if (values.version || positionals[0] === 'version') {
424
524
  console.log(RELEASE_VERSION);
425
525
  return 0;
426
526
  }
427
- return runParsedCommand(values, positionals, entry, argv);
527
+ try {
528
+ return await runParsedCommand(values, positionals, entry, argv);
529
+ }
530
+ finally {
531
+ // **어느 명령이든** 자기가 띄운 도구 자식을 닫는다 (JAM MCP 서버 등).
532
+ //
533
+ // 예전에는 `proceed` 한 곳에서만 닫았다. 감시 경로가 JAM 통로를 열게 되자
534
+ // `monitor scan` 과 `runtime tick` 이 할 일을 다 하고도 종료하지 못했고, 등록된
535
+ // 서비스가 회차마다 그 프로세스를 하나씩 남겼다 — 실측에서 10분 넘게 살아 있었다.
536
+ // 닫는 자리를 명령마다 두면 언젠가 또 빠진다. 나가는 문은 하나다.
537
+ await closeToolClients();
538
+ }
428
539
  }
429
540
  function parseArgsOrThrow(argv) {
430
541
  return parseArgs({
@@ -457,6 +568,7 @@ function parseArgsOrThrow(argv) {
457
568
  intent: { type: 'string' },
458
569
  grant: { type: 'string' },
459
570
  'interval-min': { type: 'string' },
571
+ detach: { type: 'boolean' },
460
572
  'delta-min': { type: 'string' },
461
573
  'reconcile-min': { type: 'string' },
462
574
  'census-min': { type: 'string' },
@@ -563,7 +675,15 @@ async function runParsedCommand(values, positionals, entry, argv) {
563
675
  // 선택된 build로 넘길 것이 있으면 여기서 넘긴다. **선택 자체를 다루는 명령은 넘기지
564
676
  // 않는다** — 잘못 가리키는 선택을 고치거나 들여다보는 명령이 그 선택 때문에 못 돌면
565
677
  // 사람이 갇힌다.
566
- if (!(group === 'runtime' && (command === 'use' || command === 'status'))) {
678
+ // 기계 수준 명령은 **프로젝트와 무관하다.** 붙지 않은 자리에서도 답해야 하고,
679
+ // 등록된 서비스는 어느 프로젝트 안에서 도는 것이 아니다 (설계 §4.1).
680
+ const machineLevelRuntime = group === 'runtime' &&
681
+ (command === 'use' ||
682
+ command === 'status' ||
683
+ command === 'list' ||
684
+ command === 'service' ||
685
+ (command === 'tick' && Boolean(values.all)));
686
+ if (!machineLevelRuntime) {
567
687
  const redispatched = await redispatchIfNeeded(argv);
568
688
  if (redispatched !== null)
569
689
  return redispatched;
@@ -576,12 +696,24 @@ async function runParsedCommand(values, positionals, entry, argv) {
576
696
  if (group === 'runtime' && (command === 'use' || command === 'status')) {
577
697
  return runRuntimeSelect(command, positionals[2], positionals[3], values);
578
698
  }
699
+ if (group === 'runtime' && command === 'list')
700
+ return runRuntimeList(values);
701
+ if (group === 'runtime' && command === 'service')
702
+ return runRuntimeService(positionals[2], values);
703
+ // 기계 전체 회차 — 등록된 서비스가 부르는 갈래다.
704
+ if (group === 'runtime' && command === 'tick' && values.all)
705
+ return runRuntimeTickAll(values);
579
706
  if (group === 'host')
580
707
  return runHost(command, positionals[2], positionals[3], values);
581
708
  // setup은 **붙기 전에도** 답을 줘야 한다. 아래 discoverRoot 실패는 exit 2로 끊는데,
582
709
  // 그러면 "아직 안 붙었다"를 확인하려고 부른 명령이 안 붙었다는 이유로 죽는다.
583
710
  if (group === 'setup')
584
711
  return runSetup(command, values, entry);
712
+ // `front open` 도 같은 이유로 여기서 답한다. Host lifecycle이 부르는 갈래라
713
+ // **붙지 않은 자리에서 실패하면 안 된다** — 그 자리는 대부분 ASC와 무관한 프로젝트이고,
714
+ // 거기서 exit 2를 내는 것은 남의 세션에 오류를 얹는 일이다 (C-11 불변식 ⑪).
715
+ if (group === 'front' && command === 'open')
716
+ return runFrontOpen(values);
585
717
  // adopt는 **붙기 전의 명령이다.** 붙을 Profile을 만드는 것이 일이므로 attach를 요구하면
586
718
  // 순서가 뒤집힌다. 나머지 profile 명령은 아래 attach 경로에 그대로 남는다.
587
719
  if (group === 'profile' && command === 'adopt')
@@ -947,7 +1079,7 @@ async function runInit(values) {
947
1079
  installRoot: installRoot(),
948
1080
  externalProfileRoot: externalProfileRoot(),
949
1081
  ...(attachedRoot ? { ascRoot: attachedRoot } : {}),
950
- hosts: [{ id: 'claude', installed: await verifyInstalled(defaultPaths()) }],
1082
+ hosts: [{ id: 'claude', installed: await verifyInstalled(hostPaths()) }],
951
1083
  bindings,
952
1084
  declaredPolicies: declaredPolicies(attachedRoot ? await attachedRuntime(attachedRoot) : undefined),
953
1085
  });
@@ -1057,11 +1189,18 @@ async function runRuntimeSelect(command, mode, source, values) {
1057
1189
  if (command === 'status') {
1058
1190
  const selection = await readRuntimeSelection(home);
1059
1191
  const target = await resolveRuntimeTarget(selection);
1192
+ // 어느 build를 쓰는지(C-14)와 지금 관측하고 있는지(C-12)는 다른 사실이다. 한 화면에
1193
+ // 같이 두되 섞지 않는다 — 붙지 않은 곳에서는 아래 절이 통째로 없다.
1194
+ const background = await backgroundHere(values);
1195
+ // 서비스 등록은 workspace 와 무관한 기계의 사실이다. 같은 화면에 두되 섞지 않는다.
1196
+ const service = await serviceHealth(values);
1060
1197
  if (values.json) {
1061
1198
  console.log(JSON.stringify({
1062
1199
  selection: selection ?? null,
1063
1200
  target,
1064
1201
  file: selectionPath(home),
1202
+ ...(service ? { service } : {}),
1203
+ ...(background ? { background } : {}),
1065
1204
  // 해법은 데이터로 준다 — agent가 산문에서 경로를 추론하지 않는다
1066
1205
  ...('code' in target ? { action: remediationAction(target) } : {}),
1067
1206
  }, null, 2));
@@ -1073,6 +1212,11 @@ async function runRuntimeSelect(command, mode, source, values) {
1073
1212
  return 1;
1074
1213
  }
1075
1214
  console.log(runtimeSelectionLine(target));
1215
+ if (service)
1216
+ console.log(service.line);
1217
+ if (background)
1218
+ for (const line of renderBackground(background))
1219
+ console.log(line);
1076
1220
  return 0;
1077
1221
  }
1078
1222
  if (mode !== 'package' && mode !== 'development') {
@@ -1178,7 +1322,7 @@ async function detectSetupState(values, entry) {
1178
1322
  // "붙어 있음"으로 넘기면 plan이 applied를 답하며 실패할 proceed를 준다 (실측 ASC-2).
1179
1323
  const attachmentBroken = ascRoot ? (await inspectSetup(ascRoot)).attachment === 'BROKEN' : false;
1180
1324
  const scope = values.scope === 'project' ? 'project' : 'local';
1181
- const hostReport = await verifyInstall(defaultPaths());
1325
+ const hostReport = await verifyInstall(hostPaths());
1182
1326
  return {
1183
1327
  entry,
1184
1328
  projectRoot,
@@ -1189,11 +1333,129 @@ async function detectSetupState(values, entry) {
1189
1333
  profileCandidates: await availableProfiles(installRoot(), externalProfileRoot()),
1190
1334
  scope,
1191
1335
  host: [{ id: 'claude', status: hostReport.status }],
1336
+ // Profile 이 작업 도구를 선언했으면 그 준비 상태까지 본다 (설계 §9.3).
1337
+ ...(await workBindingState(ascRoot, projectRoot)),
1338
+ // 이 기계의 지속 등록. **별도 onboarding 을 만들지 않는다** — 같은 계획에 함께 든다.
1339
+ ...(await persistentRuntimeState(values)),
1192
1340
  // **bootstrap으로 들어왔을 때만 본다.** 설치된 runtime이 자기를 다시 설치할 이유가
1193
1341
  // 없고, 그 축을 안 그리면 plan은 설치된 `asc` 를 전제하지도 않는다 (C-14 §3.4).
1194
1342
  ...(entry === 'bootstrap' ? { stableRuntime: await detectStableInstall(nodeProcessRunner) } : {}),
1195
1343
  };
1196
1344
  }
1345
+ /**
1346
+ * 지금 도는 것이 **설치된 패키지인가.**
1347
+ *
1348
+ * 등록물은 지금 도는 진입점의 절대 경로를 박는다. checkout 이나 일회용 prefix 에서
1349
+ * 등록하면 사라질 경로를 기계에 남기는 일이 된다 — 등록은 이 기계에 오래 남을 설치본이
1350
+ * 스스로 할 때만 의미가 있다.
1351
+ */
1352
+ function runningFromInstalledPackage() {
1353
+ return fileURLToPath(import.meta.url).replace(/\\/g, '/').includes('/node_modules/@asc-agent/runtime/');
1354
+ }
1355
+ /**
1356
+ * 지금 이 실행에서 기계 등록을 다뤄도 되는가.
1357
+ *
1358
+ * **기계 서비스는 HOME 으로 격리되지 않는다.** ASC_HOME 을 옮겨도 launchd·Task
1359
+ * Scheduler·systemd 는 같은 기계 하나를 본다 — 그래서 격리된 환경에서 setup 을 통째로
1360
+ * 돌려 보는 검증에는 이 축을 끄는 길이 필요하다. 그 길이 없으면 검증이 실행한 기계에
1361
+ * 진짜 서비스를 남긴다.
1362
+ */
1363
+ function serviceRegistrationAllowed() {
1364
+ if (process.env.ASC_SERVICE === 'off')
1365
+ return false;
1366
+ return runningFromInstalledPackage();
1367
+ }
1368
+ /**
1369
+ * 이 기계에 ASC runtime 이 등록돼 있는가 (설계 §6, Gate 7).
1370
+ *
1371
+ * 물어볼 수 없으면 이 축을 그리지 않는다 — 모르는 것을 "해야 한다"로 적으면 설치되지
1372
+ * 않은 기계에서 setup 이 영영 끝나지 않는다.
1373
+ */
1374
+ async function persistentRuntimeState(values) {
1375
+ if (!serviceRegistrationAllowed())
1376
+ return {};
1377
+ const adapter = serviceAdapter();
1378
+ if (!adapter)
1379
+ return {};
1380
+ const plan = await planPersistentRuntime(adapter, serviceCommand(serviceInterval(values))).catch(() => null);
1381
+ if (!plan)
1382
+ return {};
1383
+ return {
1384
+ persistentRuntime: {
1385
+ action: plan.action,
1386
+ adapter: adapter.id,
1387
+ ...(plan.state.kind !== 'CURRENT' && 'detail' in plan.state && plan.state.detail
1388
+ ? { detail: plan.state.detail }
1389
+ : {}),
1390
+ },
1391
+ };
1392
+ }
1393
+ /**
1394
+ * Profile 이 선언한 작업 도구가 지금 쓸 수 있는가 (설계 §9.3).
1395
+ *
1396
+ * **선언이 없으면 아무것도 보지 않는다** — 선언되지 않은 도구를 준비시키는 것은
1397
+ * 사람이 하지 않은 결정을 대신 내리는 일이다.
1398
+ *
1399
+ * 판정은 그 도구가 한다. 여기서 하는 것은 그 판정을 setup 이 읽을 수 있는 모양으로
1400
+ * 옮기는 것뿐이고, 도구를 실행하지 못하면 이 축을 그리지 않는다(모르는 것을
1401
+ * "준비 안 됨"으로 적지 않는다).
1402
+ */
1403
+ async function workBindingState(ascRoot, projectRoot) {
1404
+ if (!ascRoot)
1405
+ return {};
1406
+ const runtime = await attachedRuntime(ascRoot);
1407
+ const declared = (runtime?.layers.profile.bindings ?? []).find((binding) => binding.role === 'work');
1408
+ // 지금 준비 상태를 물어볼 수 있는 도구는 JAM 뿐이다. 다른 adapter 가 생기면 여기가 는다.
1409
+ if (!declared || declared.adapter !== 'jam')
1410
+ return {};
1411
+ const launcher = jamLauncher();
1412
+ const adapter = new JamAdapter(process.env.ASC_JAM_PATH ? {} : { command: launcher.command, args: launcher.args });
1413
+ const status = await adapter
1414
+ .runtime({ projectRoot, env: process.env })
1415
+ .catch(() => ({ state: 'UNAVAILABLE', detail: 'could not ask' }));
1416
+ if (status.state === 'AVAILABLE') {
1417
+ return { workBinding: { adapter: 'jam', resource: declared.resource, ready: true } };
1418
+ }
1419
+ const remedy = adapter.lastRemedy();
1420
+ // 도구를 아예 못 불렀으면 이 축을 그리지 않는다 — 그 상태에서 "고쳐야 한다"고 적으면
1421
+ // 설치되지 않은 기계에서 setup 이 영영 끝나지 않는다.
1422
+ if (!remedy)
1423
+ return {};
1424
+ const version = await jamVersion(projectRoot);
1425
+ return {
1426
+ workBinding: {
1427
+ adapter: 'jam',
1428
+ resource: declared.resource,
1429
+ ready: false,
1430
+ remedy: remedy.kind,
1431
+ detail: remedy.detail,
1432
+ ...(version ? { version } : {}),
1433
+ },
1434
+ };
1435
+ }
1436
+ /**
1437
+ * JAM 이 말한 자기 버전. **여기서 정하지 않는다** — 두 제품의 릴리스를 묶지 않기 위해서다.
1438
+ * 못 읽으면 undefined 이고, 그러면 계획이 그 도구를 부르지 않는다.
1439
+ */
1440
+ async function jamVersion(projectRoot) {
1441
+ const launcher = jamLauncher();
1442
+ const { command, args } = resolveJamCommand(process.env, launcher.command, launcher.args);
1443
+ try {
1444
+ const { stdout } = await execFileAsync(command, [...args, 'doctor', '--json'], { cwd: projectRoot });
1445
+ return JSON.parse(stdout).axes?.packageVersion;
1446
+ }
1447
+ catch (error) {
1448
+ const stdout = error.stdout;
1449
+ if (!stdout)
1450
+ return undefined;
1451
+ try {
1452
+ return JSON.parse(stdout).axes?.packageVersion;
1453
+ }
1454
+ catch {
1455
+ return undefined;
1456
+ }
1457
+ }
1458
+ }
1197
1459
  /**
1198
1460
  * `npm` 을 shim으로 부르지 않는다.
1199
1461
  *
@@ -1374,6 +1636,28 @@ async function runSetupLifecycle(command, values, entry) {
1374
1636
  if (code !== 0)
1375
1637
  throw new Error(`attach 실패 (exit ${code})`);
1376
1638
  },
1639
+ registerPersistentRuntime: async () => {
1640
+ const adapter = serviceAdapter();
1641
+ if (!adapter)
1642
+ return;
1643
+ try {
1644
+ await adapter.install(serviceCommand(serviceInterval(values)));
1645
+ console.log(`Persistent runtime registered with ${adapter.id}.`);
1646
+ }
1647
+ catch (error) {
1648
+ // **등록 실패는 attach 실패가 아니다.** 기계 등록은 이 프로젝트가 붙는 것과
1649
+ // 다른 축이고, 서비스 관리자가 없거나 거절하는 환경은 흔하다. 조용히 넘기지도
1650
+ // 않는다 — 무엇이 안 됐는지 말하고, 상태는 `runtime status` 가 계속 답한다.
1651
+ console.error(`Persistent runtime could not be registered with ${adapter.id}: ${String(error)}`);
1652
+ }
1653
+ },
1654
+ // 그 도구의 공식 setup 을 부른다. ASC 가 그 설정을 손으로 조립하지 않는다.
1655
+ setupWorkBinding: async (change) => {
1656
+ const outcome = await healJam({ cwd: process.cwd(), version: change.version });
1657
+ console.log(healLine(outcome));
1658
+ if (outcome.kind === 'FAILED')
1659
+ throw new Error(healLine(outcome));
1660
+ },
1377
1661
  installHost: async (change) => {
1378
1662
  const code = await runHost('claude', 'install', undefined, { ...values, json: false });
1379
1663
  if (code !== 0)
@@ -1668,7 +1952,7 @@ async function runHost(provider, command, target, values) {
1668
1952
  console.error(`Unknown host: ${provider ?? '(none)'} — only claude is supported today`);
1669
1953
  return 2;
1670
1954
  }
1671
- const paths = defaultPaths();
1955
+ const paths = hostPaths();
1672
1956
  switch (command) {
1673
1957
  case 'install': {
1674
1958
  const before = await verifyInstall(paths);
@@ -2084,13 +2368,7 @@ function dependencyCandidates(workItem) {
2084
2368
  */
2085
2369
  async function buildWorkIngress(store, root, runtime, resolved) {
2086
2370
  const { root: projectRoot } = await discoverProjectRoot(process.cwd());
2087
- // JAM 아직 `jam` 바이너리를 깔지 않는 설치가 기본이다 — 그 경우 launcher 를 통해 부른다.
2088
- const jamCommand = process.env.ASC_JAM_PATH ? undefined : { command: 'npx', args: ['--yes', '@jam-mcp/launcher'] };
2089
- const adapters = [
2090
- new GitHubAdapter(),
2091
- new GitLabAdapter(),
2092
- new JamAdapter(jamCommand ?? {}),
2093
- ];
2371
+ const adapters = monitorAdapters();
2094
2372
  const declared = resolved?.layers.profile.bindings ?? [];
2095
2373
  const plan = await composeBindings({
2096
2374
  context: { projectRoot, env: process.env },
@@ -2111,7 +2389,7 @@ async function buildWorkIngress(store, root, runtime, resolved) {
2111
2389
  // capability 해석은 후보가 유일할 때 이미 스스로 풀린다.
2112
2390
  roles: rolesFor(plan, declared),
2113
2391
  perPage: 30,
2114
- jam: { command: jamCommand?.command ?? 'jam', args: [...(jamCommand?.args ?? []), 'serve'], cwd: projectRoot },
2392
+ ...jamComposition(projectRoot),
2115
2393
  endpointFor: (binding) => endpointOf(adapters, binding),
2116
2394
  });
2117
2395
  const work = ports.resourceContext;
@@ -2374,7 +2652,7 @@ async function runSession(command, target, values, store, resolved) {
2374
2652
  switch (command) {
2375
2653
  case 'issue': {
2376
2654
  // 누락과 오값은 다른 실수다 — "필수"라고 답하면 이미 준 사람은 자기가 무엇을
2377
- // 틀렸는지 모른다 (SSAFESTA Windows 실측 ASC-3).
2655
+ // 틀렸는지 모른다 (Windows 실전 실측 ASC-3).
2378
2656
  const roleChoices = SessionRole.options.join('|');
2379
2657
  if (values.role === undefined || !values.goal) {
2380
2658
  console.error(`--role and --goal are required (role: ${roleChoices})`);
@@ -3046,17 +3324,7 @@ function parseEnumArg(raw, allowed, flag) {
3046
3324
  console.error(`Invalid value for --${flag}: ${String(raw)} (allowed: ${allowed.join('|')})`);
3047
3325
  return null;
3048
3326
  }
3049
- /** 회차 감지. 읽기만 하고, 무엇을 할지는 사람이 정한다. */
3050
- /**
3051
- * 감시 엔진을 세운다. **조립과 출력을 나눈다** — 상시 Runtime(C-12)이 같은 엔진을
3052
- * 주기적으로 부르려면 조립이 명령 출력 안에 갇혀 있으면 안 된다.
3053
- */
3054
- async function buildMonitorEngine(values, store, resolved) {
3055
- const token = await discoverToken();
3056
- if (!token) {
3057
- console.error('No GitHub token found. Set ASC_GITHUB_TOKEN, or run `gh auth login`.');
3058
- return { ok: false, code: 2 };
3059
- }
3327
+ async function buildMonitorEngines(values, store, resolved) {
3060
3328
  // 감시 대상도, 누가 나인지도, 누가 승인자인지도 전부 설정에서 온다.
3061
3329
  // 명령줄로 받던 값들은 각자 제자리(Profile·Override)를 찾았다.
3062
3330
  const repo = resolved.layers.profile.project.repository;
@@ -3072,79 +3340,307 @@ async function buildMonitorEngine(values, store, resolved) {
3072
3340
  // provider를 CLI가 고르지 않는다. 선언된 요구와 실제 remote에서 Binding이 풀리고,
3073
3341
  // 갈리면 고르지 않고 말한다 — silent substitution 0 (C-11 §7).
3074
3342
  const { root: projectRoot } = await discoverProjectRoot(process.cwd());
3075
- const adapters = defaultAdapters();
3343
+ const adapters = monitorAdapters();
3076
3344
  const declared = resolved.layers.profile.bindings ?? [];
3077
3345
  const plan = await composeBindings({
3078
3346
  context: { projectRoot, env: process.env },
3079
3347
  adapters,
3080
3348
  roles: declared.map((b) => ({ adapterId: b.adapter, resource: b.resource, role: b.role })),
3081
3349
  });
3082
- const ports = await buildRuntimePorts({
3350
+ const built = await buildObservationChannels({
3351
+ plan,
3352
+ perPage: 30,
3353
+ ...jamComposition(projectRoot),
3354
+ endpointFor: (binding) => endpointOf(adapters, binding),
3355
+ });
3356
+ // canonical.read 처럼 한 곳이어야 의미가 서는 것은 예전 경로 그대로 역할로 고른다.
3357
+ const singular = await buildRuntimePorts({
3083
3358
  plan,
3084
3359
  roles: rolesFor(plan, declared),
3085
3360
  perPage: 30,
3361
+ ...jamComposition(projectRoot),
3086
3362
  endpointFor: (binding) => endpointOf(adapters, binding),
3087
3363
  });
3088
- if (!ports.eventSource) {
3364
+ if (built.channels.length === 0) {
3089
3365
  console.error('No monitoring channel could be built:');
3090
- for (const reason of ports.unavailable)
3366
+ for (const reason of built.unavailable)
3091
3367
  console.error(` - ${reason}`);
3092
3368
  console.error('\nDeclare which provider takes which role in the Profile bindings.');
3093
3369
  return { ok: false, code: 2 };
3094
3370
  }
3095
3371
  const canonicalPaths = resolved.layers.profile.canonical.sources.flatMap((source) => source.paths);
3096
3372
  const myRoles = resolved.layers.override?.monitor.roles ?? [];
3097
- const changeContext = ports.changeContext;
3098
- const engine = new MonitorEngine({
3099
- store,
3100
- source: ports.eventSource,
3101
- ...(ports.scm ? { scm: ports.scm } : {}),
3102
- ...(ports.inventory ? { inventory: ports.inventory } : {}),
3103
- // 밖에서 알아 사실을 실제로 공급한다 (C-07 §2~§4). 이것이 없으면 Relevance·
3104
- // Shadow·Material Change가 코드에만 있고 실행 경로에는 없다.
3105
- ...(changeContext
3106
- ? {
3107
- observe: buildEventObservation({
3108
- change: changeContext,
3109
- ...(resolved.ownership ? { ownership: resolved.ownership } : {}),
3110
- ...(myRoles.length ? { myRoles } : {}),
3111
- ...(canonicalPaths.length ? { canonicalPaths } : {}),
3112
- }),
3113
- }
3114
- : {}),
3115
- // scope는 실제로 붙은 source가 정한다 — provider 이름을 CLI가 박지 않는다.
3116
- observations: new ObservationLedger(store.scope(monitorScope(ports.eventSource.id))),
3117
- // 조사 단계가 요청하는 통로들. 없는 것은 단계가 판정 불성립으로 남는다 (C-07 §6.2).
3118
- // 없는 것을 있는 척하지 않는다.
3119
- investigation: {
3120
- ...(ports.resourceContext ? { resource: ports.resourceContext } : {}),
3121
- ...(changeContext ? { change: changeContext } : {}),
3122
- },
3123
- // Core는 Profile을 모른다 조사에 필요한 프로젝트 사실을 Surface가 꺼내 준다.
3124
- investigationContext: () => ({
3125
- ...(resolved.ownership ? { ownership: resolved.ownership } : {}),
3126
- canonicalPaths,
3127
- }),
3128
- config: resolved.monitor,
3129
- authorizedApprover: approver,
3130
- canonicalSources: resolved.canonicalSources,
3131
- // 처음 돌 때 과거를 통째로 긁으면 사람이 읽을 수 없다 (OM §18)
3132
- ...(values.backfill ? {} : { startFrom: new Date().toISOString() }),
3373
+ const channels = built.channels.map((channel) => {
3374
+ const changeContext = channel.changeContext;
3375
+ const engine = new MonitorEngine({
3376
+ store,
3377
+ source: channel.eventSource,
3378
+ // 정본 대조는 채널이 아니라 프로젝트의 사실이다 — 역할로 고른 하나를 함께 쓴다.
3379
+ ...(singular.scm ? { scm: singular.scm } : {}),
3380
+ ...(channel.inventory ? { inventory: channel.inventory } : {}),
3381
+ // 밖에서 알아 온 사실을 실제로 공급한다 (C-07 §2~§4). 이것이 없으면 Relevance·
3382
+ // Shadow·Material Change가 코드에만 있고 실행 경로에는 없다.
3383
+ ...(changeContext
3384
+ ? {
3385
+ observe: buildEventObservation({
3386
+ change: changeContext,
3387
+ ...(resolved.ownership ? { ownership: resolved.ownership } : {}),
3388
+ ...(myRoles.length ? { myRoles } : {}),
3389
+ ...(canonicalPaths.length ? { canonicalPaths } : {}),
3390
+ }),
3391
+ }
3392
+ : {}),
3393
+ // scope는 실제로 붙은 source가 정한다 채널이 둘이면 기록도 둘로 갈린다.
3394
+ observations: new ObservationLedger(store.scope(monitorScope(channel.eventSource.id))),
3395
+ // 조사 단계가 요청하는 통로들. 없는 것은 그 단계가 판정 불성립으로 남는다 (C-07 §6.2).
3396
+ // 없는 것을 있는 척하지 않는다.
3397
+ investigation: {
3398
+ ...(channel.resourceContext ? { resource: channel.resourceContext } : {}),
3399
+ ...(changeContext ? { change: changeContext } : {}),
3400
+ },
3401
+ // Core는 Profile을 모른다 조사에 필요한 프로젝트 사실을 Surface가 꺼내 준다.
3402
+ investigationContext: () => ({
3403
+ ...(resolved.ownership ? { ownership: resolved.ownership } : {}),
3404
+ canonicalPaths,
3405
+ }),
3406
+ config: resolved.monitor,
3407
+ authorizedApprover: approver,
3408
+ canonicalSources: resolved.canonicalSources,
3409
+ // 처음 돌 때 과거를 통째로 긁으면 사람이 읽을 수 없다 (OM §18)
3410
+ ...(values.backfill ? {} : { startFrom: new Date().toISOString() }),
3411
+ });
3412
+ return {
3413
+ engine,
3414
+ sourceId: channel.eventSource.id,
3415
+ label: `${channel.role ?? '(undeclared)'} · ${channel.adapterId}:${channel.resource}`,
3416
+ };
3133
3417
  });
3134
- await rememberMonitorSource(store, ports.eventSource.id);
3418
+ await rememberMonitorSources(store, channels.map((channel) => channel.sourceId));
3135
3419
  // **밀려난 후보를 말한다.** 실 프로젝트 관측에서 나온 것이다: primary가 자격 없음으로
3136
3420
  // 빠지고 mirror에 조용히 붙으면 "감시가 도는데 아무것도 안 잡히는" 상태가 된다.
3137
3421
  // 고르는 것 자체는 막지 않되, 무엇이 밀렸는지 모르게 두지 않는다 (C-11 §7).
3138
3422
  const skipped = plan.bindings.filter((b) => b.state !== 'AVAILABLE' && b.state !== 'DEGRADED');
3139
3423
  if (skipped.length > 0) {
3140
- console.error(`Monitoring runs on ${ports.eventSource.id}. Some candidates could not be used:`);
3424
+ console.error('Some candidates could not be used:');
3141
3425
  for (const binding of skipped) {
3142
3426
  console.error(` - ${binding.adapterId}:${binding.resource} — ${binding.state}${binding.detail ? ` (${binding.detail})` : ''}`);
3143
3427
  }
3144
3428
  }
3145
- for (const reason of ports.unavailable)
3429
+ for (const reason of built.unavailable)
3146
3430
  console.error(` - ${reason}`);
3147
- return { ok: true, engine, sourceId: ports.eventSource.id, repo };
3431
+ return { ok: true, channels, repo };
3432
+ }
3433
+ /**
3434
+ * 이 자리에서 background runtime이 돌고 있는가.
3435
+ *
3436
+ * **붙지 않았으면 없는 것으로 답한다** (`null`). `runtime status` 는 붙기 전에도 답해야
3437
+ * 하는 명령이라(C-14 §5) 여기서 attach를 요구하면 그 성질이 깨진다.
3438
+ */
3439
+ async function backgroundHere(values) {
3440
+ const root = await discoverRoot(process.cwd(), values.root).catch(() => null);
3441
+ if (!root)
3442
+ return null;
3443
+ try {
3444
+ const scope = new MarkdownStateStore(root).scope('runtime');
3445
+ // 기본 주기 1분에서 나온 회수 기준. start가 다른 주기를 썼다면 그쪽이 더 길 뿐이고,
3446
+ // 살아 있는 lease를 죽은 것으로 읽지는 않는다 — heartbeat가 회차마다 갱신된다.
3447
+ return await readBackground(scope, staleAfter(60_000));
3448
+ }
3449
+ catch {
3450
+ // 읽지 못한 것을 "안 돌고 있다"로 적지 않는다 (C-12 불변식 ⑫).
3451
+ return null;
3452
+ }
3453
+ }
3454
+ /**
3455
+ * 이 기계의 지속 등록 상태 (설계 §13.1).
3456
+ *
3457
+ * PID 는 여기 없다 — 등록물이 정본이고 프로세스는 갈아 끼우는 실행 인스턴스다
3458
+ * (설계 §4.2). 물어볼 수 없으면 `null` 이고, 그때 화면에 그 줄이 없다.
3459
+ */
3460
+ async function serviceHealth(values) {
3461
+ const adapter = serviceAdapter();
3462
+ if (!adapter)
3463
+ return null;
3464
+ const plan = await planPersistentRuntime(adapter, serviceCommand(serviceInterval(values))).catch(() => null);
3465
+ if (!plan)
3466
+ return null;
3467
+ return { adapter: adapter.id, action: plan.action, line: persistentRuntimeLine(adapter.id, plan) };
3468
+ }
3469
+ /**
3470
+ * 이 기계가 아는 workspace 들 (설계 §6).
3471
+ *
3472
+ * **두 번째 등록부를 읽지 않는다** — workspace index 하나에서 계산한다.
3473
+ */
3474
+ async function machineWorkspaces() {
3475
+ const home = ascHome();
3476
+ const index = await readIndex(home);
3477
+ return viewWorkspaces(Object.values(index.workspaces).map((workspace) => ({
3478
+ workspaceId: workspace.workspaceId,
3479
+ root: join(home, 'workspaces', workspace.workspaceId),
3480
+ aliases: workspace.aliases,
3481
+ locators: locatorsOf(index, workspace.workspaceId).map((entry) => entry.locator),
3482
+ })), (path) => existsSync(path));
3483
+ }
3484
+ /**
3485
+ * `asc runtime list` — 기계 전체 화면 (설계 §13.2).
3486
+ *
3487
+ * workspace 마다 `cd` 해서 status 를 반복하게 만들지 않는다. 여기서 밖을 치지 않는다 —
3488
+ * 이것은 읽기이고, 관측은 회차가 한다.
3489
+ */
3490
+ async function runRuntimeList(values) {
3491
+ const views = await machineWorkspaces();
3492
+ if (values.json) {
3493
+ console.log(JSON.stringify({ workspaces: views }, null, 2));
3494
+ return 0;
3495
+ }
3496
+ for (const line of renderWorkspaces(views))
3497
+ console.log(line);
3498
+ return 0;
3499
+ }
3500
+ /** 이 OS 의 등록 통로. 모르는 OS 면 `null` — 없는 것을 있는 척하지 않는다. */
3501
+ function serviceAdapter() {
3502
+ switch (serviceAdapterFor(process.platform)) {
3503
+ case 'launchd':
3504
+ return launchdAdapter();
3505
+ case 'schtasks':
3506
+ return schtasksAdapter();
3507
+ case 'systemd-user':
3508
+ return systemdUserAdapter();
3509
+ default:
3510
+ return null;
3511
+ }
3512
+ }
3513
+ /**
3514
+ * 등록물이 실행할 명령. **한 회차만 돈다.**
3515
+ *
3516
+ * 지금 도는 실행 파일과 진입점을 그대로 쓴다 — 어느 build 를 쓸지는 그 진입점이 다시
3517
+ * 정하므로(C-14), 등록물이 build 를 고르는 일은 없다.
3518
+ */
3519
+ function serviceCommand(intervalSeconds) {
3520
+ return {
3521
+ program: process.execPath,
3522
+ args: [fileURLToPath(import.meta.url), 'runtime', 'tick', '--all'],
3523
+ intervalSeconds,
3524
+ };
3525
+ }
3526
+ const serviceInterval = (values) => values['interval-min'] === undefined ? 5 * 60 : Math.max(60, Number(values['interval-min']) * 60);
3527
+ /**
3528
+ * `asc runtime service` — 이 기계의 지속 등록 (설계 §4).
3529
+ *
3530
+ * **workspace 마다 하나가 아니다.** 사용자/기계당 하나이고, 그 하나가 아는 workspace
3531
+ * 전부를 돌본다. 등록물은 우리가 만든 것만 다룬다.
3532
+ */
3533
+ async function runRuntimeService(command, values) {
3534
+ const adapter = serviceAdapter();
3535
+ if (!adapter) {
3536
+ const detail = `no user-scope service manager for ${process.platform}`;
3537
+ if (values.json)
3538
+ console.log(JSON.stringify({ supported: false, detail }, null, 2));
3539
+ else
3540
+ console.error(`Persistent runtime: ${detail}`);
3541
+ // 못 하는 것을 "했다"로 적지 않는다. 다만 이것이 오류는 아니다.
3542
+ return 0;
3543
+ }
3544
+ const wanted = serviceCommand(serviceInterval(values));
3545
+ if (command === undefined || command === 'status') {
3546
+ const plan = await planPersistentRuntime(adapter, wanted);
3547
+ if (values.json) {
3548
+ console.log(JSON.stringify({ adapter: adapter.id, ...plan }, null, 2));
3549
+ return 0;
3550
+ }
3551
+ console.log(persistentRuntimeLine(adapter.id, plan));
3552
+ return 0;
3553
+ }
3554
+ if (command === 'install') {
3555
+ const plan = await planPersistentRuntime(adapter, wanted);
3556
+ if (plan.action === 'unsupported') {
3557
+ console.error(persistentRuntimeLine(adapter.id, plan));
3558
+ return 1;
3559
+ }
3560
+ if (plan.action === 'none') {
3561
+ // 멱등이다 — 같은 것을 다시 등록하지 않는다
3562
+ console.log(persistentRuntimeLine(adapter.id, plan));
3563
+ return 0;
3564
+ }
3565
+ await adapter.install(wanted);
3566
+ console.log(`Persistent runtime registered with ${adapter.id} — one pass every ${wanted.intervalSeconds / 60} min.`);
3567
+ return 0;
3568
+ }
3569
+ if (command === 'uninstall') {
3570
+ await adapter.uninstall();
3571
+ console.log(`Persistent runtime registration removed from ${adapter.id}.`);
3572
+ return 0;
3573
+ }
3574
+ console.error(`Unknown runtime service command: ${command}\n\n${USAGE}`);
3575
+ return 2;
3576
+ }
3577
+ /**
3578
+ * 기계 전체 한 회차 (설계 §7).
3579
+ *
3580
+ * 싱글턴은 **판단하지 않는다** — 어느 workspace 가 지금 돌 수 있는지만 고르고, 회차 자체는
3581
+ * 기존 경로가 그대로 돈다. workspace 마다 별개의 짧은 프로세스로 도는 이유는 둘이다:
3582
+ * 한 workspace 의 실패가 다른 workspace 를 세우지 않고, 기존 lease·cursor·dedupe 경로를
3583
+ * 한 줄도 바꾸지 않고 그대로 쓴다.
3584
+ *
3585
+ * DORMANT 는 건너뛴다. 살아 있는 checkout 이 없는 자리를 대신해 외부에 묻지 않는다.
3586
+ */
3587
+ async function runRuntimeTickAll(values) {
3588
+ // 기계 수준 lease. workspace lease 와 다른 소유 영역이다 (설계 §5.1) — 저쪽은 회차
3589
+ // 하나를, 이쪽은 이 기계의 runtime 을 직렬화한다. 등록된 서비스와 사람이 친 명령이
3590
+ // 겹치면 늦게 온 쪽이 조용히 물러난다 (C-12 불변식 ⑥).
3591
+ const interval = serviceInterval(values) * 1000;
3592
+ const lease = new RuntimeLease({
3593
+ scope: fileScope(join(ascHome(), 'runtime-lease.json')),
3594
+ owner: `${process.pid}@${hostname()}`,
3595
+ staleMs: staleAfter(interval),
3596
+ });
3597
+ if (!(await lease.acquire())) {
3598
+ const held = await lease.read();
3599
+ const detail = held.kind === 'HELD' ? ` (pid ${held.record.pid})` : '';
3600
+ if (values.json)
3601
+ console.log(JSON.stringify({ ran: [], skipped: [], busy: true }, null, 2));
3602
+ else
3603
+ console.log(`Another machine-wide pass is already running${detail} — leaving it to that one.`);
3604
+ return 0;
3605
+ }
3606
+ try {
3607
+ return await tickAllWorkspaces(values, lease);
3608
+ }
3609
+ finally {
3610
+ await lease.release();
3611
+ }
3612
+ }
3613
+ async function tickAllWorkspaces(values, lease) {
3614
+ const views = await machineWorkspaces();
3615
+ const due = dueWorkspaces(views);
3616
+ const skipped = views.filter((view) => view.health !== 'ACTIVE');
3617
+ const results = [];
3618
+ for (const workspace of due) {
3619
+ const child = spawnSync(process.execPath, [fileURLToPath(import.meta.url), 'runtime', 'tick'], {
3620
+ cwd: workspace.cwd,
3621
+ stdio: values.json ? 'ignore' : 'inherit',
3622
+ env: process.env,
3623
+ });
3624
+ results.push({ workspaceId: workspace.workspaceId, code: child.status ?? 1 });
3625
+ // 회차가 길어져도 이 기계의 lease 는 살아 있어야 한다 — 갱신하지 않으면 도는 중에
3626
+ // 죽은 것으로 보이고 두 번째 프로세스가 끼어든다.
3627
+ await lease.renew();
3628
+ }
3629
+ if (values.json) {
3630
+ console.log(JSON.stringify({
3631
+ ran: results,
3632
+ // 건너뛴 것을 조용히 빼지 않는다 — "아무 일도 없었다"와 다른 사실이다
3633
+ skipped: skipped.map((view) => ({ workspaceId: view.workspaceId, health: view.health })),
3634
+ }, null, 2));
3635
+ }
3636
+ else {
3637
+ for (const result of results)
3638
+ console.log(`${result.workspaceId}: pass exited ${result.code}`);
3639
+ for (const view of skipped)
3640
+ console.log(`${view.workspaceId}: ${view.health} — not observed this pass`);
3641
+ }
3642
+ // 한 workspace 가 실패해도 회차 자체는 성공이다. 다음 회차가 다시 본다.
3643
+ return 0;
3148
3644
  }
3149
3645
  /**
3150
3646
  * 상시 Runtime (C-12). 대화를 켜 두지 않고 **상태를 지속시키고 계산을 짧게 돌린다.**
@@ -3153,7 +3649,7 @@ async function buildMonitorEngine(values, store, resolved) {
3153
3649
  * 재기동해도 cursor·lease·observation이 저장소에 있으므로 같은 사건을 다시 만들지 않는다.
3154
3650
  */
3155
3651
  async function runRuntime(command, values, store, renderer, resolved) {
3156
- if (command !== 'start' && command !== 'tick') {
3652
+ if (command !== 'start' && command !== 'tick' && command !== 'stop') {
3157
3653
  console.error(`Unknown runtime command: ${command ?? '(none)'}\n\n${USAGE}`);
3158
3654
  return 2;
3159
3655
  }
@@ -3161,10 +3657,17 @@ async function runRuntime(command, values, store, renderer, resolved) {
3161
3657
  console.error('This only runs inside an attached project. Run `asc init --profile <id>` first.');
3162
3658
  return 2;
3163
3659
  }
3164
- const built = await buildMonitorEngine(values, store, resolved);
3660
+ // 멈추는 것은 감시를 조립하지 않아도 된다 — 외부 자격이 상해 있어도 끌 수 있어야 한다.
3661
+ if (command === 'stop')
3662
+ return stopBackground(store);
3663
+ // 떨어져 나가는 것은 감시를 조립하기 **전에** 한다. 부모가 외부를 한 번 치고 나서
3664
+ // 자식이 또 치면 같은 회차를 두 번 부르는 셈이다.
3665
+ if (command === 'start' && values.detach)
3666
+ return detachRuntime(values);
3667
+ const built = await buildMonitorEngines(values, store, resolved);
3165
3668
  if (!built.ok)
3166
3669
  return built.code;
3167
- const { engine } = built;
3670
+ const { channels } = built;
3168
3671
  const minutes = (value, fallback) => value === undefined ? fallback * 60_000 : Number(value) * 60_000;
3169
3672
  // 주기는 Core 상수가 아니다 (C-12 불변식 ③). 명령줄이 정하고, 기본값은 여기 산다.
3170
3673
  const schedule = {
@@ -3179,33 +3682,46 @@ async function runRuntime(command, values, store, renderer, resolved) {
3179
3682
  log: (line) => console.log(line),
3180
3683
  lastRunAt: {
3181
3684
  read: async () => {
3182
- const raw = await scope.get('last-run');
3685
+ const raw = await scope.get(LAST_RUN_KEY);
3183
3686
  return raw ? JSON.parse(raw) : {};
3184
3687
  },
3185
3688
  write: async (kind, at) => {
3186
- const raw = await scope.get('last-run');
3689
+ const raw = await scope.get(LAST_RUN_KEY);
3187
3690
  const current = raw ? JSON.parse(raw) : {};
3188
- await scope.set('last-run', JSON.stringify({ ...current, [kind]: at }));
3691
+ await scope.set(LAST_RUN_KEY, JSON.stringify({ ...current, [kind]: at }));
3189
3692
  },
3190
3693
  },
3191
3694
  actions: {
3695
+ // 채널마다 돈다. 한 채널이 터져도 나머지는 계속한다 — 자격 하나가 상했다고
3696
+ // 다른 통로까지 조용해지면 그것이 "변경 없음"으로 읽힌다 (C-12 불변식 ⑫).
3192
3697
  delta: async () => {
3193
- const outcome = await engine.scan();
3194
- if (outcome.skipped)
3195
- return;
3196
- console.log(` detected ${outcome.detected} · logged ${outcome.logged} · packets ${outcome.packets.length}`);
3698
+ for (const channel of channels) {
3699
+ const outcome = await channel.engine.scan().catch((error) => {
3700
+ console.error(` [${channel.label}] scan failed — ${String(error)}`);
3701
+ return null;
3702
+ });
3703
+ if (!outcome || outcome.skipped)
3704
+ continue;
3705
+ console.log(` [${channel.label}] detected ${outcome.detected} · logged ${outcome.logged} · packets ${outcome.packets.length}`);
3706
+ }
3197
3707
  },
3198
3708
  reconcile: async () => {
3199
- const sweep = await engine.reconcile();
3200
- if (!sweep.skipped && !sweep.complete) {
3201
- // 것을 "변경 없음"으로 읽지 않는다 (C-12 불변식 ⑫)
3202
- console.error(` Reconcile did not complete${sweep.detail ? ` — ${sweep.detail}` : ''}`);
3709
+ for (const channel of channels) {
3710
+ const sweep = await channel.engine.reconcile().catch((error) => {
3711
+ console.error(` [${channel.label}] reconcile failed ${String(error)}`);
3712
+ return null;
3713
+ });
3714
+ if (sweep && !sweep.skipped && !sweep.complete) {
3715
+ console.error(` [${channel.label}] reconcile did not complete${sweep.detail ? ` — ${sweep.detail}` : ''}`);
3716
+ }
3203
3717
  }
3204
3718
  },
3205
3719
  census: async () => {
3206
- const sweep = await engine.census();
3207
- if (!sweep.skipped && sweep.missing.length > 0) {
3208
- console.log(` ${sweep.missing.length} known items absent from this listing`);
3720
+ for (const channel of channels) {
3721
+ const sweep = await channel.engine.census().catch(() => null);
3722
+ if (sweep && !sweep.skipped && sweep.missing.length > 0) {
3723
+ console.log(` [${channel.label}] ${sweep.missing.length} known items absent from this listing`);
3724
+ }
3209
3725
  }
3210
3726
  },
3211
3727
  digest: async () => {
@@ -3215,20 +3731,128 @@ async function runRuntime(command, values, store, renderer, resolved) {
3215
3731
  },
3216
3732
  },
3217
3733
  });
3734
+ const intervalMs = minutes(values['interval-min'], 1);
3735
+ // 회수 기준은 주기가 정한다 — 주기보다 짧은 만료는 자기 lease를 죽은 것으로 읽는다.
3736
+ const staleMs = staleAfter(intervalMs);
3737
+ const lease = new RuntimeLease({ scope, owner: `${process.pid}@${hostname()}`, staleMs });
3738
+ // cron이 부르는 tick과 떨어져 나간 루프가 겹쳐 돌면 같은 회차를 둘이 한다.
3739
+ // 같은 문을 지나게 해서 하나만 돌린다 (C-12 불변식 ⑥).
3740
+ if (!(await lease.acquire())) {
3741
+ const held = await lease.read();
3742
+ console.log(held.kind === 'HELD'
3743
+ ? `Another runtime already holds this workspace (pid ${held.record.pid}) — leaving it to that one.`
3744
+ : 'Another runtime already holds this workspace — leaving it to that one.');
3745
+ return 0;
3746
+ }
3218
3747
  if (command === 'tick') {
3219
- const outcome = await orchestrator.tick();
3220
- console.log(renderTick(outcome));
3221
- return outcome.failures.length > 0 ? 1 : 0;
3748
+ try {
3749
+ const outcome = await orchestrator.tick();
3750
+ console.log(renderTick(outcome));
3751
+ return outcome.failures.length > 0 ? 1 : 0;
3752
+ }
3753
+ finally {
3754
+ await lease.release();
3755
+ }
3222
3756
  }
3223
- const intervalMs = minutes(values['interval-min'], 1);
3224
3757
  console.log(`Always-on runtime started — one pass every ${intervalMs / 60_000} min. Ctrl+C to stop.`);
3758
+ // 자는 동안에도 멈출 수 있어야 한다. 깨우지 않으면 `runtime stop` 이 한 주기를 통째로
3759
+ // 기다리고, 주기가 30분이면 30분 동안 "멈추는 중"이 된다.
3760
+ let wake = null;
3225
3761
  const stop = () => {
3226
3762
  console.log('Stopping — a pass still running is carried to the next start by its lease.');
3227
3763
  orchestrator.stop();
3764
+ wake?.();
3228
3765
  };
3229
3766
  process.once('SIGINT', stop);
3230
3767
  process.once('SIGTERM', stop);
3231
- await orchestrator.run(intervalMs, (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref?.()));
3768
+ try {
3769
+ await orchestrator.run(intervalMs, async (ms) => {
3770
+ // 회차마다 살아 있다고 말한다. 밀려났으면 조용히 물러난다 — 두 루프가 같은
3771
+ // workspace를 갈아 대는 것보다 하나가 도는 편이 낫다.
3772
+ if (!(await lease.renew())) {
3773
+ console.log('Another runtime took this workspace — stopping.');
3774
+ orchestrator.stop();
3775
+ return;
3776
+ }
3777
+ await new Promise((resolve) => {
3778
+ // **unref 하지 않는다.** 예전에는 여기서 타이머를 unref 했는데, 그러면 이벤트
3779
+ // 루프를 붙잡는 것이 아무것도 없어 첫 잠에서 프로세스가 끝난다 — Node 는
3780
+ // `unsettled top-level await` 로 exit 13 을 낸다. 상시로 돌아야 하는 루프가
3781
+ // 조용히 한 회차만 돌고 죽던 원인이다. 멈출 때는 아래 wake 가 타이머를 지운다.
3782
+ const timer = setTimeout(resolve, ms);
3783
+ wake = () => {
3784
+ clearTimeout(timer);
3785
+ resolve();
3786
+ };
3787
+ });
3788
+ wake = null;
3789
+ });
3790
+ }
3791
+ finally {
3792
+ await lease.release();
3793
+ }
3794
+ return 0;
3795
+ }
3796
+ /**
3797
+ * 이 터미널이 닫혀도 계속 보게 한다 (C-12 §0).
3798
+ *
3799
+ * scheduler 제품을 설치하지 않는다 (불변식 ④) — 같은 명령을 떨어져 나간 프로세스로 다시
3800
+ * 띄울 뿐이다. cron·launchd·Task Scheduler로 `asc runtime tick` 을 부르는 길도 그대로
3801
+ * 열려 있고, 둘은 같은 lease를 지나므로 겹쳐 돌지 않는다.
3802
+ */
3803
+ async function detachRuntime(values) {
3804
+ // 지금 프로세스가 받은 인자를 그대로 쓰되 --detach 만 뺀다. 옵션을 다시 조립하면
3805
+ // 언젠가 부모와 자식이 다른 주기로 돌게 된다.
3806
+ const args = process.argv.slice(2).filter((arg) => arg !== '--detach');
3807
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...args], {
3808
+ detached: true,
3809
+ stdio: 'ignore',
3810
+ cwd: process.cwd(),
3811
+ env: process.env,
3812
+ });
3813
+ child.unref();
3814
+ if (child.pid === undefined) {
3815
+ console.error('Could not start a background runtime.');
3816
+ return 1;
3817
+ }
3818
+ console.log(`Background runtime started (pid ${child.pid}). \`asc runtime status\` says whether it is observing.`);
3819
+ console.log('`asc runtime stop` ends it.');
3820
+ void values;
3821
+ return 0;
3822
+ }
3823
+ /**
3824
+ * 도는 runtime에게 끝내라고 한다.
3825
+ *
3826
+ * **lease를 지우는 것으로 멈추지 않는다** — 그러면 프로세스는 살아 있는데 아무도 그것을
3827
+ * 모르는 상태가 된다. 신호를 보내고, 그 프로세스가 자기 lease를 놓게 한다.
3828
+ */
3829
+ async function stopBackground(store) {
3830
+ const scope = store.scope('runtime');
3831
+ const state = await new RuntimeLease({ scope, owner: '(reader)' }).read();
3832
+ if (state.kind === 'FREE') {
3833
+ console.log('No background runtime is registered here.');
3834
+ return 0;
3835
+ }
3836
+ if (state.kind === 'STALE') {
3837
+ // 죽은 프로세스가 남긴 기록이다. 신호를 보낼 곳이 없으므로 기록만 치운다.
3838
+ await scope.delete('runtime-lease');
3839
+ console.log(`Cleared a stale lease from pid ${state.record.pid} — nothing was running.`);
3840
+ return 0;
3841
+ }
3842
+ try {
3843
+ process.kill(state.record.pid, 'SIGTERM');
3844
+ }
3845
+ catch (error) {
3846
+ // 이미 죽은 프로세스다. 기록만 남았으므로 치운다 — 못 지우면 그 사실을 말한다.
3847
+ if (error.code !== 'ESRCH') {
3848
+ console.error(`Could not signal pid ${state.record.pid}: ${String(error)}`);
3849
+ return 1;
3850
+ }
3851
+ await scope.delete('runtime-lease');
3852
+ console.log(`pid ${state.record.pid} was already gone — cleared its lease.`);
3853
+ return 0;
3854
+ }
3855
+ console.log(`Asked pid ${state.record.pid} to finish its pass and stop.`);
3232
3856
  return 0;
3233
3857
  }
3234
3858
  /**
@@ -3465,6 +4089,58 @@ async function runFreeze(group, command, values, store) {
3465
4089
  ${USAGE}`);
3466
4090
  return 2;
3467
4091
  }
4092
+ /** 판정 결과에서 workspace 신원만 꺼낸다. 갈래마다 모양이 달라 한 곳에서 접는다. */
4093
+ function workspaceOf(resolution) {
4094
+ return resolution.kind === 'REGISTERED' || resolution.kind === 'LINKED_WORKTREE'
4095
+ ? { workspaceId: resolution.workspaceId, locator: resolution.locator }
4096
+ : null;
4097
+ }
4098
+ /**
4099
+ * Host 세션이 열렸다 (C-12 §4). **이 명령은 실패하지 않는다.**
4100
+ *
4101
+ * Host lifecycle이 부르는 자리라 세 가지를 지킨다:
4102
+ *
4103
+ * 붙지 않은 자리 아무 말도 하지 않고 exit 0 — 남의 프로젝트를 방해하지 않는다
4104
+ * 상태를 못 읽는 자리 왜인지 말한다 — 조용한 빈 화면을 주지 않는다 (불변식 ⑰)
4105
+ * 미등록 linked worktree 공용 resolver가 풀고 등록까지 한다 (C-11 §1.3)
4106
+ *
4107
+ * **workspace 신원을 여기서 다시 구현하지 않는다.** `resolveRoot` 하나만 부른다.
4108
+ */
4109
+ async function runFrontOpen(values) {
4110
+ const emit = async (opening) => {
4111
+ const lines = frontOpeningLines(opening);
4112
+ if (values.json) {
4113
+ // payload는 Claude Code가 SessionStart에서 읽는 봉투다. 다른 host는 lines를 쓴다.
4114
+ console.log(JSON.stringify({ kind: opening.kind, lines, payload: sessionStartPayload(lines) }, null, 2));
4115
+ return 0;
4116
+ }
4117
+ for (const line of lines)
4118
+ console.log(line);
4119
+ return 0;
4120
+ };
4121
+ const resolution = await resolveRoot(process.cwd(), values.root).catch(() => null);
4122
+ const workspace = resolution ? workspaceOf(resolution) : null;
4123
+ // EXPLICIT·PROJECT_LOCAL 은 workspace id가 없지만 붙은 자리다 — 신원 없이도 상태는 읽는다.
4124
+ const root = resolution && resolution.kind !== 'UNRESOLVED' ? resolution.root : null;
4125
+ if (!root)
4126
+ return emit({ kind: 'NOT_ASC' });
4127
+ const store = new MarkdownStateStore(root);
4128
+ return emit(await openFront({
4129
+ // 신원을 모르는 자리(project-local)라도 붙은 것은 붙은 것이다
4130
+ workspace: workspace ?? { workspaceId: '(project-local)', locator: root },
4131
+ restore: async () => {
4132
+ const scope = await activeMonitorScope(store);
4133
+ return restoreFront({
4134
+ store,
4135
+ pending: await new LocalOperator({ store }).list({}),
4136
+ escalations: await escalationLedger(store).pending(),
4137
+ health: evaluateHealth(await new CoverageLedger(store.scope(scope)).health(), new Date().toISOString(), HEALTH_THRESHOLDS),
4138
+ ...(workspace ? { workspace } : {}),
4139
+ bindings: claudeBindings(store),
4140
+ });
4141
+ },
4142
+ }));
4143
+ }
3468
4144
  async function runFront(command, values, store, root) {
3469
4145
  if (command !== undefined && command !== 'status') {
3470
4146
  console.error(`Unknown front command: ${command}
@@ -3473,8 +4149,9 @@ ${USAGE}`);
3473
4149
  return 2;
3474
4150
  }
3475
4151
  const scope = await activeMonitorScope(store);
3476
- const index = await readIndex(ascHome());
3477
- const located = lookupLocator(index, process.cwd());
4152
+ // **index 직접 뒤지지 않는다.** 모든 root 판정이 지나는 같은 문을 쓴다 — 그래야
4153
+ // 미등록 linked worktree도 같은 workspace로 풀리고, 그 자리가 등록까지 된다 (C-11 §1.3).
4154
+ const located = workspaceOf(await resolveRoot(process.cwd(), values.root));
3478
4155
  const state = await restoreFront({
3479
4156
  store,
3480
4157
  pending: await new LocalOperator({ store }).list({}),
@@ -3508,51 +4185,71 @@ async function runMonitor(command, values, store, renderer, resolved) {
3508
4185
  console.error(`Monitoring is not running — ${readJudgment.detail}`);
3509
4186
  return 2;
3510
4187
  }
3511
- const built = await buildMonitorEngine(values, store, resolved);
4188
+ const built = await buildMonitorEngines(values, store, resolved);
3512
4189
  if (!built.ok)
3513
4190
  return built.code;
3514
- const { engine, repo } = built;
4191
+ const { channels, repo } = built;
4192
+ // 채널마다 자기 회차를 돈다. **한 채널의 실패가 다른 채널을 세우지 않는다** — 코드 쪽이
4193
+ // 자격을 잃었다고 작업 항목 쪽까지 못 보게 되면, 그것이 곧 "변화 없음"으로 읽힌다.
4194
+ let worst = 0;
3515
4195
  // 어디까지 확인했는지 보여주기만 한다. 판정하지도, 무엇을 고치지도 않는다.
3516
4196
  if (command === 'status') {
3517
- const health = await engine.health();
3518
- console.log(values.json ? JSON.stringify(health, null, 2) : renderHealth(repo, health).join('\n'));
3519
- return 0;
3520
- }
3521
- if (command === 'reconcile' || command === 'census') {
3522
- const sweep = command === 'reconcile' ? await engine.reconcile() : await engine.census();
3523
- if (sweep.skipped) {
3524
- console.log('Another run is in progress. The next pass will pick it up.');
3525
- return 0;
3526
- }
3527
4197
  if (values.json) {
3528
- console.log(JSON.stringify(sweep, null, 2));
3529
- return sweep.complete ? 0 : 1;
3530
- }
3531
- console.log(`${sweep.kind}: ${sweep.seen} listed · ${sweep.changed} changed · ${sweep.packets.length} packets`);
3532
- if (sweep.missing.length > 0) {
3533
- console.log(`${sweep.missing.length} known items absent from this listing: ${sweep.missing.join(', ')}`);
3534
- console.log(' (whether that is deletion, permissions, visibility or a query error is not judged here — a person looks)');
4198
+ const perChannel = [];
4199
+ for (const channel of channels) {
4200
+ perChannel.push({ channel: channel.label, source: channel.sourceId, health: await channel.engine.health() });
4201
+ }
4202
+ console.log(JSON.stringify({ repo, channels: perChannel }, null, 2));
4203
+ return 0;
3535
4204
  }
3536
- if (!sweep.complete) {
3537
- // 완주하지 못한 회차를 성공으로 보이면 "확인했다"는 거짓말이 된다
3538
- console.error(`The listing did not complete${sweep.detail ? ` — ${sweep.detail}` : ''}. This pass makes no judgement about disappearances.`);
3539
- return 1;
4205
+ for (const channel of channels) {
4206
+ console.log(`[${channel.label}]`);
4207
+ console.log(renderHealth(repo, await channel.engine.health()).join('\n'));
3540
4208
  }
3541
4209
  return 0;
3542
4210
  }
3543
- const outcome = await engine.scan();
3544
- if (outcome.skipped) {
3545
- console.log('Another scan is in progress. The next pass will pick it up.');
3546
- return 0;
4211
+ if (command === 'reconcile' || command === 'census') {
4212
+ for (const channel of channels) {
4213
+ const sweep = command === 'reconcile' ? await channel.engine.reconcile() : await channel.engine.census();
4214
+ if (sweep.skipped) {
4215
+ console.log(`[${channel.label}] another run is in progress. The next pass will pick it up.`);
4216
+ continue;
4217
+ }
4218
+ if (values.json) {
4219
+ console.log(JSON.stringify({ channel: channel.label, ...sweep }, null, 2));
4220
+ }
4221
+ else {
4222
+ console.log(`[${channel.label}] ${sweep.kind}: ${sweep.seen} listed · ${sweep.changed} changed · ${sweep.packets.length} packets`);
4223
+ if (sweep.missing.length > 0) {
4224
+ console.log(` ${sweep.missing.length} known items absent from this listing: ${sweep.missing.join(', ')}`);
4225
+ console.log(' (whether that is deletion, permissions, visibility or a query error is not judged here — a person looks)');
4226
+ }
4227
+ }
4228
+ if (!sweep.complete) {
4229
+ // 완주하지 못한 회차를 성공으로 보이면 "확인했다"는 거짓말이 된다
4230
+ console.error(`[${channel.label}] the listing did not complete${sweep.detail ? ` — ${sweep.detail}` : ''}. This pass makes no judgement about disappearances.`);
4231
+ worst = 1;
4232
+ }
4233
+ }
4234
+ return worst;
3547
4235
  }
3548
- console.log(`감지 ${outcome.detected} · 중복 ${outcome.duplicates} · 기록 ${outcome.logged} · ` +
3549
- `보고서 ${outcome.packets.length} · 재시도 ${outcome.retries.length}`);
3550
- if (outcome.packets.length > 0) {
4236
+ let packets = 0;
4237
+ for (const channel of channels) {
4238
+ const outcome = await channel.engine.scan();
4239
+ if (outcome.skipped) {
4240
+ console.log(`[${channel.label}] another scan is in progress. The next pass will pick it up.`);
4241
+ continue;
4242
+ }
4243
+ console.log(`[${channel.label}] 감지 ${outcome.detected} · 중복 ${outcome.duplicates} · 기록 ${outcome.logged} · ` +
4244
+ `보고서 ${outcome.packets.length} · 재시도 ${outcome.retries.length}`);
4245
+ packets += outcome.packets.length;
4246
+ }
4247
+ if (packets > 0) {
3551
4248
  const operator = new LocalOperator({ store });
3552
4249
  console.log();
3553
4250
  console.log(renderer.renderList(await operator.list()).text);
3554
4251
  }
3555
- return 0;
4252
+ return worst;
3556
4253
  }
3557
4254
  /**
3558
4255
  * 외부로 내보내는 경로. 승인과 분리돼 있는 이유는 OM §11.8 — 승인은 내용에 동의한