@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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Adapter 계약이 실제로 계약이라는 증거다.
|
|
5
5
|
import { execFile } from 'node:child_process';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
|
-
import { GitLabClient, discoverToken, encodeProject } from "./client.js";
|
|
7
|
+
import { GitLabClient, discoverToken, encodeProject, glabAvailable } from "./client.js";
|
|
8
8
|
const run = promisify(execFile);
|
|
9
9
|
const PROVIDES = [
|
|
10
10
|
'observe.delta',
|
|
@@ -20,6 +20,11 @@ const PROVIDES = [
|
|
|
20
20
|
* provider 목록을 아는 물건이 됐다는 뜻이다.
|
|
21
21
|
*/
|
|
22
22
|
const CLAIMED_ELSEWHERE = new Set(['github.com']);
|
|
23
|
+
/** 기본 실행 통로. 실패는 예외로 올린다 — 호출측이 "없다"와 "터졌다"를 구분한다. */
|
|
24
|
+
const defaultRun = async (command, args) => {
|
|
25
|
+
const { stdout } = await run(command, [...args]);
|
|
26
|
+
return stdout;
|
|
27
|
+
};
|
|
23
28
|
export class GitLabAdapter {
|
|
24
29
|
#listRemotes;
|
|
25
30
|
#findToken;
|
|
@@ -33,7 +38,9 @@ export class GitLabAdapter {
|
|
|
33
38
|
* 그렇지 않은 호출자는 `ASC_GITLAB_URL` 로 명시하면 된다.
|
|
34
39
|
*/
|
|
35
40
|
#endpoints = new Map();
|
|
41
|
+
#run;
|
|
36
42
|
constructor(deps = {}) {
|
|
43
|
+
this.#run = deps.run ?? defaultRun;
|
|
37
44
|
this.#listRemotes = deps.listRemotes ?? defaultListRemotes;
|
|
38
45
|
this.#findToken = deps.findToken ?? discoverToken;
|
|
39
46
|
this.#reach = deps.reach;
|
|
@@ -44,7 +51,7 @@ export class GitLabAdapter {
|
|
|
44
51
|
id: 'gitlab',
|
|
45
52
|
version: '1',
|
|
46
53
|
provides: PROVIDES,
|
|
47
|
-
requiresCredential: ['ASC_GITLAB_TOKEN | GITLAB_TOKEN'],
|
|
54
|
+
requiresCredential: ['ASC_GITLAB_TOKEN | GITLAB_TOKEN, 또는 로그인된 glab (읽기 전용)'],
|
|
48
55
|
prerequisites: ['git remote 가 이 host를 가리켜야 한다'],
|
|
49
56
|
};
|
|
50
57
|
}
|
|
@@ -80,7 +87,19 @@ export class GitLabAdapter {
|
|
|
80
87
|
async probe(candidate, context) {
|
|
81
88
|
const token = this.#findToken(context.env);
|
|
82
89
|
if (!token) {
|
|
83
|
-
|
|
90
|
+
// 토큰이 없다고 통로가 없는 것은 아니다 — 사람이 이미 `glab` 에 로그인해 뒀다면
|
|
91
|
+
// 그 도구에게 요청을 대신 보내 달라고 할 수 있다. 토큰을 꺼내 오지는 않는다.
|
|
92
|
+
if (await glabAvailable(this.#run)) {
|
|
93
|
+
return {
|
|
94
|
+
state: 'DEGRADED',
|
|
95
|
+
provides: candidate.provides,
|
|
96
|
+
detail: '토큰은 없지만 로그인된 glab 를 통로로 쓴다 (읽기 전용)',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
state: 'UNCONFIGURED',
|
|
101
|
+
detail: '토큰이 없다 — ASC_GITLAB_TOKEN 또는 GITLAB_TOKEN 을 두거나, `glab auth login` 을 해 두어라',
|
|
102
|
+
};
|
|
84
103
|
}
|
|
85
104
|
const baseUrl = context.env?.ASC_GITLAB_URL ?? this.#endpoints.get(candidate.resource);
|
|
86
105
|
const reach = this.#reach ?? ((project, secret) => defaultReach(project, secret, baseUrl));
|
|
@@ -15,7 +15,33 @@ export type GitLabResponse<T> = {
|
|
|
15
15
|
};
|
|
16
16
|
/** 환경변수에서만 찾는다. 값을 파일이나 Profile에 남기지 않는다. */
|
|
17
17
|
export declare function discoverToken(env?: NodeJS.ProcessEnv): string | null;
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* 이미 로그인된 `glab` 을 통로로 쓸 수 있는가 (P1-H).
|
|
20
|
+
*
|
|
21
|
+
* 토큰을 꺼내 오지 않는다 — 꺼낼 수 있어도 하지 않는다. 자격은 그 도구 안에 있고, ASC 는
|
|
22
|
+
* 그 도구에게 **요청을 대신 보내 달라고** 부탁할 뿐이다. env 토큰이 있으면 그쪽이 먼저다:
|
|
23
|
+
* 명시적으로 준 것이 추론보다 앞선다.
|
|
24
|
+
*/
|
|
25
|
+
export type ProcessRunner = (command: string, args: readonly string[]) => Promise<string>;
|
|
26
|
+
export declare function glabAvailable(run: ProcessRunner): Promise<boolean>;
|
|
27
|
+
/**
|
|
28
|
+
* `glab api` 를 읽기 통로로 감싼 클라이언트. GET 만 다룬다 — 쓰기는 Grant 를 지나야 하고,
|
|
29
|
+
* 그 경로를 우회하는 통로를 여기에 만들지 않는다.
|
|
30
|
+
*/
|
|
31
|
+
export declare class GlabApiClient implements GitLabReader {
|
|
32
|
+
#private;
|
|
33
|
+
constructor(run: ProcessRunner);
|
|
34
|
+
get<T>(path: string): Promise<GitLabResponse<T>>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 읽기 통로. GitLabClient(토큰)와 GlabApiClient(로그인된 도구) 둘 다 이것이다 —
|
|
38
|
+
* Port 들은 어느 쪽인지 몰라야 한다. 통로가 바뀌었다고 조회 코드가 바뀌면, 통로를 늘릴
|
|
39
|
+
* 때마다 같은 코드가 갈라진다.
|
|
40
|
+
*/
|
|
41
|
+
export interface GitLabReader {
|
|
42
|
+
get<T>(path: string): Promise<GitLabResponse<T>>;
|
|
43
|
+
}
|
|
44
|
+
export declare class GitLabClient implements GitLabReader {
|
|
19
45
|
#private;
|
|
20
46
|
constructor(deps: GitLabClientDeps);
|
|
21
47
|
get<T>(path: string): Promise<GitLabResponse<T>>;
|
|
@@ -10,6 +10,36 @@
|
|
|
10
10
|
export function discoverToken(env = process.env) {
|
|
11
11
|
return env.ASC_GITLAB_TOKEN ?? env.GITLAB_TOKEN ?? null;
|
|
12
12
|
}
|
|
13
|
+
export async function glabAvailable(run) {
|
|
14
|
+
try {
|
|
15
|
+
await run('glab', ['auth', 'status']);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `glab api` 를 읽기 통로로 감싼 클라이언트. GET 만 다룬다 — 쓰기는 Grant 를 지나야 하고,
|
|
24
|
+
* 그 경로를 우회하는 통로를 여기에 만들지 않는다.
|
|
25
|
+
*/
|
|
26
|
+
export class GlabApiClient {
|
|
27
|
+
#run;
|
|
28
|
+
constructor(run) {
|
|
29
|
+
this.#run = run;
|
|
30
|
+
}
|
|
31
|
+
async get(path) {
|
|
32
|
+
try {
|
|
33
|
+
// 페이지 헤더는 `glab api` 가 돌려주지 않는다. 다음 페이지를 모르는 채로
|
|
34
|
+
// 있다고 말하지 않는다 — nextPage 를 비워 두면 호출측이 한 페이지로 끝낸다.
|
|
35
|
+
const stdout = await this.#run('glab', ['api', path.replace(/^\//, '')]);
|
|
36
|
+
return { ok: true, status: 200, data: JSON.parse(stdout) };
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return { ok: false, status: 0, data: null, error: String(error.message ?? error).slice(0, 200) };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
13
43
|
export class GitLabClient {
|
|
14
44
|
#token;
|
|
15
45
|
#fetch;
|
|
@@ -2,16 +2,16 @@ import type { ChangeContextPort, ChangeSummary } from '../../ports/change-contex
|
|
|
2
2
|
import type { Cursor, EventBatch, EventSource } from '../../ports/event-source.ts';
|
|
3
3
|
import type { InventoryPage, InventoryPort, InventoryQuery } from '../../ports/inventory.ts';
|
|
4
4
|
import type { CommentQuery, ContextComment, ResourceContextPort, ResourceSnapshot } from '../../ports/resource-context.ts';
|
|
5
|
-
import { type
|
|
5
|
+
import { type GitLabReader } from './client.ts';
|
|
6
6
|
export type GitLabDeps = {
|
|
7
|
-
client:
|
|
7
|
+
client: GitLabReader;
|
|
8
8
|
/** `group/sub/project`. */
|
|
9
9
|
project: string;
|
|
10
10
|
perPage?: number;
|
|
11
11
|
};
|
|
12
12
|
declare abstract class GitLabBase {
|
|
13
13
|
readonly id: string;
|
|
14
|
-
protected client:
|
|
14
|
+
protected client: GitLabReader;
|
|
15
15
|
protected project: string;
|
|
16
16
|
protected perPage: number;
|
|
17
17
|
constructor(deps: GitLabDeps);
|
|
@@ -11,6 +11,10 @@ export type JamAdapterDeps = {
|
|
|
11
11
|
authStatus?: (context: DiscoveryContext) => Promise<AuthStatus>;
|
|
12
12
|
/** 선언 파일 읽기 통로. */
|
|
13
13
|
readDeclaration?: (projectRoot: string) => Promise<string | null>;
|
|
14
|
+
/** 개인 선언 파일 읽기 통로. */
|
|
15
|
+
readPersonalBindings?: (home: string) => Promise<string | null>;
|
|
16
|
+
/** 이 저장소의 remote 들. 개인 선언과 맞출 때만 쓴다. */
|
|
17
|
+
listRemotes?: (projectRoot: string) => Promise<string[]>;
|
|
14
18
|
};
|
|
15
19
|
/** `jam auth status --json` 의 응답. **토큰 값은 여기 오지 않는다.** */
|
|
16
20
|
export type AuthStatus = {
|
|
@@ -51,3 +55,13 @@ export declare class JamAdapter implements Adapter {
|
|
|
51
55
|
/** 프로젝트와 무관하게 "도구가 지금 쓸 수 있는가". binding 유무와 별개 사실이다. */
|
|
52
56
|
runtime(context: DiscoveryContext): Promise<RuntimeStatus>;
|
|
53
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* 개인 선언에서 `workspace` 와 `key` 짝만 꺼낸다. YAML 파서를 들이지 않는다 —
|
|
60
|
+
* 읽을 것이 두 줄이고, 의존성 하나가 그 값보다 비싸다.
|
|
61
|
+
*/
|
|
62
|
+
export declare function parsePersonalBindings(text: string): {
|
|
63
|
+
workspace: string;
|
|
64
|
+
key: string;
|
|
65
|
+
}[];
|
|
66
|
+
/** `git:host/group/project` 형태로 맞춘다. remote URL 도 같은 형태로 접어 비교한다. */
|
|
67
|
+
export declare function normalizeWorkspace(value: string): string | null;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// 로그인을 대신 실행하면 그 설계를 우회하는 것이다 — 하지 않는다.
|
|
6
6
|
import { execFile } from 'node:child_process';
|
|
7
7
|
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
8
9
|
import { join } from 'node:path';
|
|
9
10
|
import { promisify } from 'node:util';
|
|
10
11
|
const run = promisify(execFile);
|
|
@@ -26,8 +27,16 @@ const PROVIDES = [
|
|
|
26
27
|
'context.resource',
|
|
27
28
|
'context.thread',
|
|
28
29
|
];
|
|
29
|
-
/** 프로젝트가 어느 작업 항목 묶음에 붙어 있는지 선언하는 파일.
|
|
30
|
+
/** 프로젝트가 어느 작업 항목 묶음에 붙어 있는지 선언하는 파일. 팀이 채택했을 때 저장소에 생긴다. */
|
|
30
31
|
const DECLARATION = join('.jira-agent', 'project.yaml');
|
|
32
|
+
/**
|
|
33
|
+
* 같은 선언의 **개인 자리**. 저장소를 건드리지 않고 붙여 쓰는 것이 provider 의 기본값이라,
|
|
34
|
+
* 저장소 안만 보면 "붙어 있는데 안 붙은 것으로 보이는" 경우가 생긴다. 그 상태에서 조사를
|
|
35
|
+
* 포기하면 읽을 수 있는 것을 안 읽은 것이 된다 — 그래서 여기도 본다.
|
|
36
|
+
*
|
|
37
|
+
* 개인 선언은 remote 로 맞춘다(파일의 `path` 는 출처 기록일 뿐이다).
|
|
38
|
+
*/
|
|
39
|
+
const PERSONAL_BINDINGS = join('.jam', 'projects.yaml');
|
|
31
40
|
/**
|
|
32
41
|
* 어떻게 실행할 것인가.
|
|
33
42
|
*
|
|
@@ -54,11 +63,15 @@ export class JamAdapter {
|
|
|
54
63
|
#args;
|
|
55
64
|
#authStatus;
|
|
56
65
|
#readDeclaration;
|
|
66
|
+
#readPersonalBindings;
|
|
67
|
+
#listRemotes;
|
|
57
68
|
constructor(deps = {}) {
|
|
58
69
|
this.#command = deps.command ?? 'jam';
|
|
59
70
|
this.#args = deps.args ?? [];
|
|
60
71
|
this.#authStatus = deps.authStatus;
|
|
61
72
|
this.#readDeclaration = deps.readDeclaration ?? defaultRead;
|
|
73
|
+
this.#readPersonalBindings = deps.readPersonalBindings ?? defaultReadPersonal;
|
|
74
|
+
this.#listRemotes = deps.listRemotes ?? defaultListRemotes;
|
|
62
75
|
}
|
|
63
76
|
describe() {
|
|
64
77
|
return {
|
|
@@ -81,12 +94,30 @@ export class JamAdapter {
|
|
|
81
94
|
*/
|
|
82
95
|
async discover(context) {
|
|
83
96
|
const text = await this.#readDeclaration(context.projectRoot);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
const key = text ? parseProjectKey(text) : null;
|
|
98
|
+
if (key)
|
|
99
|
+
return [{ adapterId: 'jam', resource: key, provides: PROVIDES, discoveredBy: DECLARATION }];
|
|
100
|
+
// 저장소에 선언이 없다고 붙어 있지 않은 것은 아니다 — 개인 자리를 본다.
|
|
101
|
+
const personal = await this.#personalKey(context);
|
|
102
|
+
if (!personal)
|
|
88
103
|
return [];
|
|
89
|
-
return [{ adapterId: 'jam', resource:
|
|
104
|
+
return [{ adapterId: 'jam', resource: personal, provides: PROVIDES, discoveredBy: PERSONAL_BINDINGS }];
|
|
105
|
+
}
|
|
106
|
+
/** 개인 선언에서 이 저장소의 키를 찾는다. remote 가 일치할 때만 — 이름이 비슷한 것은 근거가 아니다. */
|
|
107
|
+
async #personalKey(context) {
|
|
108
|
+
const home = context.env?.ASC_JAM_HOME ?? context.env?.HOME ?? homedir();
|
|
109
|
+
const text = await this.#readPersonalBindings(home);
|
|
110
|
+
if (!text)
|
|
111
|
+
return null;
|
|
112
|
+
const remotes = await this.#listRemotes(context.projectRoot).catch(() => []);
|
|
113
|
+
const mine = new Set(remotes.map(normalizeWorkspace).filter((value) => value !== null));
|
|
114
|
+
if (mine.size === 0)
|
|
115
|
+
return null;
|
|
116
|
+
for (const entry of parsePersonalBindings(text)) {
|
|
117
|
+
if (mine.has(entry.workspace))
|
|
118
|
+
return entry.key;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
90
121
|
}
|
|
91
122
|
async probe(candidate, context) {
|
|
92
123
|
const status = await this.#status(context);
|
|
@@ -142,3 +173,50 @@ async function defaultRead(projectRoot) {
|
|
|
142
173
|
return null;
|
|
143
174
|
}
|
|
144
175
|
}
|
|
176
|
+
async function defaultReadPersonal(home) {
|
|
177
|
+
try {
|
|
178
|
+
return await readFile(join(home, PERSONAL_BINDINGS), 'utf8');
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function defaultListRemotes(projectRoot) {
|
|
185
|
+
const { stdout } = await run('git', ['-C', projectRoot, 'remote', '-v']);
|
|
186
|
+
return stdout
|
|
187
|
+
.split('\n')
|
|
188
|
+
.map((line) => line.split(/\s+/)[1])
|
|
189
|
+
.filter((url) => Boolean(url));
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 개인 선언에서 `workspace` 와 `key` 짝만 꺼낸다. YAML 파서를 들이지 않는다 —
|
|
193
|
+
* 읽을 것이 두 줄이고, 의존성 하나가 그 값보다 비싸다.
|
|
194
|
+
*/
|
|
195
|
+
export function parsePersonalBindings(text) {
|
|
196
|
+
const found = [];
|
|
197
|
+
let workspace = null;
|
|
198
|
+
for (const line of text.split('\n')) {
|
|
199
|
+
const w = /^\s*-?\s*workspace:\s*"?([^"\n]+?)"?\s*$/.exec(line);
|
|
200
|
+
if (w?.[1]) {
|
|
201
|
+
workspace = w[1];
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const k = /^\s*key:\s*"?([^"\n]+?)"?\s*$/.exec(line);
|
|
205
|
+
if (k?.[1] && workspace) {
|
|
206
|
+
found.push({ workspace: normalizeWorkspace(workspace) ?? workspace, key: k[1] });
|
|
207
|
+
workspace = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return found;
|
|
211
|
+
}
|
|
212
|
+
/** `git:host/group/project` 형태로 맞춘다. remote URL 도 같은 형태로 접어 비교한다. */
|
|
213
|
+
export function normalizeWorkspace(value) {
|
|
214
|
+
const raw = value.trim().replace(/^git:/, '');
|
|
215
|
+
const ssh = /^[^@]+@([^:]+):(.+?)(?:\.git)?$/.exec(raw);
|
|
216
|
+
const https = /^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/.exec(raw);
|
|
217
|
+
const plain = /^([^/]+)\/(.+?)(?:\.git)?$/.exec(raw);
|
|
218
|
+
const match = ssh ?? https ?? plain;
|
|
219
|
+
if (!match)
|
|
220
|
+
return null;
|
|
221
|
+
return `git:${match[1].toLowerCase()}/${match[2].toLowerCase()}`;
|
|
222
|
+
}
|
|
@@ -38,6 +38,14 @@ export declare class JamInventory extends JamBase implements InventoryPort {
|
|
|
38
38
|
enumerate(query: InventoryQuery, cursor?: string): Promise<InventoryPage>;
|
|
39
39
|
}
|
|
40
40
|
export declare class JamResourceContext extends JamBase implements ResourceContextPort {
|
|
41
|
+
/**
|
|
42
|
+
* `jira_context` 가 아니라 `jira_full` 을 부른다.
|
|
43
|
+
*
|
|
44
|
+
* context 단계는 본문(description)을 싣지 않는다. 그런데 ResourceSnapshot 의 `body` 는
|
|
45
|
+
* 조사와 계약 초안이 완료 조건을 읽는 유일한 자리다 — 그것을 비운 채 넘기면 "작업 항목에
|
|
46
|
+
* 완료 조건이 없다"는 **거짓 사실**이 만들어지고, 없는 조건을 사람에게 되묻게 된다.
|
|
47
|
+
* provider 자신의 지침도 계약·합의 판정에는 full 을 쓰라고 말한다.
|
|
48
|
+
*/
|
|
41
49
|
getResource(reference: string): Promise<ResourceSnapshot>;
|
|
42
50
|
/**
|
|
43
51
|
* 논의는 full에만 실린다.
|
|
@@ -48,4 +56,5 @@ export declare class JamResourceContext extends JamBase implements ResourceConte
|
|
|
48
56
|
*/
|
|
49
57
|
getComments(reference: string, query?: CommentQuery): Promise<ContextComment[]>;
|
|
50
58
|
}
|
|
59
|
+
export declare function statusIndicatesDone(status: string | undefined): boolean | undefined;
|
|
51
60
|
export {};
|
|
@@ -100,8 +100,16 @@ export class JamInventory extends JamBase {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
export class JamResourceContext extends JamBase {
|
|
103
|
+
/**
|
|
104
|
+
* `jira_context` 가 아니라 `jira_full` 을 부른다.
|
|
105
|
+
*
|
|
106
|
+
* context 단계는 본문(description)을 싣지 않는다. 그런데 ResourceSnapshot 의 `body` 는
|
|
107
|
+
* 조사와 계약 초안이 완료 조건을 읽는 유일한 자리다 — 그것을 비운 채 넘기면 "작업 항목에
|
|
108
|
+
* 완료 조건이 없다"는 **거짓 사실**이 만들어지고, 없는 조건을 사람에게 되묻게 된다.
|
|
109
|
+
* provider 자신의 지침도 계약·합의 판정에는 full 을 쓰라고 말한다.
|
|
110
|
+
*/
|
|
103
111
|
async getResource(reference) {
|
|
104
|
-
const response = await this.client.callTool('
|
|
112
|
+
const response = await this.client.callTool('jira_full', { issueKeys: [reference] });
|
|
105
113
|
if (!response.ok)
|
|
106
114
|
return missing(reference);
|
|
107
115
|
const issue = response.value.issues?.[0];
|
|
@@ -109,10 +117,17 @@ export class JamResourceContext extends JamBase {
|
|
|
109
117
|
// 우리도 합쳐진 사실 이상을 지어내지 않는다.
|
|
110
118
|
if (!issue)
|
|
111
119
|
return missing(reference);
|
|
120
|
+
// 막는 것을 먼저 싣는다. Core 는 이 배열의 **순서만** 알고 종류는 모른다 — 순서가
|
|
121
|
+
// 곧 "무엇부터 확인해야 하는가" 다. 상한에 걸려 잘릴 때 blocker 가 부모·하위 작업
|
|
122
|
+
// 뒤에 밀려 사라지는 것을 막는다.
|
|
123
|
+
const blocking = (issue.links ?? []).filter((link) => link.blocksThisIssue === true);
|
|
124
|
+
const otherLinks = (issue.links ?? []).filter((link) => link.blocksThisIssue !== true);
|
|
125
|
+
const blockedBy = blocking.flatMap((link) => (link.issue?.key ? [link.issue.key] : []));
|
|
112
126
|
const related = [
|
|
127
|
+
...blockedBy,
|
|
128
|
+
...otherLinks.flatMap((link) => (link.issue?.key ? [link.issue.key] : [])),
|
|
113
129
|
...(issue.parent?.key ? [issue.parent.key] : []),
|
|
114
130
|
...(issue.subtasks ?? []).flatMap((sub) => (sub.key ? [sub.key] : [])),
|
|
115
|
-
...(issue.links ?? []).flatMap((link) => (link.issue?.key ? [link.issue.key] : [])),
|
|
116
131
|
];
|
|
117
132
|
return {
|
|
118
133
|
reference,
|
|
@@ -124,6 +139,7 @@ export class JamResourceContext extends JamBase {
|
|
|
124
139
|
updatedAt: issue.updated,
|
|
125
140
|
revisionMarker: issue.updated,
|
|
126
141
|
...(related.length > 0 ? { related } : {}),
|
|
142
|
+
...(blockedBy.length > 0 ? { blockedBy } : {}),
|
|
127
143
|
};
|
|
128
144
|
}
|
|
129
145
|
/**
|
|
@@ -165,3 +181,34 @@ const missing = (reference) => ({
|
|
|
165
181
|
revisionMarker: '',
|
|
166
182
|
missing: true,
|
|
167
183
|
});
|
|
184
|
+
/**
|
|
185
|
+
* tracker 가 "끝났다"고 말하는가. **판정이 아니라 어휘 번역이다** — Core 는 Jira 상태
|
|
186
|
+
* 문자열을 알면 안 되고(C-09 §6.1), 그렇다고 상태 문자열을 아무도 안 읽으면 "진행 중인데
|
|
187
|
+
* 이미 병합됨"을 알아볼 수 없다. 그 번역을 adapter 자리에서 한 줄로 한다.
|
|
188
|
+
*
|
|
189
|
+
* 모르는 어휘는 undefined 로 남긴다 — 모르는 것을 "안 끝났다"로 읽으면 없는 stale 을 만든다.
|
|
190
|
+
*
|
|
191
|
+
* ponytail: 닫힘 상태 목록은 휴리스틱이다. JAM 이 statusCategory 를 실어 주면 그것으로 바꾼다.
|
|
192
|
+
*/
|
|
193
|
+
const DONE_STATUSES = new Set(['done', 'closed', 'resolved', 'complete', 'completed', '완료', '닫힘', '해결됨']);
|
|
194
|
+
const OPEN_STATUSES = new Set([
|
|
195
|
+
'to do',
|
|
196
|
+
'todo',
|
|
197
|
+
'open',
|
|
198
|
+
'in progress',
|
|
199
|
+
'in review',
|
|
200
|
+
'backlog',
|
|
201
|
+
'해야 할 일',
|
|
202
|
+
'진행 중',
|
|
203
|
+
'검토 중',
|
|
204
|
+
]);
|
|
205
|
+
export function statusIndicatesDone(status) {
|
|
206
|
+
if (!status)
|
|
207
|
+
return undefined;
|
|
208
|
+
const normalized = status.trim().toLowerCase();
|
|
209
|
+
if (DONE_STATUSES.has(normalized))
|
|
210
|
+
return true;
|
|
211
|
+
if (OPEN_STATUSES.has(normalized))
|
|
212
|
+
return false;
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LocalRepoPort, RepoObservation, RepoQuery } from '../../ports/local-repo.ts';
|
|
2
|
+
/** 명령 실행. 성공하면 stdout, 실패하면 null — 종료코드로 답하는 질문이 있어서다. */
|
|
3
|
+
export type GitRunner = (args: readonly string[]) => Promise<string | null>;
|
|
4
|
+
export type LocalRepoDeps = {
|
|
5
|
+
cwd: string;
|
|
6
|
+
git?: GitRunner;
|
|
7
|
+
/** 경로 존재 확인. 테스트에서 갈아끼운다. */
|
|
8
|
+
exists?: (path: string) => Promise<boolean>;
|
|
9
|
+
};
|
|
10
|
+
export declare class LocalRepoAdapter implements LocalRepoPort {
|
|
11
|
+
#private;
|
|
12
|
+
readonly id = "local-repo";
|
|
13
|
+
constructor(deps: LocalRepoDeps);
|
|
14
|
+
observe(query: RepoQuery): Promise<RepoObservation>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// 로컬 git 관측. 읽기 전용 서브커맨드만 쓰고, 아무것도 바꾸지 않는다.
|
|
2
|
+
//
|
|
3
|
+
// 실패는 관측 안에서 흡수한다 — git 이 없거나 저장소가 아니면 `unavailable` 이 채워진
|
|
4
|
+
// 빈 관측이 나온다. 던지지 않는 이유는 호출측이 "보려 했으나 못 봤다"와 "아예 안 봤다"를
|
|
5
|
+
// 구분해야 하기 때문이다. 후자는 이 함수를 부르지 않은 것이고, 그건 판정에서 거부된다.
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { access } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const defaultGit = (cwd) => async (args) => {
|
|
12
|
+
try {
|
|
13
|
+
const { stdout } = await run('git', ['-C', cwd, ...args]);
|
|
14
|
+
return stdout;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const defaultExists = async (path) => {
|
|
21
|
+
try {
|
|
22
|
+
await access(path);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
export class LocalRepoAdapter {
|
|
30
|
+
id = 'local-repo';
|
|
31
|
+
#cwd;
|
|
32
|
+
#git;
|
|
33
|
+
#exists;
|
|
34
|
+
constructor(deps) {
|
|
35
|
+
this.#cwd = deps.cwd;
|
|
36
|
+
this.#git = deps.git ?? defaultGit(deps.cwd);
|
|
37
|
+
this.#exists = deps.exists ?? defaultExists;
|
|
38
|
+
}
|
|
39
|
+
async observe(query) {
|
|
40
|
+
const head = await this.#git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
41
|
+
if (head === null) {
|
|
42
|
+
return {
|
|
43
|
+
branch: null,
|
|
44
|
+
remotes: [],
|
|
45
|
+
refs: [],
|
|
46
|
+
pathsExist: {},
|
|
47
|
+
unavailable: 'git 저장소를 읽지 못했다 (git 부재 또는 저장소 아님)',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const observation = {
|
|
51
|
+
branch: head.trim() || null,
|
|
52
|
+
remotes: parseRemotes(await this.#git(['remote', '-v'])),
|
|
53
|
+
refs: [],
|
|
54
|
+
pathsExist: {},
|
|
55
|
+
};
|
|
56
|
+
if (query.refHint) {
|
|
57
|
+
observation.refs = filterRefs(await this.#git(['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes']), query.refHint);
|
|
58
|
+
}
|
|
59
|
+
const canonicalRef = query.canonicalRef ?? (await this.#defaultCanonicalRef());
|
|
60
|
+
if (canonicalRef) {
|
|
61
|
+
observation.canonicalRef = canonicalRef;
|
|
62
|
+
observation.mergedIntoCanonical = await this.#anyMerged(observation.refs, canonicalRef);
|
|
63
|
+
if (query.refHint) {
|
|
64
|
+
// 가지가 지워졌어도 이력은 남는다 — 커밋 메시지가 이 작업을 언급하는지 본다.
|
|
65
|
+
const log = await this.#git(['log', '--format=%h %s', `--grep=${query.refHint}`, '-n', '5', canonicalRef]);
|
|
66
|
+
const mentions = (log ?? '')
|
|
67
|
+
.split('\n')
|
|
68
|
+
.map((line) => line.trim())
|
|
69
|
+
.filter((line) => line.length > 0);
|
|
70
|
+
observation.mentionedOnCanonical = mentions;
|
|
71
|
+
if (mentions.length > 0) {
|
|
72
|
+
// 언급만으로는 부족하다. 무엇을 건드린 커밋인지, 그 결과가 지금도 남아 있는지
|
|
73
|
+
// 본다 — 되돌린 커밋도 이 작업을 "언급"하기 때문이다.
|
|
74
|
+
const survival = await this.#survivalOf(mentions, canonicalRef);
|
|
75
|
+
observation.mentionedOnlyReverts = survival.onlyReverts;
|
|
76
|
+
if (survival.artifactsPresent !== undefined) {
|
|
77
|
+
observation.mentionedArtifactsPresent = survival.artifactsPresent;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const path of query.paths ?? []) {
|
|
83
|
+
observation.pathsExist[path] = await this.#exists(join(this.#cwd, path));
|
|
84
|
+
}
|
|
85
|
+
if ((query.modulePaths?.length ?? 0) > 0) {
|
|
86
|
+
const modules = {};
|
|
87
|
+
for (const path of query.modulePaths ?? [])
|
|
88
|
+
modules[path] = await this.#exists(join(this.#cwd, path));
|
|
89
|
+
observation.modulesPresent = modules;
|
|
90
|
+
}
|
|
91
|
+
if (canonicalRef && (query.paths?.length ?? 0) > 0) {
|
|
92
|
+
const onCanonical = {};
|
|
93
|
+
for (const path of query.paths ?? []) {
|
|
94
|
+
// squash 병합이면 ref 는 조상이 아니다. 산출물이 정본에 있는지는 따로 물어야 한다.
|
|
95
|
+
onCanonical[path] = (await this.#git(['cat-file', '-e', `${canonicalRef}:${path}`])) !== null;
|
|
96
|
+
}
|
|
97
|
+
observation.pathsOnCanonical = onCanonical;
|
|
98
|
+
}
|
|
99
|
+
return observation;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Profile 이 정본 ref 를 선언하지 않았을 때, 저장소 자신에게 묻는다 (origin/HEAD).
|
|
103
|
+
* 추측이 아니라 관측이다 — 없으면 없는 대로 둔다.
|
|
104
|
+
*/
|
|
105
|
+
async #defaultCanonicalRef() {
|
|
106
|
+
const head = await this.#git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
|
107
|
+
return head?.trim() || undefined;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 언급 커밋들이 남긴 것이 지금도 있는가. 되돌리기만 있으면 그 사실을 따로 말한다.
|
|
111
|
+
*
|
|
112
|
+
* 파일 목록을 하나도 못 읽으면 `artifactsPresent` 는 undefined 다 — "없다"가 아니라
|
|
113
|
+
* "확인하지 못했다" 이고, 그 둘을 합치면 판정이 거짓을 만든다.
|
|
114
|
+
*/
|
|
115
|
+
async #survivalOf(mentions, canonicalRef) {
|
|
116
|
+
const commits = mentions.map((line) => {
|
|
117
|
+
const at = line.indexOf(' ');
|
|
118
|
+
return { hash: at > 0 ? line.slice(0, at) : line, subject: at > 0 ? line.slice(at + 1) : '' };
|
|
119
|
+
});
|
|
120
|
+
const onlyReverts = commits.every((commit) => /^revert\b/i.test(commit.subject.trim()));
|
|
121
|
+
let readAny = false;
|
|
122
|
+
for (const commit of commits) {
|
|
123
|
+
if (/^revert\b/i.test(commit.subject.trim()))
|
|
124
|
+
continue;
|
|
125
|
+
const listed = await this.#git(['show', '--name-status', '--format=', commit.hash]);
|
|
126
|
+
if (listed === null)
|
|
127
|
+
continue;
|
|
128
|
+
readAny = true;
|
|
129
|
+
for (const path of changedPaths(listed).slice(0, 20)) {
|
|
130
|
+
if ((await this.#git(['cat-file', '-e', `${canonicalRef}:${path}`])) !== null) {
|
|
131
|
+
return { onlyReverts, artifactsPresent: true };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return readAny ? { onlyReverts, artifactsPresent: false } : { onlyReverts };
|
|
136
|
+
}
|
|
137
|
+
async #anyMerged(refs, canonicalRef) {
|
|
138
|
+
for (const ref of refs) {
|
|
139
|
+
if ((await this.#git(['merge-base', '--is-ancestor', ref, canonicalRef])) !== null)
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function parseRemotes(stdout) {
|
|
146
|
+
if (!stdout)
|
|
147
|
+
return [];
|
|
148
|
+
const seen = new Map();
|
|
149
|
+
for (const line of stdout.split('\n')) {
|
|
150
|
+
const [name, url] = line.trim().split(/\s+/);
|
|
151
|
+
if (name && url && !seen.has(name))
|
|
152
|
+
seen.set(name, url);
|
|
153
|
+
}
|
|
154
|
+
return [...seen].map(([name, url]) => ({ name, url }));
|
|
155
|
+
}
|
|
156
|
+
function filterRefs(stdout, hint) {
|
|
157
|
+
if (!stdout)
|
|
158
|
+
return [];
|
|
159
|
+
const needle = hint.toLowerCase();
|
|
160
|
+
return stdout
|
|
161
|
+
.split('\n')
|
|
162
|
+
.map((line) => line.trim())
|
|
163
|
+
.filter((ref) => ref.length > 0 && ref.toLowerCase().includes(needle));
|
|
164
|
+
}
|
|
165
|
+
/** `--name-status` 출력에서 지금도 존재할 수 있는 경로만. 삭제(D)는 세지 않는다. */
|
|
166
|
+
function changedPaths(stdout) {
|
|
167
|
+
const paths = [];
|
|
168
|
+
for (const line of stdout.split('\n')) {
|
|
169
|
+
const parts = line.trim().split(/\s+/);
|
|
170
|
+
const status = parts[0];
|
|
171
|
+
if (!status || status.startsWith('D'))
|
|
172
|
+
continue;
|
|
173
|
+
// 이름이 바뀐 경우(R100 old new)는 새 이름이 지금의 경로다.
|
|
174
|
+
const path = parts[parts.length - 1];
|
|
175
|
+
if (path && path !== status)
|
|
176
|
+
paths.push(path);
|
|
177
|
+
}
|
|
178
|
+
return paths;
|
|
179
|
+
}
|
|
@@ -138,6 +138,13 @@ export class MarkdownStateStore {
|
|
|
138
138
|
async create(kind, entity) {
|
|
139
139
|
const id = String(entity[ENTITY_KEY[kind]]);
|
|
140
140
|
const file = entityFile(this.#root, kind, id);
|
|
141
|
+
// 회수돼 보관된 id 도 이미 쓴 id 다. 이것을 막지 않으면 같은 이름의 두 계약이 생기고,
|
|
142
|
+
// 보관 파일이 덮이면서 앞의 기록이 사라진다 (실제로 그렇게 잃었다).
|
|
143
|
+
const archived = join(archiveDir(this.#root, kind), `${toFileName(id)}.md`);
|
|
144
|
+
if (await exists(archived)) {
|
|
145
|
+
const current = (await this.#read(kind, archived));
|
|
146
|
+
return { ok: false, reason: 'ALREADY_EXISTS', current };
|
|
147
|
+
}
|
|
141
148
|
try {
|
|
142
149
|
// 'wx'가 원자적 배타 생성이라 락 없이도 중복이 걸린다
|
|
143
150
|
await writeFile(file, serializeEntity(kind, entity), { encoding: 'utf8', flag: 'wx' });
|
|
@@ -177,7 +184,12 @@ export class MarkdownStateStore {
|
|
|
177
184
|
return false;
|
|
178
185
|
const dir = archiveDir(this.#root, kind);
|
|
179
186
|
await mkdir(dir, { recursive: true });
|
|
180
|
-
|
|
187
|
+
const to = join(dir, `${toFileName(id)}.md`);
|
|
188
|
+
// 보관은 덮어쓰기가 아니다. 같은 이름이 이미 있으면 옮기지 않고 그대로 둔다 —
|
|
189
|
+
// 기록 하나를 살리자고 다른 기록을 지우지 않는다.
|
|
190
|
+
if (await exists(to))
|
|
191
|
+
return false;
|
|
192
|
+
await rename(from, to);
|
|
181
193
|
return true;
|
|
182
194
|
}
|
|
183
195
|
// ── Control State ─────────────────────────────────────────────────────────
|
|
@@ -36,6 +36,10 @@ export class MemoryStateStore {
|
|
|
36
36
|
const existing = table.get(id);
|
|
37
37
|
if (existing)
|
|
38
38
|
return { ok: false, reason: 'ALREADY_EXISTS', current: clone(existing) };
|
|
39
|
+
// 회수된 것도 쓴 id 다. 다시 쓰면 그 기록 위에 다른 계약이 앉는다.
|
|
40
|
+
const archived = this.#archived.get(kind)?.get(id);
|
|
41
|
+
if (archived)
|
|
42
|
+
return { ok: false, reason: 'ALREADY_EXISTS', current: clone(archived) };
|
|
39
43
|
table.set(id, clone(entity));
|
|
40
44
|
return { ok: true, entity: clone(entity) };
|
|
41
45
|
}
|
package/dist/cli/asc.d.ts
CHANGED
|
@@ -6,4 +6,10 @@
|
|
|
6
6
|
* 설치하는 것은 말이 안 되고, 그것을 계획에 넣으면 매 setup이 npm을 부른다.
|
|
7
7
|
*/
|
|
8
8
|
export type AscEntry = 'runtime' | 'bootstrap';
|
|
9
|
+
/**
|
|
10
|
+
* bootstrap이 자기 USAGE에 적을 값. **거기서 손으로 적지 않게 하려고 내보낸다** —
|
|
11
|
+
* 두 패키지가 각자 버전 문자열을 들면 릴리스마다 한쪽이 뒤처지고, 그 지연은 곧
|
|
12
|
+
* 사용자가 실행하는 명령이 된다 (0.2.0 회차의 skill.ts가 그랬다).
|
|
13
|
+
*/
|
|
14
|
+
export { BOOTSTRAP_SPEC } from '../core/distribution/release.ts';
|
|
9
15
|
export declare function runAscCommand(argv: string[], entry?: AscEntry): Promise<number>;
|