@asc-agent/runtime 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -25
- package/dist/adapters/claude-code/skill.js +249 -235
- package/dist/adapters/gitlab/adapter.d.ts +3 -0
- package/dist/adapters/gitlab/adapter.js +22 -3
- package/dist/adapters/gitlab/client.d.ts +27 -1
- package/dist/adapters/gitlab/client.js +30 -0
- package/dist/adapters/gitlab/ports.d.ts +3 -3
- package/dist/adapters/jam/adapter.d.ts +14 -0
- package/dist/adapters/jam/adapter.js +84 -6
- package/dist/adapters/jam/ports.d.ts +9 -0
- package/dist/adapters/jam/ports.js +49 -2
- package/dist/adapters/local/repo.d.ts +15 -0
- package/dist/adapters/local/repo.js +179 -0
- package/dist/adapters/markdown/state-store.js +13 -1
- package/dist/adapters/memory/state-store.js +4 -0
- package/dist/cli/asc.d.ts +6 -0
- package/dist/cli/asc.js +525 -8
- package/dist/composition/propose.d.ts +19 -0
- package/dist/composition/propose.js +35 -0
- package/dist/composition/runtime.d.ts +2 -0
- package/dist/composition/runtime.js +41 -5
- package/dist/core/attach/init.d.ts +17 -0
- package/dist/core/attach/init.js +28 -0
- package/dist/core/attach/setup-plan.d.ts +9 -0
- package/dist/core/attach/setup-plan.js +16 -5
- package/dist/core/distribution/node-runtime.d.ts +44 -0
- package/dist/core/distribution/node-runtime.js +75 -0
- package/dist/core/distribution/release.d.ts +3 -3
- package/dist/core/distribution/release.js +1 -1
- package/dist/core/monitor/investigation.d.ts +16 -0
- package/dist/core/monitor/investigation.js +18 -9
- package/dist/core/operator/contract-draft.d.ts +124 -0
- package/dist/core/operator/contract-draft.js +234 -0
- package/dist/core/operator/derive-draft.d.ts +37 -0
- package/dist/core/operator/derive-draft.js +216 -0
- package/dist/core/operator/proceed.d.ts +79 -0
- package/dist/core/operator/proceed.js +123 -1
- package/dist/core/operator/work-state.d.ts +54 -0
- package/dist/core/operator/work-state.js +145 -0
- package/dist/core/runtime/closure.d.ts +2 -2
- package/dist/ports/local-repo.d.ts +61 -0
- package/dist/ports/local-repo.js +9 -0
- package/dist/ports/resource-context.d.ts +7 -0
- package/dist/schemas/profile.d.ts +2 -2
- package/package.json +2 -2
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { EscalationPredicate } from '../runtime/escalation.ts';
|
|
3
|
+
import { type OwnershipMap } from '../policy/ownership.ts';
|
|
4
|
+
import { type PreflightResult } from './preflight.ts';
|
|
5
|
+
import type { ResolvedPolicy } from '../policy/policy.ts';
|
|
6
|
+
/**
|
|
7
|
+
* 이 값이 어디서 왔는가. **claims.ts의 CONFIRMED/INFERRED/PENDING과 같은 축이다** —
|
|
8
|
+
* 이름만 계약 초안의 어휘로 부른다 (agent가 읽는 문서에서 "추론"보다 "PROPOSAL"이
|
|
9
|
+
* 무엇을 해야 하는지 더 곧게 말한다).
|
|
10
|
+
*/
|
|
11
|
+
export type DraftStatus =
|
|
12
|
+
/** 사용자·정본·work item·Profile에서 직접 확인된 값. 뒤집으려면 그 출처를 봐야 한다. */
|
|
13
|
+
'FACT'
|
|
14
|
+
/** agent가 근거를 갖고 제안한 값. 근거가 충분하고 경계 안이면 그대로 진행해도 된다. */
|
|
15
|
+
| 'PROPOSAL'
|
|
16
|
+
/** agent가 확정해서는 안 되는 값. 사람의 경계다. */
|
|
17
|
+
| 'DECISION_REQUIRED';
|
|
18
|
+
/** 값이 어디서 왔는지. 지어낸 것과 읽어 온 것을 문자열 하나로 구분한다. */
|
|
19
|
+
export declare const DRAFT_SOURCES: readonly ["user", "work_item", "profile", "repository", "canonical", "agent_proposal"];
|
|
20
|
+
export type DraftSource = (typeof DRAFT_SOURCES)[number];
|
|
21
|
+
/** 초안의 출처 한 줄. CLI가 받는 문자열도 이 스키마로 통과시켜야 들어온다. */
|
|
22
|
+
export declare const DraftProvenance: z.ZodObject<{
|
|
23
|
+
field: z.ZodEnum<["id", "role", "goal", "boundary", "criteria", "owner"]>;
|
|
24
|
+
status: z.ZodEnum<["FACT", "PROPOSAL", "DECISION_REQUIRED"]>;
|
|
25
|
+
source: z.ZodEnum<["user", "work_item", "profile", "repository", "canonical", "agent_proposal"]>;
|
|
26
|
+
/** 왜 이 값인가. PROPOSAL이면 반드시 있어야 한다 — 근거 없는 제안은 추측이다. */
|
|
27
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
28
|
+
}, "strip", z.ZodTypeAny, {
|
|
29
|
+
status: "FACT" | "PROPOSAL" | "DECISION_REQUIRED";
|
|
30
|
+
source: "canonical" | "profile" | "user" | "work_item" | "repository" | "agent_proposal";
|
|
31
|
+
field: "id" | "role" | "goal" | "owner" | "boundary" | "criteria";
|
|
32
|
+
reason?: string | undefined;
|
|
33
|
+
}, {
|
|
34
|
+
status: "FACT" | "PROPOSAL" | "DECISION_REQUIRED";
|
|
35
|
+
source: "canonical" | "profile" | "user" | "work_item" | "repository" | "agent_proposal";
|
|
36
|
+
field: "id" | "role" | "goal" | "owner" | "boundary" | "criteria";
|
|
37
|
+
reason?: string | undefined;
|
|
38
|
+
}>;
|
|
39
|
+
export type DraftField = z.infer<typeof DraftProvenance>;
|
|
40
|
+
/** agent가 만들어 오는 것. 완성된 계약이 아니라 **초안**이다. */
|
|
41
|
+
export type SessionContractDraft = {
|
|
42
|
+
id?: string;
|
|
43
|
+
role?: string;
|
|
44
|
+
goal?: string;
|
|
45
|
+
boundary?: readonly string[];
|
|
46
|
+
criteria?: readonly string[];
|
|
47
|
+
owner?: string;
|
|
48
|
+
decisionDomains?: readonly string[];
|
|
49
|
+
decisionAuthority?: Readonly<Record<string, string>>;
|
|
50
|
+
/** 각 값의 출처. 없는 필드는 `agent_proposal` 로 간주하지 않고 **출처 미상**으로 본다. */
|
|
51
|
+
provenance?: readonly DraftField[];
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* 사람이 답해야 하는 것 하나. **빠진 정보 전부가 아니라 결정 지점 하나씩** 든다 —
|
|
55
|
+
* 목록이 길면 사람은 그것을 질문이 아니라 서식으로 읽는다.
|
|
56
|
+
*/
|
|
57
|
+
export type UnresolvedDecision = {
|
|
58
|
+
field: DraftField['field'];
|
|
59
|
+
/** 왜 사람인가. escalation predicate와 같은 어휘를 쓴다 (C-13). */
|
|
60
|
+
reason: (typeof EscalationPredicate)['_type'] | 'missing_input' | 'multiple_options';
|
|
61
|
+
detail: string;
|
|
62
|
+
/** 고를 수 있는 것들. 하나뿐이면 질문이 아니라 제안이다. */
|
|
63
|
+
options?: string[];
|
|
64
|
+
/** 추천 인덱스. 추천 없이 선택지만 주는 것은 판단을 떠넘기는 것이다. */
|
|
65
|
+
recommended?: number;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* 발급 권한은 **사람의 것이다** (OM §450). 다만 그 문장이 "매번 사람이 직접 쳐야 한다"는
|
|
69
|
+
* 뜻은 아니다 — Controller가 **범위를 정해 위임**할 수 있고, 그 범위 안에서만 agent가
|
|
70
|
+
* 스스로 발급한다. 위임이 없으면 계약이 완성돼도 발급하지 않고 사람에게 넘긴다.
|
|
71
|
+
*
|
|
72
|
+
* 위임은 Profile/Override의 `policy.unionLists.issuanceDelegation` 에 **역할 이름**으로
|
|
73
|
+
* 적는다 (closureChecklist와 같은 관례 키 자리). 경로 범위는 이미 `roleScopes` 가 좁히고
|
|
74
|
+
* 있으므로 여기서 다시 정의하지 않는다 — 권한을 두 곳에 적으면 둘이 갈라진다.
|
|
75
|
+
*/
|
|
76
|
+
export declare const ISSUANCE_DELEGATION_KEY = "issuanceDelegation";
|
|
77
|
+
export type IssuanceAuthority = {
|
|
78
|
+
/** `controller` = 사람이 발급한다. `delegated` = 이 역할에 한해 agent가 발급해도 된다. */
|
|
79
|
+
authority: 'controller' | 'delegated';
|
|
80
|
+
/** Controller가 위임한 역할들. 비어 있으면 위임이 없다는 뜻이다. */
|
|
81
|
+
delegatedRoles: string[];
|
|
82
|
+
detail: string;
|
|
83
|
+
};
|
|
84
|
+
export type ContractPlanStatus =
|
|
85
|
+
/** 지금 발급해도 된다. 사람에게 물을 것이 없다. */
|
|
86
|
+
'READY_TO_ISSUE'
|
|
87
|
+
/** 구조는 맞는데 사람만 정할 수 있는 것이 남았다. */
|
|
88
|
+
| 'NEEDS_DECISION'
|
|
89
|
+
/** 이 초안으로는 계약이 성립하지 않는다 — 문법·범위가 틀렸다. */
|
|
90
|
+
| 'INVALID';
|
|
91
|
+
export type SessionContractPlan = {
|
|
92
|
+
status: ContractPlanStatus;
|
|
93
|
+
draft: SessionContractDraft;
|
|
94
|
+
/** 출처가 확인된 값들. */
|
|
95
|
+
facts: DraftField[];
|
|
96
|
+
/** 근거를 갖고 제안된 값들. */
|
|
97
|
+
proposals: DraftField[];
|
|
98
|
+
unresolved: UnresolvedDecision[];
|
|
99
|
+
/** 계약이 완성돼도 **발급해도 되는가**는 별개다 (OM §450). */
|
|
100
|
+
issuance: IssuanceAuthority;
|
|
101
|
+
/** 구조가 깨진 지점. 있으면 status는 INVALID다. */
|
|
102
|
+
invalid: {
|
|
103
|
+
field: string;
|
|
104
|
+
detail: string;
|
|
105
|
+
}[];
|
|
106
|
+
/** 경로·책임 축 판정 원본. 있는 그대로 실어 agent가 다시 계산하지 않게 한다. */
|
|
107
|
+
preflight?: PreflightResult;
|
|
108
|
+
};
|
|
109
|
+
export type ContractPlanInput = {
|
|
110
|
+
draft: SessionContractDraft;
|
|
111
|
+
policy?: ResolvedPolicy;
|
|
112
|
+
ownership?: OwnershipMap;
|
|
113
|
+
/** 이미 있는 세션 id들. 겹치면 발급이 실패하므로 미리 말한다. */
|
|
114
|
+
existingIds?: readonly string[];
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* 초안을 잰다. **아무것도 쓰지 않는다** — 순수 함수이고, 같은 입력이면 같은 답이다.
|
|
118
|
+
*
|
|
119
|
+
* 판정 순서에 뜻이 있다: 먼저 구조(문법·필수값)를 보고, 그 다음 경계(범위·책임)를 본다.
|
|
120
|
+
* 구조가 깨진 초안의 경계를 논하는 것은 의미가 없고, 사람에게 두 번 묻게 만든다.
|
|
121
|
+
*/
|
|
122
|
+
export declare function planSessionContract(input: ContractPlanInput): SessionContractPlan;
|
|
123
|
+
/** 발급 명령의 인자. plan이 통과했을 때만 부른다 — 통과하지 않은 초안은 명령이 되지 않는다. */
|
|
124
|
+
export declare function issueArgs(draft: SessionContractDraft): string[];
|
|
@@ -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
|
+
}
|