@asc-agent/runtime 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude-code/guard.d.ts +38 -0
  3. package/dist/adapters/claude-code/guard.js +162 -30
  4. package/dist/adapters/claude-code/skill.js +29 -0
  5. package/dist/adapters/github/scm.d.ts +2 -0
  6. package/dist/adapters/github/scm.js +5 -1
  7. package/dist/adapters/gitlab/scm.d.ts +43 -0
  8. package/dist/adapters/gitlab/scm.js +201 -0
  9. package/dist/adapters/markdown/layout.js +0 -2
  10. package/dist/adapters/markdown/serialize.js +0 -7
  11. package/dist/adapters/memory/runtime-binding.d.ts +27 -2
  12. package/dist/adapters/memory/runtime-binding.js +72 -1
  13. package/dist/cli/asc.js +230 -31
  14. package/dist/composition/runtime.d.ts +2 -0
  15. package/dist/composition/runtime.js +10 -0
  16. package/dist/core/attach/init.js +13 -5
  17. package/dist/core/distribution/release.d.ts +3 -3
  18. package/dist/core/distribution/release.js +1 -1
  19. package/dist/core/execution/executor.js +6 -2
  20. package/dist/core/execution/grant.d.ts +44 -1
  21. package/dist/core/execution/grant.js +59 -0
  22. package/dist/core/model/entities.d.ts +53 -27
  23. package/dist/core/model/entities.js +21 -14
  24. package/dist/core/model/transitions.d.ts +1 -3
  25. package/dist/core/model/transitions.js +0 -13
  26. package/dist/core/operator/local-operator.js +9 -1
  27. package/dist/core/operator/proceed.js +13 -3
  28. package/dist/core/operator/work-state.js +52 -14
  29. package/dist/core/runtime/background.d.ts +33 -0
  30. package/dist/core/runtime/background.js +68 -2
  31. package/dist/core/runtime/controller.d.ts +9 -1
  32. package/dist/core/runtime/controller.js +8 -7
  33. package/dist/core/runtime/workspaces.d.ts +1 -0
  34. package/dist/core/runtime/workspaces.js +1 -1
  35. package/dist/ports/scm.d.ts +8 -0
  36. package/dist/ports/state-store.d.ts +1 -2
  37. package/dist/ports/state-store.js +0 -1
  38. package/package.json +1 -1
@@ -1,12 +1,19 @@
1
1
  import type { ScopedStore } from '../../ports/state-store.ts';
2
2
  import { RuntimeBinding, type ClaimOutcome, type RuntimeBindings } from '../../core/operator/runtime-binding.ts';
3
- /** 내려놓음·승계의 흔적. 현재 view가 아니라 이력이다. */
3
+ /**
4
+ * 집음·내려놓음·승계의 흔적. 현재 view가 아니라 이력이다.
5
+ *
6
+ * `CLAIMED` 가 여기 있는 이유는 실측이다. 내려놓음만 적으니 이력이 반쪽이 되어,
7
+ * "log 의 마지막이 RELEASED 인데 현재 binding 이 남아 있다" 를 stale 로 읽을 수 없었다 —
8
+ * 다시 집은 사실이 어디에도 없어서 정상 재결합과 지워지지 않은 묘비가 같은 모양이었다.
9
+ */
4
10
  export type BindingLogEntry = {
5
11
  logicalSessionId: string;
6
12
  physicalSessionId: string;
7
13
  provider: string;
8
14
  workerId?: string;
9
- kind: 'RELEASED' | 'SUPERSEDED';
15
+ kind: 'CLAIMED' | 'RELEASED' | 'SUPERSEDED';
16
+ /** 집은 시각. CLAIMED 에서는 `endedAt` 과 같다 — 아직 끝나지 않았다. */
10
17
  claimedAt: string;
11
18
  endedAt: string;
12
19
  };
@@ -14,9 +21,27 @@ export declare class ScopedRuntimeBindings implements RuntimeBindings {
14
21
  #private;
15
22
  constructor(scope: ScopedStore, now?: () => string);
16
23
  claim(binding: Omit<RuntimeBinding, 'updatedAt'>, at: string): Promise<ClaimOutcome>;
24
+ /** 지금 살아 있는 결합 전부. 이력(log)은 여기 없다. */
25
+ current(): Promise<RuntimeBinding[]>;
17
26
  observe(logicalSessionId: string, physicalSessionId: string, patch: Partial<Pick<RuntimeBinding, 'workerId' | 'runtimeKind' | 'lastObservedState' | 'capabilitySnapshot'>>, at: string): Promise<ClaimOutcome>;
18
27
  release(logicalSessionId: string, physicalSessionId: string): Promise<boolean>;
19
28
  rebind(binding: Omit<RuntimeBinding, 'updatedAt'>, at: string): Promise<RuntimeBinding>;
29
+ /**
30
+ * 살아 있어야 할 이유가 없는 결합 (0.7.0).
31
+ *
32
+ * 판정은 이력과 시각으로만 한다: 이 세션의 마지막 사건이 **끝남**(RELEASED·SUPERSEDED)
33
+ * 인데 현재 결합이 그 시각보다 나중에 갱신되지 않았다면, 지워졌어야 할 것이 남은 것이다.
34
+ * 나중에 갱신됐으면 그것은 다시 집은 것이고 정상이다 — 그 구분이 없으면 정상 재결합을
35
+ * 죽은 것으로 읽는다.
36
+ *
37
+ * **아무것도 지우지 않는다.** 무엇이 그런 상태인지만 답하고, 치우는 것은 호출자가 한다.
38
+ */
39
+ stale(): Promise<RuntimeBinding[]>;
40
+ /**
41
+ * 그 결합을 치운다. **이력은 건드리지 않는다** — 있었던 일은 남는다.
42
+ * 이미 없으면 아무 일도 하지 않는다(멱등).
43
+ */
44
+ forget(logicalSessionId: string): Promise<boolean>;
20
45
  /** 이 세션을 거쳐 간 소유권 이력. 현재 owner는 여기 없다 — get()이 답한다. */
21
46
  history(logicalSessionId: string): Promise<BindingLogEntry[]>;
22
47
  get(logicalSessionId: string): Promise<RuntimeBinding | null>;
@@ -27,19 +27,33 @@ export class ScopedRuntimeBindings {
27
27
  }
28
28
  async claim(binding, at) {
29
29
  const parsed = RuntimeBinding.parse({ ...binding, updatedAt: at });
30
+ // **한 Physical Run 은 한 번에 한 Logical Session 만 잡는다.**
31
+ //
32
+ // 지금까지 원자성은 Logical 쪽에서만 봤다 — 같은 physical 이 다른 세션을 하나 더
33
+ // 집는 것은 아무도 막지 않았고, 실측에서 그 겹침이 회차마다 나타났다
34
+ // (S-03 이 살아 있는 동안 S-04 가 같은 physical 로 집혔고, 그 뒤로도 계속).
35
+ // 그 상태에서는 guard 가 "이 physical 은 관리 대상" 이라고 답할 때 어느 계약을
36
+ // 말하는지 정해지지 않는다. 뺏지 않는다 — 그 결정은 사람의 것이다 (C-03 §3.2).
37
+ const held = await this.#currentOf(parsed.physicalSessionId);
38
+ if (held && held.logicalSessionId !== parsed.logicalSessionId) {
39
+ return { ok: false, reason: 'RUNTIME_CONFLICT', current: held };
40
+ }
30
41
  // setIfAbsent가 원자성을 진다 — 확인과 쓰기 사이에 다른 Physical Session이 끼지 못한다.
31
42
  if (await this.#scope.setIfAbsent(keyOf(parsed.logicalSessionId), JSON.stringify(parsed))) {
43
+ await this.#log(parsed, 'CLAIMED');
32
44
  return { ok: true, binding: parsed };
33
45
  }
34
46
  const current = await this.get(parsed.logicalSessionId);
35
47
  if (!current) {
36
48
  // 그 사이 owner가 내려놨다 — 한 번 더 집어 본다. 또 지면 진 것이다.
37
49
  if (await this.#scope.setIfAbsent(keyOf(parsed.logicalSessionId), JSON.stringify(parsed))) {
50
+ await this.#log(parsed, 'CLAIMED');
38
51
  return { ok: true, binding: parsed };
39
52
  }
40
53
  return { ok: false, reason: 'RUNTIME_CONFLICT', current: (await this.get(parsed.logicalSessionId)) };
41
54
  }
42
- // 같은 Physical Session의 재-claim은 충돌이 아니라 이어 잡기다 (respawn 아님)
55
+ // 같은 Physical Session의 재-claim은 충돌이 아니라 이어 잡기다 (respawn 아님).
56
+ // 이력에 다시 적지 않는다 — 같은 결합이 이어진 것이지 새로 집은 것이 아니다.
43
57
  if (current.physicalSessionId === parsed.physicalSessionId) {
44
58
  const refreshed = { ...current, updatedAt: at };
45
59
  await this.#scope.set(keyOf(parsed.logicalSessionId), JSON.stringify(refreshed));
@@ -47,6 +61,24 @@ export class ScopedRuntimeBindings {
47
61
  }
48
62
  return { ok: false, reason: 'RUNTIME_CONFLICT', current };
49
63
  }
64
+ /** 이 Physical Run 이 지금 잡고 있는 결합. 없으면 `null`. */
65
+ async #currentOf(physicalSessionId) {
66
+ for (const binding of await this.current()) {
67
+ if (binding.physicalSessionId === physicalSessionId)
68
+ return binding;
69
+ }
70
+ return null;
71
+ }
72
+ /** 지금 살아 있는 결합 전부. 이력(log)은 여기 없다. */
73
+ async current() {
74
+ const out = [];
75
+ for (const key of await this.#scope.keys('runtime-binding:')) {
76
+ const raw = await this.#scope.get(key);
77
+ if (raw)
78
+ out.push(RuntimeBinding.parse(JSON.parse(raw)));
79
+ }
80
+ return out;
81
+ }
50
82
  async observe(logicalSessionId, physicalSessionId, patch, at) {
51
83
  const current = await this.get(logicalSessionId);
52
84
  if (!current || current.physicalSessionId !== physicalSessionId) {
@@ -78,8 +110,47 @@ export class ScopedRuntimeBindings {
78
110
  }
79
111
  const next = RuntimeBinding.parse({ ...binding, updatedAt: at });
80
112
  await this.#scope.set(keyOf(next.logicalSessionId), JSON.stringify(next));
113
+ if (!previous || previous.physicalSessionId !== next.physicalSessionId) {
114
+ await this.#log(next, 'CLAIMED');
115
+ }
81
116
  return next;
82
117
  }
118
+ /**
119
+ * 살아 있어야 할 이유가 없는 결합 (0.7.0).
120
+ *
121
+ * 판정은 이력과 시각으로만 한다: 이 세션의 마지막 사건이 **끝남**(RELEASED·SUPERSEDED)
122
+ * 인데 현재 결합이 그 시각보다 나중에 갱신되지 않았다면, 지워졌어야 할 것이 남은 것이다.
123
+ * 나중에 갱신됐으면 그것은 다시 집은 것이고 정상이다 — 그 구분이 없으면 정상 재결합을
124
+ * 죽은 것으로 읽는다.
125
+ *
126
+ * **아무것도 지우지 않는다.** 무엇이 그런 상태인지만 답하고, 치우는 것은 호출자가 한다.
127
+ */
128
+ async stale() {
129
+ const out = [];
130
+ for (const binding of await this.current()) {
131
+ const history = await this.history(binding.logicalSessionId);
132
+ const last = history.at(-1);
133
+ if (!last || last.kind === 'CLAIMED')
134
+ continue;
135
+ if (last.physicalSessionId !== binding.physicalSessionId)
136
+ continue;
137
+ // 끝남 이후에 갱신됐으면 다시 집은 것이다.
138
+ if (binding.updatedAt > last.endedAt)
139
+ continue;
140
+ out.push(binding);
141
+ }
142
+ return out;
143
+ }
144
+ /**
145
+ * 그 결합을 치운다. **이력은 건드리지 않는다** — 있었던 일은 남는다.
146
+ * 이미 없으면 아무 일도 하지 않는다(멱등).
147
+ */
148
+ async forget(logicalSessionId) {
149
+ if (!(await this.get(logicalSessionId)))
150
+ return false;
151
+ await this.#scope.delete(keyOf(logicalSessionId));
152
+ return true;
153
+ }
83
154
  /** 이 세션을 거쳐 간 소유권 이력. 현재 owner는 여기 없다 — get()이 답한다. */
84
155
  async history(logicalSessionId) {
85
156
  const keys = await this.#scope.keys(logPrefix(logicalSessionId));
package/dist/cli/asc.js CHANGED
@@ -27,6 +27,8 @@ import { IDENTITY_FILE } from "./identity-config.js";
27
27
  import { TextRenderer } from "../adapters/text/renderer.js";
28
28
  import { ApprovalService } from "../core/approval/service.js";
29
29
  import { Executor } from "../core/execution/executor.js";
30
+ import { transitionGrant } from "../core/model/transitions.js";
31
+ import { applyTransition } from "../core/runtime/store-ops.js";
30
32
  import { GrantService } from "../core/execution/grant.js";
31
33
  import { DecisionKind } from "../core/model/entities.js";
32
34
  import { discoverProjectRoot, excludeFromGit, identitiesTemplate, overrideTemplate, writeIfAbsent } from "../core/attach/init.js";
@@ -73,7 +75,7 @@ import { renderProgress } from "../core/operator/render.js";
73
75
  import { AuditLedger, decisionLines, delegationLine, executionLines, reclaimLine, validationLines, DecisionClass, } from "../core/runtime/audit.js";
74
76
  import { ClosureLedger } from "../core/runtime/closure.js";
75
77
  import { Orchestrator, renderTick } from "../core/runtime/orchestrator.js";
76
- import { LAST_RUN_KEY, RuntimeLease, fileScope, readBackground, renderBackground, staleAfter, } from "../core/runtime/background.js";
78
+ import { LAST_RUN_KEY, RuntimeLease, fileScope, readBackground, recordPass, renderBackground, staleAfter, } from "../core/runtime/background.js";
77
79
  import { dueWorkspaces, renderWorkspaces, summarizePass, viewWorkspaces } from "../core/runtime/workspaces.js";
78
80
  import { persistentRuntimeLine, planPersistentRuntime, } from "../core/distribution/persistent-runtime.js";
79
81
  import { launchdAdapter } from "../adapters/service/launchd.js";
@@ -114,6 +116,8 @@ const USAGE = `asc — Agent Session Control
114
116
 
115
117
  asc grant issue <REQUEST_ID> --action <key> --target <ref> --as <actor>
116
118
  [--grant-id <id>] [--expires <iso>]
119
+ asc grant issue --session <S-ID> --action <key> --target <ref> --body-file <path> --as <actor>
120
+ # what a session produced, sent out because a person said so
117
121
  asc grant run <GRANT_ID> [--run-id <id>]
118
122
 
119
123
  asc monitor scan [--backfill] [--as <controller>] # fast path
@@ -126,7 +130,8 @@ const USAGE = `asc — Agent Session Control
126
130
 
127
131
  asc runtime start [--detach] [--interval-min <n>] [--delta-min <n>]
128
132
  [--reconcile-min <n>] [--census-min <n>] [--digest-min <n>]
129
- # --detach: keep observing after this terminal closes
133
+ # development and recovery only the machine's registration is
134
+ # what observes on the normal path (asc runtime service)
130
135
  asc runtime tick [--all] # --all: every workspace this machine knows
131
136
  asc runtime list [--json] # every workspace, without visiting each one
132
137
  asc runtime service [status] [--json] # the machine's persistent registration
@@ -203,8 +208,9 @@ const USAGE = `asc — Agent Session Control
203
208
  asc query list [--json]
204
209
 
205
210
  asc coordination [status] [--json] # what was asked outside, and whether it reached anyone
206
- asc coordination publish --query <ID> --title <text> --body-file <path>
211
+ asc coordination publish --grant <G-ID> --query <ID> --title <text> --body-file <path>
207
212
  [--audience <who>] [--known <objectId>] [--work <ref>] [--json]
213
+ # publishing is an outward write — it goes through an approved grant
208
214
  asc coordination observe [--json] # did anything come back on what we published
209
215
 
210
216
  asc progress show [<S-ID>]
@@ -789,7 +795,7 @@ async function runParsedCommand(values, positionals, entry, argv) {
789
795
  if (group === 'preflight')
790
796
  return runPreflight(values, store, guard.runtime);
791
797
  if (group === 'grant')
792
- return runGrant(command, target, values, store, root);
798
+ return runGrant(command, target, values, store, root, guard.runtime);
793
799
  if (group === 'monitor')
794
800
  return runMonitor(command, values, store, renderer, guard.runtime);
795
801
  if (group === 'runtime')
@@ -2047,6 +2053,8 @@ const installRoot = () => join(dirname(fileURLToPath(import.meta.url)), '..');
2047
2053
  * **경로를 아는 것은 Surface의 몫이다.** Core에 홈을 알려 주지 않는다 (C-11).
2048
2054
  */
2049
2055
  const externalProfileRoot = () => join(ascHome(), 'profiles');
2056
+ /** 한 프로세스에서 한 번만 말한다. 사실은 매번 같고, 반복은 읽히지 않는다. */
2057
+ let staleLockReported = false;
2050
2058
  async function checkBootstrap(root) {
2051
2059
  const outcome = await bootstrapGuard({
2052
2060
  ascRoot: root,
@@ -2059,10 +2067,14 @@ async function checkBootstrap(root) {
2059
2067
  if (outcome.ok) {
2060
2068
  // 판번호만 낡은 lock 은 멈출 이유가 아니다. 다만 조용히 지나가지도 않는다 —
2061
2069
  // 다음 재고정 때 따라온다는 것을 여기서 한 번 말한다.
2062
- if (outcome.staleLock) {
2070
+ // 명령 안에서 이 문이 두 번 지나간다. 같은 말을 두 번 하면 그때부터 사람은
2071
+ // 이 줄을 읽지 않는다 — 회차 기록에도 매번 두 줄씩 쌓였다.
2072
+ if (outcome.staleLock && !staleLockReported) {
2063
2073
  const moved = outcome.staleLock.find((drift) => drift.field === 'ascCore.version');
2064
- if (moved)
2074
+ if (moved) {
2075
+ staleLockReported = true;
2065
2076
  console.error(`(profile.lock was written by ASC ${moved.locked}; this is ${moved.current}. \`asc profile resolve --write\` records it.)`);
2077
+ }
2066
2078
  }
2067
2079
  return { code: 0, runtime: outcome.runtime };
2068
2080
  }
@@ -2436,6 +2448,14 @@ async function runHost(provider, command, target, values) {
2436
2448
  }));
2437
2449
  return 0;
2438
2450
  }
2451
+ // 지워졌어야 할 결합이 남아 있으면 여기서 치운다 (0.7.0).
2452
+ // 별도 migration 명령을 만들지 않는 이유는 하나다 — 이 상태를 만나는 자리가
2453
+ // 여기이고, 사람이 따로 기억해야 하는 정리 절차는 결국 안 돌아간다.
2454
+ for (const dead of await bindings.stale()) {
2455
+ if (await bindings.forget(dead.logicalSessionId)) {
2456
+ console.error(`(stale binding cleared: ${dead.logicalSessionId} ← ${dead.physicalSessionId})`);
2457
+ }
2458
+ }
2439
2459
  if (!values.physical) {
2440
2460
  console.error('--physical <Claude session id> is required.');
2441
2461
  return 2;
@@ -2449,8 +2469,19 @@ async function runHost(provider, command, target, values) {
2449
2469
  if (released)
2450
2470
  for (const evidence of running)
2451
2471
  await audit.endExecution(evidence.executionId, 'RELEASED', at);
2452
- console.log(released ? `${target} ownership released` : 'Release failed — you are not the owner');
2453
- return released ? 0 : 1;
2472
+ if (!released) {
2473
+ console.error('Release failed you are not the owner');
2474
+ return 1;
2475
+ }
2476
+ // **놓았다고 말하기 전에 확인한다.** guard 는 이 파일 하나로 관리 대상을 정하므로,
2477
+ // 지워지지 않은 채 "released" 라고 적으면 그 세션의 외부 write 가 계속 막힌다.
2478
+ const after = await bindings.get(target);
2479
+ if (after) {
2480
+ console.error(`Release did not take — ${target} is still bound to ${after.physicalSessionId}.`);
2481
+ return 1;
2482
+ }
2483
+ console.log(`${target} ownership released`);
2484
+ return 0;
2454
2485
  }
2455
2486
  const spec = {
2456
2487
  logicalSessionId: target,
@@ -2487,8 +2518,14 @@ async function runHost(provider, command, target, values) {
2487
2518
  }
2488
2519
  const claimed = await bindings.claim(spec, at);
2489
2520
  if (!claimed.ok) {
2490
- console.error(`RUNTIME_CONFLICT: ${target} owner는 ${claimed.current.physicalSessionId} 다. ` +
2491
- '죽은 세션이 확실하면 --force rebind하라.');
2521
+ // 부딪힌 상대가 **어느 세션인지** 말한다. 같은 Run 이 다른 세션을 잡고 있는 경우와
2522
+ // 세션을 다른 Run 이 잡고 있는 경우는 사람이 할 일이 다르다.
2523
+ const other = claimed.current.logicalSessionId;
2524
+ console.error(other === target
2525
+ ? `RUNTIME_CONFLICT: ${target} 은 이미 ${claimed.current.physicalSessionId} 가 잡고 있다. ` +
2526
+ '죽은 세션이 확실하면 --force 로 rebind하라.'
2527
+ : `RUNTIME_CONFLICT: 이 Run(${physical}) 은 지금 ${other} 을 잡고 있다. ` +
2528
+ `한 Run 은 한 세션만 잡는다 — 먼저 놓아라: asc host claude release ${other} --physical ${physical}`);
2492
2529
  return 1;
2493
2530
  }
2494
2531
  await recordExecution('host bind');
@@ -3329,7 +3366,7 @@ async function runController(command, values, store, resolved) {
3329
3366
  // 누가 거뒀는지 모르면 History에 'controller' 라는 익명이 남는다 (C-10 §2.4)
3330
3367
  ...(reclaimedBy ? { reclaimedBy } : {}),
3331
3368
  });
3332
- console.log(renderCollect(outcome, await store.list('session')));
3369
+ console.log(renderCollect(outcome));
3333
3370
  // 거둔 세션의 live 진행 표시는 여기서 정리한다 — 종결 보고(terminal)는 남는다
3334
3371
  const cleared = await progressService(store).collect(outcome.collected);
3335
3372
  if (cleared.length > 0)
@@ -4024,15 +4061,14 @@ async function applyUpdate(plan, values) {
4024
4061
  console.log(`installed: ${RUNTIME_PACKAGE}@${target}`);
4025
4062
  let worst = 0;
4026
4063
  // host 설치물은 버전마다 내용이 바뀐다. 새 runtime 에 낡은 hook 을 남기지 않는다.
4027
- // **사람이 고친 것은 덮지 않는다** — 그것은 남의 파일이고, 덮으면 그 사람의 수정이
4028
- // 말없이 사라진다. 남은 것은 말하고, 업데이트 자체는 계속 간다.
4029
- const host = await install(hostPaths(), undefined, { force: false });
4030
- for (const path of host.written)
4031
- console.log(`host: ${path}`);
4032
- for (const skip of host.skipped) {
4033
- console.error(`host: ${skip.path} left as it is ${skip.reason}`);
4034
- worst = 1;
4035
- }
4064
+ //
4065
+ // **새로 설치된 build 자기 내용을 쓴다.** 프로세스는 아직 갈아 끼우기 **전의**
4066
+ // build 이고, build 의 `hookScript()` 내용을 만든다 — 실기계에서 update 가
4067
+ // "host: …/SKILL.md" 적고 끝났는데 probe 는 여전히 INSTALLED_STALE 이었다. 갱신했다고
4068
+ // 말하면서 옛 내용을 다시 쓴 것이다.
4069
+ //
4070
+ // 사람이 고친 것을 덮지 않는 규칙은 그쪽(`host claude install`)이 그대로 진다.
4071
+ worst = Math.max(worst, await refreshHost());
4036
4072
  // 등록물이 낡았으면 지금 형태로 수렴시킨다. 기존 STALE→수렴 경로를 그대로 쓴다.
4037
4073
  worst = Math.max(worst, await convergeService(values));
4038
4074
  // 마지막은 언제나 확인이다.
@@ -4092,6 +4128,31 @@ function diffState(before, after) {
4092
4128
  changed.push(path);
4093
4129
  return changed.sort();
4094
4130
  }
4131
+ /**
4132
+ * 새 build 에게 host 설치물을 자기 내용으로 맞추게 한다.
4133
+ *
4134
+ * 지금 도는 프로세스로 부르지 않는 이유는 하나다 — 이 프로세스는 교체되기 전의 build 다.
4135
+ * 전역 실행물을 못 찾으면 갱신하지 않고 그 사실을 말한다. 조용히 건너뛰면 낡은 hook 이
4136
+ * 새 runtime 옆에 남고, 그 조합은 아무도 시험한 적이 없다.
4137
+ */
4138
+ async function refreshHost() {
4139
+ const entry = await globalRuntimeEntry();
4140
+ if (!entry) {
4141
+ console.error('host: could not find the installed runtime to refresh with — run `asc host claude install`');
4142
+ return 1;
4143
+ }
4144
+ const child = spawnSync(process.execPath, [entry, 'host', 'claude', 'install'], { encoding: 'utf8' });
4145
+ const output = `${child.stdout ?? ''}${child.stderr ?? ''}`;
4146
+ for (const line of output.split('\n')) {
4147
+ if (line.startsWith('installed:') || line.startsWith('skipped:'))
4148
+ console.log(`host: ${line}`);
4149
+ }
4150
+ if (child.status !== 0) {
4151
+ console.error(`host: refresh failed${output.trim() ? ` — ${output.trim().split('\n').at(-1)}` : ''}`);
4152
+ return 1;
4153
+ }
4154
+ return 0;
4155
+ }
4095
4156
  /**
4096
4157
  * 되돌린다. **되돌릴 자리가 있을 때만** — 없던 것으로 되돌릴 수는 없고, 그때는 돌던 것이
4097
4158
  * 없었다는 사실을 그대로 말한다.
@@ -4247,7 +4308,12 @@ async function tickAllWorkspaces(values, lease) {
4247
4308
  stdio: values.json ? 'ignore' : 'inherit',
4248
4309
  env: process.env,
4249
4310
  });
4250
- results.push({ workspaceId: workspace.workspaceId, code: child.status ?? 1 });
4311
+ const code = child.status ?? 1;
4312
+ results.push({ workspaceId: workspace.workspaceId, code });
4313
+ // **회차가 어떻게 끝났는지 그 자리에 적는다** (Phase J). 이것이 없으면 실패는
4314
+ // service.log 로만 흘러가고, 붙어 있는 workspace 가 여러 릴리스 동안 한 번도 돌지
4315
+ // 못한 채 건강해 보인다 — 실측에서 셋 중 둘이 그 상태였다.
4316
+ await recordPass(new MarkdownStateStore(workspace.root).scope('runtime'), code).catch(() => undefined);
4251
4317
  // 회차가 길어져도 이 기계의 lease 는 살아 있어야 한다 — 갱신하지 않으면 도는 중에
4252
4318
  // 죽은 것으로 보이고 두 번째 프로세스가 끼어든다.
4253
4319
  await lease.renew();
@@ -4809,6 +4875,39 @@ async function runCoordinationPublish(values, store, resolved) {
4809
4875
  console.error('coordination publish needs --query, --title and --body-file\n\n' + USAGE);
4810
4876
  return 2;
4811
4877
  }
4878
+ // **밖으로 나가는 쓰기는 승인된 계약을 지난다** (OM §11.5, 0.7.0 / Phase H).
4879
+ //
4880
+ // 조율 게시는 오래 이 규칙 밖에 있었다. 근거는 adapter 주석 하나였다 — "물어본 것이
4881
+ // 밖에 실제로 있게 하는 행위이지 승인된 단일 행동이 아니다". 그 구분은 뜻이 있지만,
4882
+ // 계약을 대체할 결정으로 어디에도 기록되지 않았고 실제로 하는 일은 남의 저장소에
4883
+ // 글을 만드는 것이다. 살아 있는 불변식을 따른다.
4884
+ //
4885
+ // 읽기(status·observe)는 그대로다. 계약을 요구하는 것은 실제로 나가는 이 한 번뿐이다.
4886
+ const grantId = typeof values.grant === 'string' ? values.grant : undefined;
4887
+ if (!grantId) {
4888
+ console.error('밖으로 나가는 게시는 승인된 계약을 지난다 — --grant <G-ID> 가 필요하다.');
4889
+ console.error('세션이 만든 결과라면:');
4890
+ console.error(' asc grant issue --session <S-ID> --action coordination.publish \\');
4891
+ console.error(' --target <query-id> --body-file <path> --as <actor>');
4892
+ return 2;
4893
+ }
4894
+ const grant = await store.get('grant', grantId);
4895
+ if (!grant) {
4896
+ console.error(`${grantId} 를 찾지 못했다.`);
4897
+ return 1;
4898
+ }
4899
+ if (grant.status !== 'READY') {
4900
+ console.error(`${grantId} 는 지금 ${grant.status} 다 — 한 번 쓴 계약은 다시 쓰지 않는다.`);
4901
+ return 1;
4902
+ }
4903
+ if (grant.action !== 'coordination.publish') {
4904
+ console.error(`${grantId} 가 승인한 것은 '${grant.action}' 이지 게시가 아니다.`);
4905
+ return 1;
4906
+ }
4907
+ if (grant.target !== queryId) {
4908
+ console.error(`${grantId} 가 승인한 대상은 ${grant.target} 인데 지금 게시하려는 것은 ${queryId} 다.`);
4909
+ return 1;
4910
+ }
4812
4911
  const { surface, unavailable } = await coordinationSurfaceFor(resolved);
4813
4912
  if (!surface) {
4814
4913
  console.error('No coordination surface is bound to this workspace — nothing was published.');
@@ -4831,6 +4930,16 @@ async function runCoordinationPublish(values, store, resolved) {
4831
4930
  ...(workReference ? { workReference } : {}),
4832
4931
  }, { surface, bindingRole: 'coordination-surface' });
4833
4932
  if (outcome.ok) {
4933
+ // 한 번 쓴 계약은 다시 쓰이지 않는다 (OM §11.5 single_use). 게시가 실제로 나간
4934
+ // 뒤에 옮긴다 — 먼저 옮기면 실패한 게시가 계약만 태운다.
4935
+ const consumedAt = new Date().toISOString();
4936
+ const claimed = await applyTransition(store, 'grant', grantId, (g) => transitionGrant(g, 'CLAIMED', 'executor', { claimedBy: `cli-${process.pid}` }));
4937
+ if (claimed.ok) {
4938
+ await applyTransition(store, 'grant', grantId, (g) => transitionGrant(g, 'EXECUTED', 'executor', {
4939
+ resultRef: outcome.identity.objectId,
4940
+ consumedAt,
4941
+ }));
4942
+ }
4834
4943
  const recorded = await recordPublication(coordinationLedger(store), outcome);
4835
4944
  if (values.json) {
4836
4945
  console.log(JSON.stringify({ publish: outcome, recorded: recorded.ok }, null, 2));
@@ -5055,15 +5164,70 @@ async function runMonitor(command, values, store, renderer, resolved) {
5055
5164
  * 외부로 내보내는 경로. 승인과 분리돼 있는 이유는 OM §11.8 — 승인은 내용에 동의한
5056
5165
  * 것이고, 무엇을 어디에 쓸지는 Controller가 따로 지정한다.
5057
5166
  */
5058
- async function runGrant(command, target, values, store, root) {
5167
+ async function runGrant(command, target, values, store, root, runtime) {
5059
5168
  switch (command) {
5060
5169
  case 'issue': {
5061
- if (!target || !values.action || !values.target || !values.as) {
5170
+ // 근거는 하나다 밖에서 들어온 판단 요청, 또는 계약 안에서 일한 세션.
5171
+ const fromSession = values.session ?? undefined;
5172
+ if ((!target && !fromSession) || !values.action || !values.target || !values.as) {
5062
5173
  console.error('Usage: asc grant issue REQ-0042 --action <key> --target <ref> --as <actor>');
5174
+ console.error(' or: asc grant issue --session <S-ID> --action <key> --target <ref> --body-file <path> --as <actor>');
5175
+ return 2;
5176
+ }
5177
+ // **할 수 없는 일을 승인시키지 않는다** (0.7.0 / F-3).
5178
+ //
5179
+ // 예전에는 아무 action 으로나 Grant 가 발급되고, 사람이 승인한 **뒤에** 실행에서
5180
+ // "unsupported action" 이 나왔다. 승인의 의미가 그 자리에서 무너진다 — 사람은
5181
+ // 나갈 것에 동의했는데 나갈 수 없는 것이었다.
5182
+ const outward = await externalWritePort(runtime);
5183
+ if (!outward) {
5184
+ console.error('밖으로 내보낼 통로가 없다 — 이 행위를 수행할 결합이 Profile 에 없다.');
5185
+ console.error('지금 무엇이 풀리는지: asc setup status');
5186
+ return 2;
5187
+ }
5188
+ if (outward.supports && !outward.supports(values.action)) {
5189
+ console.error(`'${String(values.action)}' 를 수행할 수 있는 통로가 없다 (${outward.id}).`);
5190
+ console.error('승인 뒤에 실패하는 것보다 지금 멈추는 편이 낫다 — 발급하지 않았다.');
5063
5191
  return 2;
5064
5192
  }
5065
5193
  // 발급도 승인 권한자만 할 수 있다 — 외부로 나가는 권한이 여기서 만들어지기 때문이다
5066
5194
  const grants = new GrantService(store, new LocalIdentityBinding(await loadIdentityMap(root)));
5195
+ if (fromSession) {
5196
+ // 사람이 지금 내보내라고 한 것이 승인이다. 그 말과 함께 온 내용이 payload 이고,
5197
+ // 여기서 지어내지 않는다 — 사람이 본 적 없는 글이 사람의 이름을 달고 나가면 안 된다.
5198
+ const bodyFile = values['body-file'];
5199
+ if (!bodyFile) {
5200
+ console.error('--body-file <path> 가 필요하다 — 내보낼 내용은 사람이 준 것이어야 한다.');
5201
+ return 2;
5202
+ }
5203
+ const payload = await readFile(bodyFile, 'utf8').catch(() => null);
5204
+ if (payload === null) {
5205
+ console.error(`내용을 읽지 못했다: ${bodyFile}`);
5206
+ return 2;
5207
+ }
5208
+ const forSession = await grants.issueForSession({
5209
+ grantId: values['grant-id'] ?? `G-${String(Date.now()).slice(-4)}`,
5210
+ sessionId: fromSession,
5211
+ issuedBy: values.as,
5212
+ channel: 'local',
5213
+ action: values.action,
5214
+ target: values.target,
5215
+ payload,
5216
+ ...(values.expires ? { expiresAt: values.expires } : {}),
5217
+ issuedAt: new Date().toISOString(),
5218
+ });
5219
+ if (!forSession.ok) {
5220
+ console.error(GRANT_ERROR[forSession.failure.kind] ?? forSession.failure.kind);
5221
+ return 1;
5222
+ }
5223
+ console.log(`${forSession.grant.id} READY — ${forSession.grant.action} → ${forSession.grant.target}`);
5224
+ console.log('The target state is checked again before execution. If it changed in the meantime, nothing runs.');
5225
+ return 0;
5226
+ }
5227
+ if (!target) {
5228
+ console.error('요청 근거가 없다 — REQUEST_ID 를 주거나 --session <S-ID> 를 쓰라.');
5229
+ return 2;
5230
+ }
5067
5231
  const issued = await grants.issue({
5068
5232
  grantId: values['grant-id'] ?? `G-${String(Date.now()).slice(-4)}`,
5069
5233
  requestId: target,
@@ -5087,19 +5251,22 @@ async function runGrant(command, target, values, store, root) {
5087
5251
  console.error('Usage: asc grant run G-0001');
5088
5252
  return 2;
5089
5253
  }
5090
- const token = await discoverToken();
5091
- if (!token) {
5092
- console.error('No GitHub token found. Set ASC_GITHUB_TOKEN, or run `gh auth login`.');
5093
- return 2;
5094
- }
5095
5254
  const grant = await store.get('grant', target);
5096
5255
  if (!grant) {
5097
5256
  console.error(`${target} was not found.`);
5098
5257
  return 1;
5099
5258
  }
5100
- // 번이 실제로 밖에 나간다. 계약이 지정한 대상·내용 그대로이며,
5101
- // 직전에 대상 상태를 다시 확인한다.
5102
- const scm = new GitHubScm({ client: new GitHubClient({ token }), defaultRepo: repoOf(grant.target) });
5259
+ // **어느 provider 나갈지는 여기서 고르지 않는다** (C-09 · B-49).
5260
+ //
5261
+ // 예전에는 자리에서 GitHub client 직접 만들었다. 관측 경로는 진작 결합으로
5262
+ // 풀리고 있었는데 실행 경로만 한 갈래에 묶여 있어서, 코드가 다른 곳에 있는
5263
+ // 프로젝트에서는 승인이 끝난 **뒤에야** 실행할 통로가 없다는 것이 드러났다.
5264
+ const scm = await externalWritePort(runtime);
5265
+ if (!scm) {
5266
+ console.error('밖으로 내보낼 통로가 없다 — Profile bindings 에 외부 쓰기를 제공하는 결합이 필요하다.');
5267
+ console.error('지금 무엇이 풀리는지: asc setup status');
5268
+ return 2;
5269
+ }
5103
5270
  const outcome = await new Executor({
5104
5271
  store,
5105
5272
  scm,
@@ -5117,6 +5284,36 @@ async function runGrant(command, target, values, store, root) {
5117
5284
  return 2;
5118
5285
  }
5119
5286
  }
5287
+ /**
5288
+ * 승인된 행위가 실제로 나갈 통로 (C-09 · OM §11.5).
5289
+ *
5290
+ * 조립은 Composition 의 몫이다 — 이 자리에서 provider 를 알면 provider 교체가 다시 CLI
5291
+ * 수술이 된다. 없으면 `null` 이고, 없는 것을 있는 척하지 않는다.
5292
+ */
5293
+ async function externalWritePort(runtime) {
5294
+ const { root: projectRoot } = await discoverProjectRoot(process.cwd());
5295
+ const adapters = monitorAdapters();
5296
+ const declared = runtime?.layers.profile.bindings ?? [];
5297
+ const plan = await composeBindings({
5298
+ context: { projectRoot, env: process.env },
5299
+ adapters,
5300
+ roles: declared.map((b) => ({ adapterId: b.adapter, resource: b.resource, role: b.role })),
5301
+ });
5302
+ const ports = await buildRuntimePorts({
5303
+ plan,
5304
+ roles: rolesFor(plan, declared),
5305
+ repoRoot: projectRoot,
5306
+ ...(runtime?.layers.profile.canonical.sources
5307
+ ? {
5308
+ sourceRefs: Object.fromEntries(runtime.layers.profile.canonical.sources
5309
+ .filter((source) => typeof source.ref === 'string')
5310
+ .map((source) => [source.id, { ref: source.ref }])),
5311
+ }
5312
+ : {}),
5313
+ endpointFor: (binding) => endpointOf(adapters, binding),
5314
+ });
5315
+ return ports.scm ?? null;
5316
+ }
5120
5317
  /** `owner/repo#19` 에서 저장소만. 짧은 참조를 풀 때 쓴다. */
5121
5318
  function repoOf(target) {
5122
5319
  const match = /^([^/\s]+\/[^#\s]+)#\d+$/.exec(target);
@@ -5128,6 +5325,8 @@ const GRANT_ERROR = {
5128
5325
  FORBIDDEN_ISSUER: '계약을 발급할 권한이 없다. .asc/identities.json 에 `"이름": ["local:계정"]` 형태로 매핑을 추가하라 ' +
5129
5326
  '(현재 상태는 `asc setup status`).',
5130
5327
  NO_PAYLOAD: '내보낼 내용이 없다.',
5328
+ SESSION_NOT_FOUND: '그 세션을 찾지 못했다.',
5329
+ SESSION_NOT_RUNNABLE: '아직 시작하지 않은 세션이다 — 내보낼 결과가 없다.',
5131
5330
  GRANT_EXISTS: '같은 id의 계약이 이미 있다.',
5132
5331
  };
5133
5332
  // import 만으로 명령이 돌면 안 된다 — bootstrap이 이 모듈을 불러오는 순간 세상이 바뀐다.
@@ -38,6 +38,8 @@ export type BuildInput = {
38
38
  sourceRefs?: Readonly<Record<string, {
39
39
  ref: string;
40
40
  }>>;
41
+ /** 이 결합이 가리키는 저장소가 이 기계 어디에 있는가. 원격에 올리는 행위에만 쓴다. */
42
+ repoRoot?: string;
41
43
  /** 이벤트 조회 페이지 크기. */
42
44
  perPage?: number;
43
45
  /** 자격 조회 통로 주입점(테스트용). adapter id를 받아 그 adapter의 자격을 돌려준다. */
@@ -12,6 +12,7 @@ import { GitHubClient, discoverToken } from "../adapters/github/client.js";
12
12
  import { GitHubChangeContext, GitHubInventory, GitHubResourceContext } from "../adapters/github/context.js";
13
13
  import { GitHubEventSource } from "../adapters/github/event-source.js";
14
14
  import { GitHubScm } from "../adapters/github/scm.js";
15
+ import { GitLabScm } from "../adapters/gitlab/scm.js";
15
16
  import { GitLabClient, GlabApiClient, discoverToken as discoverGitLabToken, glabAvailable, } from "../adapters/gitlab/client.js";
16
17
  import { GitLabCoordinationSurface } from "../adapters/gitlab/coordination.js";
17
18
  import { GitLabChangeContext, GitLabEventSource, GitLabInventory, GitLabResourceContext, } from "../adapters/gitlab/ports.js";
@@ -34,6 +35,15 @@ const FACTORIES = {
34
35
  // 조율 표면. 토큰 통로일 때만 쓰기가 가능하다 — 도구를 통로로 쓰는 경우도 POST 를
35
36
  // 대신 보내 준다. 둘 다 아니면 create 가 그 사실을 그대로 말한다.
36
37
  coordinationSurface: new GitLabCoordinationSurface({ reader: client, writer: client, project }),
38
+ // 승인된 외부 write 의 통로. 관측은 진작 provider-neutral 이었는데 실행만 한 갈래에
39
+ // 묶여 있어, 승인이 끝난 뒤에야 "실행할 통로가 없다" 가 드러나던 자리다.
40
+ scm: new GitLabScm({
41
+ reader: client,
42
+ writer: client,
43
+ defaultProject: project,
44
+ sourceRefs: input.sourceRefs ?? {},
45
+ ...(input.repoRoot ? { repoRoot: input.repoRoot } : {}),
46
+ }),
37
47
  // canonical 통로는 아직 없다. 없는 것을 있는 척하지 않는다.
38
48
  };
39
49
  },
@@ -110,12 +110,20 @@ export function withIdentity(identities, override, input) {
110
110
  const nextIdentities = { ...identities };
111
111
  const nextOverride = { ...override };
112
112
  if (input.controller) {
113
- nextIdentities[input.name] = [input.actor];
113
+ // **한 사람이 여러 채널 identity 를 갖는다** (OM §11.6). schema 가 배열인 것이 그
114
+ // 뜻이고, 읽는 쪽(LocalIdentityBinding)도 배열 전체를 Set 으로 담아 대조한다.
115
+ // 그런데 여기서는 배열을 통째로 덮고 있었다 — 한 채널로 잡아 둔 identity 가 다음
116
+ // setup 에서 다른 채널 하나로 바뀌어 사라졌다. 더하고, 중복만 지운다.
117
+ const merged = (existing) => [
118
+ ...new Set([
119
+ ...(Array.isArray(existing) ? existing.filter((v) => typeof v === 'string') : []),
120
+ input.actor,
121
+ ]),
122
+ ];
123
+ nextIdentities[input.name] = merged(nextIdentities[input.name]);
114
124
  const controller = { ...(nextOverride.controller ?? {}) };
115
- controller.identities = {
116
- ...(controller.identities ?? {}),
117
- [input.name]: [input.actor],
118
- };
125
+ const controllerIdentities = controller.identities ?? {};
126
+ controller.identities = { ...controllerIdentities, [input.name]: merged(controllerIdentities[input.name]) };
119
127
  nextOverride.controller = controller;
120
128
  }
121
129
  if (input.monitor) {