@tienne/gestalt 0.83.1 → 0.84.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 (153) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.ko.md +3 -1
  3. package/README.md +3 -1
  4. package/dist/package.json +1 -1
  5. package/dist/plugin/review-agents/harness-reviewer/AGENT.md +211 -0
  6. package/dist/plugin/role-agents/_shared/references/ai-tell-quick-rules.md +4 -1
  7. package/dist/plugin/role-agents/_shared/references/comment-rules.md +3 -3
  8. package/dist/plugin/role-agents/_shared/references/rule-path-walk.md +1 -1
  9. package/dist/plugin/role-agents/_shared/references/style-guide.md +1 -0
  10. package/dist/plugin/role-agents/_shared/references/truncation-rules.md +1 -1
  11. package/dist/plugin/role-agents/suggestion-verifier/AGENT.md +7 -1
  12. package/dist/plugin/skills/_shared/proactive-routing.md +1 -0
  13. package/dist/plugin/skills/_shared/rule-sources.md +1 -1
  14. package/dist/plugin/skills/agent/SKILL.md +2 -0
  15. package/dist/plugin/skills/review/SKILL.md +343 -7
  16. package/dist/plugin/skills/review-loop/SKILL.md +124 -2
  17. package/dist/plugin/skills/ship/SKILL.md +40 -1
  18. package/dist/schemas/gestalt.schema.json +12 -0
  19. package/dist/src/cli/commands/approve-gate.d.ts +24 -0
  20. package/dist/src/cli/commands/approve-gate.d.ts.map +1 -0
  21. package/dist/src/cli/commands/approve-gate.js +90 -0
  22. package/dist/src/cli/commands/approve-gate.js.map +1 -0
  23. package/dist/src/cli/commands/harness-refs-cross-pr.d.ts +218 -0
  24. package/dist/src/cli/commands/harness-refs-cross-pr.d.ts.map +1 -0
  25. package/dist/src/cli/commands/harness-refs-cross-pr.js +934 -0
  26. package/dist/src/cli/commands/harness-refs-cross-pr.js.map +1 -0
  27. package/dist/src/cli/commands/harness-refs.d.ts +19 -0
  28. package/dist/src/cli/commands/harness-refs.d.ts.map +1 -0
  29. package/dist/src/cli/commands/harness-refs.js +51 -0
  30. package/dist/src/cli/commands/harness-refs.js.map +1 -0
  31. package/dist/src/cli/index.d.ts.map +1 -1
  32. package/dist/src/cli/index.js +97 -0
  33. package/dist/src/cli/index.js.map +1 -1
  34. package/dist/src/core/config.d.ts +3 -0
  35. package/dist/src/core/config.d.ts.map +1 -1
  36. package/dist/src/core/config.js +23 -0
  37. package/dist/src/core/config.js.map +1 -1
  38. package/dist/src/core/types.d.ts +2 -0
  39. package/dist/src/core/types.d.ts.map +1 -1
  40. package/dist/src/harness-review/approve-gate.d.ts +53 -0
  41. package/dist/src/harness-review/approve-gate.d.ts.map +1 -0
  42. package/dist/src/harness-review/approve-gate.js +148 -0
  43. package/dist/src/harness-review/approve-gate.js.map +1 -0
  44. package/dist/src/harness-review/backward-search.d.ts +50 -0
  45. package/dist/src/harness-review/backward-search.d.ts.map +1 -0
  46. package/dist/src/harness-review/backward-search.js +183 -0
  47. package/dist/src/harness-review/backward-search.js.map +1 -0
  48. package/dist/src/harness-review/collect.d.ts +75 -0
  49. package/dist/src/harness-review/collect.d.ts.map +1 -0
  50. package/dist/src/harness-review/collect.js +253 -0
  51. package/dist/src/harness-review/collect.js.map +1 -0
  52. package/dist/src/harness-review/copy-drift.d.ts +83 -0
  53. package/dist/src/harness-review/copy-drift.d.ts.map +1 -0
  54. package/dist/src/harness-review/copy-drift.js +477 -0
  55. package/dist/src/harness-review/copy-drift.js.map +1 -0
  56. package/dist/src/harness-review/follow-up-marker.d.ts +29 -0
  57. package/dist/src/harness-review/follow-up-marker.d.ts.map +1 -0
  58. package/dist/src/harness-review/follow-up-marker.js +143 -0
  59. package/dist/src/harness-review/follow-up-marker.js.map +1 -0
  60. package/dist/src/harness-review/forward-search.d.ts +30 -0
  61. package/dist/src/harness-review/forward-search.d.ts.map +1 -0
  62. package/dist/src/harness-review/forward-search.js +381 -0
  63. package/dist/src/harness-review/forward-search.js.map +1 -0
  64. package/dist/src/harness-review/github-backend.d.ts +42 -0
  65. package/dist/src/harness-review/github-backend.d.ts.map +1 -0
  66. package/dist/src/harness-review/github-backend.js +130 -0
  67. package/dist/src/harness-review/github-backend.js.map +1 -0
  68. package/dist/src/harness-review/github-code-search.d.ts +46 -0
  69. package/dist/src/harness-review/github-code-search.d.ts.map +1 -0
  70. package/dist/src/harness-review/github-code-search.js +94 -0
  71. package/dist/src/harness-review/github-code-search.js.map +1 -0
  72. package/dist/src/harness-review/identifiers.d.ts +75 -0
  73. package/dist/src/harness-review/identifiers.d.ts.map +1 -0
  74. package/dist/src/harness-review/identifiers.js +678 -0
  75. package/dist/src/harness-review/identifiers.js.map +1 -0
  76. package/dist/src/harness-review/placeholders.d.ts +44 -0
  77. package/dist/src/harness-review/placeholders.d.ts.map +1 -0
  78. package/dist/src/harness-review/placeholders.js +138 -0
  79. package/dist/src/harness-review/placeholders.js.map +1 -0
  80. package/dist/src/harness-review/related-pr.d.ts +133 -0
  81. package/dist/src/harness-review/related-pr.d.ts.map +1 -0
  82. package/dist/src/harness-review/related-pr.js +553 -0
  83. package/dist/src/harness-review/related-pr.js.map +1 -0
  84. package/dist/src/harness-review/related-repos.d.ts +69 -0
  85. package/dist/src/harness-review/related-repos.d.ts.map +1 -0
  86. package/dist/src/harness-review/related-repos.js +430 -0
  87. package/dist/src/harness-review/related-repos.js.map +1 -0
  88. package/dist/src/harness-review/remote-url.d.ts +8 -0
  89. package/dist/src/harness-review/remote-url.d.ts.map +1 -0
  90. package/dist/src/harness-review/remote-url.js +35 -0
  91. package/dist/src/harness-review/remote-url.js.map +1 -0
  92. package/dist/src/harness-review/replay.d.ts +163 -0
  93. package/dist/src/harness-review/replay.d.ts.map +1 -0
  94. package/dist/src/harness-review/replay.js +575 -0
  95. package/dist/src/harness-review/replay.js.map +1 -0
  96. package/dist/src/harness-review/rule-id-lists.d.ts +38 -0
  97. package/dist/src/harness-review/rule-id-lists.d.ts.map +1 -0
  98. package/dist/src/harness-review/rule-id-lists.js +189 -0
  99. package/dist/src/harness-review/rule-id-lists.js.map +1 -0
  100. package/dist/src/harness-review/search-backend.d.ts +59 -0
  101. package/dist/src/harness-review/search-backend.d.ts.map +1 -0
  102. package/dist/src/harness-review/search-backend.js +178 -0
  103. package/dist/src/harness-review/search-backend.js.map +1 -0
  104. package/dist/src/harness-review/self-contamination.d.ts +83 -0
  105. package/dist/src/harness-review/self-contamination.d.ts.map +1 -0
  106. package/dist/src/harness-review/self-contamination.js +560 -0
  107. package/dist/src/harness-review/self-contamination.js.map +1 -0
  108. package/dist/src/harness-review/self-references.d.ts +20 -0
  109. package/dist/src/harness-review/self-references.d.ts.map +1 -0
  110. package/dist/src/harness-review/self-references.js +173 -0
  111. package/dist/src/harness-review/self-references.js.map +1 -0
  112. package/dist/src/harness-review/three-state.d.ts +79 -0
  113. package/dist/src/harness-review/three-state.d.ts.map +1 -0
  114. package/dist/src/harness-review/three-state.js +238 -0
  115. package/dist/src/harness-review/three-state.js.map +1 -0
  116. package/dist/src/harness-review/types.d.ts +148 -0
  117. package/dist/src/harness-review/types.d.ts.map +1 -0
  118. package/dist/src/harness-review/types.js +74 -0
  119. package/dist/src/harness-review/types.js.map +1 -0
  120. package/dist/src/humanize/detectors.d.ts.map +1 -1
  121. package/dist/src/humanize/detectors.js +3 -1
  122. package/dist/src/humanize/detectors.js.map +1 -1
  123. package/dist/src/index.d.ts +1 -0
  124. package/dist/src/index.d.ts.map +1 -1
  125. package/dist/src/index.js +1 -0
  126. package/dist/src/index.js.map +1 -1
  127. package/dist/src/mcp/tools/review-passthrough.js +7 -1
  128. package/dist/src/mcp/tools/review-passthrough.js.map +1 -1
  129. package/dist/src/review/agent-matcher.d.ts +16 -1
  130. package/dist/src/review/agent-matcher.d.ts.map +1 -1
  131. package/dist/src/review/agent-matcher.js +31 -4
  132. package/dist/src/review/agent-matcher.js.map +1 -1
  133. package/dist/src/review/passthrough-engine.d.ts.map +1 -1
  134. package/dist/src/review/passthrough-engine.js +40 -2
  135. package/dist/src/review/passthrough-engine.js.map +1 -1
  136. package/package.json +1 -1
  137. package/plugin/.codex-plugin/plugin.json +1 -1
  138. package/plugin/.mcp.json +1 -1
  139. package/plugin/mcp.json +1 -1
  140. package/plugin/review-agents/harness-reviewer/AGENT.md +211 -0
  141. package/plugin/role-agents/_shared/references/ai-tell-quick-rules.md +4 -1
  142. package/plugin/role-agents/_shared/references/comment-rules.md +3 -3
  143. package/plugin/role-agents/_shared/references/rule-path-walk.md +1 -1
  144. package/plugin/role-agents/_shared/references/style-guide.md +1 -0
  145. package/plugin/role-agents/_shared/references/truncation-rules.md +1 -1
  146. package/plugin/role-agents/suggestion-verifier/AGENT.md +7 -1
  147. package/plugin/skills/_shared/proactive-routing.md +1 -0
  148. package/plugin/skills/_shared/rule-sources.md +1 -1
  149. package/plugin/skills/agent/SKILL.md +2 -0
  150. package/plugin/skills/review/SKILL.md +343 -7
  151. package/plugin/skills/review-loop/SKILL.md +124 -2
  152. package/plugin/skills/ship/SKILL.md +40 -1
  153. package/schemas/gestalt.schema.json +12 -0
@@ -0,0 +1,934 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { loadConfig } from '../../core/config.js';
4
+ import { parseRepoDirs } from '../../harness-review/collect.js';
5
+ import { buildFollowUpMarker, matchFollowUp, parseFollowUpMarkers, isFulfilled, } from '../../harness-review/follow-up-marker.js';
6
+ import { classifyGhFailure } from '../../harness-review/github-backend.js';
7
+ import { MERGED_LOOKBACK_DAYS, RELATED_PR_MODES, confirmedRelatedPrs, findRelatedPrs, } from '../../harness-review/related-pr.js';
8
+ import { readOriginRepo } from '../../harness-review/related-repos.js';
9
+ import { judgeThreeState, needsRelatedPr, } from '../../harness-review/three-state.js';
10
+ import { CHANGE_TYPES, CONFIRMATION_TYPES, FOUND_BY_TYPES, IDENTIFIER_KINDS, isAwaitingConfirmation, PR_STATES, } from '../../harness-review/types.js';
11
+ import { runGh } from '../../review-loop/fetch.js';
12
+ import { parseTarget } from '../../review-loop/target.js';
13
+ const REPO_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+$/;
14
+ const SHA_RE = /^[0-9a-f]{7,64}$/;
15
+ // gh 응답에서 온 브랜치 이름을 git 인자로 넘긴다. 옵션처럼 읽히는 값과 상위 경로를 막는다
16
+ const BRANCH_RE = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]+$/;
17
+ const GH_BLOCK_REASONS = new Set(['notLoggedIn', 'noPermission', 'rateLimited']);
18
+ const DAY_MS = 24 * 60 * 60 * 1000;
19
+ /** 세 상태 판정 쌍 상한. 식별자와 레포 곱이 커져도 git 호출 수가 묶인다 */
20
+ export const MAX_THREE_STATE_PAIRS = 100;
21
+ /** 후속 마커를 찾으려고 레포마다 훑는 머지된 PR 상한 */
22
+ export const MAX_FOLLOWUP_PRS_PER_REPO = 30;
23
+ const defaultRunGit = (dir, args) => {
24
+ const r = spawnSync('git', ['-C', dir, ...args], {
25
+ encoding: 'utf8',
26
+ maxBuffer: 64 * 1024 * 1024,
27
+ });
28
+ if (r.error)
29
+ throw r.error;
30
+ return { status: r.status ?? 1, stdout: r.stdout };
31
+ };
32
+ function configRelatedRepos(deps) {
33
+ if (deps.configRepos)
34
+ return deps.configRepos();
35
+ try {
36
+ return loadConfig({}, { skipDotEnv: true }).relatedRepos;
37
+ }
38
+ catch (e) {
39
+ // 설정이 깨져도 다른 입력으로 돈다. 빠진 레포는 stderr로만 알린다
40
+ console.error('[gestalt] gestalt.json의 relatedRepos를 읽지 못했다:', e);
41
+ return [];
42
+ }
43
+ }
44
+ function assertRepo(value, label) {
45
+ if (!REPO_RE.test(value))
46
+ throw new Error(`${label}는 owner/name 꼴이어야 한다: ${value}`);
47
+ return value;
48
+ }
49
+ function uniqueRepos(repos) {
50
+ const seen = new Map();
51
+ for (const r of repos)
52
+ if (!seen.has(r.toLowerCase()))
53
+ seen.set(r.toLowerCase(), r);
54
+ return [...seen.values()];
55
+ }
56
+ function readTextFile(path, label) {
57
+ try {
58
+ return readFileSync(path, 'utf-8');
59
+ }
60
+ catch (e) {
61
+ throw new Error(`${label} 파일을 못 읽었다: ${path}`, { cause: e });
62
+ }
63
+ }
64
+ function readJsonFile(path, label) {
65
+ const text = readTextFile(path, label);
66
+ try {
67
+ return JSON.parse(text);
68
+ }
69
+ catch (e) {
70
+ throw new Error(`${label} 파일이 JSON이 아니다: ${path}`, { cause: e });
71
+ }
72
+ }
73
+ function isRecord(v) {
74
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
75
+ }
76
+ function ghFailure(e) {
77
+ const { stderr, message } = (isRecord(e) ? e : {});
78
+ const detail = [stderr, message]
79
+ .filter((v) => v !== undefined && v !== null)
80
+ .map(String)
81
+ .join('\n')
82
+ .trim();
83
+ return { reason: classifyGhFailure(detail) ?? 'unknown', detail: detail || String(e) };
84
+ }
85
+ function readCollectResult(path) {
86
+ const raw = readJsonFile(path, '--candidates');
87
+ if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.candidates)) {
88
+ throw new Error(`--candidates는 harness-refs collect --json 결과여야 한다: ${path}`);
89
+ }
90
+ const candidates = {};
91
+ for (const [k, v] of Object.entries(raw.candidates))
92
+ if (Array.isArray(v))
93
+ candidates[k] = v;
94
+ return {
95
+ repo: typeof raw.repo === 'string' ? raw.repo : '',
96
+ relatedRepos: Array.isArray(raw.relatedRepos)
97
+ ? raw.relatedRepos.filter((r) => typeof r === 'string' && REPO_RE.test(r))
98
+ : [],
99
+ identifiers: Array.isArray(raw.identifiers)
100
+ ? raw.identifiers.flatMap((i) => {
101
+ const id = toIdentifier(i);
102
+ return id ? [id] : [];
103
+ })
104
+ : [],
105
+ candidates,
106
+ };
107
+ }
108
+ function toIdentifier(raw) {
109
+ if (!isRecord(raw))
110
+ return null;
111
+ const { kind, value, changeType, extractedBy } = raw;
112
+ if (typeof kind !== 'string' || !IDENTIFIER_KINDS.includes(kind))
113
+ return null;
114
+ if (typeof value !== 'string' || value === '')
115
+ return null;
116
+ return {
117
+ kind: kind,
118
+ value,
119
+ changeType: CHANGE_TYPES.includes(changeType)
120
+ ? changeType
121
+ : 'modified',
122
+ extractedBy: (extractedBy === 'llm' ? 'llm' : 'pattern'),
123
+ };
124
+ }
125
+ // 다른 레포에 있는 참조 대상. 역방향 검색 결과의 repo와 path를 그대로 쓴다
126
+ function referenceTargetsOf(collect, selfRepo) {
127
+ const out = [];
128
+ const seen = new Set();
129
+ for (const list of Object.values(collect.candidates)) {
130
+ for (const c of list) {
131
+ if (!isRecord(c) || typeof c.targetRepo !== 'string' || typeof c.targetPath !== 'string')
132
+ continue;
133
+ if (c.targetPath === '' || c.targetRepo.toLowerCase() === selfRepo.toLowerCase())
134
+ continue;
135
+ if (!REPO_RE.test(c.targetRepo))
136
+ continue;
137
+ const key = `${c.targetRepo.toLowerCase()}\0${c.targetPath}`;
138
+ if (seen.has(key))
139
+ continue;
140
+ seen.add(key);
141
+ out.push({ repo: c.targetRepo, path: c.targetPath });
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ function resolveCurrentRepo(explicit, fromTarget, cwd, fallback) {
147
+ if (fromTarget && explicit && fromTarget.toLowerCase() !== explicit.toLowerCase()) {
148
+ throw new Error(`--pr URL의 레포(${fromTarget})와 --repo(${explicit})가 다르다`);
149
+ }
150
+ const chosen = fromTarget ?? explicit;
151
+ if (chosen)
152
+ return assertRepo(chosen, '--repo');
153
+ if (fallback && REPO_RE.test(fallback))
154
+ return fallback;
155
+ const origin = readOriginRepo(cwd);
156
+ if (origin)
157
+ return `${origin.owner}/${origin.name}`;
158
+ throw new Error('이번 PR의 레포를 못 정했다. --repo owner/name을 준다');
159
+ }
160
+ function parsePr(value) {
161
+ if (value === undefined)
162
+ return {};
163
+ const t = parseTarget(value);
164
+ return { number: t.prNumber, repo: t.owner && t.repo ? `${t.owner}/${t.repo}` : undefined };
165
+ }
166
+ export function runRelatedPrs(opts, deps = {}) {
167
+ if (!RELATED_PR_MODES.includes(opts.mode)) {
168
+ throw new Error(`--mode는 ${RELATED_PR_MODES.join('|')} 중 하나다: ${opts.mode}`);
169
+ }
170
+ const mode = opts.mode;
171
+ const gh = deps.gh ?? runGh;
172
+ const cwd = deps.cwd ?? process.cwd();
173
+ const pr = parsePr(opts.pr);
174
+ const collect = opts.candidates ? readCollectResult(opts.candidates) : null;
175
+ const extra = (opts.relatedRepo ?? []).map((r) => assertRepo(r, '--related-repo'));
176
+ const body = opts.bodyFile ? readTextFile(opts.bodyFile, '--body-file') : undefined;
177
+ const repo = resolveCurrentRepo(opts.repo, pr.repo, cwd, collect?.repo);
178
+ const relatedRepos = uniqueRepos([
179
+ ...(collect?.relatedRepos ?? []),
180
+ ...configRelatedRepos(deps),
181
+ ...extra,
182
+ ]).filter((r) => r.toLowerCase() !== repo.toLowerCase());
183
+ const limitations = [];
184
+ const blockedResult = (reason, detail) => ({
185
+ version: 1,
186
+ mode,
187
+ status: 'blocked',
188
+ current: {
189
+ repo,
190
+ number: pr.number ?? null,
191
+ branch: opts.branch ?? '',
192
+ author: opts.author ?? '',
193
+ },
194
+ relatedRepos,
195
+ candidates: [],
196
+ skipped: [
197
+ {
198
+ step: 'bodyLink',
199
+ repo,
200
+ number: pr.number,
201
+ reason: reason,
202
+ detail,
203
+ },
204
+ ],
205
+ notices: [],
206
+ suggestBodyLink: false,
207
+ ticketKeys: [],
208
+ confirmed: [],
209
+ unconfirmed: [],
210
+ relatedPrUnconfirmed: false,
211
+ lookupBlocked: true,
212
+ limitations: ['이번 PR 정보를 못 읽어 연관 PR 탐색을 시작하지 못했다'],
213
+ });
214
+ let current;
215
+ if (pr.number !== undefined) {
216
+ let raw;
217
+ try {
218
+ raw = JSON.parse(gh([
219
+ 'pr',
220
+ 'view',
221
+ String(pr.number),
222
+ '--repo',
223
+ repo,
224
+ '--json',
225
+ 'number,title,body,author,headRefName,createdAt',
226
+ ]));
227
+ }
228
+ catch (e) {
229
+ const f = ghFailure(e);
230
+ return blockedResult(f.reason, f.detail);
231
+ }
232
+ if (!isRecord(raw))
233
+ return blockedResult('unparsable', 'gh pr view 응답 형식이 다르다');
234
+ current = {
235
+ repo,
236
+ number: pr.number,
237
+ title: typeof raw.title === 'string' ? raw.title : '',
238
+ body: body ?? (typeof raw.body === 'string' ? raw.body : ''),
239
+ author: opts.author ??
240
+ (isRecord(raw.author) && typeof raw.author.login === 'string' ? raw.author.login : ''),
241
+ branch: opts.branch ?? (typeof raw.headRefName === 'string' ? raw.headRefName : ''),
242
+ ...(typeof raw.createdAt === 'string' ? { createdAt: raw.createdAt } : {}),
243
+ };
244
+ }
245
+ else {
246
+ // ship은 GitHub PR이 아직 없다. 브랜치와 작성자를 로컬에서 채운다
247
+ let author = opts.author;
248
+ if (author === undefined) {
249
+ try {
250
+ author = gh(['api', 'user', '--jq', '.login']).trim();
251
+ }
252
+ catch {
253
+ author = '';
254
+ limitations.push('gh 로그인을 못 읽어 같은 작성자 단계를 건너뛰었다. --author로 줄 수 있다');
255
+ }
256
+ }
257
+ current = {
258
+ repo,
259
+ title: opts.title ?? '',
260
+ body: body ?? '',
261
+ author,
262
+ branch: opts.branch ?? currentBranch(cwd),
263
+ };
264
+ }
265
+ const result = findRelatedPrs({
266
+ current,
267
+ mode,
268
+ relatedRepos,
269
+ referenceTargets: collect ? referenceTargetsOf(collect, repo) : [],
270
+ gh,
271
+ now: deps.now,
272
+ });
273
+ const lookupBlocked = result.skipped.some((s) => GH_BLOCK_REASONS.has(s.reason));
274
+ const incomplete = result.skipped.some((s) => s.reason !== 'outsideRelatedRepos');
275
+ if (incomplete && !lookupBlocked)
276
+ limitations.push('일부 조회가 실패해 후보가 빠졌을 수 있다');
277
+ if (relatedRepos.length === 0)
278
+ limitations.push('관련 레포가 없어 본문 링크 밖은 찾지 않았다');
279
+ const unconfirmed = result.candidates
280
+ .filter((c) => isAwaitingConfirmation(c.confirmation))
281
+ .map((c) => ({ repo: c.repo, number: c.number, confirmation: c.confirmation }));
282
+ return {
283
+ version: 1,
284
+ ...result,
285
+ status: lookupBlocked ? 'blocked' : result.candidates.length > 0 ? 'found' : 'none',
286
+ current: {
287
+ repo,
288
+ number: current.number ?? null,
289
+ branch: current.branch,
290
+ author: current.author,
291
+ },
292
+ relatedRepos,
293
+ confirmed: confirmedRelatedPrs(result.candidates).map(toRelatedPr),
294
+ unconfirmed,
295
+ relatedPrUnconfirmed: unconfirmed.length > 0,
296
+ lookupBlocked,
297
+ limitations,
298
+ };
299
+ }
300
+ function toRelatedPr(c) {
301
+ return {
302
+ repo: c.repo,
303
+ number: c.number,
304
+ headSha: c.headSha,
305
+ state: c.state,
306
+ ...(c.mergedBranch ? { mergedBranch: c.mergedBranch } : {}),
307
+ foundBy: c.foundBy,
308
+ confirmation: c.confirmation,
309
+ };
310
+ }
311
+ function currentBranch(cwd) {
312
+ try {
313
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
314
+ cwd,
315
+ encoding: 'utf-8',
316
+ stdio: ['ignore', 'pipe', 'ignore'],
317
+ }).trim();
318
+ }
319
+ catch {
320
+ return '';
321
+ }
322
+ }
323
+ function readRelatedPrs(path) {
324
+ const raw = readJsonFile(path, '--related-prs');
325
+ if (!isRecord(raw) || !Array.isArray(raw.candidates)) {
326
+ throw new Error(`--related-prs는 harness-refs related-prs --json 결과여야 한다: ${path}`);
327
+ }
328
+ let invalid = 0;
329
+ const prs = raw.candidates.flatMap((c) => {
330
+ const pr = toRelatedPrInput(c);
331
+ if (!pr)
332
+ invalid++;
333
+ return pr ? [pr] : [];
334
+ });
335
+ return { prs, lookupBlocked: raw.lookupBlocked === true || raw.status === 'blocked', invalid };
336
+ }
337
+ function toRelatedPrInput(c) {
338
+ if (!isRecord(c))
339
+ return null;
340
+ const { repo, number, headSha, state, mergedBranch, foundBy, confirmation } = c;
341
+ if (typeof repo !== 'string' || !REPO_RE.test(repo))
342
+ return null;
343
+ if (typeof number !== 'number' || !Number.isSafeInteger(number) || number <= 0)
344
+ return null;
345
+ if (typeof headSha !== 'string' || !SHA_RE.test(headSha))
346
+ return null;
347
+ if (typeof state !== 'string' || !PR_STATES.includes(state))
348
+ return null;
349
+ if (typeof foundBy !== 'string' || !FOUND_BY_TYPES.includes(foundBy))
350
+ return null;
351
+ if (typeof confirmation !== 'string' ||
352
+ !CONFIRMATION_TYPES.includes(confirmation))
353
+ return null;
354
+ if (mergedBranch !== undefined &&
355
+ (typeof mergedBranch !== 'string' || !BRANCH_RE.test(mergedBranch)))
356
+ return null;
357
+ return {
358
+ repo,
359
+ number,
360
+ headSha,
361
+ state: state,
362
+ ...(mergedBranch ? { mergedBranch } : {}),
363
+ foundBy: foundBy,
364
+ confirmation: confirmation,
365
+ };
366
+ }
367
+ function parseConfirm(values) {
368
+ const out = new Set();
369
+ for (const v of values) {
370
+ const m = /^([A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+)#(\d+)$/.exec(v);
371
+ if (!m)
372
+ throw new Error(`--confirm은 owner/name#번호 꼴이어야 한다: ${v}`);
373
+ out.add(`${m[1].toLowerCase()}#${Number(m[2])}`);
374
+ }
375
+ return out;
376
+ }
377
+ function parseDefaultBranches(values) {
378
+ const out = new Map();
379
+ for (const v of values) {
380
+ const eq = v.indexOf('=');
381
+ const repo = eq > 0 ? v.slice(0, eq) : '';
382
+ const branch = v.slice(eq + 1);
383
+ if (!REPO_RE.test(repo) || !BRANCH_RE.test(branch)) {
384
+ throw new Error(`--default-branch는 owner/name=브랜치 꼴이어야 한다: ${v}`);
385
+ }
386
+ out.set(repo.toLowerCase(), branch);
387
+ }
388
+ return out;
389
+ }
390
+ // 판정할 (식별자, 대상 레포) 쌍. 역방향 참조, 다른 레포 경로를 가리키는 순방향 참조,
391
+ // 그리고 연관 PR이 있는 레포마다 이번 PR의 식별자를 짝지은 쌍 순이다
392
+ function threeStatePairs(collect, selfRepo, reposWithPrs) {
393
+ const pairs = [];
394
+ const seen = new Set();
395
+ const add = (identifier, targetRepo, detected) => {
396
+ if (targetRepo.toLowerCase() === selfRepo.toLowerCase() || !REPO_RE.test(targetRepo))
397
+ return;
398
+ const key = `${targetRepo.toLowerCase()}\0${identifier.kind}\0${identifier.value}`;
399
+ if (seen.has(key))
400
+ return;
401
+ seen.add(key);
402
+ pairs.push({ identifier, targetRepo, detected });
403
+ };
404
+ for (const c of collect.candidates.backwardRef ?? []) {
405
+ if (!isRecord(c) || typeof c.targetRepo !== 'string')
406
+ continue;
407
+ const id = toIdentifier(c.identifier);
408
+ if (id)
409
+ add(id, c.targetRepo, true);
410
+ }
411
+ for (const c of collect.candidates.forwardRef ?? []) {
412
+ if (!isRecord(c) || typeof c.targetRepo !== 'string' || typeof c.targetPath !== 'string')
413
+ continue;
414
+ if (c.targetPath === '')
415
+ continue;
416
+ add({ kind: 'path', value: c.targetPath, changeType: 'modified', extractedBy: 'pattern' }, c.targetRepo, true);
417
+ }
418
+ for (const repo of reposWithPrs) {
419
+ for (const id of collect.identifiers)
420
+ if (id.extractedBy !== 'llm')
421
+ add(id, repo, false);
422
+ }
423
+ return pairs;
424
+ }
425
+ function detectDefaultBranch(dir, runGit) {
426
+ const r = runGit(dir, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
427
+ const ref = r.status === 0 ? r.stdout.trim() : '';
428
+ const branch = ref.startsWith('origin/') ? ref.slice('origin/'.length) : '';
429
+ return BRANCH_RE.test(branch) ? branch : 'main';
430
+ }
431
+ function hasCommit(dir, sha, runGit) {
432
+ return runGit(dir, ['cat-file', '-e', `${sha}^{commit}`]).status === 0;
433
+ }
434
+ // judgeThreeState는 fetch를 안 한다. 기본 브랜치, 머지된 브랜치, 연관 PR head를 여기서 먼저 받아 둔다
435
+ function fetchRefs(repo, dir, defaultBranch, prs, runGit) {
436
+ const fetches = [];
437
+ const fetch = (ref, refspec) => {
438
+ let r;
439
+ try {
440
+ r = runGit(dir, ['fetch', '--quiet', '--no-tags', 'origin', refspec]);
441
+ }
442
+ catch (e) {
443
+ fetches.push({ repo, ref, ok: false, detail: e instanceof Error ? e.message : String(e) });
444
+ return;
445
+ }
446
+ fetches.push(r.status === 0
447
+ ? { repo, ref, ok: true }
448
+ : { repo, ref, ok: false, detail: `git fetch status ${r.status}` });
449
+ };
450
+ const branches = new Set([defaultBranch]);
451
+ for (const pr of prs)
452
+ if (pr.mergedBranch)
453
+ branches.add(pr.mergedBranch);
454
+ for (const b of branches)
455
+ fetch(b, `+refs/heads/${b}:refs/remotes/origin/${b}`);
456
+ for (const pr of prs) {
457
+ if (hasCommit(dir, pr.headSha, runGit))
458
+ continue;
459
+ fetch(`pull/${pr.number}/head`, `refs/pull/${pr.number}/head`);
460
+ }
461
+ return fetches;
462
+ }
463
+ function statusOf(j) {
464
+ if (!j.checked)
465
+ return 'blocked';
466
+ if (!j.verdict)
467
+ return 'ok';
468
+ return j.verdict.verdict === 'notDefect_mergeOrder' ? 'mergeOrder' : j.verdict.verdict;
469
+ }
470
+ export async function runThreeState(opts, deps = {}) {
471
+ const runGit = deps.runGit ?? defaultRunGit;
472
+ const collect = readCollectResult(opts.candidates);
473
+ const related = readRelatedPrs(opts.relatedPrs);
474
+ const confirmedByUser = parseConfirm(opts.confirm ?? []);
475
+ const defaultBranches = parseDefaultBranches(opts.defaultBranch ?? []);
476
+ const currentRepo = resolveCurrentRepo(opts.repo, undefined, deps.cwd ?? process.cwd(), collect.repo);
477
+ const repoDirs = new Map(parseRepoDirs(opts.repoDir ?? [], currentRepo.split('/')[0] ?? null).map((d) => [
478
+ d.repo.toLowerCase(),
479
+ d,
480
+ ]));
481
+ const limitations = [];
482
+ if (related.invalid > 0)
483
+ limitations.push(`형식이 틀린 연관 PR ${related.invalid}개를 뺐다`);
484
+ // ship ⓐ에서 사용자가 확인한 후보만 확정으로 올린다. 목록에 없는 PR을 새로 만들지는 않는다
485
+ const prs = related.prs.map((pr) => confirmedByUser.has(`${pr.repo.toLowerCase()}#${pr.number}`)
486
+ ? { ...pr, confirmation: 'confirmed' }
487
+ : pr);
488
+ const reposWithPrs = uniqueRepos(prs.map((p) => p.repo));
489
+ let pairs = threeStatePairs(collect, currentRepo, reposWithPrs);
490
+ // 상한에 걸려 못 본 쌍의 레포는 main을 못 본 것과 같게 다룬다
491
+ const unjudgedRepos = new Set();
492
+ if (pairs.length > MAX_THREE_STATE_PAIRS) {
493
+ limitations.push(`판정 쌍이 ${pairs.length}개라 앞의 ${MAX_THREE_STATE_PAIRS}개만 봤다. 나머지는 재확인이 필요하다`);
494
+ for (const p of pairs.slice(MAX_THREE_STATE_PAIRS))
495
+ if (p.detected)
496
+ unjudgedRepos.add(p.targetRepo.toLowerCase());
497
+ pairs = pairs.slice(0, MAX_THREE_STATE_PAIRS);
498
+ }
499
+ const fetches = [];
500
+ const targets = new Map();
501
+ for (const repo of uniqueRepos(pairs.map((p) => p.targetRepo))) {
502
+ const key = repo.toLowerCase();
503
+ const dir = repoDirs.get(key);
504
+ if (!dir) {
505
+ targets.set(key, null);
506
+ continue;
507
+ }
508
+ const defaultBranch = defaultBranches.get(key) ?? detectDefaultBranch(dir.dir, runGit);
509
+ const usable = prs.filter((p) => p.repo.toLowerCase() === key && p.confirmation === 'confirmed' && p.state !== 'closed');
510
+ fetches.push(...fetchRefs(repo, dir.dir, defaultBranch, usable, runGit));
511
+ targets.set(key, { repo: dir.repo, dir: dir.dir, defaultBranch });
512
+ }
513
+ const judgments = [];
514
+ // 참조로 감지된 대상 레포 중 main에서 깨졌거나 main을 못 본 레포. 이 레포의 미확정 후보만 묻는다
515
+ const askRepos = new Set(unjudgedRepos);
516
+ const noCloneUndetected = new Set();
517
+ for (const pair of pairs) {
518
+ const target = targets.get(pair.targetRepo.toLowerCase());
519
+ if (!target && !pair.detected) {
520
+ noCloneUndetected.add(pair.targetRepo);
521
+ continue;
522
+ }
523
+ if (!target) {
524
+ judgments.push({
525
+ identifier: pair.identifier,
526
+ targetRepo: pair.targetRepo,
527
+ checked: false,
528
+ states: null,
529
+ verdict: null,
530
+ basis: null,
531
+ unconfirmedRelatedPrs: prs
532
+ .filter((p) => p.repo.toLowerCase() === pair.targetRepo.toLowerCase() &&
533
+ isAwaitingConfirmation(p.confirmation))
534
+ .map((p) => ({ repo: p.repo, number: p.number })),
535
+ limitations: [`${pair.targetRepo}의 로컬 clone이 없다. --repo-dir로 준다`],
536
+ status: 'blocked',
537
+ });
538
+ askRepos.add(pair.targetRepo.toLowerCase());
539
+ continue;
540
+ }
541
+ const results = await judgeThreeState({
542
+ currentRepo,
543
+ identifier: pair.identifier,
544
+ target: { ...target, repo: pair.targetRepo },
545
+ relatedPrs: prs,
546
+ detectedTarget: pair.detected,
547
+ runGit,
548
+ });
549
+ for (const j of results) {
550
+ const status = statusOf(j);
551
+ // 참조가 감지 안 된 레포에 이번 PR 식별자가 없는 건 당연하다. 연관 PR이 쓰던 걸 지우는 경우만 남긴다
552
+ if (!pair.detected && status !== 'relatedRemovesUsed')
553
+ continue;
554
+ judgments.push({ ...j, status });
555
+ }
556
+ if (pair.detected && results.some(needsRelatedPr))
557
+ askRepos.add(pair.targetRepo.toLowerCase());
558
+ }
559
+ if (noCloneUndetected.size > 0)
560
+ limitations.push(`연관 PR이 있는 ${[...noCloneUndetected].join(', ')}의 로컬 clone이 없어 연관 PR이 이번 PR 식별자를 지우는지 못 봤다`);
561
+ const counts = {
562
+ ok: 0,
563
+ mergeOrder: 0,
564
+ defect: 0,
565
+ relatedRemovesUsed: 0,
566
+ blocked: 0,
567
+ };
568
+ for (const j of judgments)
569
+ counts[j.status]++;
570
+ const unconfirmedMap = new Map();
571
+ const referenceOnly = [];
572
+ for (const p of prs) {
573
+ if (!isAwaitingConfirmation(p.confirmation))
574
+ continue;
575
+ const entry = { repo: p.repo, number: p.number };
576
+ if (askRepos.has(p.repo.toLowerCase()))
577
+ unconfirmedMap.set(`${p.repo.toLowerCase()}#${p.number}`, entry);
578
+ else if (p.state === 'open')
579
+ referenceOnly.push(entry);
580
+ }
581
+ const heads = new Map();
582
+ for (const j of judgments) {
583
+ const r = j.basis?.relatedPr;
584
+ if (r)
585
+ heads.set(`${r.repo.toLowerCase()}#${r.number}`, r);
586
+ }
587
+ return {
588
+ version: 1,
589
+ currentRepo,
590
+ judgments,
591
+ counts,
592
+ needsRecheck: counts.blocked > 0 || related.lookupBlocked,
593
+ relatedPrUnconfirmed: unconfirmedMap.size > 0,
594
+ unconfirmedRelatedPrs: [...unconfirmedMap.values()],
595
+ referenceOnlyRelatedPrs: referenceOnly,
596
+ relatedPrHeads: [...heads.values()],
597
+ relatedPrLookupBlocked: related.lookupBlocked,
598
+ fetches,
599
+ limitations,
600
+ };
601
+ }
602
+ function listComments(gh, repo, number, kind) {
603
+ const out = gh([
604
+ 'api',
605
+ '--paginate',
606
+ `repos/${repo}/${kind}/${number}/comments`,
607
+ '--jq',
608
+ '.[] | {body: .body, login: .user.login, url: .html_url}',
609
+ ]);
610
+ return out
611
+ .split('\n')
612
+ .filter((l) => l.trim() !== '')
613
+ .flatMap((line) => {
614
+ try {
615
+ const v = JSON.parse(line);
616
+ if (!isRecord(v) || typeof v.body !== 'string')
617
+ return [];
618
+ return [
619
+ {
620
+ body: v.body,
621
+ login: typeof v.login === 'string' ? v.login : '',
622
+ url: typeof v.url === 'string' ? v.url : '',
623
+ },
624
+ ];
625
+ }
626
+ catch {
627
+ return [];
628
+ }
629
+ });
630
+ }
631
+ export function runFollowupFind(opts, deps = {}) {
632
+ const gh = deps.gh ?? runGh;
633
+ const now = deps.now ?? new Date();
634
+ const pr = parsePr(opts.pr);
635
+ const collect = opts.candidates ? readCollectResult(opts.candidates) : null;
636
+ const extra = (opts.relatedRepo ?? []).map((r) => assertRepo(r, '--related-repo'));
637
+ const repo = resolveCurrentRepo(opts.repo, pr.repo, deps.cwd ?? process.cwd(), collect?.repo);
638
+ const scannedRepos = uniqueRepos([
639
+ repo,
640
+ ...(collect?.relatedRepos ?? []),
641
+ ...configRelatedRepos(deps),
642
+ ...extra,
643
+ ]);
644
+ const since = new Date(now.getTime() - MERGED_LOOKBACK_DAYS * DAY_MS).toISOString().slice(0, 10);
645
+ const selfRef = pr.number !== undefined ? `${repo}#${pr.number}`.toLowerCase() : null;
646
+ const trustedBase = new Set((opts.trustedAuthor ?? []).map((a) => a.toLowerCase()));
647
+ const limitations = [`머지된 지 ${MERGED_LOOKBACK_DAYS}일 안의 PR만 훑는다`];
648
+ try {
649
+ const me = gh(['api', 'user', '--jq', '.login']).trim();
650
+ if (me)
651
+ trustedBase.add(me.toLowerCase());
652
+ }
653
+ catch {
654
+ limitations.push('gh 로그인을 못 읽어 내 코멘트를 신뢰 목록에 못 넣었다');
655
+ }
656
+ const skipped = [];
657
+ const ignored = [];
658
+ const byThread = new Map();
659
+ let scannedPrs = 0;
660
+ for (const r of scannedRepos) {
661
+ let hits;
662
+ try {
663
+ const raw = JSON.parse(gh([
664
+ 'search',
665
+ 'prs',
666
+ '--repo',
667
+ r,
668
+ '--merged',
669
+ `--merged-at=>=${since}`,
670
+ '--json',
671
+ 'number,author',
672
+ '--limit',
673
+ String(MAX_FOLLOWUP_PRS_PER_REPO),
674
+ ]));
675
+ hits = Array.isArray(raw)
676
+ ? raw.flatMap((h) => isRecord(h) && typeof h.number === 'number'
677
+ ? [
678
+ {
679
+ number: h.number,
680
+ author: isRecord(h.author) && typeof h.author.login === 'string'
681
+ ? h.author.login
682
+ : '',
683
+ },
684
+ ]
685
+ : [])
686
+ : [];
687
+ }
688
+ catch (e) {
689
+ const f = ghFailure(e);
690
+ skipped.push({ repo: r, reason: f.reason, detail: f.detail });
691
+ continue;
692
+ }
693
+ for (const hit of hits) {
694
+ if (`${r}#${hit.number}`.toLowerCase() === selfRef)
695
+ continue;
696
+ scannedPrs++;
697
+ let comments;
698
+ try {
699
+ comments = [
700
+ ...listComments(gh, r, hit.number, 'pulls'),
701
+ ...listComments(gh, r, hit.number, 'issues'),
702
+ ];
703
+ }
704
+ catch (e) {
705
+ const f = ghFailure(e);
706
+ skipped.push({ repo: r, number: hit.number, reason: f.reason, detail: f.detail });
707
+ continue;
708
+ }
709
+ // 마커는 원래 PR 작성자나 이 세션 사용자가 남긴 것만 믿는다. 다른 사람이 흉내 낸 마커가 요구를 만들지 못하게 한다
710
+ const trusted = new Set(trustedBase);
711
+ if (hit.author)
712
+ trusted.add(hit.author.toLowerCase());
713
+ for (const c of comments) {
714
+ const markers = parseFollowUpMarkers(c.body);
715
+ if (markers.length === 0)
716
+ continue;
717
+ if (!trusted.has(c.login.toLowerCase())) {
718
+ ignored.push({ repo: r, number: hit.number, reason: 'untrustedAuthor' });
719
+ continue;
720
+ }
721
+ for (const m of markers) {
722
+ if (m.originRepo.toLowerCase() !== r.toLowerCase() || m.originPrNumber !== hit.number) {
723
+ ignored.push({ repo: r, number: hit.number, reason: 'originMismatch' });
724
+ continue;
725
+ }
726
+ if (m.targetRepo.toLowerCase() !== repo.toLowerCase())
727
+ continue;
728
+ const key = `${m.originRepo.toLowerCase()}#${m.originPrNumber}\0${m.threadId}`;
729
+ const entry = byThread.get(key) ?? { fulfilledElsewhere: false };
730
+ if (isFulfilled(m) && m.fulfilledByPr.toLowerCase() !== selfRef) {
731
+ entry.fulfilledElsewhere = true;
732
+ }
733
+ else {
734
+ entry.open = {
735
+ marker: m,
736
+ source: { repo: r, number: hit.number, url: c.url, author: c.login },
737
+ };
738
+ }
739
+ byThread.set(key, entry);
740
+ }
741
+ }
742
+ }
743
+ }
744
+ const markers = [...byThread.values()].flatMap((e) => e.open && !e.fulfilledElsewhere ? [e.open] : []);
745
+ const lookupBlocked = skipped.some((s) => GH_BLOCK_REASONS.has(s.reason));
746
+ if (skipped.length > 0 && !lookupBlocked)
747
+ limitations.push('일부 조회가 실패해 마커가 빠졌을 수 있다');
748
+ return {
749
+ version: 1,
750
+ status: lookupBlocked ? 'blocked' : markers.length > 0 ? 'found' : 'none',
751
+ repo,
752
+ pr: pr.number ?? null,
753
+ scannedRepos,
754
+ scannedPrs,
755
+ markers,
756
+ ignored,
757
+ skipped,
758
+ lookupBlocked,
759
+ limitations,
760
+ };
761
+ }
762
+ function readMarkers(path) {
763
+ const raw = readJsonFile(path, '--markers');
764
+ if (!isRecord(raw) || !Array.isArray(raw.markers)) {
765
+ throw new Error(`--markers는 harness-refs followup find --json 결과여야 한다: ${path}`);
766
+ }
767
+ const hits = raw.markers.flatMap((h) => {
768
+ if (!isRecord(h) || !isRecord(h.source) || !isRecord(h.marker))
769
+ return [];
770
+ // 파일을 거친 마커도 정해진 형식으로 다시 걸러 필드가 섞여 들어오지 못하게 한다
771
+ const m = h.marker;
772
+ if (typeof m.plannedWork !== 'string' || typeof m.targetRepo !== 'string')
773
+ return [];
774
+ const reparsed = parseFollowUpMarkers(buildFollowUpMarker(m))[0];
775
+ const { repo, number, url, author } = h.source;
776
+ if (!reparsed || typeof repo !== 'string' || typeof number !== 'number')
777
+ return [];
778
+ return [
779
+ {
780
+ marker: reparsed,
781
+ source: {
782
+ repo,
783
+ number,
784
+ url: typeof url === 'string' ? url : '',
785
+ author: typeof author === 'string' ? author : '',
786
+ },
787
+ },
788
+ ];
789
+ });
790
+ return { hits, blocked: raw.lookupBlocked === true || raw.status === 'blocked' };
791
+ }
792
+ export function runFollowupCheck(opts, deps = {}) {
793
+ const gh = deps.gh ?? runGh;
794
+ const pr = parsePr(opts.pr);
795
+ const { hits, blocked: upstreamBlocked } = readMarkers(opts.markers);
796
+ if (pr.number === undefined && opts.bodyFile === undefined) {
797
+ throw new Error('--pr 또는 --body-file 중 하나를 준다');
798
+ }
799
+ const repo = resolveCurrentRepo(opts.repo, pr.repo, deps.cwd ?? process.cwd());
800
+ const limitations = [];
801
+ let body = opts.bodyFile ? readTextFile(opts.bodyFile, '--body-file') : undefined;
802
+ let diff = opts.diffFile ? readTextFile(opts.diffFile, '--diff-file') : undefined;
803
+ const base = { version: 1, repo, pr: pr.number ?? null, upstreamBlocked };
804
+ if (pr.number !== undefined && (body === undefined || diff === undefined)) {
805
+ try {
806
+ if (body === undefined) {
807
+ const raw = JSON.parse(gh(['pr', 'view', String(pr.number), '--repo', repo, '--json', 'body']));
808
+ body = isRecord(raw) && typeof raw.body === 'string' ? raw.body : '';
809
+ }
810
+ if (diff === undefined)
811
+ diff = gh(['pr', 'diff', String(pr.number), '--repo', repo]);
812
+ }
813
+ catch (e) {
814
+ const f = ghFailure(e);
815
+ return {
816
+ ...base,
817
+ status: 'blocked',
818
+ results: [],
819
+ needsBodyMention: false,
820
+ unmetCount: 0,
821
+ lookupBlocked: true,
822
+ limitations: [`후속 PR 본문이나 diff를 못 읽었다 (${f.reason})`],
823
+ };
824
+ }
825
+ }
826
+ if (diff === undefined)
827
+ limitations.push('diff 없이 본문만 보고 작업 이행을 판정했다');
828
+ const results = hits.map((h) => {
829
+ const match = matchFollowUp(h.marker, {
830
+ repo,
831
+ number: pr.number ?? 0,
832
+ body: body ?? '',
833
+ diff: diff ?? '',
834
+ });
835
+ const actions = [];
836
+ if (match.status === 'wrongRepo')
837
+ actions.push('wrongRepo');
838
+ else {
839
+ if (!match.mentionsOrigin)
840
+ actions.push('askBodyMention');
841
+ if (match.status === 'unmet')
842
+ actions.push('missingWork');
843
+ }
844
+ return { ...h, match, actions };
845
+ });
846
+ const needsBodyMention = results.some((r) => r.actions.includes('askBodyMention'));
847
+ const unmetCount = results.filter((r) => r.actions.includes('missingWork')).length;
848
+ const needsComment = results.some((r) => r.actions.length > 0);
849
+ return {
850
+ ...base,
851
+ status: upstreamBlocked ? 'blocked' : needsComment ? 'needsComment' : 'ok',
852
+ results,
853
+ needsBodyMention,
854
+ unmetCount,
855
+ lookupBlocked: false,
856
+ limitations,
857
+ };
858
+ }
859
+ /**
860
+ * review-loop가 '다음 PR에서 한다' 스레드를 resolve할 때 답글에 붙일 마커를 만든다.
861
+ * 원래 PR은 지금 리뷰 중인 PR이다. find가 코멘트 위치와 originRepo, originPrNumber를 맞춰 보므로
862
+ * 다른 PR 번호를 넣으면 후속 PR 리뷰에서 그 마커는 버려진다.
863
+ */
864
+ export function runFollowupBuild(opts, deps = {}) {
865
+ const pr = parsePr(opts.pr);
866
+ if (pr.number === undefined)
867
+ throw new Error(`--pr는 PR 번호나 URL이어야 한다: ${opts.pr}`);
868
+ const originRepo = resolveCurrentRepo(opts.repo, pr.repo, deps.cwd ?? process.cwd());
869
+ const marker = {
870
+ originRepo,
871
+ originPrNumber: pr.number,
872
+ threadId: opts.threadId,
873
+ targetRepo: assertRepo(opts.targetRepo, '--target-repo'),
874
+ plannedWork: plannedWorkOf(opts),
875
+ };
876
+ const text = buildFollowUpMarker(marker);
877
+ // 읽는 쪽이 버릴 마커를 게시하면 이어받기가 조용히 끊긴다. 쓰는 자리에서 같은 파서로 걸러 인자 오류로 돌려준다
878
+ const reparsed = parseFollowUpMarkers(text)[0];
879
+ if (!reparsed) {
880
+ throw new Error('--thread-id와 --work는 비어 있지 않고 500자 이하여야 한다');
881
+ }
882
+ return { version: 1, marker: reparsed, text };
883
+ }
884
+ function plannedWorkOf(opts) {
885
+ if ((opts.work === undefined) === (opts.workFile === undefined)) {
886
+ throw new Error('--work와 --work-file 중 하나만 준다');
887
+ }
888
+ return opts.work ?? readTextFile(opts.workFile, '--work-file').trim();
889
+ }
890
+ // ─── 커맨드 진입점 ──────────────────────────────────────────────────────────
891
+ function formatStatus(label, status, extra) {
892
+ const head = status === 'blocked' ? `${label}: 막힘, 재확인 필요` : `${label}: ${status}`;
893
+ return [head, ...extra].join('\n');
894
+ }
895
+ async function emit(json, body, summary) {
896
+ let result;
897
+ try {
898
+ result = await body();
899
+ }
900
+ catch (e) {
901
+ // 실패하면 stdout을 비운다. 빈 출력이 "없음"으로 읽히지 않게 종료 코드로 답한다
902
+ console.error(e instanceof Error ? e.message : String(e));
903
+ process.exit(1);
904
+ }
905
+ console.log(json ? JSON.stringify(result) : summary(result));
906
+ }
907
+ export function harnessRefsRelatedPrsCommand(opts) {
908
+ return emit(opts.json, () => runRelatedPrs(opts), (r) => formatStatus('연관 PR', r.status, [
909
+ `확정 ${r.confirmed.length}, 미확정 ${r.unconfirmed.length}`,
910
+ ...r.limitations,
911
+ ]));
912
+ }
913
+ export function harnessRefsThreeStateCommand(opts) {
914
+ return emit(opts.json, () => runThreeState(opts), (r) => formatStatus('세 상태', r.needsRecheck ? 'blocked' : 'done', [
915
+ `결함 ${r.counts.defect}, 머지 순서 ${r.counts.mergeOrder}, 연관 PR이 지움 ${r.counts.relatedRemovesUsed}, 못 봄 ${r.counts.blocked}`,
916
+ ...(r.relatedPrUnconfirmed ? ['확정 안 된 연관 PR이 있다'] : []),
917
+ ...r.limitations,
918
+ ]));
919
+ }
920
+ export function harnessRefsFollowupFindCommand(opts) {
921
+ return emit(opts.json, () => runFollowupFind(opts), (r) => formatStatus('후속 마커', r.status, [
922
+ `마커 ${r.markers.length}, 훑은 PR ${r.scannedPrs}`,
923
+ ...r.limitations,
924
+ ]));
925
+ }
926
+ export function harnessRefsFollowupBuildCommand(opts) {
927
+ return emit(opts.json, () => runFollowupBuild(opts), (r) => r.text);
928
+ }
929
+ export function harnessRefsFollowupCheckCommand(opts) {
930
+ return emit(opts.json, () => runFollowupCheck(opts), (r) => formatStatus('후속 작업', r.status, [
931
+ `본문 명시 요청 ${r.needsBodyMention ? '필요' : '없음'}, 빠진 작업 ${r.unmetCount}`,
932
+ ]));
933
+ }
934
+ //# sourceMappingURL=harness-refs-cross-pr.js.map