makdoong2-team 3.0.0 → 3.0.2
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/agents/makdoong2-team-leader.md +2 -0
- package/agents/makdoong2-verifier.md +11 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +27 -11
- package/dist/opencode-plugin.js +207 -56
- package/dist/poll-sub-session.d.ts +14 -1
- package/dist/poll-sub-session.js +41 -4
- package/dist/sub-session-registry.d.ts +74 -0
- package/dist/sub-session-registry.js +204 -0
- package/gates/stage4-dev-post-verify.sh +37 -0
- package/package.json +1 -1
- package/scripts/run-tests.mts +2 -0
- package/scripts/state.sh +32 -0
- package/stages/01-planning.md +15 -0
- package/stages/02-requirements.md +22 -0
- package/stages/05-worktree-dev.md +37 -1
|
@@ -147,6 +147,8 @@ permission:
|
|
|
147
147
|
2. **REJECTED 사유는 dispatch_verifier 가 자동으로 state.json 에 기록**한다 (`last_verdict_reason` / `last_verdict_reason_hash` / `same_reason_streak` / `rejected_count`). 부장님이 별도 기록할 필요 없다.
|
|
148
148
|
3. **재-dispatch 시 dispatch_stage 가 자동으로 이전 사유를 프롬프트에 재주입**한다. 부장님은 그냥 `dispatch_stage(issue, target_stage, worktree)` 를 다시 호출하면 된다.
|
|
149
149
|
4. **동일 REJECTED 사유 연속 5회 감지 시 dispatch_verifier 응답에 `same_reason_streak_exceeded: true`** 가 포함된다. 이때는 재시도를 중단하고 사용자에게 상황을 보고한다 (해시 기반 자동 무한루프 방지장치).
|
|
150
|
+
5. **`.done=false` 재설정은 당신의 cwd(main repo)에서 그대로 실행한다.** worktree 로 옮겨가지 않는다 — `dispatch_stage` 가 서브세션 생성 전에 main→worktree 정방향 동기화를 하므로 그 값이 전달된다. worktree 사본을 직접 고치면 그 동기화가 덮어쓴다.
|
|
151
|
+
6. 그런데도 `already_done: true` 가 오면 응답의 `state_copy_mismatch` / `main_repo_done` / `worktree_done` 을 먼저 읽는다. `state_copy_mismatch: true` 는 **자동 동기화가 실패했다**는 뜻이다 — 동기화를 손으로 다시 실행하지 말고 `next_action` 이 지시하는 `state.sh status` 로 확인한 뒤, 해소되지 않으면 사용자에게 에스컬레이션한다.
|
|
150
152
|
|
|
151
153
|
### 응답 처리 순서
|
|
152
154
|
|
|
@@ -87,6 +87,8 @@ bash <SCRIPTS_DIR>/state.sh get <이슈키> '.stages."2_implementation".substage
|
|
|
87
87
|
|
|
88
88
|
기대: 모든 항목(boolean)이 `true`인 JSON 객체. 누락·`false`·문법 오류 = **REJECTED**.
|
|
89
89
|
|
|
90
|
+
> **유일한 예외**: `2_implementation.dev` 의 `new_tests_added` 는 `1_planning.requirements` 가 테스트 범위 제외를 선언한 경우(`test_scope.new_tests_required == false`) `false` 가 정상이다. 판정 규칙은 §2 의 `2_implementation.dev` 항목에 있다. 이 예외를 모르는 채 "모든 항목 true" 만 적용하면, 승인된 스코프 아웃이 무한 반려로 되돌아온다 (issue #11).
|
|
91
|
+
|
|
90
92
|
> **⚠️ 스키마 규약:** state.sh 의 모든 jq path 는 `.stages."<PHASE>".substages."<SUBSTAGE>".<field>` 형태를 따른다. 참조: CLAUDE.md "워크플로우 상태 & 위임 규약".
|
|
91
93
|
|
|
92
94
|
### 2. 단계 명세 재대조
|
|
@@ -119,7 +121,15 @@ bash <SCRIPTS_DIR>/state.sh get <이슈키> '.stages."2_implementation".substage
|
|
|
119
121
|
git status --porcelain | grep -v '\.makdoong2-team/' | grep -v 'workspace-analysis\.json'
|
|
120
122
|
```
|
|
121
123
|
출력이 비어 있어야 한다. `.makdoong2-team/` 은 플러그인이 작업 트리 안에 만드는 **자기 상태**이지 analyzer 의 부산물이 아니다. 이것을 위반으로 세면 해당 패턴이 git exclude 에 없는 저장소에서 **항상 REJECTED** 가 나고, 그 시점에 exclude 를 고칠 권한을 가진 역할이 파이프라인에 없어(analyzer 는 산출물 1개만 쓰기 가능 · team-leader 는 하드룰 2 로 차단 · engineer 는 analysis 통과 후 단계) 동일 사유 무한 루프가 된다 (issue #6-②).
|
|
122
|
-
- 2_implementation.dev: `done=true` +
|
|
124
|
+
- 2_implementation.dev: `done=true` + `self_check` 6항목 + **테스트 동반 원칙은 requirements 의 선언을 따른다** (issue #11)
|
|
125
|
+
- 판정 근거는 **state.json 마커와 게이트 재실행**이다. sub-agent output 은 보조 근거이며, **output 이 비어 있거나 특정 문구가 없다는 사실만으로 REJECTED 하지 않는다** — 마커가 충족되면 VERIFIED 다. (`dispatch_stage` 는 텍스트 없이 종료한 세션도 `.done=true` 만으로 성공 처리한다. 그 경로를 verifier 가 문구 검색으로 뒤집으면 재작업 루프만 남는다.)
|
|
126
|
+
- `REQ` = `.stages."1_planning".substages."requirements".test_scope.new_tests_required` 를 읽는다. **마커 부재·`null` 은 `true` 로 간주한다 (fail-closed).**
|
|
127
|
+
```bash
|
|
128
|
+
bash <SCRIPTS_DIR>/state.sh get <이슈키> '.stages."1_planning".substages."requirements".test_scope'
|
|
129
|
+
```
|
|
130
|
+
- `REQ == true` → `self_check.new_tests_added == true` 여야 한다. 추가로 staged 변경(`git diff --cached --name-only`)에 실제 테스트 파일이 있는지 확인한다 — 판단 기준은 `workspace-analysis.json` 의 `test_conventions` 다. 마커는 true 인데 테스트 파일이 하나도 없으면 **REJECTED**.
|
|
131
|
+
- `REQ == false` → 테스트 범위 제외가 **요구사항 단계에서 이미 승인·동결된 것**이다. `self_check.new_tests_added` 가 `false` 여도 정상이며, 이때 `.stages."2_implementation".substages."dev".new_tests_waived == true` 만 확인한다. **테스트가 없다는 이유로 REJECTED 하지 않는다** — 그 반려가 승인된 스코프 밖의 테스트 코드를 유입시킨 사고의 직접 원인이었다.
|
|
132
|
+
- 어느 경우든 `gates/stage4-dev-post-verify.sh <이슈키>` 를 재실행해 exit 0 을 확인한다.
|
|
123
133
|
- 2_implementation.test: `.stages."2_implementation".substages."test"` 의 각 필드가 아래 조건을 모두 충족해야 함
|
|
124
134
|
- `.unit` ∈ `{"pass", "fail", "skip"}` — `none`/`null` 은 미기록 → REJECTED
|
|
125
135
|
- `.integration` ∈ `{"pass", "fail", "skip"}` — `none`/`null` 은 미기록 → REJECTED
|
package/dist/config.d.ts
CHANGED
|
@@ -92,7 +92,7 @@ export declare function parseJsoncLoose(text: string): unknown;
|
|
|
92
92
|
* 서브세션의 permission 요청이 auto-reject 됐다.
|
|
93
93
|
* (install-lib 의 computeExternalDirPaths 가 그 값을 쓰는 쪽이다.)
|
|
94
94
|
*/
|
|
95
|
-
export declare function loadOpencodeExternalDirAllows(): string[];
|
|
95
|
+
export declare function loadOpencodeExternalDirAllows(diag?: (reason: string) => void): string[];
|
|
96
96
|
/** Resolve the five runtime path roots (JSON paths.* override → default).
|
|
97
97
|
*
|
|
98
98
|
* `skills` default is the opencode config dir (`${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills`)
|
package/dist/config.js
CHANGED
|
@@ -175,20 +175,36 @@ export function parseJsoncLoose(text) {
|
|
|
175
175
|
* 서브세션의 permission 요청이 auto-reject 됐다.
|
|
176
176
|
* (install-lib 의 computeExternalDirPaths 가 그 값을 쓰는 쪽이다.)
|
|
177
177
|
*/
|
|
178
|
-
export function loadOpencodeExternalDirAllows() {
|
|
178
|
+
export function loadOpencodeExternalDirAllows(diag) {
|
|
179
|
+
// 빈 배열로 끝나는 이유를 호출자가 알 수 있게 한다. 이 모듈은 logger 를 import
|
|
180
|
+
// 하지 못하므로(logger→config) 콜백으로 돌려준다. 같은 pid 의 두 플러그인 사본이
|
|
181
|
+
// 서로 다른 개수를 읽은 사례(issue #10: 7개→5개)에서 파일 부재였는지 파싱
|
|
182
|
+
// 실패였는지 키 부재였는지가 로그에 남지 않았다.
|
|
183
|
+
const file = join(configDir(), "opencode.json");
|
|
184
|
+
let raw;
|
|
179
185
|
try {
|
|
180
|
-
|
|
181
|
-
const oc = parseJsoncLoose(raw);
|
|
182
|
-
const ext = oc?.permission?.external_directory;
|
|
183
|
-
if (!ext || typeof ext !== "object")
|
|
184
|
-
return [];
|
|
185
|
-
return Object.entries(ext)
|
|
186
|
-
.filter(([, action]) => action === "allow")
|
|
187
|
-
.map(([pattern]) => pattern);
|
|
186
|
+
raw = readFileSync(file, "utf8");
|
|
188
187
|
}
|
|
189
|
-
catch {
|
|
190
|
-
|
|
188
|
+
catch (err) {
|
|
189
|
+
diag?.(`read failed: ${file}: ${err.message}`);
|
|
190
|
+
return []; // 부재 → 설정된 allow 없음
|
|
191
|
+
}
|
|
192
|
+
let oc;
|
|
193
|
+
try {
|
|
194
|
+
oc = parseJsoncLoose(raw);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
diag?.(`parse failed: ${file} (${raw.length} bytes): ${err.message}`);
|
|
198
|
+
return []; // 파싱 실패 → 설정된 allow 없음
|
|
199
|
+
}
|
|
200
|
+
const ext = oc?.permission?.external_directory;
|
|
201
|
+
if (!ext || typeof ext !== "object") {
|
|
202
|
+
diag?.(`permission.external_directory absent: ${file}`);
|
|
203
|
+
return [];
|
|
191
204
|
}
|
|
205
|
+
return Object.entries(ext)
|
|
206
|
+
.filter(([, action]) => action === "allow")
|
|
207
|
+
.map(([pattern]) => pattern);
|
|
192
208
|
}
|
|
193
209
|
/** Resolve the five runtime path roots (JSON paths.* override → default).
|
|
194
210
|
*
|
package/dist/opencode-plugin.js
CHANGED
|
@@ -21,6 +21,7 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { basename as pathBasename, dirname as pathDirname, join, resolve as pathResolve, sep as pathSep } from "node:path";
|
|
22
22
|
import { tool } from "@opencode-ai/plugin";
|
|
23
23
|
import { appendSessionIndex, findWorktreeRoot, lookupSessionFromIndex } from "./session-index.js";
|
|
24
|
+
import { activeToolCount, clearToolCalls, forgetSession, isToolExecuting as registryIsToolExecuting, normalizePermissionEvent, notifySessionDeleted, pendingPermissionsFor, permissionAsked, permissionReplied, settleToolCalls, sharedSubSessionRegistry, toolFinished, toolStarted, } from "./sub-session-registry.js";
|
|
24
25
|
import { computeVerdictHash } from "./verdict-hash.js";
|
|
25
26
|
import { classifyVerifierOutcome, nextVerifierErrorStreak, verifierErrorStreakExceeded, VERIFIER_ERROR_STREAK_LIMIT, } from "./verifier-verdict.js";
|
|
26
27
|
import { nextModel, applyConfigOverrides, POLICIES } from "./model-fallback-policy.js";
|
|
@@ -327,6 +328,15 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
327
328
|
const cwd = worktree || directory || ".";
|
|
328
329
|
const config = loadConfig();
|
|
329
330
|
applyConfigOverrides(config.agents, config.model_policy);
|
|
331
|
+
// opencode 는 디렉토리(Instance)마다 이 factory 를 따로 부른다 — 같은 pid 안에
|
|
332
|
+
// 사본이 여럿이다. 어느 사본이 어떤 로그를 찍는지 구분하려고 진입 시점에 남긴다.
|
|
333
|
+
// 로그에 worktree 경로의 `[init]` 이 없으면 서브세션 훅이 어디서도 발화하지
|
|
334
|
+
// 않는 것이고, 그것이 issue #10 의 `tool_call_stall` 처방 1번이다.
|
|
335
|
+
logger.debug(`[init] plugin instance directory=${directory} worktree=${worktree} pid=${process.pid}`);
|
|
336
|
+
// 사본을 가로지르는 세션 신호(툴 실행 · 권한 요청 · 이슈키 · worktree · deleted
|
|
337
|
+
// 대기자)는 프로세스 전역 레지스트리에 둔다. 사본마다 Map 을 들면 worktree 사본의
|
|
338
|
+
// 훅이 올린 신호를 main 사본의 폴러가 영영 보지 못한다 (issue #10).
|
|
339
|
+
const registry = sharedSubSessionRegistry();
|
|
330
340
|
// Sub-session monitor — splits a tmux pane next to 부장님 for each spawned
|
|
331
341
|
// 막둥이 when invoked inside tmux with tmux.enabled=true in makdoong2-team.json.
|
|
332
342
|
// No-op otherwise, so non-tmux runs are unaffected.
|
|
@@ -422,8 +432,8 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
422
432
|
const guard = orphanCleanupGuard(p, {
|
|
423
433
|
nowMs,
|
|
424
434
|
ownerPid: tmuxMonitor.ownerProcessId,
|
|
425
|
-
lastToolExecuteAtMs:
|
|
426
|
-
activeToolCount:
|
|
435
|
+
lastToolExecuteAtMs: registry.lastToolExecuteAt.get(sid),
|
|
436
|
+
activeToolCount: activeToolCount(registry, sid),
|
|
427
437
|
toolAliveWindowMs: TOOL_EXECUTE_ALIVE_WINDOW_MS,
|
|
428
438
|
});
|
|
429
439
|
if (guard) {
|
|
@@ -448,11 +458,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
448
458
|
if (sessionGone) {
|
|
449
459
|
logger.debug(`[orphan-scan] pane-kill-only (session gone) sid=${sid} reason=${reason}`);
|
|
450
460
|
await tmuxMonitor.forceKillBySessionId(sid).catch(() => undefined);
|
|
451
|
-
|
|
461
|
+
forgetSession(registry, sid);
|
|
452
462
|
pendingDispatch.delete(sid);
|
|
453
463
|
subSessionIds.delete(sid);
|
|
454
|
-
sessionLastToolExecuteAt.delete(sid);
|
|
455
|
-
sessionActiveToolCount.delete(sid);
|
|
456
464
|
}
|
|
457
465
|
else {
|
|
458
466
|
logger.debug(`[orphan-scan] cleaning sid=${sid} reason=${reason}`);
|
|
@@ -607,29 +615,29 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
607
615
|
const pluginOwnPatterns = pluginOwnAllowPatterns();
|
|
608
616
|
const configuredAllowPatterns = [
|
|
609
617
|
...pluginOwnPatterns,
|
|
610
|
-
...loadOpencodeExternalDirAllows(),
|
|
618
|
+
...loadOpencodeExternalDirAllows((reason) => logger.warn(`[permission] opencode.json external_directory allows unavailable — ${reason} directory=${directory}`)),
|
|
611
619
|
];
|
|
620
|
+
// 사본마다 한 번씩 찍힌다 — 같은 pid 에 개수가 다른 두 줄이 있으면 사본이
|
|
621
|
+
// 둘이라는 뜻이지 설정이 바뀐 것이 아니다 (issue #10 의 7개→5개). directory 를
|
|
622
|
+
// 같이 남겨 어느 사본이 몇 개를 읽었는지 바로 대응시킨다.
|
|
612
623
|
logger.debug(`[permission] plugin-own allows: ${pluginOwnPatterns.length}개 ` +
|
|
613
|
-
`(${pluginOwnPatterns.join(", ")})`);
|
|
624
|
+
`(${pluginOwnPatterns.join(", ")}) directory=${directory}`);
|
|
614
625
|
logger.debug(`[permission] configured external_directory allows: ${configuredAllowPatterns.length}개` +
|
|
615
|
-
(configuredAllowPatterns.length ? ` (${configuredAllowPatterns.slice(0, 3).join(", ")}…)` : "")
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const cur = sessionActiveToolCount.get(sid) ?? 0;
|
|
621
|
-
if (cur <= 1)
|
|
622
|
-
sessionActiveToolCount.delete(sid);
|
|
623
|
-
else
|
|
624
|
-
sessionActiveToolCount.set(sid, cur - 1);
|
|
625
|
-
}
|
|
626
|
+
(configuredAllowPatterns.length ? ` (${configuredAllowPatterns.slice(0, 3).join(", ")}…)` : "") +
|
|
627
|
+
` directory=${directory}`);
|
|
628
|
+
// 툴 실행 신호는 레지스트리(`registry.activeToolCalls` / `lastToolExecuteAt`)에
|
|
629
|
+
// 있다 — before 훅에서 toolStarted, after 훅·툴 part 종결·idle 에서 toolFinished /
|
|
630
|
+
// clearToolCalls. 종전의 사본-로컬 카운터는 issue #10 으로 제거됐다.
|
|
626
631
|
const TOOL_EXECUTE_ALIVE_WINDOW_MS = 300_000;
|
|
627
632
|
// MESSAGE_STALL 후 client.session.abort() 는 즉시 반환하지만 opencode 서버는 잠시 후
|
|
628
633
|
// session.deleted 이벤트를 fire 한다. 그 사이(관측 최대 112s) sub-agent 가 tool call 을
|
|
629
634
|
// 계속 발사할 수 있어 좀비 실행이 발생한다. abort 직후 session.deleted 를 대기하는
|
|
630
635
|
// 헬퍼로 이 race window 를 닫는다. event 핸들러가 session.deleted 를 수신하면 이 map 의
|
|
631
636
|
// pending waiter 를 resolve 한다.
|
|
632
|
-
|
|
637
|
+
// 대기자 목록은 레지스트리에 있다 — session.deleted 이벤트는 세션의 디렉토리
|
|
638
|
+
// 사본에만 도착하므로(worktree 서브세션이면 worktree 사본), 여기서 기다리는
|
|
639
|
+
// main 사본과 이벤트를 받는 사본이 같은 목록을 봐야 한다.
|
|
640
|
+
const sessionDeletedWaiters = registry.sessionDeletedWaiters;
|
|
633
641
|
const waitForSessionDeleted = (sessionId, maxWaitMs) => {
|
|
634
642
|
return new Promise((resolve) => {
|
|
635
643
|
let done = false;
|
|
@@ -659,7 +667,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
659
667
|
const SESSION_DELETED_WAIT_MS = 30_000;
|
|
660
668
|
const eventMaxChars = readLoggingConfig(config.logging).eventMaxChars;
|
|
661
669
|
const WRITE_TOOLS = new Set(["write", "edit", "apply_patch"]);
|
|
662
|
-
|
|
670
|
+
// dispatch 사본이 채우고 worktree 사본의 tool.execute.after 가 읽는다 — 레지스트리.
|
|
671
|
+
// 디스크의 session-index.ndjson 은 사본이 다른 프로세스에 있을 때의 2차 경로로 남는다.
|
|
672
|
+
const sessionWorktree = registry.sessionWorktree;
|
|
663
673
|
const extractFilePathFromToolArgs = (args) => {
|
|
664
674
|
if (!args || typeof args !== "object")
|
|
665
675
|
return undefined;
|
|
@@ -749,7 +759,41 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
749
759
|
}
|
|
750
760
|
return { ok: true, relPath };
|
|
751
761
|
};
|
|
752
|
-
|
|
762
|
+
// 폴러에 건네는 권한 요청 소스. v1 SDK 클라이언트에는 `permission` 네임스페이스가
|
|
763
|
+
// 없고, `GET /permission` 은 클라이언트가 묶인 디렉토리 스코프라 worktree
|
|
764
|
+
// 서브세션의 요청이 보이지 않는다 — 종전에는 이 소스가 항상 undefined 라
|
|
765
|
+
// 자동 승인·거부 루프가 한 번도 돌지 않았고 stall 메시지는 언제나 "unknown" 이었다
|
|
766
|
+
// (issue #10). 대신 그 세션의 사본이 `permission.asked` 이벤트로 레지스트리에
|
|
767
|
+
// 넣은 것을 읽고, 응답은 세션-라우팅 엔드포인트
|
|
768
|
+
// (`POST /session/{id}/permissions/{permissionID}`) 로 보낸다 — 세션 ID 로
|
|
769
|
+
// 라우팅되므로 어느 사본에서 불러도 맞는 Instance 에 닿는다.
|
|
770
|
+
const permissionSourceFor = (sessionId) => ({
|
|
771
|
+
list: async () => ({
|
|
772
|
+
data: pendingPermissionsFor(registry, sessionId).map((p) => ({
|
|
773
|
+
id: p.id,
|
|
774
|
+
sessionID: p.sessionID,
|
|
775
|
+
permission: p.permission,
|
|
776
|
+
patterns: p.patterns,
|
|
777
|
+
})),
|
|
778
|
+
}),
|
|
779
|
+
reply: async (req) => {
|
|
780
|
+
const c = client;
|
|
781
|
+
if (typeof c.postSessionIdPermissionsPermissionId !== "function") {
|
|
782
|
+
throw new Error("permission reply endpoint unavailable on this client");
|
|
783
|
+
}
|
|
784
|
+
const res = await c.postSessionIdPermissionsPermissionId({
|
|
785
|
+
path: { id: sessionId, permissionID: req.path.requestID },
|
|
786
|
+
body: { response: req.body.reply },
|
|
787
|
+
});
|
|
788
|
+
// permission.replied 이벤트는 세션의 사본에 도착하지만, 그 사본이 이벤트를
|
|
789
|
+
// 놓치더라도 다음 폴이 같은 요청을 다시 처리하지 않도록 즉시 뺀다.
|
|
790
|
+
permissionReplied(registry, sessionId, req.path.requestID);
|
|
791
|
+
return res;
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
const pollSubSession = (sessionId, timeoutMs = substageTimeoutMs, allowedWorktree, onNudge, messageStallThresholdMs) =>
|
|
795
|
+
// 클래스 인스턴스를 spread 하면 프로토타입 메서드가 빠지므로 필요한 것만 집는다.
|
|
796
|
+
pollSubSessionCore({ session: client.session, permission: permissionSourceFor(sessionId) }, sessionId, {
|
|
753
797
|
timeoutMs,
|
|
754
798
|
allowedWorktree,
|
|
755
799
|
configuredAllowPatterns,
|
|
@@ -764,16 +808,26 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
764
808
|
contentStableCompletionMs: 300_000,
|
|
765
809
|
preambleOnlyTextThreshold: 200,
|
|
766
810
|
isRecentlyActive: () => {
|
|
767
|
-
if ((
|
|
811
|
+
if (registryIsToolExecuting(registry, sessionId))
|
|
768
812
|
return true;
|
|
769
|
-
const last =
|
|
813
|
+
const last = registry.lastToolExecuteAt.get(sessionId);
|
|
770
814
|
return typeof last === "number" && Date.now() - last < TOOL_EXECUTE_ALIVE_WINDOW_MS;
|
|
771
815
|
},
|
|
772
|
-
// "지금 이 순간 툴이 실행 중인가" — before 훅에서
|
|
773
|
-
//
|
|
816
|
+
// "지금 이 순간 툴이 실행 중인가" — before 훅에서 넣고 after 훅에서 빼는
|
|
817
|
+
// callID 집합 그대로다. isRecentlyActive 의 5분 창과 달리 순간값이라 완료
|
|
774
818
|
// 판정에 쓸 수 있다. 폴러가 읽는 메시지 스냅샷보다 항상 앞선다 (issue #7:
|
|
775
819
|
// tool.execute.before 발화 110ms 뒤의 폴이 tool part 를 아직 못 봤다).
|
|
776
|
-
|
|
820
|
+
//
|
|
821
|
+
// 읽기 전에 스냅샷과 대조한다: 툴이 throw 하면 after 훅이 오지 않아 항목이
|
|
822
|
+
// 남는데, 스냅샷에서 이미 completed/error 인 callID 는 확실히 끝난 것이다.
|
|
823
|
+
// 스냅샷에 아직 없는 callID 는 그대로 둔다 (110ms 창).
|
|
824
|
+
isToolExecuting: (snapshot) => {
|
|
825
|
+
const settled = settleToolCalls(registry, sessionId, snapshot.settledCallIDs);
|
|
826
|
+
if (settled > 0) {
|
|
827
|
+
logger.debug(`[pollSubSession] settled ${settled} stale tool call(s) from snapshot sid=${sessionId}`);
|
|
828
|
+
}
|
|
829
|
+
return registryIsToolExecuting(registry, sessionId);
|
|
830
|
+
},
|
|
777
831
|
});
|
|
778
832
|
// ── sessionID → agent 매핑. chat.params hook에서 채우고, tool.execute.before
|
|
779
833
|
// hook에서 조회한다. 이 매핑을 통해 hook input에 없는 agent 식별을 우회한다.
|
|
@@ -796,7 +850,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
796
850
|
// git branch 이름이나 worktree 경로를 추론하지 않고 신뢰할 수 있는 ISSUE를
|
|
797
851
|
// 직접 전달받을 수 있다. 이 설계로 multi-worktree / 비-makdoong2-team 워크트리
|
|
798
852
|
// 오탐 문제가 근본적으로 해결된다.
|
|
799
|
-
|
|
853
|
+
// 레지스트리에 둔다 — dispatch 사본이 넣고, 서브세션의 훅 사본(guard-bash.sh /
|
|
854
|
+
// sync-state.sh 인자)이 읽는다.
|
|
855
|
+
const sessionIssue = registry.sessionIssue;
|
|
800
856
|
// ── skill_mcp lazy-load 방어 (mcp_name → skill_name 매핑).
|
|
801
857
|
// SKILL.md frontmatter 를 스캔해 각 MCP 서버가 어떤 skill 에 embedded 인지
|
|
802
858
|
// 캐시한다. `skill(name="...")` 로 skill 이 로드되기 전에는 opencode 가
|
|
@@ -873,11 +929,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
873
929
|
}
|
|
874
930
|
}
|
|
875
931
|
finally {
|
|
876
|
-
|
|
932
|
+
forgetSession(registry, sid);
|
|
877
933
|
pendingDispatch.delete(sid);
|
|
878
934
|
subSessionIds.delete(sid);
|
|
879
|
-
sessionLastToolExecuteAt.delete(sid);
|
|
880
|
-
sessionActiveToolCount.delete(sid);
|
|
881
935
|
}
|
|
882
936
|
};
|
|
883
937
|
// team-leader가 직접 실행 시 물리적으로 차단할 툴 목록.
|
|
@@ -992,26 +1046,72 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
992
1046
|
}
|
|
993
1047
|
})();
|
|
994
1048
|
return {
|
|
1049
|
+
// 이벤트는 **세션의 디렉토리 사본**에만 도착한다 (opencode 의 이벤트 브리지가
|
|
1050
|
+
// `location.directory` 로 필터링한다). worktree 서브세션의 permission.asked ·
|
|
1051
|
+
// message.part.updated · session.deleted 는 worktree 사본이 받고, 그 세션을
|
|
1052
|
+
// 폴링하는 것은 main 사본이다. 그래서 여기서 관측한 것은 전부 레지스트리에
|
|
1053
|
+
// 쓴다 — 사본-로컬 변수에 쓰면 폴러는 영영 보지 못한다 (issue #10).
|
|
995
1054
|
event: async ({ event }) => {
|
|
996
|
-
if (logger.isDebug() && event?.type?.startsWith("session.")) {
|
|
1055
|
+
if (logger.isDebug() && (event?.type?.startsWith("session.") || event?.type?.startsWith("permission."))) {
|
|
997
1056
|
try {
|
|
998
|
-
logger.debug(`[event] type=${event.type} properties=${JSON.stringify(event.properties).slice(0, eventMaxChars)}`);
|
|
1057
|
+
logger.debug(`[event] type=${event.type} directory=${directory} properties=${JSON.stringify(event.properties).slice(0, eventMaxChars)}`);
|
|
999
1058
|
}
|
|
1000
1059
|
catch { /* ignore */ }
|
|
1001
1060
|
}
|
|
1061
|
+
if (event?.type === "permission.asked" || event?.type === "permission.updated") {
|
|
1062
|
+
const req = normalizePermissionEvent(event.type, event.properties);
|
|
1063
|
+
if (req) {
|
|
1064
|
+
permissionAsked(registry, req);
|
|
1065
|
+
logger.debug(`[event] permission pending sid=${req.sessionID} requestID=${req.id} ` +
|
|
1066
|
+
`permission=${req.permission} patterns=${JSON.stringify(req.patterns)}`);
|
|
1067
|
+
}
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (event?.type === "permission.replied") {
|
|
1071
|
+
const props = event.properties;
|
|
1072
|
+
const sid = typeof props?.sessionID === "string" ? props.sessionID : undefined;
|
|
1073
|
+
const requestID = typeof props?.requestID === "string" ? props.requestID
|
|
1074
|
+
: typeof props?.permissionID === "string" ? props.permissionID
|
|
1075
|
+
: undefined;
|
|
1076
|
+
if (sid && requestID)
|
|
1077
|
+
permissionReplied(registry, sid, requestID);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (event?.type === "message.part.updated") {
|
|
1081
|
+
// 툴 part 가 completed/error 로 바뀌면 그 호출은 끝났다. 툴이 throw 한
|
|
1082
|
+
// 경우 tool.execute.after 가 오지 않으므로 이것이 유일한 종결 신호다.
|
|
1083
|
+
const props = event.properties;
|
|
1084
|
+
const part = props?.part;
|
|
1085
|
+
if (part && part.type === "tool") {
|
|
1086
|
+
const state = part.state;
|
|
1087
|
+
const status = typeof state?.status === "string" ? state.status : undefined;
|
|
1088
|
+
if (status === "completed" || status === "error") {
|
|
1089
|
+
const sid = typeof part.sessionID === "string" ? part.sessionID
|
|
1090
|
+
: typeof props?.sessionID === "string" ? props.sessionID
|
|
1091
|
+
: undefined;
|
|
1092
|
+
const callID = typeof part.callID === "string" ? part.callID : undefined;
|
|
1093
|
+
if (sid)
|
|
1094
|
+
toolFinished(registry, sid, callID);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
if (event?.type === "session.idle") {
|
|
1100
|
+
// idle 로 전이한 세션에 실행 중인 툴은 정의상 없다 — 남은 항목은 전부 누수다.
|
|
1101
|
+
const props = event.properties;
|
|
1102
|
+
if (typeof props?.sessionID === "string")
|
|
1103
|
+
clearToolCalls(registry, props.sessionID);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1002
1106
|
if (event?.type === "session.deleted") {
|
|
1003
1107
|
const props = event.properties;
|
|
1004
1108
|
const sid = props?.info?.id ?? props?.sessionID;
|
|
1005
1109
|
if (sid) {
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
}
|
|
1012
|
-
catch { /* ignore */ }
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1110
|
+
notifySessionDeleted(registry, sid);
|
|
1111
|
+
// 세션이 사라졌으니 실행 신호·권한 요청도 의미가 없다. sessionIssue /
|
|
1112
|
+
// sessionWorktree 는 dispatch 사본의 cleanupSubSession 이 지운다.
|
|
1113
|
+
clearToolCalls(registry, sid);
|
|
1114
|
+
registry.pendingPermissions.delete(sid);
|
|
1015
1115
|
}
|
|
1016
1116
|
return;
|
|
1017
1117
|
}
|
|
@@ -1066,16 +1166,17 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1066
1166
|
: undefined;
|
|
1067
1167
|
const toolLower = (input.tool || "").toLowerCase();
|
|
1068
1168
|
logger.debug(`[makdoong2-team hook] tool.execute.before fired: tool="${input.tool}" sessionID="${sessionID}" agent="${agent ?? "unknown"}" callID="${input.callID}"`);
|
|
1069
|
-
// 활성 툴
|
|
1169
|
+
// 활성 툴 집합은 **넣기와 빼기가 반드시 짝을 이뤄야 한다**.
|
|
1070
1170
|
// tool.execute.after 는 툴이 실제로 실행된 뒤에만 돈다. 아래 가드들이 throw 하면
|
|
1071
1171
|
// (차단된 bash · sealed 서브에이전트의 outer-world 툴 · leader 의 파일 쓰기 등)
|
|
1072
|
-
// after 훅이 돌지 않아
|
|
1172
|
+
// after 훅이 돌지 않아 항목이 영구히 남고, 그러면 isRecentlyActive() 가
|
|
1073
1173
|
// 항상 true 가 되어 그 세션의 gone 감지와 orphan 회수가 **영구 비활성**된다.
|
|
1074
1174
|
// 차단은 정상 동작이라 반드시 일어나므로 확정적으로 누수됐다.
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1175
|
+
// (툴 자체가 throw 하는 경우 — 권한 거부 · 파일 없음 — 는 여기서 잡을 수 없다.
|
|
1176
|
+
// 그쪽은 폴러가 스냅샷의 completed/error part 로 정리한다: settleToolCalls.)
|
|
1177
|
+
const activeToolKey = sessionID
|
|
1178
|
+
? toolStarted(registry, sessionID, input.callID)
|
|
1179
|
+
: undefined;
|
|
1079
1180
|
try {
|
|
1080
1181
|
if (sessionID && (toolLower === "dispatch_stage" || toolLower === "dispatch_verifier")) {
|
|
1081
1182
|
const callID = input.callID;
|
|
@@ -1322,9 +1423,9 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1322
1423
|
}
|
|
1323
1424
|
}
|
|
1324
1425
|
catch (err) {
|
|
1325
|
-
// 차단으로 끝난 호출은 실행되지 않았다 →
|
|
1426
|
+
// 차단으로 끝난 호출은 실행되지 않았다 → 넣은 항목을 되돌린다.
|
|
1326
1427
|
if (sessionID)
|
|
1327
|
-
|
|
1428
|
+
toolFinished(registry, sessionID, activeToolKey);
|
|
1328
1429
|
throw err;
|
|
1329
1430
|
}
|
|
1330
1431
|
},
|
|
@@ -1337,8 +1438,7 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1337
1438
|
const toolLowerAfter = (input.tool || "").toLowerCase();
|
|
1338
1439
|
const afterSessionID = input.sessionID;
|
|
1339
1440
|
if (afterSessionID) {
|
|
1340
|
-
|
|
1341
|
-
releaseActiveTool(afterSessionID);
|
|
1441
|
+
toolFinished(registry, afterSessionID, input.callID);
|
|
1342
1442
|
}
|
|
1343
1443
|
// ── Issue-reporter 표시 증명 기록·소멸 ──
|
|
1344
1444
|
// 기록: 단독 `cat <payload>` 가 실행되면 그 시점의 파일 해시를 남긴다. 이
|
|
@@ -1611,22 +1711,73 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1611
1711
|
effectiveWorktree = storedWt;
|
|
1612
1712
|
}
|
|
1613
1713
|
}
|
|
1714
|
+
// ── main repo → worktree 정방향 동기화 ──
|
|
1715
|
+
// dispatch_verifier 는 서브세션 생성 전에 이 동기화를 하는데 dispatch_stage
|
|
1716
|
+
// 에는 없었다. 그 비대칭이 REJECTED 재작업 규약을 구조적으로 깨뜨린다:
|
|
1717
|
+
// 규약은 team-leader 에게 `.done=false` 재설정을 시키는데, 리더의 cwd 는
|
|
1718
|
+
// main repo 이고 아래 done 검사는 worktree 사본을 본다. 리더가 규약대로
|
|
1719
|
+
// 했는데도 `already_done: true` 로 차단되고, 리더는 오류 문구만으로는
|
|
1720
|
+
// 원인을 알 수 없어 같은 실수를 반복했다 (issue #11).
|
|
1721
|
+
// 동기화 방향은 파이프라인 불변식과 같다 — main 이 durable 사본, worktree
|
|
1722
|
+
// 는 forward-seed / reverse-merge 되는 작업 사본이다 (finally 의 REVERSE).
|
|
1723
|
+
if (effectiveWorktree !== cwd) {
|
|
1724
|
+
logger.debug(`[wt-sync] FORWARD issue=${args.issue} worktree=${effectiveWorktree} ` +
|
|
1725
|
+
`caller=dispatch_stage stage=${args.target_stage}`);
|
|
1726
|
+
const fwdSync = await $ `bash ${SCRIPTS_DIR}/wt-sync-ignored.sh ${effectiveWorktree} ${args.issue}`
|
|
1727
|
+
.cwd(cwd).quiet().nothrow();
|
|
1728
|
+
if (fwdSync.exitCode !== 0) {
|
|
1729
|
+
logger.warn(`[wt-sync] FORWARD FAIL issue=${args.issue} caller=dispatch_stage exit=${fwdSync.exitCode} ` +
|
|
1730
|
+
`stderr=${redactAndTruncate(fwdSync.stderr?.toString() ?? "", 200)}`);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1614
1733
|
// done=true stage 재-dispatch 방지 (sub-agent tool-call loop → timeout/empty output).
|
|
1615
1734
|
// 3_delivery.* 는 hybrid stage (publisher = spec provider) 로 재-진입이 정상 흐름이라 제외.
|
|
1616
1735
|
const isHybridDelivery = args.target_stage.startsWith("3_delivery.");
|
|
1617
1736
|
if (!isHybridDelivery) {
|
|
1618
|
-
const
|
|
1737
|
+
const donePath = stageJqPath(args.target_stage) + ".done";
|
|
1738
|
+
const doneR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${donePath}`
|
|
1619
1739
|
.cwd(effectiveWorktree).quiet().nothrow();
|
|
1620
1740
|
if (doneR.exitCode === 0 && doneR.stdout?.toString().trim() === "true") {
|
|
1741
|
+
// 위 정방향 동기화가 성공했다면 두 사본은 같아야 한다. 그래도 다르면
|
|
1742
|
+
// 동기화가 실패한 것이고, 그 사실을 추측이 아니라 관측으로 알린다 —
|
|
1743
|
+
// 종전 문구는 "이미 done=true" 만 말해서, 사본 불일치라는 실제 원인을
|
|
1744
|
+
// 리더가 스스로 추론해야 했다 (state_unreadable 은 이미 안내한다).
|
|
1745
|
+
let mainRepoDone = null;
|
|
1746
|
+
if (effectiveWorktree !== cwd) {
|
|
1747
|
+
const mainDoneR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${donePath}`
|
|
1748
|
+
.cwd(cwd).quiet().nothrow();
|
|
1749
|
+
if (mainDoneR.exitCode === 0)
|
|
1750
|
+
mainRepoDone = mainDoneR.stdout?.toString().trim() ?? null;
|
|
1751
|
+
}
|
|
1752
|
+
const copyMismatch = mainRepoDone !== null && mainRepoDone !== "true";
|
|
1621
1753
|
return JSON.stringify({
|
|
1622
1754
|
ok: false,
|
|
1623
1755
|
gate: args.target_stage,
|
|
1624
1756
|
stage: args.target_stage,
|
|
1625
1757
|
agent: spec.id,
|
|
1626
1758
|
already_done: true,
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1759
|
+
state_copy_mismatch: copyMismatch,
|
|
1760
|
+
worktree_done: "true",
|
|
1761
|
+
main_repo_done: mainRepoDone,
|
|
1762
|
+
reason: copyMismatch
|
|
1763
|
+
? `Stage '${args.target_stage}' is done=true in the worktree state.json copy but ` +
|
|
1764
|
+
`done=${mainRepoDone} in the main repo copy. state.sh 는 호출 cwd 의 git toplevel 을 ` +
|
|
1765
|
+
`쓰므로 두 사본은 분리돼 있고, 이 검사는 worktree 사본을 본다. ` +
|
|
1766
|
+
`main repo cwd 에서 '.done' 을 되돌렸다면 그 변경은 worktree 사본에 반영되지 않은 것이다 ` +
|
|
1767
|
+
`(정방향 동기화를 이미 1회 자동 시도했고 실패했다).`
|
|
1768
|
+
: `Stage '${args.target_stage}' is already done=true. ` +
|
|
1769
|
+
`Re-dispatching a completed stage causes sub-agent tool-call loops (timeout/empty output). ` +
|
|
1770
|
+
`Call auto_advance_stage to obtain the correct next stage instead. ` +
|
|
1771
|
+
`참고: 방금 '.done=false' 로 되돌렸는데도 이 응답이 왔다면, 다른 cwd(main repo)에서 ` +
|
|
1772
|
+
`state.json 을 조작해 worktree 사본과 불일치한 경우다 — state_unreadable 과 같은 메커니즘이다.`,
|
|
1773
|
+
next_action: copyMismatch
|
|
1774
|
+
? `동기화는 이미 자동 시도했고 실패했습니다 — 직접 재실행하지 마세요. ` +
|
|
1775
|
+
`'bash ${SCRIPTS_DIR}/state.sh status ${args.issue}' 로 사본 상태를 확인하고, ` +
|
|
1776
|
+
`해소되지 않으면 사용자에게 에스컬레이션하세요.`
|
|
1777
|
+
: `auto_advance_stage(issue: "${args.issue}") 로 올바른 다음 단계를 받으세요. ` +
|
|
1778
|
+
`REJECTED 재작업 중이라면 '.done' 재설정이 실제로 반영됐는지 ` +
|
|
1779
|
+
`bash ${SCRIPTS_DIR}/state.sh get ${args.issue} <done jq-path> 로 먼저 확인하세요 ` +
|
|
1780
|
+
`(jq-path: ${donePath}).`,
|
|
1630
1781
|
});
|
|
1631
1782
|
}
|
|
1632
1783
|
}
|
|
@@ -1742,7 +1893,7 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1742
1893
|
`Stage 명세 파일은 위 Stages directory 경로에서 읽으시오. \`<SCRIPTS_DIR>/../stages/\` 상대경로를 사용하지 마시오.`,
|
|
1743
1894
|
];
|
|
1744
1895
|
if (args.target_stage === "2_implementation.dev") {
|
|
1745
|
-
base.push(`\n=== dev substage 요구사항 소스 우선순위 ===`, `구현 착수 전 요구사항은 반드시 다음 순서로 참조한다:`, ` a) FIRST — requirements-draft.md 를 우선 확인 (아래 bash 스니펫 그대로 실행):`, buildDraftPathReadSnippet(args.issue, " "), ` 파일이 존재하고 비어있지 않으면 이 파일이 요구사항의 진실의 원천이다.`, ` b) FALLBACK — draft_path 미기록/파일 부재/빈 파일 인 경우 Jira 이슈 조회:`, ` skill(name="jira-research") 로 works MCP 로드 →`, ` skill_mcp(mcp_name="works", tool_name="getIssue", arguments={"issueKey":"${args.issue}"})`, ` c) 두 소스 모두 접근 불가하면 사용자에게 상황을 보고하고 즉시 종료.`, `주의: requirements-draft.md 없이 Jira 이슈만으로 구현 범위를 재결정하지 말 것 — draft 가 없다는 것은 planning 이 부적절하게 진행된 신호이므로 사용자에게 보고하고
|
|
1896
|
+
base.push(`\n=== dev substage 요구사항 소스 우선순위 ===`, `구현 착수 전 요구사항은 반드시 다음 순서로 참조한다:`, ` a) FIRST — requirements-draft.md 를 우선 확인 (아래 bash 스니펫 그대로 실행):`, buildDraftPathReadSnippet(args.issue, " "), ` 파일이 존재하고 비어있지 않으면 이 파일이 요구사항의 진실의 원천이다.`, ` b) FALLBACK — draft_path 미기록/파일 부재/빈 파일 인 경우 Jira 이슈 조회:`, ` skill(name="jira-research") 로 works MCP 로드 →`, ` skill_mcp(mcp_name="works", tool_name="getIssue", arguments={"issueKey":"${args.issue}"})`, ` c) 두 소스 모두 접근 불가하면 사용자에게 상황을 보고하고 즉시 종료.`, `주의: requirements-draft.md 없이 Jira 이슈만으로 구현 범위를 재결정하지 말 것 — draft 가 없다는 것은 planning 이 부적절하게 진행된 신호이므로 사용자에게 보고하고 종료하시오.`, `\n=== 테스트 동반 원칙은 requirements 의 선언을 따른다 (issue #11) ===`, `테스트 추가 여부는 스스로 정하지 않는다. 아래를 실행해 1_planning.requirements 가 승인·동결한 선언을 먼저 읽는다:`, ` bash ${SCRIPTS_DIR}/state.sh get ${args.issue} '.stages."1_planning".substages."requirements".test_scope'`, ` - new_tests_required=true (마커 부재·null 도 true 로 간주 — fail-closed) → 변경에 대한 테스트를 함께 추가하고 self_check.new_tests_added=true 로 기록한다.`, ` - new_tests_required=false → 테스트 추가는 이번 이슈의 범위 밖이다. 추가하지 않는 것이 정답이며, self_check.new_tests_added=false 와 함께 dev.new_tests_waived=true 마커를 남긴다.`, `이 마커는 읽기 전용이다 — engineer 가 test_scope 를 쓰거나 고치지 않는다. 선언과 실제 작업이 맞지 않으면 임의로 면제·추가하지 말고 최종 출력에 적어 보고한다.`);
|
|
1746
1897
|
}
|
|
1747
1898
|
if (attemptNum > 1) {
|
|
1748
1899
|
base.push(`\n=== 재개(resume) 지시 — 이전 세션 중단됨 ===`, `이전 세션 ID: ${priorSessionIds.join(", ")} (attempt ${attemptNum - 1})`, `중단 원인: 이전 sub-session이 stall/gone 감지되어 새 세션으로 이어서 진행합니다.`, `context 승계 방식: opencode API는 세션 간 대화 이력을 옮기지 못하므로 state.json 을 진실의 원천으로 사용합니다.`, `첫 번째 필수 작업:`, ` 1) bash ${SCRIPTS_DIR}/state.sh get ${args.issue} '.' 로 현재 상태 전량 조회`, ` 2) 이미 done=true 로 기록된 substage / 마커는 재실행하지 말고 skip`, ` 3) 미완료 substage 부터 stage spec 순서대로 이어서 진행`, ` 4) 완료 시 관례대로 요약 출력 후 종료`, `주의: state.json 마커가 이미 target substage 완료를 나타내면 즉시 요약만 출력하고 종료하시오 (재작업 금지).`);
|
|
@@ -5,6 +5,19 @@ export type MessagePartLike = {
|
|
|
5
5
|
state?: {
|
|
6
6
|
status?: string;
|
|
7
7
|
};
|
|
8
|
+
/** ToolPart 의 호출 ID — `tool.execute.before/after` 훅의 callID 와 같은 값. */
|
|
9
|
+
callID?: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* 폴 시점 스냅샷에서 읽은 툴 호출 상태. `isToolExecuting` 에 넘겨 훅 레지스트리와
|
|
13
|
+
* 대조하게 한다 — throw 로 끝난 툴은 after 훅이 돌지 않아 레지스트리에 남는데,
|
|
14
|
+
* 스냅샷의 completed/error part 가 그 호출이 끝났음을 확정해 준다.
|
|
15
|
+
*/
|
|
16
|
+
export type ToolSnapshot = {
|
|
17
|
+
/** completed / error 로 끝난 호출 ID (스냅샷 전체 메시지 기준). */
|
|
18
|
+
settledCallIDs: ReadonlySet<string>;
|
|
19
|
+
/** pending / running 인 호출 ID (마지막 assistant 메시지 기준). */
|
|
20
|
+
inFlightCallIDs: ReadonlySet<string>;
|
|
8
21
|
};
|
|
9
22
|
export type MessageInfoLike = {
|
|
10
23
|
id?: string;
|
|
@@ -109,7 +122,7 @@ export interface PollOptions {
|
|
|
109
122
|
messageStallThresholdMs?: number;
|
|
110
123
|
statusAbsentGraceMs?: number;
|
|
111
124
|
isRecentlyActive?: () => boolean;
|
|
112
|
-
isToolExecuting?: () => boolean;
|
|
125
|
+
isToolExecuting?: (snapshot: ToolSnapshot) => boolean;
|
|
113
126
|
contentStableCompletionMs?: number;
|
|
114
127
|
preambleOnlyTextThreshold?: number;
|
|
115
128
|
nudgeAtFraction?: number;
|
package/dist/poll-sub-session.js
CHANGED
|
@@ -29,6 +29,37 @@
|
|
|
29
29
|
const TOOL_CALL_PART_TYPES = new Set(["tool", "tool_call", "tool-call", "tool_use"]);
|
|
30
30
|
/** 아직 끝나지 않은 툴 상태 (ToolStatePending / ToolStateRunning). */
|
|
31
31
|
const IN_FLIGHT_TOOL_STATUSES = new Set(["pending", "running"]);
|
|
32
|
+
/** 확실히 끝난 툴 상태 (ToolStateCompleted / ToolStateError). */
|
|
33
|
+
const SETTLED_TOOL_STATUSES = new Set(["completed", "error"]);
|
|
34
|
+
/**
|
|
35
|
+
* 스냅샷에서 툴 호출 ID 를 상태별로 모은다. settled 는 전체 메시지를 훑는다 —
|
|
36
|
+
* 레지스트리에 남은 항목은 이전 turn 의 호출일 수도 있다. inFlight 는 마지막
|
|
37
|
+
* assistant 메시지만 본다 (`hasPendingToolCall` 과 같은 기준).
|
|
38
|
+
*/
|
|
39
|
+
function collectToolSnapshot(messages, lastAssistant) {
|
|
40
|
+
const settledCallIDs = new Set();
|
|
41
|
+
const inFlightCallIDs = new Set();
|
|
42
|
+
for (const m of messages) {
|
|
43
|
+
if (m.info?.role !== "assistant")
|
|
44
|
+
continue;
|
|
45
|
+
for (const part of m.parts ?? []) {
|
|
46
|
+
if (!TOOL_CALL_PART_TYPES.has(part.type))
|
|
47
|
+
continue;
|
|
48
|
+
if (typeof part.callID !== "string" || part.callID.length === 0)
|
|
49
|
+
continue;
|
|
50
|
+
const status = part.state?.status;
|
|
51
|
+
if (typeof status === "string" && SETTLED_TOOL_STATUSES.has(status))
|
|
52
|
+
settledCallIDs.add(part.callID);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const part of lastAssistant?.parts ?? []) {
|
|
56
|
+
if (!isInFlightToolPart(part))
|
|
57
|
+
continue;
|
|
58
|
+
if (typeof part.callID === "string" && part.callID.length > 0)
|
|
59
|
+
inFlightCallIDs.add(part.callID);
|
|
60
|
+
}
|
|
61
|
+
return { settledCallIDs, inFlightCallIDs };
|
|
62
|
+
}
|
|
32
63
|
/**
|
|
33
64
|
* "지금 실행 중인 툴 호출이 있는가".
|
|
34
65
|
*
|
|
@@ -236,7 +267,9 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
236
267
|
// 둘 중 하나라도 "툴이 떠 있다" 고 하면 완료로 판정하지 않는다 — 스냅샷은
|
|
237
268
|
// 서버 반영이 늦고(issue #7), 훅 신호는 훅이 안 붙은 환경에서 아예 없다.
|
|
238
269
|
// OR 로 묶어야 각자의 사각지대를 서로 덮는다.
|
|
239
|
-
const toolExecuting = isToolExecuting
|
|
270
|
+
const toolExecuting = isToolExecuting
|
|
271
|
+
? isToolExecuting(collectToolSnapshot(messages, lastAssistant)) === true
|
|
272
|
+
: false;
|
|
240
273
|
const toolInFlight = hasPendingToolCall || toolExecuting;
|
|
241
274
|
const stalledMs = now() - lastProgressAt;
|
|
242
275
|
// session_gone (status_absent) has two admission paths with different
|
|
@@ -376,7 +409,9 @@ export async function pollSubSession(client, sessionId, options = {}) {
|
|
|
376
409
|
err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount} stalledMs=${stalledMs}` +
|
|
377
410
|
(stalledPerm
|
|
378
411
|
? ` pending permissionID=${stalledPerm.id} type=${stalledPerm.permission} patterns=${JSON.stringify(stalledPerm.patterns)}`
|
|
379
|
-
:
|
|
412
|
+
: client.permission
|
|
413
|
+
? ` no pending permission observed for this session (tool part in flight, no tool.execute signal)`
|
|
414
|
+
: ` pending permission unknown (permission source unavailable)`));
|
|
380
415
|
await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
|
|
381
416
|
return {
|
|
382
417
|
kind: "permission_stall",
|
|
@@ -629,8 +664,10 @@ export function pollOutcomeToLegacy(outcome) {
|
|
|
629
664
|
"에이전트 permission 설정 문제다. 해당 에이전트의 frontmatter `permission:` 블록을 점검하라 " +
|
|
630
665
|
"(정식 키는 `edit`; `write` 키는 존재하지 않아 조용히 무시된다).";
|
|
631
666
|
default:
|
|
632
|
-
return "
|
|
633
|
-
"(`
|
|
667
|
+
return "툴 호출 part 가 떠 있는데 실행 신호(tool.execute.before)도 권한 요청도 관측되지 않았다. 점검: " +
|
|
668
|
+
"(1) 세션의 디렉토리에서도 플러그인이 로드되는가 — 로그의 `[init] plugin instance directory=…` 가 " +
|
|
669
|
+
"worktree 경로로도 찍혀야 한다 (opencode 는 디렉토리마다 플러그인을 따로 초기화한다) " +
|
|
670
|
+
"(2) opencode.json 의 permission.external_directory 시드 (`npx makdoong2-team doctor`).";
|
|
634
671
|
}
|
|
635
672
|
})();
|
|
636
673
|
return {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type PendingPermission = {
|
|
2
|
+
id: string;
|
|
3
|
+
sessionID: string;
|
|
4
|
+
permission: string;
|
|
5
|
+
patterns: string[];
|
|
6
|
+
askedAt: number;
|
|
7
|
+
};
|
|
8
|
+
export interface SubSessionRegistry {
|
|
9
|
+
readonly version: 1;
|
|
10
|
+
/** sessionID → (callID → startedAt). before 에서 넣고 after / 툴 part 종결에서 뺀다. */
|
|
11
|
+
readonly activeToolCalls: Map<string, Map<string, number>>;
|
|
12
|
+
/** sessionID → 마지막 tool.execute.* 발화 시각. */
|
|
13
|
+
readonly lastToolExecuteAt: Map<string, number>;
|
|
14
|
+
/** sessionID → (requestID → 요청). permission.asked 로 넣고 replied 로 뺀다. */
|
|
15
|
+
readonly pendingPermissions: Map<string, Map<string, PendingPermission>>;
|
|
16
|
+
/** sessionID → 이슈키. dispatch 사본이 쓰고 훅 사본(guard-bash.sh 인자)이 읽는다. */
|
|
17
|
+
readonly sessionIssue: Map<string, string>;
|
|
18
|
+
/** sessionID → worktree 절대경로. auto-git-add 가 읽는다. */
|
|
19
|
+
readonly sessionWorktree: Map<string, string>;
|
|
20
|
+
/** session.deleted 대기자. abort 후 재디스패치 전 race window 를 닫는다. */
|
|
21
|
+
readonly sessionDeletedWaiters: Map<string, Array<() => void>>;
|
|
22
|
+
/** callID 가 없는 before 훅에 붙일 대체 키 일련번호. */
|
|
23
|
+
anonSeq: number;
|
|
24
|
+
}
|
|
25
|
+
export declare function createSubSessionRegistry(): SubSessionRegistry;
|
|
26
|
+
/**
|
|
27
|
+
* 프로세스 전역 레지스트리. 첫 호출이 만들고 이후 호출은 같은 객체를 돌려준다.
|
|
28
|
+
* 키에 버전을 박아 두어, 형태가 다른 미래 버전이 같은 프로세스에 섞여도 서로의
|
|
29
|
+
* 객체를 깨뜨리지 않는다.
|
|
30
|
+
*/
|
|
31
|
+
export declare function sharedSubSessionRegistry(): SubSessionRegistry;
|
|
32
|
+
/** 테스트 전용 — 전역 객체를 비운다. */
|
|
33
|
+
export declare function resetSharedSubSessionRegistryForTests(): void;
|
|
34
|
+
/** `tool.execute.before` — 이 세션에서 callID 툴이 실행을 시작했다. 사용한 키를 돌려준다. */
|
|
35
|
+
export declare function toolStarted(reg: SubSessionRegistry, sessionID: string, callID: string | undefined, now?: number): string;
|
|
36
|
+
/**
|
|
37
|
+
* `tool.execute.after` 또는 툴 part 의 completed/error 관측 — 실행이 끝났다.
|
|
38
|
+
* 멱등이다: 같은 callID 를 두 번 끝내도, 모르는 callID 를 끝내도 아무 일도 없다.
|
|
39
|
+
* callID 가 없으면 가장 최근에 시작한 항목을 뺀다 (종전 카운터 의미 유지).
|
|
40
|
+
*/
|
|
41
|
+
export declare function toolFinished(reg: SubSessionRegistry, sessionID: string, callID: string | undefined, now?: number): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* 메시지 스냅샷과 대조해 이미 끝난 호출을 정리한다.
|
|
44
|
+
*
|
|
45
|
+
* 툴이 throw 하면(권한 거부 · 파일 없음 · MCP 오류) opencode 는 `tool.execute.after`
|
|
46
|
+
* 를 부르지 않는다 — 항목이 영영 남아 `isToolExecuting()` 이 참으로 굳고, 그러면
|
|
47
|
+
* 완료 판정이 유보돼 substage 가 절대 타임아웃까지 기다린다. 스냅샷의 tool part
|
|
48
|
+
* 가 completed/error 면 그 호출은 확실히 끝난 것이므로 여기서 뺀다. 스냅샷에
|
|
49
|
+
* **아직 없는** 호출은 건드리지 않는다 (issue #7 의 110ms 창 — 훅은 발화했는데
|
|
50
|
+
* 서버가 part 를 아직 반영하지 않은 상태). 정리한 개수를 돌려준다.
|
|
51
|
+
*/
|
|
52
|
+
export declare function settleToolCalls(reg: SubSessionRegistry, sessionID: string, settledCallIDs: Iterable<string>): number;
|
|
53
|
+
/** 지금 이 순간 이 세션에서 실행 중인 툴이 있는가 (순간값). */
|
|
54
|
+
export declare function isToolExecuting(reg: SubSessionRegistry, sessionID: string): boolean;
|
|
55
|
+
export declare function activeToolCount(reg: SubSessionRegistry, sessionID: string): number;
|
|
56
|
+
/** 세션이 idle 로 전이했다 — 실행 중인 툴은 정의상 없다. */
|
|
57
|
+
export declare function clearToolCalls(reg: SubSessionRegistry, sessionID: string): void;
|
|
58
|
+
/**
|
|
59
|
+
* 플러그인 `event` 훅이 받는 권한 요청 이벤트를 공통 형태로 바꾼다.
|
|
60
|
+
*
|
|
61
|
+
* 1.18 v1 브리지는 `permission.asked` 를 `PermissionRequest` 그대로 싣는다
|
|
62
|
+
* (`id / sessionID / permission / patterns`). 더 오래된 SDK 형태 `permission.updated`
|
|
63
|
+
* 는 `type` · `pattern`(문자열 또는 배열) 을 쓴다. 둘 다 받아 둔다 — 어느 쪽이 오든
|
|
64
|
+
* 폴러가 보는 것은 같은 shape 이어야 한다. 요청으로 볼 수 없는 페이로드는 null.
|
|
65
|
+
*/
|
|
66
|
+
export declare function normalizePermissionEvent(type: string, properties: unknown, now?: number): PendingPermission | null;
|
|
67
|
+
export declare function permissionAsked(reg: SubSessionRegistry, req: PendingPermission): void;
|
|
68
|
+
export declare function permissionReplied(reg: SubSessionRegistry, sessionID: string, requestID: string): void;
|
|
69
|
+
/** 이 세션에 답을 기다리는 권한 요청 — 오래된 순. */
|
|
70
|
+
export declare function pendingPermissionsFor(reg: SubSessionRegistry, sessionID: string): PendingPermission[];
|
|
71
|
+
/** 세션 하나의 흔적을 전부 지운다 (cleanupSubSession · session.deleted). */
|
|
72
|
+
export declare function forgetSession(reg: SubSessionRegistry, sessionID: string): void;
|
|
73
|
+
/** session.deleted — 대기자를 전부 깨운다. 깨운 수를 돌려준다. */
|
|
74
|
+
export declare function notifySessionDeleted(reg: SubSessionRegistry, sessionID: string): number;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// 프로세스 전역 서브세션 레지스트리 — 플러그인 사본 사이의 공유 신호.
|
|
2
|
+
//
|
|
3
|
+
// opencode 1.18 은 **디렉토리(Instance)마다 플러그인을 따로 초기화한다**.
|
|
4
|
+
// `dispatch_stage` 는 서브세션을 `directory=<worktree>` 로 만들므로 그 세션의
|
|
5
|
+
// `tool.execute.before/after` · `event` 훅은 worktree Instance 의 플러그인 사본에서
|
|
6
|
+
// 발화하고, 그 세션을 감시하는 `pollSubSession` 은 main Instance 의 사본에서 돈다.
|
|
7
|
+
// 사본마다 자기 Map 을 들고 있으면 폴러는 언제나 빈 Map 을 본다 —
|
|
8
|
+
// `isToolExecuting()` 이 영영 false 라 60초를 넘는 모든 툴(sbt test 등)이
|
|
9
|
+
// `permission_stall`(reason=tool_call_stall) 로 abort 됐다 (GitHub issue #10).
|
|
10
|
+
//
|
|
11
|
+
// 그래서 사본을 가로지르는 신호는 전부 여기, `globalThis` 에 둔다. 같은 프로세스
|
|
12
|
+
// 안의 모든 사본이 — 모듈 경로가 달라 module cache 가 갈리더라도 — 한 객체를 본다.
|
|
13
|
+
// 여기 두는 것은 "훅 사본이 쓰고 폴러 사본이 읽는" 세션 단위 신호뿐이다.
|
|
14
|
+
// `pendingDispatch` 같은 디스패치 소유 상태는 공유하지 않는다 — 공유하면 worktree
|
|
15
|
+
// 사본의 `session.created` 핸들러가 pane 을 한 번 더 띄운다.
|
|
16
|
+
//
|
|
17
|
+
// 이 파일은 src/opencode-plugin.ts 에서 import 만 한다 (re-export 금지 —
|
|
18
|
+
// 로더가 모든 named export 를 plugin factory 로 호출한다).
|
|
19
|
+
const SHARED_KEY = Symbol.for("makdoong2-team.sub-session-registry.v1");
|
|
20
|
+
export function createSubSessionRegistry() {
|
|
21
|
+
return {
|
|
22
|
+
version: 1,
|
|
23
|
+
activeToolCalls: new Map(),
|
|
24
|
+
lastToolExecuteAt: new Map(),
|
|
25
|
+
pendingPermissions: new Map(),
|
|
26
|
+
sessionIssue: new Map(),
|
|
27
|
+
sessionWorktree: new Map(),
|
|
28
|
+
sessionDeletedWaiters: new Map(),
|
|
29
|
+
anonSeq: 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 프로세스 전역 레지스트리. 첫 호출이 만들고 이후 호출은 같은 객체를 돌려준다.
|
|
34
|
+
* 키에 버전을 박아 두어, 형태가 다른 미래 버전이 같은 프로세스에 섞여도 서로의
|
|
35
|
+
* 객체를 깨뜨리지 않는다.
|
|
36
|
+
*/
|
|
37
|
+
export function sharedSubSessionRegistry() {
|
|
38
|
+
const g = globalThis;
|
|
39
|
+
const existing = g[SHARED_KEY];
|
|
40
|
+
if (existing && existing.version === 1)
|
|
41
|
+
return existing;
|
|
42
|
+
const created = createSubSessionRegistry();
|
|
43
|
+
g[SHARED_KEY] = created;
|
|
44
|
+
return created;
|
|
45
|
+
}
|
|
46
|
+
/** 테스트 전용 — 전역 객체를 비운다. */
|
|
47
|
+
export function resetSharedSubSessionRegistryForTests() {
|
|
48
|
+
delete globalThis[SHARED_KEY];
|
|
49
|
+
}
|
|
50
|
+
// ── 툴 실행 신호 ─────────────────────────────────────────────────────────────
|
|
51
|
+
const ANON_PREFIX = "anon#";
|
|
52
|
+
/** `tool.execute.before` — 이 세션에서 callID 툴이 실행을 시작했다. 사용한 키를 돌려준다. */
|
|
53
|
+
export function toolStarted(reg, sessionID, callID, now = Date.now()) {
|
|
54
|
+
const key = typeof callID === "string" && callID.length > 0 ? callID : `${ANON_PREFIX}${++reg.anonSeq}`;
|
|
55
|
+
let calls = reg.activeToolCalls.get(sessionID);
|
|
56
|
+
if (!calls) {
|
|
57
|
+
calls = new Map();
|
|
58
|
+
reg.activeToolCalls.set(sessionID, calls);
|
|
59
|
+
}
|
|
60
|
+
calls.set(key, now);
|
|
61
|
+
reg.lastToolExecuteAt.set(sessionID, now);
|
|
62
|
+
return key;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `tool.execute.after` 또는 툴 part 의 completed/error 관측 — 실행이 끝났다.
|
|
66
|
+
* 멱등이다: 같은 callID 를 두 번 끝내도, 모르는 callID 를 끝내도 아무 일도 없다.
|
|
67
|
+
* callID 가 없으면 가장 최근에 시작한 항목을 뺀다 (종전 카운터 의미 유지).
|
|
68
|
+
*/
|
|
69
|
+
export function toolFinished(reg, sessionID, callID, now = Date.now()) {
|
|
70
|
+
reg.lastToolExecuteAt.set(sessionID, now);
|
|
71
|
+
const calls = reg.activeToolCalls.get(sessionID);
|
|
72
|
+
if (!calls || calls.size === 0)
|
|
73
|
+
return false;
|
|
74
|
+
let key;
|
|
75
|
+
if (typeof callID === "string" && callID.length > 0) {
|
|
76
|
+
if (!calls.has(callID))
|
|
77
|
+
return false;
|
|
78
|
+
key = callID;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
let latest = -Infinity;
|
|
82
|
+
for (const [k, startedAt] of calls) {
|
|
83
|
+
if (startedAt >= latest) {
|
|
84
|
+
latest = startedAt;
|
|
85
|
+
key = k;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (key === undefined)
|
|
90
|
+
return false;
|
|
91
|
+
calls.delete(key);
|
|
92
|
+
if (calls.size === 0)
|
|
93
|
+
reg.activeToolCalls.delete(sessionID);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 메시지 스냅샷과 대조해 이미 끝난 호출을 정리한다.
|
|
98
|
+
*
|
|
99
|
+
* 툴이 throw 하면(권한 거부 · 파일 없음 · MCP 오류) opencode 는 `tool.execute.after`
|
|
100
|
+
* 를 부르지 않는다 — 항목이 영영 남아 `isToolExecuting()` 이 참으로 굳고, 그러면
|
|
101
|
+
* 완료 판정이 유보돼 substage 가 절대 타임아웃까지 기다린다. 스냅샷의 tool part
|
|
102
|
+
* 가 completed/error 면 그 호출은 확실히 끝난 것이므로 여기서 뺀다. 스냅샷에
|
|
103
|
+
* **아직 없는** 호출은 건드리지 않는다 (issue #7 의 110ms 창 — 훅은 발화했는데
|
|
104
|
+
* 서버가 part 를 아직 반영하지 않은 상태). 정리한 개수를 돌려준다.
|
|
105
|
+
*/
|
|
106
|
+
export function settleToolCalls(reg, sessionID, settledCallIDs) {
|
|
107
|
+
const calls = reg.activeToolCalls.get(sessionID);
|
|
108
|
+
if (!calls || calls.size === 0)
|
|
109
|
+
return 0;
|
|
110
|
+
let n = 0;
|
|
111
|
+
for (const id of settledCallIDs) {
|
|
112
|
+
if (calls.delete(id))
|
|
113
|
+
n++;
|
|
114
|
+
}
|
|
115
|
+
if (calls.size === 0)
|
|
116
|
+
reg.activeToolCalls.delete(sessionID);
|
|
117
|
+
return n;
|
|
118
|
+
}
|
|
119
|
+
/** 지금 이 순간 이 세션에서 실행 중인 툴이 있는가 (순간값). */
|
|
120
|
+
export function isToolExecuting(reg, sessionID) {
|
|
121
|
+
return (reg.activeToolCalls.get(sessionID)?.size ?? 0) > 0;
|
|
122
|
+
}
|
|
123
|
+
export function activeToolCount(reg, sessionID) {
|
|
124
|
+
return reg.activeToolCalls.get(sessionID)?.size ?? 0;
|
|
125
|
+
}
|
|
126
|
+
/** 세션이 idle 로 전이했다 — 실행 중인 툴은 정의상 없다. */
|
|
127
|
+
export function clearToolCalls(reg, sessionID) {
|
|
128
|
+
reg.activeToolCalls.delete(sessionID);
|
|
129
|
+
}
|
|
130
|
+
// ── 권한 요청 관측 ───────────────────────────────────────────────────────────
|
|
131
|
+
/**
|
|
132
|
+
* 플러그인 `event` 훅이 받는 권한 요청 이벤트를 공통 형태로 바꾼다.
|
|
133
|
+
*
|
|
134
|
+
* 1.18 v1 브리지는 `permission.asked` 를 `PermissionRequest` 그대로 싣는다
|
|
135
|
+
* (`id / sessionID / permission / patterns`). 더 오래된 SDK 형태 `permission.updated`
|
|
136
|
+
* 는 `type` · `pattern`(문자열 또는 배열) 을 쓴다. 둘 다 받아 둔다 — 어느 쪽이 오든
|
|
137
|
+
* 폴러가 보는 것은 같은 shape 이어야 한다. 요청으로 볼 수 없는 페이로드는 null.
|
|
138
|
+
*/
|
|
139
|
+
export function normalizePermissionEvent(type, properties, now = Date.now()) {
|
|
140
|
+
if (type !== "permission.asked" && type !== "permission.updated")
|
|
141
|
+
return null;
|
|
142
|
+
if (!properties || typeof properties !== "object")
|
|
143
|
+
return null;
|
|
144
|
+
const p = properties;
|
|
145
|
+
const id = typeof p.id === "string" ? p.id : undefined;
|
|
146
|
+
const sessionID = typeof p.sessionID === "string" ? p.sessionID : undefined;
|
|
147
|
+
if (!id || !sessionID)
|
|
148
|
+
return null;
|
|
149
|
+
const permission = typeof p.permission === "string" ? p.permission
|
|
150
|
+
: typeof p.type === "string" ? p.type
|
|
151
|
+
: "unknown";
|
|
152
|
+
const rawPatterns = p.patterns ?? p.pattern;
|
|
153
|
+
const patterns = Array.isArray(rawPatterns)
|
|
154
|
+
? rawPatterns.filter((x) => typeof x === "string")
|
|
155
|
+
: typeof rawPatterns === "string" ? [rawPatterns]
|
|
156
|
+
: [];
|
|
157
|
+
return { id, sessionID, permission, patterns, askedAt: now };
|
|
158
|
+
}
|
|
159
|
+
export function permissionAsked(reg, req) {
|
|
160
|
+
let bySession = reg.pendingPermissions.get(req.sessionID);
|
|
161
|
+
if (!bySession) {
|
|
162
|
+
bySession = new Map();
|
|
163
|
+
reg.pendingPermissions.set(req.sessionID, bySession);
|
|
164
|
+
}
|
|
165
|
+
bySession.set(req.id, req);
|
|
166
|
+
}
|
|
167
|
+
export function permissionReplied(reg, sessionID, requestID) {
|
|
168
|
+
const bySession = reg.pendingPermissions.get(sessionID);
|
|
169
|
+
if (!bySession)
|
|
170
|
+
return;
|
|
171
|
+
bySession.delete(requestID);
|
|
172
|
+
if (bySession.size === 0)
|
|
173
|
+
reg.pendingPermissions.delete(sessionID);
|
|
174
|
+
}
|
|
175
|
+
/** 이 세션에 답을 기다리는 권한 요청 — 오래된 순. */
|
|
176
|
+
export function pendingPermissionsFor(reg, sessionID) {
|
|
177
|
+
const bySession = reg.pendingPermissions.get(sessionID);
|
|
178
|
+
if (!bySession)
|
|
179
|
+
return [];
|
|
180
|
+
return [...bySession.values()].sort((a, b) => a.askedAt - b.askedAt);
|
|
181
|
+
}
|
|
182
|
+
// ── 수명 ─────────────────────────────────────────────────────────────────────
|
|
183
|
+
/** 세션 하나의 흔적을 전부 지운다 (cleanupSubSession · session.deleted). */
|
|
184
|
+
export function forgetSession(reg, sessionID) {
|
|
185
|
+
reg.activeToolCalls.delete(sessionID);
|
|
186
|
+
reg.lastToolExecuteAt.delete(sessionID);
|
|
187
|
+
reg.pendingPermissions.delete(sessionID);
|
|
188
|
+
reg.sessionIssue.delete(sessionID);
|
|
189
|
+
reg.sessionWorktree.delete(sessionID);
|
|
190
|
+
}
|
|
191
|
+
/** session.deleted — 대기자를 전부 깨운다. 깨운 수를 돌려준다. */
|
|
192
|
+
export function notifySessionDeleted(reg, sessionID) {
|
|
193
|
+
const waiters = reg.sessionDeletedWaiters.get(sessionID);
|
|
194
|
+
if (!waiters || waiters.length === 0)
|
|
195
|
+
return 0;
|
|
196
|
+
const copy = [...waiters];
|
|
197
|
+
for (const w of copy) {
|
|
198
|
+
try {
|
|
199
|
+
w();
|
|
200
|
+
}
|
|
201
|
+
catch { /* waiter 오류는 이벤트 처리를 막지 않는다 */ }
|
|
202
|
+
}
|
|
203
|
+
return copy.length;
|
|
204
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
# 1. worktree 가 존재하는가
|
|
6
6
|
# 2. dev-written-files.txt 에 기록된 모든 파일이 staging(index) 혹은 HEAD tree 에 존재하는가
|
|
7
7
|
# 3. .gitignore 를 존중한 untracked 파일이 0인가
|
|
8
|
+
# 4. 테스트 동반 원칙이 1_planning.requirements 의 test_scope 선언과 일치하는가
|
|
8
9
|
#
|
|
9
10
|
# 불변식: 3_delivery.commit 는 untracked 를 자동 제외하므로,
|
|
10
11
|
# 본 게이트를 통과한 파일만이 커밋 대상에 진입한다.
|
|
@@ -66,4 +67,40 @@ $(echo "$UNTRACKED" | sed 's/^/ - /')"
|
|
|
66
67
|
fail "$MSG"
|
|
67
68
|
fi
|
|
68
69
|
|
|
70
|
+
# ── 4. 테스트 동반 원칙 — requirements 의 선언을 따른다 (issue #11) ──────────
|
|
71
|
+
#
|
|
72
|
+
# 종전에는 이 검사가 게이트에 없고 verifier 만 알고 있었으며, 그 verifier 기준은
|
|
73
|
+
# "sub-agent output 에 '테스트 추가' 명시" 라는 무조건 요구였다. requirements 가
|
|
74
|
+
# 테스트 범위 제외를 승인·동결해도 그 결정을 참조하는 경로가 없어서, 순수 설정·
|
|
75
|
+
# 인프라 전환 작업마다 REJECTED 가 반복되고 결국 engineer 가 승인된 스코프 밖의
|
|
76
|
+
# 테스트를 추가했다. 게이트·stage spec·verifier 세 곳이 같은 선언을 보게 한다.
|
|
77
|
+
#
|
|
78
|
+
# 판정 규칙 (fail-closed):
|
|
79
|
+
# REQ = requirements.test_scope.new_tests_required — 부재/null = true 로 간주
|
|
80
|
+
# REQ=true → self_check.new_tests_added 는 true 여야 한다
|
|
81
|
+
# REQ=false → new_tests_added 가 false 여도 되지만, 슬립과 구분하기 위해
|
|
82
|
+
# dev.new_tests_waived=true 마커가 함께 있어야 한다
|
|
83
|
+
#
|
|
84
|
+
# self_check 자체가 없는 구형 state 는 검사하지 않는다 (기존 동작 보존).
|
|
85
|
+
q(){ local __v; if __v="$("$HERE/../scripts/state.sh" get "$ISSUE" "$1" 2>/dev/null)"; then printf "%s" "$__v"; else printf "__MISSING__"; fi; }
|
|
86
|
+
|
|
87
|
+
SELF_CHECK="$(q '.stages."2_implementation".substages."dev".self_check')"
|
|
88
|
+
if [ "${SELF_CHECK}" != "__MISSING__" ] && [ "${SELF_CHECK}" != "null" ] && [ -n "${SELF_CHECK}" ]; then
|
|
89
|
+
NEW_TESTS_ADDED="$(q '.stages."2_implementation".substages."dev".self_check.new_tests_added')"
|
|
90
|
+
if [ "${NEW_TESTS_ADDED}" != "true" ]; then
|
|
91
|
+
REQ="$(q '.stages."1_planning".substages."requirements".test_scope.new_tests_required')"
|
|
92
|
+
if [ "${REQ}" != "false" ]; then
|
|
93
|
+
fail "테스트 동반 원칙 미충족: self_check.new_tests_added=${NEW_TESTS_ADDED} 인데
|
|
94
|
+
requirements 의 테스트 범위 선언이 테스트 추가를 요구한다 (test_scope.new_tests_required=${REQ}; 부재/null 은 true 로 간주).
|
|
95
|
+
→ 조치 A: 변경에 대한 테스트를 추가하고 new_tests_added=true 로 다시 기록한다.
|
|
96
|
+
→ 조치 B: 이 이슈가 테스트를 붙일 수 없는 성질이라면 임의로 면제하지 말고 부장님에게 보고한다 —
|
|
97
|
+
테스트 범위 제외는 1_planning.requirements 에서만 승인·기록할 수 있다 (stages/02-requirements.md §2-6a)."
|
|
98
|
+
fi
|
|
99
|
+
WAIVED="$(q '.stages."2_implementation".substages."dev".new_tests_waived')"
|
|
100
|
+
[ "${WAIVED}" = "true" ] || fail "테스트 면제 마커 누락: requirements 가 테스트 추가를 제외했지만(test_scope.new_tests_required=false)
|
|
101
|
+
dev.new_tests_waived 마커가 없다 — new_tests_added=false 가 의도된 면제인지 기록 누락인지 구분할 수 없다.
|
|
102
|
+
→ 조치: bash <SCRIPTS_DIR>/state.sh set ${ISSUE} '.stages.\"2_implementation\".substages.\"dev\".new_tests_waived' 'true'"
|
|
103
|
+
fi
|
|
104
|
+
fi
|
|
105
|
+
|
|
69
106
|
echo "MAKDOONG2-GATE OK: 2_implementation.dev_post"
|
package/package.json
CHANGED
package/scripts/run-tests.mts
CHANGED
|
@@ -60,6 +60,7 @@ const STEPS = [
|
|
|
60
60
|
"node --test test/gate-post-pr-verify.test.ts",
|
|
61
61
|
"node --test test/gate-post-review-verify.test.ts",
|
|
62
62
|
"node --test test/gate-requirements-quality.test.ts",
|
|
63
|
+
"node --test test/dev-test-scope-declaration.test.ts",
|
|
63
64
|
"node --test test/worktree-sync-gate.test.ts",
|
|
64
65
|
"node --test test/planner-prompt-early-exit.test.ts",
|
|
65
66
|
"node --test test/plugin-bug-fixes.test.ts",
|
|
@@ -76,6 +77,7 @@ const STEPS = [
|
|
|
76
77
|
"node --test test/verdict-reason-injection.test.ts",
|
|
77
78
|
"node --test test/verdict-hash-normalize.test.ts",
|
|
78
79
|
"node --test test/session-index-fallback.test.ts",
|
|
80
|
+
"node --test test/sub-session-registry.test.ts",
|
|
79
81
|
"node --test test/plugin-exports-shape.test.ts",
|
|
80
82
|
"node --test test/stale-worktree-recovery.test.ts",
|
|
81
83
|
"node --test test/rollback-commits.test.ts",
|
package/scripts/state.sh
CHANGED
|
@@ -61,6 +61,37 @@ validate_issue() {
|
|
|
61
61
|
|
|
62
62
|
sp() { validate_issue "$1"; echo "$(root)/.makdoong2-team/$1/state.json"; }
|
|
63
63
|
|
|
64
|
+
# ── 사본 불일치 경고 ────────────────────────────────────────────────────────
|
|
65
|
+
# state.json 사본은 cwd(git toplevel)마다 하나씩이다. 전용 worktree 가 이미 있는데
|
|
66
|
+
# main repo cwd 에서 `set` 을 실행하면, 갱신되는 것은 main 사본이고 dispatch_stage /
|
|
67
|
+
# 게이트가 보는 것은 worktree 사본이다 — 쓴 사람은 반영됐다고 믿는데 파이프라인은
|
|
68
|
+
# 옛 값을 본다. 실제로 REJECTED 재작업 규약(`.done=false` 재설정)이 이 경로에서
|
|
69
|
+
# 두 번 연속 `already_done: true` 오차단으로 되돌아왔고, 오류 문구도 원인을 알려주지
|
|
70
|
+
# 않아 같은 실수가 반복됐다 (issue #11).
|
|
71
|
+
#
|
|
72
|
+
# 쓰기 자체는 막지 않는다 — main 사본을 고치는 것이 옳은 상황도 있다. 어느 사본을
|
|
73
|
+
# 건드렸는지 stderr 로 알리기만 한다 (stdout 계약·종료 코드 불변).
|
|
74
|
+
norm_path() { if [ -d "$1" ]; then (cd "$1" && pwd -P); else printf '%s' "$1"; fi; }
|
|
75
|
+
|
|
76
|
+
warn_if_copy_split() {
|
|
77
|
+
local file="$1" here there
|
|
78
|
+
[ -f "${file}" ] || return 0
|
|
79
|
+
there="$(jq -r '.worktree // ""' "${file}" 2>/dev/null || true)"
|
|
80
|
+
{ [ -n "${there}" ] && [ "${there}" != "null" ] && [ -d "${there}" ]; } || return 0
|
|
81
|
+
here="$(norm_path "$(root)")"
|
|
82
|
+
there="$(norm_path "${there}")"
|
|
83
|
+
[ "${here}" != "${there}" ] || return 0
|
|
84
|
+
cat >&2 <<WARN
|
|
85
|
+
[state.sh] 경고: 지금 갱신한 것은 이 cwd 의 사본이지, 이 이슈의 전용 worktree 사본이 아니다.
|
|
86
|
+
갱신한 사본 : ${here}
|
|
87
|
+
worktree : ${there}
|
|
88
|
+
state.json 사본은 cwd(git toplevel)마다 하나씩이고, dispatch_stage 와 dev 이후 게이트는
|
|
89
|
+
worktree 사본을 본다. 자동 동기화(wt-sync-ignored.sh)는 서브에이전트 세션 앞뒤에서만 돌므로
|
|
90
|
+
이 쓰기는 다음 dispatch 까지 worktree 사본에 반영되지 않을 수 있다.
|
|
91
|
+
의도한 것이 worktree 사본이면 그 경로를 cwd 로 하여 다시 실행한다.
|
|
92
|
+
WARN
|
|
93
|
+
}
|
|
94
|
+
|
|
64
95
|
# usage_die <시그니처> [부연 설명...]
|
|
65
96
|
# `${2:?}` 가 뱉는 raw bash 에러(`line 127: 2: parameter null or not set`)는 복구
|
|
66
97
|
# 작업 중인 에이전트에게 아무것도 알려주지 못한다. 대신 무엇을 어떻게 부를지 적는다.
|
|
@@ -294,6 +325,7 @@ JSON
|
|
|
294
325
|
P="$(sp "${ISSUE}")"
|
|
295
326
|
check_flat_stage_notation "$Q"
|
|
296
327
|
write_json_atomic "$P" "set ${Q}" "$Q = $V"
|
|
328
|
+
warn_if_copy_split "$P"
|
|
297
329
|
echo "state[$ISSUE] $Q = $V" ;;
|
|
298
330
|
append)
|
|
299
331
|
ISSUE="${1:-}"; Q="${2:-}"; V="${3:-}"
|
package/stages/01-planning.md
CHANGED
|
@@ -225,6 +225,21 @@ bash <SCRIPTS_DIR>/state.sh set <이슈키> '.policy.scope_size' '"large"'
|
|
|
225
225
|
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.policy.categorized_by' '"1_planning.requirements"'
|
|
226
226
|
```
|
|
227
227
|
|
|
228
|
+
**테스트 범위 선언 (`test_scope` — 기계 판독 마커, 필수)**: 위 `**테스트 범위**` 서술은 사람이 읽는 문장이라 dev 게이트·verifier 가 해석할 수 없다. 같은 결정을 마커로 한 번 더 기록한다 — 없으면 `2_implementation.dev` 의 테스트 동반 원칙이 승인된 스코프 아웃을 보지 못하고 무조건 적용된다 (issue #11). 상세: `stages/02-requirements.md` §2-6a.
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
# 테스트를 동반하는 일반적인 경우
|
|
232
|
+
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."1_planning".substages."requirements".test_scope' \
|
|
233
|
+
'{"new_tests_required": true, "unit": "<대상 클래스/메서드>", "integration": "<빌드 플랜명/시나리오>", "rationale": "<한 줄 근거>"}'
|
|
234
|
+
|
|
235
|
+
# 테스트 추가를 이번 이슈 범위에서 제외하기로 승인한 경우 (스코프 아웃에도 함께 명시)
|
|
236
|
+
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."1_planning".substages."requirements".test_scope' \
|
|
237
|
+
'{"new_tests_required": false, "unit": null, "integration": "<기존 통합 테스트로만 검증>", "rationale": "<왜 제외가 타당한지>"}'
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
- 마커 부재는 `new_tests_required: true` 로 간주된다 (fail-closed) — 선언 누락이 테스트 면제로 둔갑하지 않는다.
|
|
241
|
+
- `test_scope_defined` self_check 항목은 **이 마커를 실제로 기록했다는 뜻**이다. 기록 후 값을 읽어 확인한다.
|
|
242
|
+
|
|
228
243
|
### 2-6. Requirements 완료 기록
|
|
229
244
|
|
|
230
245
|
```bash
|
|
@@ -352,3 +352,25 @@ bash <SCRIPTS_DIR>/state.sh set <이슈키> '.policy.categorized_by' '"1_plannin
|
|
|
352
352
|
```
|
|
353
353
|
|
|
354
354
|
self_check 에 `paths_explicit` / `test_scope_defined` / `atomic_units` / `scope_out_listed` 4항목을 함께 기록한다.
|
|
355
|
+
|
|
356
|
+
### 2-6a. 테스트 범위 선언 (`test_scope` — 기계 판독 마커, 필수)
|
|
357
|
+
|
|
358
|
+
위 `**테스트 범위**` 서술은 사람이 읽는 문장이라 dev 단계의 게이트·verifier 가 해석할 수 없다. **같은 결정을 기계가 읽는 마커로 한 번 더 기록한다** — 이 마커가 없으면 `2_implementation.dev` 의 "테스트 동반 원칙" 이 승인된 스코프 아웃을 보지 못한 채 무조건 적용되어, 요구사항 밖의 테스트 코드가 강제로 추가된다 (issue #11).
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
# 테스트를 동반하는 일반적인 경우
|
|
362
|
+
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."1_planning".substages."requirements".test_scope' \
|
|
363
|
+
'{"new_tests_required": true, "unit": "<대상 클래스/메서드>", "integration": "<빌드 플랜명/시나리오>", "rationale": "<한 줄 근거>"}'
|
|
364
|
+
|
|
365
|
+
# 테스트 추가를 이번 이슈의 범위에서 제외하기로 승인한 경우
|
|
366
|
+
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."1_planning".substages."requirements".test_scope' \
|
|
367
|
+
'{"new_tests_required": false, "unit": null, "integration": "<기존 통합 테스트로만 검증>", "rationale": "<왜 제외가 타당한지 — 예: 애플리케이션 코드가 아닌 배포 설정 전환이라 단위 테스트 대상이 없음>"}'
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
- `new_tests_required` 는 **`true` 가 기본값**이다. 마커를 기록하지 않으면 하류(게이트·verifier)는 `true` 로 간주한다 (fail-closed) — 선언 누락이 "테스트 면제" 로 둔갑하지 않는다.
|
|
371
|
+
- `false` 로 선언하려면 **스코프 아웃 항목에 그 사실이 함께 적혀 있어야 하고**, §2-3-2 의 "스코프 아웃 항목은 항상 명시적으로 확인한다" 절차를 거쳐야 한다. `rationale` 은 빈 문자열·`null` 금지.
|
|
372
|
+
- `test_scope_defined` self_check 항목은 **이 마커를 실제로 기록했다는 뜻**이다. 자기선언이 아니라 값을 읽어 확인한다:
|
|
373
|
+
```bash
|
|
374
|
+
bash <SCRIPTS_DIR>/state.sh get <이슈키> '.stages."1_planning".substages."requirements".test_scope'
|
|
375
|
+
```
|
|
376
|
+
- 이 선언은 요구사항 명세의 일부다 — 동결(2-4a) 이후 변경은 §2-4a 3번의 재승인 절차만 허용한다. **engineer / verifier 는 이 마커를 쓰지 않는다. 읽기만 한다.**
|
|
@@ -99,6 +99,25 @@ bash <SCRIPTS_DIR>/wt-sync-ignored.sh "$WT" "<이슈키>"
|
|
|
99
99
|
- **outer-world 에이전트 위임 금지** — engineer 프론트매터에 `Task` 툴이 없으므로 물리적으로 스폰 불가. 구현·조사·리팩토링 모두 본 에이전트가 직접 수행한다. 조사가 필요하면 `skill_mcp` 로 makdoong2 스킬(`bitbucket-research` 등)만 사용.
|
|
100
100
|
- 3단계 작업 단위 순서대로 구현한다. 한 단위가 끝나면 커밋 가능 상태로 만들어 둔다(실제 커밋은 6단계).
|
|
101
101
|
|
|
102
|
+
## 4-4-pre. 테스트 범위 선언 조회 (필수 — 자가 검증 전)
|
|
103
|
+
|
|
104
|
+
**테스트 동반 원칙은 무조건 적용되지 않는다.** `1_planning.requirements` 가 승인·동결한 테스트 범위 선언(`test_scope`)을 먼저 읽고, 그 선언이 정한 대로만 적용한다. 이 조회를 건너뛰면 "단위 테스트는 이번 변경 대상에 적용하지 않는다" 고 **이미 승인된** 이슈에서도 테스트 추가가 강제되어, 승인된 스코프 밖의 코드가 유입된다 (issue #11).
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
NEW_TESTS_REQUIRED="$(bash <SCRIPTS_DIR>/state.sh get <이슈키> \
|
|
108
|
+
'.stages."1_planning".substages."requirements".test_scope.new_tests_required' 2>/dev/null || echo 'true')"
|
|
109
|
+
# 마커 부재("null")·조회 실패는 true 로 간주한다 (fail-closed).
|
|
110
|
+
[ "$NEW_TESTS_REQUIRED" = "false" ] || NEW_TESTS_REQUIRED=true
|
|
111
|
+
echo "new_tests_required=$NEW_TESTS_REQUIRED"
|
|
112
|
+
|
|
113
|
+
# 근거(왜 제외됐는지)도 함께 읽어 최종 출력에 인용한다.
|
|
114
|
+
bash <SCRIPTS_DIR>/state.sh get <이슈키> \
|
|
115
|
+
'.stages."1_planning".substages."requirements".test_scope.rationale' 2>/dev/null || true
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- **이 마커를 engineer 가 쓰는 것은 금지다 (hardrule).** 읽기 전용이다. 테스트가 불필요해 보인다는 자체 판단으로 `test_scope` 를 기록·수정하면 요구사항 동결(§2-4a)을 우회하는 것이다. 선언이 실제 작업과 맞지 않으면 `done` 을 기록하지 말고 부장님에게 보고한다.
|
|
119
|
+
- `new_tests_required=true` 인데 대상이 테스트를 붙일 수 없는 성질(순수 설정 파일 등)이라고 판단되면 — **임의로 면제하지 말고** 그 사실을 최종 출력에 적어 부장님이 requirements 재작업 여부를 결정하게 한다.
|
|
120
|
+
|
|
102
121
|
## 4-4. 최종 자가 검증 (Pre-Completion Checklist)
|
|
103
122
|
|
|
104
123
|
`done=true` 직전, 아래 6체크를 자가 검증한다.
|
|
@@ -108,18 +127,35 @@ bash <SCRIPTS_DIR>/wt-sync-ignored.sh "$WT" "<이슈키>"
|
|
|
108
127
|
|---|---|
|
|
109
128
|
| 1 | 3단계에서 합의한 모든 수정/추가 파일이 구현되었다 (스코프 100% 충족) |
|
|
110
129
|
| 2 | 기존 테스트(`sbt test` / `./gradlew test` / `mvn test` 등)가 모두 통과한다 |
|
|
111
|
-
| 3 | 새 기능·버그 수정에 대한 테스트가 함께 추가되었다 (테스트 동반 원칙) |
|
|
130
|
+
| 3 | **`new_tests_required=true` 인 경우에만** — 새 기능·버그 수정에 대한 테스트가 함께 추가되었다 (테스트 동반 원칙). `false` 면 이 항목은 면제되며 테스트를 추가하지 않는 것이 정답이다 |
|
|
112
131
|
| 4 | 타입/린트/컴파일 에러가 0이다 |
|
|
113
132
|
| 5 | `.env` / secrets / API 키 / 하드코딩된 비밀이 코드·테스트·로그에 노출되지 않았다 |
|
|
114
133
|
| 6 | `write`/`edit`/`patch`/`multiedit` 로 편집한 모든 파일이 staging area 에 반영되었다 (`git ls-files --others --exclude-standard` 결과 0) |
|
|
115
134
|
|
|
116
135
|
> 항목 6은 `tool.execute.after` 훅이 매 write 완료 시 자동으로 `git add`를 수행하므로 기본적으로 자동 충족된다. 훅 실패로 untracked 가 남으면 §4-5 exit gate 가 BLOCK 하여 재작업을 요구한다.
|
|
117
136
|
|
|
137
|
+
**`new_tests_required=true` (기본)** — 테스트를 추가하고 `new_tests_added: true` 로 기록한다:
|
|
138
|
+
|
|
118
139
|
```bash
|
|
119
140
|
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."2_implementation".substages."dev".self_check' \
|
|
120
141
|
'{"scope_met": true, "existing_tests_pass": true, "new_tests_added": true, "type_lint_clean": true, "no_secrets": true, "all_writes_staged": true}'
|
|
121
142
|
```
|
|
122
143
|
|
|
144
|
+
**`new_tests_required=false` (승인된 스코프 아웃)** — `new_tests_added` 를 `false` 로 기록하고, 그것이 슬립이 아니라 선언에 따른 면제임을 나타내는 `new_tests_waived` 마커를 함께 남긴다. **두 기록은 한 쌍이다** — `new_tests_waived` 없이 `new_tests_added: false` 만 있으면 §4-5 exit gate 가 BLOCK 한다:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."2_implementation".substages."dev".self_check' \
|
|
148
|
+
'{"scope_met": true, "existing_tests_pass": true, "new_tests_added": false, "type_lint_clean": true, "no_secrets": true, "all_writes_staged": true}'
|
|
149
|
+
bash <SCRIPTS_DIR>/state.sh set <이슈키> '.stages."2_implementation".substages."dev".new_tests_waived' 'true'
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### 최종 출력에 반드시 포함할 것
|
|
153
|
+
|
|
154
|
+
verifier 는 state.json 마커로 판정하지만, 사람이 스코프 이탈을 조기에 발견할 수 있도록 출력에 한 줄을 남긴다:
|
|
155
|
+
|
|
156
|
+
- `new_tests_required=true` → 추가한 테스트 파일 목록과 실행 결과
|
|
157
|
+
- `new_tests_required=false` → `테스트 추가 없음 — requirements 의 test_scope.new_tests_required=false (사유: <rationale>)`
|
|
158
|
+
|
|
123
159
|
## 4-5. Exit Gate 실행 (staging 강제)
|
|
124
160
|
|
|
125
161
|
```bash
|