@asc-agent/runtime 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +25 -25
  2. package/dist/adapters/claude-code/skill.js +245 -239
  3. package/dist/adapters/gitlab/adapter.d.ts +3 -0
  4. package/dist/adapters/gitlab/adapter.js +22 -3
  5. package/dist/adapters/gitlab/client.d.ts +27 -1
  6. package/dist/adapters/gitlab/client.js +30 -0
  7. package/dist/adapters/gitlab/ports.d.ts +3 -3
  8. package/dist/adapters/jam/adapter.d.ts +14 -0
  9. package/dist/adapters/jam/adapter.js +84 -6
  10. package/dist/adapters/jam/ports.d.ts +9 -0
  11. package/dist/adapters/jam/ports.js +49 -2
  12. package/dist/adapters/local/repo.d.ts +15 -0
  13. package/dist/adapters/local/repo.js +179 -0
  14. package/dist/adapters/markdown/state-store.js +13 -1
  15. package/dist/adapters/memory/state-store.js +4 -0
  16. package/dist/cli/asc.js +452 -4
  17. package/dist/composition/propose.d.ts +19 -0
  18. package/dist/composition/propose.js +35 -0
  19. package/dist/composition/runtime.d.ts +2 -0
  20. package/dist/composition/runtime.js +41 -5
  21. package/dist/core/attach/init.d.ts +17 -0
  22. package/dist/core/attach/init.js +28 -0
  23. package/dist/core/distribution/release.d.ts +3 -3
  24. package/dist/core/distribution/release.js +1 -1
  25. package/dist/core/monitor/investigation.d.ts +16 -0
  26. package/dist/core/monitor/investigation.js +18 -9
  27. package/dist/core/operator/contract-draft.d.ts +124 -0
  28. package/dist/core/operator/contract-draft.js +234 -0
  29. package/dist/core/operator/derive-draft.d.ts +37 -0
  30. package/dist/core/operator/derive-draft.js +216 -0
  31. package/dist/core/operator/proceed.d.ts +79 -0
  32. package/dist/core/operator/proceed.js +123 -1
  33. package/dist/core/operator/work-state.d.ts +54 -0
  34. package/dist/core/operator/work-state.js +145 -0
  35. package/dist/core/runtime/closure.d.ts +2 -2
  36. package/dist/ports/local-repo.d.ts +61 -0
  37. package/dist/ports/local-repo.js +9 -0
  38. package/dist/ports/resource-context.d.ts +7 -0
  39. package/dist/schemas/profile.d.ts +2 -2
  40. package/package.json +2 -2
@@ -0,0 +1,234 @@
1
+ // Session Contract Drafting — 자연어 업무 요청과 세션 계약 사이의 빈 칸 (C-04 · C-13).
2
+ //
3
+ // **왜 필요한가**: ASC는 완성된 계약을 실행·통제하는 데는 강한데, 사람이 "ABC-123 구현해"
4
+ // 라고 말한 것을 계약으로 바꾸는 자리에 규약이 없었다. 그래서 두 가지 중 하나가 일어났다 —
5
+ // agent가 goal·boundary·criteria를 통째로 지어내거나(fail-closed 위반), 아니면 넷을 전부
6
+ // 사람에게 입력하라고 되물었다(자율성 포기). 둘 다 틀렸다.
7
+ //
8
+ // **여기서 하는 일과 하지 않는 일**:
9
+ // 한다 — agent가 만든 초안을 **검증**한다. 무엇이 사실이고 무엇이 추론인지 갈라 두고,
10
+ // 사람만 정할 수 있는 것이 남았는지 판정한다.
11
+ // 안 한다 — 초안을 만들지 않는다. 계약을 발급하지 않는다. LLM을 품지 않는다.
12
+ // 해석은 coding agent가 하고, 이 모듈은 그 결과를 구조·정책으로 잰다.
13
+ //
14
+ // 판정 로직을 새로 만들지 않는다. 경로 축은 preflight, 책임 축은 ownership, 문법은 scope,
15
+ // 사람에게 넘길 사유는 escalation predicate — 전부 이미 있는 것을 부른다.
16
+ import { z } from 'zod';
17
+ import { EscalationPredicate } from "../runtime/escalation.js";
18
+ import { SessionRole } from "../model/entities.js";
19
+ import { SessionId } from "../model/ids.js";
20
+ import { isWithinScopes, parseScope } from "../policy/scope.js";
21
+ import { lookupOwnerByPaths } from "../policy/ownership.js";
22
+ import { preflight } from "./preflight.js";
23
+ /** 값이 어디서 왔는지. 지어낸 것과 읽어 온 것을 문자열 하나로 구분한다. */
24
+ // provider 이름을 적지 않는다 (C-09 §6.1). 어느 tracker의 항목인지는 Adapter가 알고,
25
+ // 계약 초안이 아는 것은 "추적 항목에서 읽었다"까지다.
26
+ export const DRAFT_SOURCES = ['user', 'work_item', 'profile', 'repository', 'canonical', 'agent_proposal'];
27
+ /** 초안의 출처 한 줄. CLI가 받는 문자열도 이 스키마로 통과시켜야 들어온다. */
28
+ export const DraftProvenance = z.object({
29
+ field: z.enum(['id', 'role', 'goal', 'boundary', 'criteria', 'owner']),
30
+ status: z.enum(['FACT', 'PROPOSAL', 'DECISION_REQUIRED']),
31
+ source: z.enum(DRAFT_SOURCES),
32
+ /** 왜 이 값인가. PROPOSAL이면 반드시 있어야 한다 — 근거 없는 제안은 추측이다. */
33
+ reason: z.string().min(1).optional(),
34
+ });
35
+ /**
36
+ * 발급 권한은 **사람의 것이다** (OM §450). 다만 그 문장이 "매번 사람이 직접 쳐야 한다"는
37
+ * 뜻은 아니다 — Controller가 **범위를 정해 위임**할 수 있고, 그 범위 안에서만 agent가
38
+ * 스스로 발급한다. 위임이 없으면 계약이 완성돼도 발급하지 않고 사람에게 넘긴다.
39
+ *
40
+ * 위임은 Profile/Override의 `policy.unionLists.issuanceDelegation` 에 **역할 이름**으로
41
+ * 적는다 (closureChecklist와 같은 관례 키 자리). 경로 범위는 이미 `roleScopes` 가 좁히고
42
+ * 있으므로 여기서 다시 정의하지 않는다 — 권한을 두 곳에 적으면 둘이 갈라진다.
43
+ */
44
+ export const ISSUANCE_DELEGATION_KEY = 'issuanceDelegation';
45
+ const provenanceOf = (draft, field) => draft.provenance?.find((entry) => entry.field === field);
46
+ /**
47
+ * 초안을 잰다. **아무것도 쓰지 않는다** — 순수 함수이고, 같은 입력이면 같은 답이다.
48
+ *
49
+ * 판정 순서에 뜻이 있다: 먼저 구조(문법·필수값)를 보고, 그 다음 경계(범위·책임)를 본다.
50
+ * 구조가 깨진 초안의 경계를 논하는 것은 의미가 없고, 사람에게 두 번 묻게 만든다.
51
+ */
52
+ export function planSessionContract(input) {
53
+ const { draft, policy, ownership, existingIds } = input;
54
+ const facts = [];
55
+ const proposals = [];
56
+ const unresolved = [];
57
+ const invalid = [];
58
+ const classify = (field, present) => {
59
+ if (!present)
60
+ return;
61
+ const entry = provenanceOf(draft, field);
62
+ if (!entry) {
63
+ // 출처를 적지 않은 값은 사실로 세지 않는다. 어디서 왔는지 모르는 값이
64
+ // 계약에 박히면, 나중에 그것이 틀렸을 때 무엇을 되짚어야 하는지 아무도 모른다.
65
+ proposals.push({ field, status: 'PROPOSAL', source: 'agent_proposal', reason: 'source not declared' });
66
+ return;
67
+ }
68
+ if (entry.status === 'FACT')
69
+ facts.push(entry);
70
+ else if (entry.status === 'PROPOSAL')
71
+ proposals.push(entry);
72
+ else
73
+ unresolved.push({
74
+ field,
75
+ reason: 'explicit_rule_requires_approval',
76
+ detail: entry.reason ?? `${field} is marked as a decision for a person`,
77
+ });
78
+ };
79
+ // ── 구조 ────────────────────────────────────────────────────────────────
80
+ if (draft.id === undefined) {
81
+ unresolved.push({
82
+ field: 'id',
83
+ reason: 'missing_input',
84
+ detail: 'No session id. Use the work item key the person named, or the task id the canonical source ties to' +
85
+ ' this work. Do not spend a real issue key on a setup check.',
86
+ });
87
+ }
88
+ else if (!SessionId.safeParse(draft.id).success) {
89
+ invalid.push({ field: 'id', detail: `'${draft.id}' is not a session id — expected S-YYYYMMDD-NN` });
90
+ }
91
+ else if (existingIds?.includes(draft.id)) {
92
+ invalid.push({ field: 'id', detail: `session ${draft.id} already exists` });
93
+ }
94
+ classify('id', draft.id !== undefined);
95
+ if (draft.role === undefined) {
96
+ unresolved.push({ field: 'role', reason: 'missing_input', detail: 'No role. Which part of the work is this?' });
97
+ }
98
+ else if (!SessionRole.safeParse(draft.role).success) {
99
+ invalid.push({ field: 'role', detail: `'${draft.role}' is not a role — expected one of ${SessionRole.options.join(', ')}` });
100
+ }
101
+ classify('role', draft.role !== undefined);
102
+ if (!draft.goal || draft.goal.trim() === '') {
103
+ unresolved.push({
104
+ field: 'goal',
105
+ reason: 'missing_input',
106
+ detail: 'No goal. Take it from what the person asked for, or from the requirement the work item states.',
107
+ });
108
+ }
109
+ classify('goal', Boolean(draft.goal && draft.goal.trim() !== ''));
110
+ for (const entry of draft.boundary ?? []) {
111
+ if (parseScope(entry) === null)
112
+ invalid.push({ field: 'boundary', detail: `'${entry}' is not valid ASC scope grammar` });
113
+ }
114
+ if ((draft.boundary ?? []).length === 0) {
115
+ unresolved.push({
116
+ field: 'boundary',
117
+ reason: 'missing_input',
118
+ detail: 'No write boundary. Propose the narrowest set that covers the work — a boundary is what the session may' +
119
+ ' write, not what it may read.',
120
+ });
121
+ }
122
+ classify('boundary', (draft.boundary ?? []).length > 0);
123
+ if ((draft.criteria ?? []).length === 0) {
124
+ unresolved.push({
125
+ field: 'criteria',
126
+ reason: 'missing_input',
127
+ detail: 'No done-criteria. Collect them from the acceptance the work item states, the canonical spec, or the' +
128
+ ' checks this repository already runs. Do not invent new product acceptance.',
129
+ });
130
+ }
131
+ classify('criteria', (draft.criteria ?? []).length > 0);
132
+ classify('owner', draft.owner !== undefined);
133
+ // ── 경계 ────────────────────────────────────────────────────────────────
134
+ // 구조가 깨졌으면 여기서 멈춘다. 틀린 초안의 범위를 따져도 답이 두 번 바뀔 뿐이다.
135
+ const role = draft.role !== undefined && SessionRole.safeParse(draft.role).success ? draft.role : undefined;
136
+ const boundary = draft.boundary ?? [];
137
+ let report;
138
+ if (invalid.length === 0 && role && boundary.length > 0) {
139
+ const maxScope = policy?.roleScopes[role];
140
+ for (const entry of boundary) {
141
+ if (maxScope && !isWithinScopes(entry, maxScope)) {
142
+ // 범위를 넓혀 해소하지 않는다 (preflight와 같은 자세). 이건 사람의 경계다.
143
+ unresolved.push({
144
+ field: 'boundary',
145
+ reason: 'ownership_boundary',
146
+ detail: `${role} may not write '${entry}' — outside ${maxScope.join(', ')}`,
147
+ options: [`narrow the boundary to ${maxScope.join(', ')}`, 'hand this part to the role that owns it', 'ask for the scope to be widened'],
148
+ recommended: 0,
149
+ });
150
+ }
151
+ }
152
+ report = preflight({
153
+ paths: boundary,
154
+ target: {
155
+ kind: 'session',
156
+ // 아직 발급하지 않았다. preflight는 이 id를 조회하지 않고 문장에만 쓴다.
157
+ sessionId: draft.id ?? '(draft)',
158
+ role,
159
+ writeBoundary: boundary,
160
+ ...(draft.owner ? { owner: draft.owner } : {}),
161
+ ...(draft.decisionDomains ? { decisionDomains: draft.decisionDomains } : {}),
162
+ ...(draft.decisionAuthority ? { decisionAuthority: draft.decisionAuthority } : {}),
163
+ },
164
+ ...(policy ? { policy } : {}),
165
+ ...(ownership ? { ownership } : {}),
166
+ });
167
+ for (const mismatch of report.mismatches) {
168
+ if (mismatch.verdict !== 'OWNERSHIP_MISMATCH')
169
+ continue;
170
+ unresolved.push({
171
+ field: 'boundary',
172
+ reason: 'ownership_boundary',
173
+ detail: `'${mismatch.path}' is inside the boundary but outside ${draft.owner}'s declared paths`,
174
+ });
175
+ }
176
+ for (const gap of report.authorityGaps) {
177
+ unresolved.push({
178
+ field: 'owner',
179
+ reason: 'ownership_boundary',
180
+ detail: gap.lookup.kind === 'AMBIGUOUS'
181
+ ? `more than one part claims '${gap.domain}': ${gap.lookup.candidates.join(', ')}`
182
+ : `no part declares authority over '${gap.domain}'`,
183
+ ...(gap.lookup.kind === 'AMBIGUOUS'
184
+ ? { options: [...gap.lookup.candidates], recommended: undefined }
185
+ : {}),
186
+ });
187
+ }
188
+ // owner를 적지 않았는데 경계로 주인을 특정할 수 있으면, 그것은 질문이 아니라 제안이다.
189
+ if (!draft.owner && ownership) {
190
+ const owner = lookupOwnerByPaths(ownership, boundary);
191
+ if (owner.kind === 'RESOLVED') {
192
+ proposals.push({ field: 'owner', status: 'PROPOSAL', source: 'profile', reason: `boundary falls inside ${owner.role}` });
193
+ }
194
+ else if (owner.kind === 'AMBIGUOUS') {
195
+ unresolved.push({
196
+ field: 'owner',
197
+ reason: 'multiple_options',
198
+ detail: 'more than one part covers this boundary',
199
+ options: [...owner.candidates],
200
+ recommended: 0,
201
+ });
202
+ }
203
+ }
204
+ }
205
+ const status = invalid.length > 0 ? 'INVALID' : unresolved.length > 0 ? 'NEEDS_DECISION' : 'READY_TO_ISSUE';
206
+ // Profile은 `policy.unionLists` 로 선언하고, 계층 병합이 끝나면 `lists` 로 온다.
207
+ const delegatedRoles = [...(policy?.lists?.[ISSUANCE_DELEGATION_KEY] ?? [])];
208
+ const delegated = role !== undefined && delegatedRoles.includes(role);
209
+ const issuance = {
210
+ authority: delegated ? 'delegated' : 'controller',
211
+ delegatedRoles,
212
+ detail: delegated
213
+ ? `the Controller delegated issuance for ${role}`
214
+ : delegatedRoles.length > 0
215
+ ? `issuance is delegated for ${delegatedRoles.join(', ')} — not for ${role ?? '(no role)'}`
216
+ : 'issuance belongs to the Controller; no role has been delegated',
217
+ };
218
+ return { status, draft, facts, proposals, unresolved, issuance, invalid, ...(report ? { preflight: report } : {}) };
219
+ }
220
+ /** 발급 명령의 인자. plan이 통과했을 때만 부른다 — 통과하지 않은 초안은 명령이 되지 않는다. */
221
+ export function issueArgs(draft) {
222
+ const args = ['session', 'issue', draft.id ?? '<ID>', '--role', draft.role ?? '<role>', '--goal', draft.goal ?? '<goal>'];
223
+ for (const entry of draft.boundary ?? [])
224
+ args.push('--boundary', entry);
225
+ for (const entry of draft.criteria ?? [])
226
+ args.push('--criteria', entry);
227
+ if (draft.owner)
228
+ args.push('--owner', draft.owner);
229
+ for (const domain of draft.decisionDomains ?? [])
230
+ args.push('--domain', domain);
231
+ for (const [domain, holder] of Object.entries(draft.decisionAuthority ?? {}))
232
+ args.push('--authority', `${domain}=${holder}`);
233
+ return args;
234
+ }
@@ -0,0 +1,37 @@
1
+ import type { RepoObservation } from '../../ports/local-repo.ts';
2
+ import type { ResourceSnapshot } from '../../ports/resource-context.ts';
3
+ import type { OwnershipMap } from '../policy/ownership.ts';
4
+ import type { SessionContractDraft } from './contract-draft.ts';
5
+ import type { WorkStateResult } from './work-state.ts';
6
+ export type DeriveInput = {
7
+ intent: {
8
+ /** 사람이 지목한 작업 항목 키. */
9
+ workRef: string;
10
+ /** 사람이 직접 말한 목표가 있으면. 없으면 작업 항목에서 읽는다. */
11
+ goal?: string;
12
+ role?: string;
13
+ };
14
+ workItem: ResourceSnapshot;
15
+ workState: WorkStateResult;
16
+ repo: RepoObservation | 'MISSING';
17
+ /**
18
+ * 이 역할이 **가질 수 있는 최대 범위** (Profile roleScopes).
19
+ *
20
+ * 여기서 boundary 를 만들지 않는다 — 상한과 이번 작업의 쓰기 범위는 다른 것이다.
21
+ * "전체까지 허용될 수 있다"가 "이번 작업이 전체를 고친다"를 뜻하지 않는다. 이 값은
22
+ * 도출한 후보가 상한을 넘지 않는지 **재는 데만** 쓴다.
23
+ */
24
+ maxScopes?: readonly string[];
25
+ ownership?: OwnershipMap;
26
+ existingIds?: readonly string[];
27
+ /** YYYYMMDD. 세션 id 는 날짜를 담는다 (S-YYYYMMDD-NN). */
28
+ today: string;
29
+ /** 저장소가 이미 돌리는 검사들 (예: 'npm test'). 검증 기준의 근거가 된다. */
30
+ repoChecks?: readonly string[];
31
+ };
32
+ export declare function deriveSessionContractDraft(input: DeriveInput): SessionContractDraft;
33
+ /** 작업 항목이 가리키는 경로 후보. 실존 여부는 저장소가 답한다 — 여기서는 줍기만 한다. */
34
+ export declare function extractPathHints(workItem: {
35
+ title?: string;
36
+ body?: string;
37
+ }): string[];
@@ -0,0 +1,216 @@
1
+ // 조사 결과 → 계약 초안 (P0-C).
2
+ //
3
+ // contract-draft.ts 는 "초안을 만들지 않는다"고 선언한다. 맞는 선언이지만, 그러면 초안을
4
+ // 만드는 자리가 어디에도 없어서 사람이 goal·boundary·criteria 를 손으로 채우거나 agent 가
5
+ // 지어냈다. 이 모듈이 그 빈 칸이다 — **읽어 온 사실로 채울 수 있는 만큼만 채우고, 나머지는
6
+ // 비운 채로 넘긴다.**
7
+ //
8
+ // 판정은 하지 않는다. 범위가 맞는지, 책임자가 누구인지, 사람이 정해야 하는지는 전부
9
+ // planSessionContract 가 이미 잰다. 여기서 같은 것을 또 재면 두 판정이 갈라진다.
10
+ //
11
+ // 순수 함수다. 날짜조차 주입받는다.
12
+ import { isWithinScopes } from "../policy/scope.js";
13
+ /**
14
+ * 완료 조건으로 읽을 만한 줄. 헤딩 아래 불릿·체크박스만 본다 — 문장을 해석하지 않는다.
15
+ * 못 찾으면 **비운다**. 없는 인수 조건을 만들어 넣는 것이 이 함수가 할 수 있는 최악이다.
16
+ */
17
+ const ACCEPTANCE_HEADING = /(완료\s*조건|인수\s*조건|acceptance|done\s*criteria)/i;
18
+ // 불릿·번호·체크박스 어느 형태든 항목으로 본다. 추적 도구마다 본문을 다르게 눌러 담는다 —
19
+ // 마크다운을 그대로 주는 곳도 있고, 헤딩·불릿 기호를 떼고 평문으로 주는 곳도 있다.
20
+ const BULLET = /^\s*(?:[-*+]\s*)?(?:\[[ xX]\]\s*)(.+?)\s*$|^\s*(?:[-*+]|\d+[.)])\s+(.+?)\s*$/;
21
+ /** 짧고 항목이 아닌 줄은 구획 이름으로 읽는다 (`### 완료 조건` 도, 평문 `완료 조건` 도). */
22
+ const SECTION_LABEL = /^\s{0,3}(?:#{1,6}\s*|\*\*)?([^\n]{1,30}?)(?:\*\*)?\s*$/;
23
+ export function deriveSessionContractDraft(input) {
24
+ const provenance = [];
25
+ const draft = {};
26
+ // ── id ── 작업 항목 키는 세션 id 가 될 수 없다 (SessionId 는 S-YYYYMMDD-NN). 키는
27
+ // goal 과 provenance 에 남고, 세션 id 는 오늘 날짜로 새로 뽑는다.
28
+ draft.id = nextSessionId(input.today, input.existingIds ?? []);
29
+ provenance.push({
30
+ field: 'id',
31
+ status: 'PROPOSAL',
32
+ source: 'agent_proposal',
33
+ reason: `${input.intent.workRef} 작업을 위해 오늘 날짜로 뽑은 세션 id`,
34
+ });
35
+ // ── role ──
36
+ if (input.intent.role) {
37
+ draft.role = input.intent.role;
38
+ provenance.push({ field: 'role', status: 'FACT', source: 'user', reason: '사람이 역할을 지정했다' });
39
+ }
40
+ else {
41
+ draft.role = 'implementer';
42
+ provenance.push({
43
+ field: 'role',
44
+ status: 'PROPOSAL',
45
+ source: 'agent_proposal',
46
+ reason: `${input.intent.workRef} 착수 요청이라 구현 역할로 제안한다`,
47
+ });
48
+ }
49
+ // ── goal ── 작업 항목이 말하는 것을 옮긴다. 다시 쓰지 않는다.
50
+ if (input.intent.goal && input.intent.goal.trim() !== '') {
51
+ draft.goal = input.intent.goal.trim();
52
+ provenance.push({ field: 'goal', status: 'FACT', source: 'user', reason: '사람이 목표를 직접 말했다' });
53
+ }
54
+ else {
55
+ draft.goal = `${input.workItem.reference}: ${input.workItem.title}`;
56
+ provenance.push({
57
+ field: 'goal',
58
+ status: 'FACT',
59
+ source: 'work_item',
60
+ reason: '작업 항목의 제목을 그대로 옮겼다',
61
+ });
62
+ }
63
+ // ── boundary ── **이번 작업이 쓸 범위**를 작업 근거에서 만든다. 역할의 최대 범위를
64
+ // 복사하지 않는다 — 정책이 없다는 것은 전체를 써도 된다는 뜻이 아니고,
65
+ // 정책이 `**` 를 허용한다는 것도 이번 작업이 전체를 고친다는 뜻이 아니다.
66
+ // 근거가 끝까지 없으면 **비운다** — planSessionContract 가 사람에게 묻는다.
67
+ const boundary = deriveBoundary(input);
68
+ if (boundary) {
69
+ draft.boundary = boundary.scopes;
70
+ provenance.push({ field: 'boundary', status: 'PROPOSAL', source: 'repository', reason: boundary.reason });
71
+ }
72
+ // ── criteria ── 작업 항목의 완료 조건은 사실이고, 저장소가 이미 돌리는 검사는 제안이다.
73
+ // 둘 다 없으면 비운다. planSessionContract 가 사람에게 묻게 두는 편이,
74
+ // 없는 인수 조건을 지어내는 것보다 낫다.
75
+ const fromItem = acceptanceLines(input.workItem.body);
76
+ const fromRepo = (input.repoChecks ?? []).map((check) => `저장소 기존 검사 통과: ${check}`);
77
+ const criteria = [...fromItem, ...fromRepo];
78
+ if (criteria.length > 0) {
79
+ draft.criteria = criteria;
80
+ provenance.push({
81
+ field: 'criteria',
82
+ status: fromItem.length > 0 ? 'FACT' : 'PROPOSAL',
83
+ source: fromItem.length > 0 ? 'work_item' : 'repository',
84
+ reason: fromItem.length > 0
85
+ ? '작업 항목이 적어 둔 완료 조건을 옮겼다'
86
+ : '작업 항목에 완료 조건이 없어 저장소가 이미 돌리는 검사를 제안한다',
87
+ });
88
+ }
89
+ // owner 는 여기서 정하지 않는다 — planSessionContract 의 lookupOwnerByPaths 가 이미 한다.
90
+ draft.provenance = provenance;
91
+ return draft;
92
+ }
93
+ /** 다음 세션 id. 같은 날 이미 쓴 번호는 건너뛴다. */
94
+ function nextSessionId(today, existing) {
95
+ const used = new Set(existing
96
+ .map((id) => /^S-(\d{8})-(\d{2})$/.exec(id))
97
+ .filter((m) => m !== null && m[1] === today)
98
+ .map((m) => Number(m[2])));
99
+ let n = 1;
100
+ while (used.has(n))
101
+ n += 1;
102
+ return `S-${today}-${String(n).padStart(2, '0')}`;
103
+ }
104
+ /**
105
+ * 참고로 읽는 곳이지 고치는 곳이 아닌 경로. 작업 항목이 spec 을 가리킨다고 해서 그 spec 을
106
+ * 고치라는 뜻이 아니다 — 읽기 근거를 쓰기 범위로 승격하면 계약이 조용히 넓어진다.
107
+ */
108
+ const READ_ONLY_PREFIXES = ['specs/', 'docs/', 'reference/'];
109
+ const isReadOnlyReference = (path) => READ_ONLY_PREFIXES.some((prefix) => path === prefix.slice(0, -1) || path.startsWith(prefix));
110
+ /** 경로처럼 생긴 토큰. 문장을 해석하지 않는다 — `a/b`, `a/b/c.ts` 만 줍는다. */
111
+ const PATH_TOKEN = /(?<![\w/@])([\w.-]+(?:\/[\w.*-]+)+)/g;
112
+ /** 작업 항목이 가리키는 경로 후보. 실존 여부는 저장소가 답한다 — 여기서는 줍기만 한다. */
113
+ export function extractPathHints(workItem) {
114
+ const text = `${workItem.title ?? ''}\n${workItem.body ?? ''}`;
115
+ const found = new Set();
116
+ for (const [, token] of text.matchAll(PATH_TOKEN)) {
117
+ if (!token)
118
+ continue;
119
+ const cleaned = token.replace(/[.,)\]]+$/, '');
120
+ // URL 조각과 버전 표기는 경로가 아니다.
121
+ if (/^https?:/.test(cleaned) || /^\d+\/\d+$/.test(cleaned))
122
+ continue;
123
+ if (isReadOnlyReference(cleaned))
124
+ continue;
125
+ found.add(cleaned);
126
+ }
127
+ return [...found];
128
+ }
129
+ /**
130
+ * 근거의 순서가 곧 좁은 정도의 순서다: 작업 항목이 지목한 것 → 저장소에서 확인된 구현
131
+ * 영역 → 이름이 유일하게 맞아떨어지는 모듈. 어느 단계든 **상한(maxScopes) 밖이면 버린다.**
132
+ */
133
+ function deriveBoundary(input) {
134
+ if (input.repo === 'MISSING')
135
+ return null;
136
+ const repo = input.repo;
137
+ const within = (scope) => (input.maxScopes?.length ?? 0) === 0 ? true : isWithinScopes(scope, [...(input.maxScopes ?? [])]);
138
+ const present = Object.entries(repo.pathsExist)
139
+ .filter(([, exists]) => exists)
140
+ .map(([path]) => path);
141
+ // 모듈 목록은 범위를 좁히는 재료다 — 판정의 증거가 아니므로 여기서만 본다.
142
+ const modules = Object.entries(repo.modulesPresent ?? {})
143
+ .filter(([, exists]) => exists)
144
+ .map(([path]) => path);
145
+ // ① 작업 항목이 지목했고 저장소에 실제로 있는 경로.
146
+ const named = extractPathHints(input.workItem).filter((hint) => present.includes(hint));
147
+ const fromItem = [...new Set(named.map(toScope))].filter(within);
148
+ if (fromItem.length > 0) {
149
+ return { scopes: fromItem, reason: '작업 항목이 지목했고 저장소에 실재하는 경로로 좁혔다' };
150
+ }
151
+ // ② 분류 이름이 **유일하게** 맞아떨어지는 모듈. 후보가 둘 이상이면 고르지 않는다.
152
+ const module = uniqueModule(input.workItem.labels ?? [], [...present, ...modules]);
153
+ if (module && within(module)) {
154
+ return { scopes: [module], reason: '작업 항목의 분류와 이름이 유일하게 맞는 모듈로 좁혔다' };
155
+ }
156
+ // ③ 저장소에서 확인된 구현 경로. 최상위 디렉터리는 쓰지 않는다 — 그 깊이는 "이 저장소"와
157
+ // 거의 같은 말이고, 조회 목록이 곧 권한이 되는 것을 막는다. 갈래가 많으면 좁힌 것이
158
+ // 아니므로 그때도 비운다.
159
+ const deep = [
160
+ ...new Set(present.filter((path) => path.split('/').length >= 2 && !isReadOnlyReference(path)).map(toScope)),
161
+ ].filter(within);
162
+ if (deep.length > 0 && deep.length <= 3) {
163
+ return { scopes: deep, reason: '저장소에서 확인된 구현 경로로 좁혔다' };
164
+ }
165
+ return null;
166
+ }
167
+ const toScope = (path) => {
168
+ if (path.endsWith('/**'))
169
+ return path;
170
+ // 파일이면 그 파일이 사는 자리까지. 디렉터리면 그 아래.
171
+ const isFile = /\.[A-Za-z0-9]+$/.test(path);
172
+ const base = isFile ? path.slice(0, path.lastIndexOf('/')) : path.replace(/\/$/, '');
173
+ return base === '' ? path : `${base}/**`;
174
+ };
175
+ const normalize = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
176
+ /**
177
+ * 분류 이름 하나가 저장소의 어느 자리 하나에만 맞을 때만 답한다. 범용 유사도 매칭을
178
+ * 만들지 않는다 — 여러 곳에 걸리면 고르는 것은 사람의 일이다.
179
+ */
180
+ function uniqueModule(labels, present) {
181
+ const roots = [...new Set(present.map((path) => path.split('/')[0]).filter((root) => Boolean(root)))];
182
+ for (const label of labels) {
183
+ const needle = normalize(label);
184
+ if (needle.length < 3)
185
+ continue;
186
+ const hits = roots.filter((root) => normalize(root).includes(needle));
187
+ if (hits.length !== 1)
188
+ continue;
189
+ const root = hits[0];
190
+ const src = present.find((path) => path === `${root}/src` || path.startsWith(`${root}/src/`));
191
+ return src ? `${root}/src/**` : `${root}/**`;
192
+ }
193
+ return null;
194
+ }
195
+ function acceptanceLines(body) {
196
+ if (!body)
197
+ return [];
198
+ const found = [];
199
+ let inside = false;
200
+ for (const line of body.split('\n')) {
201
+ if (line.trim() === '')
202
+ continue;
203
+ const bullet = BULLET.exec(line);
204
+ const item = bullet?.[1] ?? bullet?.[2];
205
+ if (item) {
206
+ if (inside)
207
+ found.push(item.trim());
208
+ continue;
209
+ }
210
+ // 항목이 아닌 줄은 구획 이름이거나 설명이다. 구획 이름이면 여기서 들고 나간다.
211
+ const label = SECTION_LABEL.exec(line);
212
+ if (label)
213
+ inside = ACCEPTANCE_HEADING.test(label[1] ?? '');
214
+ }
215
+ return found;
216
+ }
@@ -1,5 +1,10 @@
1
1
  import type { Session } from '../model/entities.ts';
2
2
  import type { StateStore } from '../../ports/state-store.ts';
3
+ import type { RepoObservation } from '../../ports/local-repo.ts';
4
+ import type { ChangeSummary } from '../../ports/change-context.ts';
5
+ import type { ContextComment, ResourceSnapshot } from '../../ports/resource-context.ts';
6
+ import type { SessionContractDraft, SessionContractPlan } from './contract-draft.ts';
7
+ import { type WorkStateResult } from './work-state.ts';
3
8
  import type { CanonicalDrift, SessionRuntime } from '../runtime/session.ts';
4
9
  import { type EscalationLedger } from '../runtime/escalation.ts';
5
10
  import { type ExecutionVerdict } from '../runtime/execution-state.ts';
@@ -15,6 +20,57 @@ export type ProceedIntent = {
15
20
  sessionId?: string;
16
21
  /** 후보가 없을 때 초안에 실어 줄 목표 힌트. 확정이 아니다. */
17
22
  goal?: string;
23
+ /**
24
+ * 사람이 지목한 작업 항목 (예: 이슈 키). 있으면 후보가 없을 때 **조사부터** 한다 —
25
+ * 빈 초안을 돌려주는 대신, 실제로 할 일이 있는지를 먼저 판정한다.
26
+ */
27
+ workRef?: string;
28
+ };
29
+ /**
30
+ * 작업 항목 하나를 실제로 조사해 계약까지 잇는 통로 (P0-D).
31
+ *
32
+ * 전부 주입이다. Operator 는 adapter 도 policy 도 모르고, 여기 담긴 함수들이 하는 판정을
33
+ * 다시 계산하지 않는다 — 범위·책임·발급 권한은 `plan` 이 돌려주는 것을 **읽기만** 한다.
34
+ */
35
+ export type WorkIngress = {
36
+ /** 작업 항목과 그 둘레를 모은다. 못 모은 것은 'UNAVAILABLE' 로 말한다. */
37
+ gather: (workRef: string) => Promise<{
38
+ workItem?: ResourceSnapshot;
39
+ trackerDone?: boolean;
40
+ comments?: readonly ContextComment[] | 'UNAVAILABLE';
41
+ change?: ChangeSummary | 'UNAVAILABLE';
42
+ dependencies?: readonly {
43
+ reference: string;
44
+ state?: string;
45
+ open?: boolean;
46
+ }[];
47
+ }>;
48
+ /** 저장소 관측. 없으면 'MISSING' 으로 취급되고, 그 상태에서는 어떤 추천도 하지 않는다. */
49
+ observeRepo?: (query: {
50
+ refHint: string;
51
+ paths?: readonly string[];
52
+ }) => Promise<RepoObservation>;
53
+ derive: (input: {
54
+ workRef: string;
55
+ goal?: string;
56
+ workItem: ResourceSnapshot;
57
+ workState: WorkStateResult;
58
+ repo: RepoObservation | 'MISSING';
59
+ /** 이미 쓰인 세션 id — 새 id 가 그 위에 겹치지 않게. */
60
+ existingIds: readonly string[];
61
+ }) => SessionContractDraft;
62
+ /** 이미 쓴 세션 id — 회수돼 보관된 것까지. 새 id 가 그 위에 겹치지 않게 한다. */
63
+ usedIds: () => Promise<readonly string[]>;
64
+ /** planSessionContract 를 policy·ownership·기존 id 로 감싼 것. */
65
+ plan: (draft: SessionContractDraft) => Promise<SessionContractPlan>;
66
+ /** 위임 범위 안에서만 불린다. 발급 경로는 SessionRuntime 하나뿐이다. */
67
+ issue: (draft: SessionContractDraft) => Promise<{
68
+ ok: true;
69
+ sessionId: string;
70
+ } | {
71
+ ok: false;
72
+ detail: string;
73
+ }>;
18
74
  };
19
75
  /** 후보 나열용 요약 — 사람이 고를 근거까지 함께 준다. */
20
76
  export type SessionCandidate = {
@@ -52,6 +108,24 @@ export type ProceedOutcome = ({
52
108
  goal: string;
53
109
  doneCriteria: string[];
54
110
  };
111
+ /** 조사에서 도출한 초안 전문. 조사 없이 온 경우엔 없다. */
112
+ full?: SessionContractDraft;
113
+ /** planSessionContract 판정 원본. 여기 있는 것을 다시 계산하지 않는다. */
114
+ plan?: SessionContractPlan;
115
+ /** 발급이 Controller 것일 때 사람이 그대로 실행할 명령. */
116
+ forController?: string[];
117
+ }
118
+ /**
119
+ * 조사해 보니 **세션을 낼 일이 아니다** (P0-B). 이미 구현돼 tracker 만 뒤처졌거나,
120
+ * 선행 작업·외부 검증에 막혔거나, 결론 요건이 모자란 경우다.
121
+ *
122
+ * 아무것도 쓰지 않는다. 파생 뷰이며 저장되지 않는다 — 상태 enum(OM §11.2)을 건드리지 않는다.
123
+ */
124
+ | {
125
+ kind: 'WORK_STATE';
126
+ workRef: string;
127
+ result: WorkStateResult;
128
+ nextAction: string;
55
129
  }
56
130
  /**
57
131
  * 미해소 상신이 실행 가능한 node를 전부 덮었다 (C-13 §6).
@@ -84,6 +158,11 @@ export type OperatorDeps = {
84
158
  * 주지 않으면 예전처럼 상태만 보고 간다(기존 호출자 무손상).
85
159
  */
86
160
  escalations?: EscalationLedger;
161
+ /**
162
+ * 작업 항목 통로 (P0-D). 없으면 후보가 없을 때 예전처럼 빈 초안을 제안한다 —
163
+ * 기존 호출자 무손상.
164
+ */
165
+ ingress?: WorkIngress;
87
166
  /**
88
167
  * 필수다. 모든 진입이 bootstrap/profile.lock 검증을 지난다 — Surface가 어디든.
89
168
  * 실 조립은 factory(cli의 createOperator)가 bootstrapGuard로 고정한다.