@asc-agent/runtime 0.6.1 → 0.7.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 (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 +189 -20
  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,4 +1,4 @@
1
- import { ExecutionGrant } from '../model/entities.ts';
1
+ import { ExecutionGrant, type CanonicalSnapshot } from '../model/entities.ts';
2
2
  import type { IdentityBinding } from '../../ports/approval.ts';
3
3
  import type { StateStore } from '../../ports/state-store.ts';
4
4
  export type IssueGrantInput = {
@@ -16,6 +16,34 @@ export type IssueGrantInput = {
16
16
  allowedWrites?: string[];
17
17
  issuedAt: string;
18
18
  };
19
+ /**
20
+ * 세션이 만든 결과를 내보내는 경우의 입력 (0.7.0).
21
+ *
22
+ * 초기 모델에는 이 자리가 없었다. Grant 는 `Monitor → Request → Approval` 을 위해
23
+ * 생겼고, 정작 **계약 안에서 일한 세션이 만든 결과**가 밖으로 나갈 자리는 없었다.
24
+ * 그래서 실제 작업은 관계없는 판단 요청을 하나 지어내 그 위에 얹혀야 했고, 지어낸
25
+ * 요청은 승인 기록을 흐린다.
26
+ *
27
+ * 여기서 승인은 **사람이 지금 그렇게 하라고 말한 것**이다. 그 말과 함께 온 내용이
28
+ * payload 이고, 호출자가 지어내지 않는다. 범위는 넓히지 않는다 — 승인된 것은 이
29
+ * 행위 하나이며 `allowedWrites` 가 그 경계다.
30
+ */
31
+ export type IssueForSessionInput = {
32
+ grantId: string;
33
+ sessionId: string;
34
+ /** 발급자. 승인 권한자로 매핑돼 있어야 한다 — 임의 문자열로는 발급되지 않는다. */
35
+ issuedBy: string;
36
+ channel: string;
37
+ action: string;
38
+ target: string;
39
+ /** 사람이 내보내라고 한 내용 그대로. */
40
+ payload: string;
41
+ expiresAt?: string;
42
+ allowedWrites?: string[];
43
+ issuedAt: string;
44
+ /** 게시 직전 대조할 기준 (OM §11.9). 없으면 대조하지 않는다. */
45
+ snapshot?: CanonicalSnapshot[];
46
+ };
19
47
  export type IssueFailure = {
20
48
  kind: 'REQUEST_NOT_FOUND';
21
49
  } | {
@@ -27,6 +55,13 @@ export type IssueFailure = {
27
55
  kind: 'NO_PAYLOAD';
28
56
  } | {
29
57
  kind: 'GRANT_EXISTS';
58
+ } | {
59
+ kind: 'SESSION_NOT_FOUND';
60
+ }
61
+ /** 아직 일하지 않은 세션의 결과는 없다. */
62
+ | {
63
+ kind: 'SESSION_NOT_RUNNABLE';
64
+ status: string;
30
65
  };
31
66
  export type IssueResult = {
32
67
  ok: true;
@@ -39,4 +74,12 @@ export declare class GrantService {
39
74
  #private;
40
75
  constructor(store: StateStore, identity: IdentityBinding);
41
76
  issue(input: IssueGrantInput): Promise<IssueResult>;
77
+ /**
78
+ * 세션이 만든 결과를 내보낼 계약. 근거는 그 세션이고, 승인은 사람이 지금 한 말이다.
79
+ *
80
+ * 검증은 두 가지다: 그 세션이 실제로 있고 일한 적이 있는가, 그리고 이 사람이 승인
81
+ * 권한자인가. 뒤엣것은 요청 경로와 같은 통로를 쓴다 — 발급이 열려 있으면 승인 이후
82
+ * 구간이 통째로 무방비가 되고, 그것은 근거가 요청이든 세션이든 같다.
83
+ */
84
+ issueForSession(input: IssueForSessionInput): Promise<IssueResult>;
42
85
  }
@@ -75,4 +75,63 @@ export class GrantService {
75
75
  });
76
76
  return { ok: true, grant: created.entity };
77
77
  }
78
+ /**
79
+ * 세션이 만든 결과를 내보낼 계약. 근거는 그 세션이고, 승인은 사람이 지금 한 말이다.
80
+ *
81
+ * 검증은 두 가지다: 그 세션이 실제로 있고 일한 적이 있는가, 그리고 이 사람이 승인
82
+ * 권한자인가. 뒤엣것은 요청 경로와 같은 통로를 쓴다 — 발급이 열려 있으면 승인 이후
83
+ * 구간이 통째로 무방비가 되고, 그것은 근거가 요청이든 세션이든 같다.
84
+ */
85
+ async issueForSession(input) {
86
+ const session = await this.#store.get('session', input.sessionId);
87
+ if (!session)
88
+ return { ok: false, failure: { kind: 'SESSION_NOT_FOUND' } };
89
+ // 아직 시작하지 않은 계약에는 내보낼 결과가 없다.
90
+ if (session.status === 'READY') {
91
+ return { ok: false, failure: { kind: 'SESSION_NOT_RUNNABLE', status: session.status } };
92
+ }
93
+ if (input.payload.length === 0)
94
+ return { ok: false, failure: { kind: 'NO_PAYLOAD' } };
95
+ const authorized = await this.#identity.verify({
96
+ channel: input.channel,
97
+ actor: input.issuedBy,
98
+ authorizedApprover: input.issuedBy,
99
+ });
100
+ if (!authorized) {
101
+ await this.#store.appendHistory({
102
+ at: input.issuedAt,
103
+ actor: input.issuedBy,
104
+ kind: 'grant_rejected',
105
+ ref: input.sessionId,
106
+ detail: `unauthorized issuer via ${input.channel} (${input.action})`,
107
+ });
108
+ return { ok: false, failure: { kind: 'FORBIDDEN_ISSUER' } };
109
+ }
110
+ const grant = ExecutionGrant.parse({
111
+ id: input.grantId,
112
+ version: 0,
113
+ sessionId: session.id,
114
+ status: 'READY',
115
+ issuedBy: input.issuedBy,
116
+ issuedAt: input.issuedAt,
117
+ ...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}),
118
+ singleUse: true,
119
+ action: input.action,
120
+ target: input.target,
121
+ payload: input.payload,
122
+ snapshot: input.snapshot ?? [],
123
+ allowedWrites: input.allowedWrites ?? [input.action],
124
+ });
125
+ const created = await this.#store.create('grant', grant);
126
+ if (!created.ok)
127
+ return { ok: false, failure: { kind: 'GRANT_EXISTS' } };
128
+ await this.#store.appendHistory({
129
+ at: input.issuedAt,
130
+ actor: input.issuedBy,
131
+ kind: 'grant_issued',
132
+ ref: grant.id,
133
+ detail: `${grant.action} → ${grant.target} (session ${session.id})`,
134
+ });
135
+ return { ok: true, grant: created.entity };
136
+ }
78
137
  }
@@ -588,10 +588,19 @@ export type GrantStatus = z.infer<typeof GrantStatus>;
588
588
  * one-shot execution contract (OM §5.2). Session 권한은 그대로 두고 별도 Executor에게만
589
589
  * 단일 Action을 허용한다.
590
590
  */
591
- export declare const ExecutionGrant: z.ZodObject<{
591
+ export declare const ExecutionGrant: z.ZodEffects<z.ZodObject<{
592
592
  id: z.ZodString;
593
593
  version: z.ZodNumber;
594
- requestId: z.ZodString;
594
+ /**
595
+ * 이 계약의 근거. **둘 중 하나는 반드시 있다.**
596
+ *
597
+ * `requestId` 는 밖에서 들어온 판단 요청을 사람이 승인한 경우다 (OM §11.8 의 경로).
598
+ * `sessionId` 는 계약 안에서 일한 세션이 만든 결과를 사람이 내보내라고 한 경우다 —
599
+ * 초기 모델에는 이 자리가 없었고, 그래서 실제 작업이 밖으로 나갈 때마다 관계없는
600
+ * 판단 요청을 하나 지어내야 했다. 지어낸 요청은 승인 기록을 흐린다.
601
+ */
602
+ requestId: z.ZodOptional<z.ZodString>;
603
+ sessionId: z.ZodOptional<z.ZodString>;
595
604
  status: z.ZodEnum<["READY", "CLAIMED", "EXECUTED", "INVALIDATED", "EXPIRED"]>;
596
605
  issuedBy: z.ZodString;
597
606
  issuedAt: z.ZodString;
@@ -624,7 +633,6 @@ export declare const ExecutionGrant: z.ZodObject<{
624
633
  }[];
625
634
  id: string;
626
635
  version: number;
627
- requestId: string;
628
636
  issuedBy: string;
629
637
  issuedAt: string;
630
638
  singleUse: boolean;
@@ -635,13 +643,14 @@ export declare const ExecutionGrant: z.ZodObject<{
635
643
  threadLastEventId?: string | undefined;
636
644
  expiresAt?: string | undefined;
637
645
  resultRef?: string | undefined;
646
+ requestId?: string | undefined;
647
+ sessionId?: string | undefined;
638
648
  claimedBy?: string | undefined;
639
649
  consumedAt?: string | undefined;
640
650
  }, {
641
651
  status: "READY" | "CLAIMED" | "EXECUTED" | "INVALIDATED" | "EXPIRED";
642
652
  id: string;
643
653
  version: number;
644
- requestId: string;
645
654
  issuedBy: string;
646
655
  issuedAt: string;
647
656
  action: string;
@@ -654,41 +663,58 @@ export declare const ExecutionGrant: z.ZodObject<{
654
663
  threadLastEventId?: string | undefined;
655
664
  expiresAt?: string | undefined;
656
665
  resultRef?: string | undefined;
666
+ requestId?: string | undefined;
667
+ sessionId?: string | undefined;
657
668
  singleUse?: boolean | undefined;
658
669
  allowedWrites?: string[] | undefined;
659
670
  claimedBy?: string | undefined;
660
671
  consumedAt?: string | undefined;
661
- }>;
662
- export type ExecutionGrant = z.infer<typeof ExecutionGrant>;
663
- export declare const QueueState: z.ZodEnum<["READY", "ACTIVE", "BLOCKED", "DONE"]>;
664
- export type QueueState = z.infer<typeof QueueState>;
665
- /** 승인되어 수행하기로 한 작업 (OM §4.8). inbox = 판단 대기, queue = 승인된 작업. */
666
- export declare const QueueItem: z.ZodObject<{
667
- id: z.ZodString;
668
- version: z.ZodNumber;
669
- state: z.ZodEnum<["READY", "ACTIVE", "BLOCKED", "DONE"]>;
670
- title: z.ZodString;
671
- sourceRequestId: z.ZodOptional<z.ZodString>;
672
- blockId: z.ZodOptional<z.ZodString>;
673
- sessionId: z.ZodOptional<z.ZodString>;
674
- }, "strip", z.ZodTypeAny, {
672
+ }>, {
673
+ status: "READY" | "CLAIMED" | "EXECUTED" | "INVALIDATED" | "EXPIRED";
674
+ snapshot: {
675
+ sourceId: string;
676
+ baseline: string;
677
+ }[];
675
678
  id: string;
676
679
  version: number;
677
- title: string;
678
- state: "READY" | "ACTIVE" | "BLOCKED" | "DONE";
679
- blockId?: string | undefined;
680
- sourceRequestId?: string | undefined;
680
+ issuedBy: string;
681
+ issuedAt: string;
682
+ singleUse: boolean;
683
+ action: string;
684
+ target: string;
685
+ payload: string;
686
+ allowedWrites: string[];
687
+ threadLastEventId?: string | undefined;
688
+ expiresAt?: string | undefined;
689
+ resultRef?: string | undefined;
690
+ requestId?: string | undefined;
681
691
  sessionId?: string | undefined;
692
+ claimedBy?: string | undefined;
693
+ consumedAt?: string | undefined;
682
694
  }, {
695
+ status: "READY" | "CLAIMED" | "EXECUTED" | "INVALIDATED" | "EXPIRED";
683
696
  id: string;
684
697
  version: number;
685
- title: string;
686
- state: "READY" | "ACTIVE" | "BLOCKED" | "DONE";
687
- blockId?: string | undefined;
688
- sourceRequestId?: string | undefined;
698
+ issuedBy: string;
699
+ issuedAt: string;
700
+ action: string;
701
+ target: string;
702
+ payload: string;
703
+ snapshot?: {
704
+ sourceId: string;
705
+ baseline: string;
706
+ }[] | undefined;
707
+ threadLastEventId?: string | undefined;
708
+ expiresAt?: string | undefined;
709
+ resultRef?: string | undefined;
710
+ requestId?: string | undefined;
689
711
  sessionId?: string | undefined;
712
+ singleUse?: boolean | undefined;
713
+ allowedWrites?: string[] | undefined;
714
+ claimedBy?: string | undefined;
715
+ consumedAt?: string | undefined;
690
716
  }>;
691
- export type QueueItem = z.infer<typeof QueueItem>;
717
+ export type ExecutionGrant = z.infer<typeof ExecutionGrant>;
692
718
  /** Phase B 처리 결과 — 부분 실패해도 cursor는 전진하고 실패분만 재시도한다 (OM §10.5). */
693
719
  export declare const EventProcessing: z.ZodEnum<["LOGGED", "PROCESSED", "PENDING_RETRY"]>;
694
720
  export type EventProcessing = z.infer<typeof EventProcessing>;
@@ -2,7 +2,7 @@
2
2
  // Core Contract다 — State Store Adapter는 이걸 저장 방식으로 투영할 뿐이다 (OM §7.0).
3
3
  // 스키마가 곧 타입의 정본이며 (z.infer로 타입 도출) 이중 정본을 두지 않는다.
4
4
  import { z } from 'zod';
5
- import { BlockId, EventKey, GrantId, QueueItemId, RequestId, SessionId, Timestamp, Version } from "./ids.js";
5
+ import { BlockId, EventKey, GrantId, RequestId, SessionId, Timestamp, Version } from "./ids.js";
6
6
  // ── 공통 ────────────────────────────────────────────────────────────────────
7
7
  /** 전이를 수행할 수 있는 주체. Writer 규칙(OM §7.2)과 상태 전이 권한의 기준. */
8
8
  export const ActorRole = z.enum(['controller', 'monitor', 'executor', 'session']);
@@ -193,7 +193,16 @@ export const GrantStatus = z.enum(['READY', 'CLAIMED', 'EXECUTED', 'INVALIDATED'
193
193
  export const ExecutionGrant = z.object({
194
194
  id: GrantId,
195
195
  version: Version,
196
- requestId: RequestId,
196
+ /**
197
+ * 이 계약의 근거. **둘 중 하나는 반드시 있다.**
198
+ *
199
+ * `requestId` 는 밖에서 들어온 판단 요청을 사람이 승인한 경우다 (OM §11.8 의 경로).
200
+ * `sessionId` 는 계약 안에서 일한 세션이 만든 결과를 사람이 내보내라고 한 경우다 —
201
+ * 초기 모델에는 이 자리가 없었고, 그래서 실제 작업이 밖으로 나갈 때마다 관계없는
202
+ * 판단 요청을 하나 지어내야 했다. 지어낸 요청은 승인 기록을 흐린다.
203
+ */
204
+ requestId: RequestId.optional(),
205
+ sessionId: SessionId.optional(),
197
206
  status: GrantStatus,
198
207
  issuedBy: z.string().min(1),
199
208
  issuedAt: Timestamp,
@@ -209,19 +218,17 @@ export const ExecutionGrant = z.object({
209
218
  claimedBy: z.string().optional(),
210
219
  consumedAt: Timestamp.optional(),
211
220
  resultRef: z.string().optional(),
221
+ })
222
+ .refine((grant) => Boolean(grant.requestId) !== Boolean(grant.sessionId), {
223
+ message: 'a grant stands on exactly one basis — an approved request or a session',
212
224
  });
213
- // ── QueueItem / MonitorEvent / State ────────────────────────────────────────
214
- export const QueueState = z.enum(['READY', 'ACTIVE', 'BLOCKED', 'DONE']);
215
- /** 승인되어 수행하기로 작업 (OM §4.8). inbox = 판단 대기, queue = 승인된 작업. */
216
- export const QueueItem = z.object({
217
- id: QueueItemId,
218
- version: Version,
219
- state: QueueState,
220
- title: z.string().min(1),
221
- sourceRequestId: RequestId.optional(),
222
- blockId: BlockId.optional(),
223
- sessionId: SessionId.optional(),
224
- });
225
+ // ── MonitorEvent / State ────────────────────────────────────────────────────
226
+ //
227
+ // 여기 있던 `QueueItem` 0.7.0 에서 지웠다. OM §1 원칙 10 이 나눈 "승인된 작업"
228
+ // 자리는 지금 `ApprovalRequest.QUEUED` 와 `asc proceed` 가 나눠 진다 — 전자가 사람이
229
+ // 하기로 한 사실을 들고, 후자가 그것을 세션으로 만든다. QueueItem 은 schema·layout·
230
+ // serializer 만 있고 만드는 코드도 읽는 코드도 없는 채 남아 있었고, 같은 사실을 두 곳에
231
+ // 두는 자리는 언젠가 갈린다.
225
232
  /** Phase B 처리 결과 — 부분 실패해도 cursor는 전진하고 실패분만 재시도한다 (OM §10.5). */
226
233
  export const EventProcessing = z.enum(['LOGGED', 'PROCESSED', 'PENDING_RETRY']);
227
234
  export const MonitorEvent = z.object({
@@ -1,4 +1,4 @@
1
- import type { ActorRole, ApprovalRequest, ExecutionGrant, MonitorEvent, QueueItem, Session } from './entities.ts';
1
+ import type { ActorRole, ApprovalRequest, ExecutionGrant, MonitorEvent, Session } from './entities.ts';
2
2
  export type TransitionRule<S extends string> = {
3
3
  from: S;
4
4
  to: S;
@@ -20,7 +20,5 @@ export declare const REQUEST_TRANSITIONS: readonly TransitionRule<ApprovalReques
20
20
  export declare function transitionRequest(request: ApprovalRequest, to: ApprovalRequest['status'], actor: ActorRole, patch?: Partial<Pick<ApprovalRequest, 'decision' | 'resultRef'>>): ApprovalRequest;
21
21
  export declare const GRANT_TRANSITIONS: readonly TransitionRule<ExecutionGrant['status']>[];
22
22
  export declare function transitionGrant(grant: ExecutionGrant, to: ExecutionGrant['status'], actor: ActorRole, patch?: Partial<Pick<ExecutionGrant, 'claimedBy' | 'consumedAt' | 'resultRef'>>): ExecutionGrant;
23
- export declare const QUEUE_TRANSITIONS: readonly TransitionRule<QueueItem['state']>[];
24
- export declare function transitionQueueItem(item: QueueItem, to: QueueItem['state'], actor: ActorRole, patch?: Partial<Pick<QueueItem, 'sessionId' | 'blockId'>>): QueueItem;
25
23
  export declare const EVENT_TRANSITIONS: readonly TransitionRule<MonitorEvent['processing']>[];
26
24
  export declare function transitionEvent(event: MonitorEvent, to: MonitorEvent['processing'], actor: ActorRole, patch?: Partial<Pick<MonitorEvent, 'requestId'>>): MonitorEvent;
@@ -96,19 +96,6 @@ export function transitionGrant(grant, to, actor, patch = {}) {
96
96
  }
97
97
  return next;
98
98
  }
99
- // ── QueueItem (OM §4.8) — Controller ONLY ───────────────────────────────────
100
- export const QUEUE_TRANSITIONS = [
101
- { from: 'READY', to: 'ACTIVE', actors: ['controller'] },
102
- { from: 'READY', to: 'BLOCKED', actors: ['controller'] },
103
- { from: 'ACTIVE', to: 'BLOCKED', actors: ['controller'] },
104
- { from: 'ACTIVE', to: 'DONE', actors: ['controller'] },
105
- { from: 'BLOCKED', to: 'READY', actors: ['controller'] },
106
- { from: 'BLOCKED', to: 'ACTIVE', actors: ['controller'] },
107
- ];
108
- export function transitionQueueItem(item, to, actor, patch = {}) {
109
- resolve(QUEUE_TRANSITIONS, item.state, to, actor);
110
- return { ...item, ...patch, state: to, version: item.version + 1 };
111
- }
112
99
  // ── MonitorEvent (OM §10.5) ─────────────────────────────────────────────────
113
100
  // Phase B 실패는 그 이벤트만 PENDING_RETRY로 남는다 — cursor는 전진하고 다음 Run이
114
101
  // 실패분만 재시도한다.
@@ -7,7 +7,15 @@
7
7
  // 별도 경로(B-06)로 나간다 — Agent가 조회하다가 이어서 승인해버릴 수 있는 문을 만들지 않는다 (C-01 §5).
8
8
  import { assembleView, assess, buildOverlay, summarize } from "../view/build-view.js";
9
9
  /** 아직 사람의 판단을 기다리는 상태. 목록의 기본 필터다. */
10
- const PENDING = new Set(['AWAITING_APPROVAL', 'APPROVED']);
10
+ /**
11
+ * 아직 사람의 손을 떠나지 않은 것들.
12
+ *
13
+ * `QUEUED` 가 여기 있는 이유 (0.7.0 / Phase L): 그것은 **승인된 작업**이다. 사람이
14
+ * "하자" 고 정했고 아직 실행되지 않았다. 그런데 목록에서 빠져 있어서, `queue` 로 결정한
15
+ * 순간 그 항목이 화면에서 사라졌다 — 사람이 방금 하기로 한 일이 어디에도 보이지 않는다.
16
+ * OM §1 원칙 10 이 Inbox 와 Queue 를 나눈 것은 그것을 잃지 않기 위해서였다.
17
+ */
18
+ const PENDING = new Set(['AWAITING_APPROVAL', 'APPROVED', 'QUEUED']);
11
19
  export class LocalOperator {
12
20
  #store;
13
21
  #scm;
@@ -250,11 +250,21 @@ function handout(session) {
250
250
  ...(session.checkpoint ? { checkpoint: session.checkpoint } : {}),
251
251
  };
252
252
  }
253
- /** 세션을 내지 않을 때, 대신 무엇을 하면 되는가. 실행하지 않고 말만 한다. */
253
+ /**
254
+ * 세션을 내지 않을 때, 대신 무엇을 하면 되는가. 실행하지 않고 말만 한다.
255
+ *
256
+ * **행동은 결론보다, 결론은 증거보다 강할 수 없다** (0.7.0 / C-4). 판정이 기울기만
257
+ * 남겼는데 행동을 확정형으로 적으면, 못 본 것이 있다고 적어 놓고 "할 일은 이것뿐" 이라고
258
+ * 말하는 결과가 된다 — 사람은 뒤 문장을 읽는다.
259
+ */
254
260
  function nextActionFor(result, workRef) {
255
- switch (result.state === 'DECIDABLE_WITH_LIMITATION' ? (result.leaning ?? 'UNDECIDABLE') : result.state) {
261
+ const tentative = result.state === 'DECIDABLE_WITH_LIMITATION';
262
+ switch (tentative ? (result.leaning ?? 'UNDECIDABLE') : result.state) {
256
263
  case 'IMPLEMENTED_STALE_TRACKER':
257
- return `구현은 정본에 있다 — 할 일은 구현이 아니라 ${workRef} 상태 정리다. 추적 시스템 반영은 외부 쓰기이므로 승인 경로(Grant)를 지난다`;
264
+ return tentative
265
+ ? `구현이 정본에 있는 것으로 보인다. 다만 확인하지 못한 것이 있다 — ${result.limitations.join(' / ')}. ` +
266
+ `그것부터 확인하고, 그 뒤에도 남는 것이 상태뿐이면 ${workRef} 상태 정리로 간다`
267
+ : `구현은 정본에 있다 — 할 일은 구현이 아니라 ${workRef} 상태 정리다. 추적 시스템 반영은 외부 쓰기이므로 승인 경로(Grant)를 지난다`;
258
268
  case 'IMPLEMENTATION_COMPLETE_BLOCKED_VERIFICATION':
259
269
  return '구현은 끝났고 검증이 막혀 있다 — 막힌 것을 먼저 풀어라. 새 구현 세션은 필요 없다';
260
270
  case 'BLOCKED_DEPENDENCY':
@@ -50,7 +50,16 @@ export function judgeWorkState(input) {
50
50
  // 언급은 그 자체로 증거가 아니다. 되돌리기만 있는 이력도 이 작업을 "언급"하고, 뒤이어
51
51
  // 걷혀 나간 변경도 그렇다. 살아남은 것이 있어야 정본에 있다고 말할 수 있다.
52
52
  const mentionSurvives = mentioned.length > 0 && repo.mentionedOnlyReverts !== true && repo.mentionedArtifactsPresent === true;
53
- const directEvidence = repo.mergedIntoCanonical === true || onCanonical.length > 0 || repo.contentEquivalent === true;
53
+ // **파일이 있다는 것은 요구가 구현됐다는 뜻이 아니다** (0.7.0 / D-01).
54
+ //
55
+ // 경로는 작업 항목 본문에서 뽑아 온다. 그런데 그 경로가 가리키는 파일은 대개 이미
56
+ // 있던 것이고, 이번 요구는 그 **안에** 무언가를 더하는 일이다. 실측에서 그 구분이
57
+ // 없어 "WorldHud.tsx 가 정본에 있다" 가 "요구 4항이 구현됐다" 로 읽혔고, 아직 한 줄도
58
+ // 쓰지 않은 작업에 "구현하지 말고 tracker 만 정리하라" 는 추천이 나갔다.
59
+ //
60
+ // 그래서 경로 존재는 **보강 증거**다 — 정본이 이 작업의 변경을 실제로 담고 있다는 것을
61
+ // 다른 근거가 말할 때 그 옆에 선다. 혼자서는 아무 결론도 만들지 않는다.
62
+ const directEvidence = repo.mergedIntoCanonical === true || repo.contentEquivalent === true;
54
63
  const merged = directEvidence || mentionSurvives;
55
64
  const hasBranch = repo.refs.length > 0;
56
65
  const artifacts = Object.entries(repo.pathsExist).filter(([, exists]) => exists);
@@ -63,10 +72,12 @@ export function judgeWorkState(input) {
63
72
  if (repo.contentEquivalent === true) {
64
73
  evidence.push('작업 가지의 내용이 전부 정본에 반영돼 있다 (조상은 아니다 — rebase·squash 등가)');
65
74
  }
66
- if (onCanonical.length > 0)
67
- evidence.push(`정본에 산출물이 있다: ${onCanonical.map(([p]) => p).join(', ')}`);
68
- if (artifacts.length > 0)
69
- evidence.push(`작업 트리 산출물: ${artifacts.map(([p]) => p).join(', ')}`);
75
+ if (onCanonical.length > 0) {
76
+ evidence.push(`정본에 관련 파일이 있다: ${onCanonical.map(([p]) => p).join(', ')} (그 파일이 있다는 사실이지, 이 요구가 반영됐다는 증거는 아니다)`);
77
+ }
78
+ if (artifacts.length > 0) {
79
+ evidence.push(`작업 트리에 관련 파일이 있다: ${artifacts.map(([p]) => p).join(', ')}`);
80
+ }
70
81
  if (mentioned.length > 0)
71
82
  evidence.push(`정본 이력이 이 작업을 언급한다: ${mentioned.join(' / ')}`);
72
83
  if (repo.mentionedOnlyReverts === true) {
@@ -89,10 +100,14 @@ export function judgeWorkState(input) {
89
100
  if (unknownDependencies.length > 0) {
90
101
  limitations.push(`선행 작업 상태를 확인하지 못했다: ${unknownDependencies.map((d) => d.reference).join(', ')}`);
91
102
  }
92
- const implemented = merged || artifacts.length > 0;
103
+ // 구현이 있다고 말할 있는 근거는 정본이 이 작업의 변경을 담고 있다는 것뿐이다.
104
+ // 파일의 존재(정본이든 작업 트리든)는 여기 들어오지 않는다 — 위 주석의 이유다.
105
+ const implemented = merged;
106
+ // 파일이 있다는 사실은 등급에도 들어가지 않는다 — 구현 증거가 아닌 것을 약한 구현
107
+ // 증거로 세면 결국 같은 오판이 한 칸 낮은 자리에서 다시 난다.
93
108
  const evidenceGrade = directEvidence
94
109
  ? 'direct'
95
- : mentionSurvives || artifacts.length > 0
110
+ : mentionSurvives
96
111
  ? 'proxy'
97
112
  : 'none';
98
113
  // ① 구현은 정본에 있는데 tracker 가 안 따라왔다. 여기서만 tracker 를 본다 — 그것도
@@ -101,26 +116,34 @@ export function judgeWorkState(input) {
101
116
  // 되돌리기를 가려낼 수 없다. 확인하지 못했으면 확정하지 않는다.
102
117
  // 가지가 정본의 조상이라는 것은 그 커밋들이 지금 정본 이력에 그대로 있다는 뜻이라
103
118
  // 그 자체가 생존 증거다. 언급(grep)만 있는 경우와 다르다.
104
- const artifactSurvives = repo.mergedIntoCanonical === true || onCanonical.length > 0 || repo.mentionedArtifactsPresent === true;
119
+ const artifactSurvives = repo.mergedIntoCanonical === true || repo.mentionedArtifactsPresent === true;
105
120
  if (merged && input.trackerDone === false && artifactSurvives && repo.mentionedOnlyReverts !== true) {
106
- limitations.push('인수 조건 전체가 지금도 충족되는지는 확인하지 않았다 여기서 말하는 것은 구현의 생존까지다');
121
+ // **이 결론이 시키는 일은 "구현하지 말고 상태만 정리하라" 다.** 그러니 근거도 그만큼
122
+ // 무거워야 한다 (0.7.0 / C-4). 구현이 정본에 살아 있다는 것까지는 위에서 봤고,
123
+ // 남은 질문은 하나다 — 그 구현이 **받아들여졌는가**.
124
+ //
125
+ // 팀에 따라 코드가 정본에 있어도 tracker 를 일부러 열어 둔다(실 acceptance 가 남아서).
126
+ // 그런 자리에 "상태 정리만 하면 된다" 고 말하면 규약을 어기게 만든다. 그래서 검토가
127
+ // 끝났다고 말해 주는 근거가 없으면 확정하지 않고 기울기만 남긴다.
128
+ const accepted = acceptedByReview(input.change);
129
+ if (!accepted) {
130
+ limitations.push('구현이 받아들여졌는지 확인하지 못했다 — 인수가 남아 있다면 지금 할 일은 상태 정리가 아니다');
131
+ }
107
132
  // 측정된 반증은 언급-생존보다 무겁다: cherry 가 "가지에 정본 미반영 커밋이 남아
108
133
  // 있다"고 말했으면, 언급 grep 만으로 "할 일은 상태 정리"를 확정하지 않는다.
109
134
  if (repo.contentEquivalent === false) {
110
135
  limitations.push('작업 가지에 정본에 반영되지 않은 커밋이 남아 있다 (patch 대조) — 상태 정리만 남았다고 확정하지 않는다');
111
136
  return decided('IMPLEMENTED_STALE_TRACKER', evidence, limitations, { demote: true, grade: evidenceGrade });
112
137
  }
113
- return decided('IMPLEMENTED_STALE_TRACKER', evidence, limitations, { demote: false, grade: evidenceGrade });
138
+ if (accepted)
139
+ evidence.push(`검토가 끝났다고 표시돼 있다: ${accepted}`);
140
+ return decided('IMPLEMENTED_STALE_TRACKER', evidence, limitations, { demote: !accepted, grade: evidenceGrade });
114
141
  }
115
142
  if (merged && input.trackerDone === false) {
116
143
  // 병합 흔적은 있는데 생존을 확인하지 못했다 — 새 구현을 시키지도, 끝났다고 하지도 않는다.
117
144
  limitations.push('정본에 병합 흔적은 있으나 구현이 지금도 남아 있는지 확인하지 못했다');
118
145
  return decided('IMPLEMENTATION_COMPLETE_BLOCKED_VERIFICATION', evidence, limitations, { demote: true, grade: evidenceGrade });
119
146
  }
120
- // ② 구현 증거는 있는데 남은 검증 경로가 막혔다.
121
- if (implemented && input.change === 'UNAVAILABLE' && !merged) {
122
- return decided('IMPLEMENTATION_COMPLETE_BLOCKED_VERIFICATION', evidence, limitations, { demote: true, grade: evidenceGrade });
123
- }
124
147
  // ③ 가지는 있는데 병합 전이고 선행 작업이 열려 있다.
125
148
  if (hasBranch && !merged && openDependencies.length > 0) {
126
149
  return decided('BLOCKED_DEPENDENCY', evidence, limitations, { demote: false, grade: evidenceGrade });
@@ -153,6 +176,21 @@ export function judgeWorkState(input) {
153
176
  // 구현 증거는 있는데 위 어디에도 안 걸린다 — 남은 것은 검증이고, 무엇이 막혔는지는 모른다.
154
177
  return decided('IMPLEMENTATION_COMPLETE_BLOCKED_VERIFICATION', evidence, limitations, { demote: true, grade: evidenceGrade });
155
178
  }
179
+ /**
180
+ * 검토가 끝났다고 말해 주는 표시가 있는가. 있으면 그 표시를 그대로 돌려준다.
181
+ *
182
+ * provider 어휘를 Core 가 읽는 자리는 최소로 둔다 — `requestsResponse` 가 이미 같은
183
+ * 방식으로 두 낱말을 보고 있고, 여기서 보는 것도 두 낱말뿐이다. 모르면 `null` 이며,
184
+ * 모른다는 것이 "받아들여졌다" 로 읽히지 않는 것이 이 함수의 요점이다.
185
+ */
186
+ function acceptedByReview(change) {
187
+ if (!change || change === 'UNAVAILABLE' || change.missing)
188
+ return null;
189
+ const state = change.reviewState?.toUpperCase() ?? '';
190
+ if (state.includes('APPROVED') || state.includes('MERGED'))
191
+ return change.reviewState;
192
+ return null;
193
+ }
156
194
  /** 검토가 응답을 요구하는가. provider 어휘를 해석하지 않고 두 가지 표시만 본다. */
157
195
  function requestsResponse(change, comments) {
158
196
  const state = change.reviewState?.toUpperCase() ?? '';
@@ -4,6 +4,18 @@ import type { TickKind } from './orchestrator.ts';
4
4
  export declare const RUNTIME_LEASE_KEY = "runtime-lease";
5
5
  /** 마지막 회차 시각이 사는 열쇠. Orchestrator가 쓰고 status가 읽는다. */
6
6
  export declare const LAST_RUN_KEY = "last-run";
7
+ /**
8
+ * 마지막 기계 회차가 이 workspace 에서 **어떻게 끝났는가** (0.7.0 / Phase J).
9
+ *
10
+ * 실측 하나가 이 열쇠의 출처다. 이 기계의 workspace 세 개 중 둘이 여러 릴리스 동안
11
+ * 회차를 한 번도 통과하지 못했는데, 화면 어디에도 그 말이 없었다 — 붙어 있고, 등록물도
12
+ * 서 있고, 그래서 건강해 보였다. 실패는 `service.log` 에만 흘러갔고 아무도 그것을 읽지
13
+ * 않는다.
14
+ *
15
+ * 새 상태 저장소가 아니다. 이미 회차가 쓰고 status 가 읽는 같은 scope 의 기록 하나이며,
16
+ * 판정도 하지 않는다 — 무슨 일이 있었는지만 적는다.
17
+ */
18
+ export declare const LAST_PASS_KEY = "last-pass";
7
19
  /**
8
20
  * lease가 죽은 것으로 보이기까지의 최소 시간.
9
21
  *
@@ -75,13 +87,34 @@ export declare class RuntimeLease {
75
87
  /** 내 것일 때만 놓는다. 남이 이미 잡았으면 건드리지 않는다. */
76
88
  release(): Promise<void>;
77
89
  }
90
+ /** 마지막 기계 회차가 이 workspace 에서 어떻게 끝났는가. */
91
+ export type PassRecord = {
92
+ at: string;
93
+ /** 회차 프로세스의 종료 코드. 0 이 아니면 그 회차는 이 workspace 를 돌지 못했다. */
94
+ code: number;
95
+ /** 연속 실패 횟수. 한 번 삐끗한 것과 계속 안 되는 것은 다른 사실이다. */
96
+ consecutiveFailures: number;
97
+ /** 마지막으로 통과한 시각. 한 번도 없으면 비어 있다. */
98
+ lastOkAt?: string;
99
+ };
78
100
  export type BackgroundStatus = {
79
101
  lease: LeaseState;
80
102
  /** 갈래별 마지막 실행 시각. 없는 갈래는 한 번도 돌지 않았다. */
81
103
  lastRun: Partial<Record<TickKind, string>>;
104
+ /** 마지막 기계 회차의 결말. 한 번도 돌지 않았으면 없다. */
105
+ lastPass?: PassRecord;
82
106
  /** 회수 기준. 사람이 "얼마나 조용하면 죽은 것인가"를 알아야 판단할 수 있다. */
83
107
  staleMs: number;
84
108
  };
109
+ /**
110
+ * 회차 결말을 적는다. **판정하지 않는다** — 코드와 연속 실패 수를 세어 둘 뿐이다.
111
+ *
112
+ * 이전 기록을 읽어 이어 세므로, 성공 한 번이 실패의 역사를 지운다. 그것이 맞다:
113
+ * 지금 돌고 있다면 사람이 볼 것은 지금이다.
114
+ */
115
+ export declare function recordPass(scope: ScopedStore, code: number, at?: string): Promise<PassRecord>;
116
+ /** 붙어 있다는 것과 돌고 있다는 것은 다른 사실이다. 무엇이 어긋났는지 한 줄로 말한다. */
117
+ export declare function passLine(record: PassRecord | undefined): string | null;
85
118
  /** 상태를 모은다. **읽기만 한다** — 보는 것이 상태를 바꾸면 아무도 못 본다. */
86
119
  export declare function readBackground(scope: ScopedStore, staleMs: number, now?: () => string): Promise<BackgroundStatus>;
87
120
  /**