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.
@@ -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` + sub-agent output에 "테스트 추가" 명시 / 5체크
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
- const raw = readFileSync(join(configDir(), "opencode.json"), "utf8");
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
- return []; // 부재 / 파싱 실패 → 설정된 allow 없음
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
  *
@@ -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: sessionLastToolExecuteAt.get(sid),
426
- activeToolCount: sessionActiveToolCount.get(sid),
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
- sessionIssue.delete(sid);
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
- const sessionLastToolExecuteAt = new Map();
617
- const sessionActiveToolCount = new Map();
618
- /** 활성 카운터를 1 감소시킨다 (0 이면 항목 삭제). */
619
- function releaseActiveTool(sid) {
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
- const sessionDeletedWaiters = new Map();
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
- const sessionWorktree = new Map();
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
- const pollSubSession = (sessionId, timeoutMs = substageTimeoutMs, allowedWorktree, onNudge, messageStallThresholdMs) => pollSubSessionCore(client, sessionId, {
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 ((sessionActiveToolCount.get(sessionId) ?? 0) > 0)
811
+ if (registryIsToolExecuting(registry, sessionId))
768
812
  return true;
769
- const last = sessionLastToolExecuteAt.get(sessionId);
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 훅에서 올리고 after 훅에서 내리는
773
- // 활성 카운터 그대로다. isRecentlyActive 의 5분 창과 달리 순간값이라 완료
816
+ // "지금 이 순간 툴이 실행 중인가" — before 훅에서 넣고 after 훅에서 빼는
817
+ // callID 집합 그대로다. isRecentlyActive 의 5분 창과 달리 순간값이라 완료
774
818
  // 판정에 쓸 수 있다. 폴러가 읽는 메시지 스냅샷보다 항상 앞선다 (issue #7:
775
819
  // tool.execute.before 발화 110ms 뒤의 폴이 tool part 를 아직 못 봤다).
776
- isToolExecuting: () => (sessionActiveToolCount.get(sessionId) ?? 0) > 0,
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
- const sessionIssue = new Map();
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
- sessionIssue.delete(sid);
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
- const waiters = sessionDeletedWaiters.get(sid);
1007
- if (waiters && waiters.length > 0) {
1008
- for (const w of [...waiters]) {
1009
- try {
1010
- w();
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 훅이 돌지 않아 카운터가 영구히 ≥1 로 남고, 그러면 isRecentlyActive() 가
1172
+ // after 훅이 돌지 않아 항목이 영구히 남고, 그러면 isRecentlyActive() 가
1073
1173
  // 항상 true 가 되어 그 세션의 gone 감지와 orphan 회수가 **영구 비활성**된다.
1074
1174
  // 차단은 정상 동작이라 반드시 일어나므로 확정적으로 누수됐다.
1075
- if (sessionID) {
1076
- sessionLastToolExecuteAt.set(sessionID, Date.now());
1077
- sessionActiveToolCount.set(sessionID, (sessionActiveToolCount.get(sessionID) ?? 0) + 1);
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
- releaseActiveTool(sessionID);
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
- sessionLastToolExecuteAt.set(afterSessionID, Date.now());
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 doneR = await $ `bash ${SCRIPTS_DIR}/state.sh get ${args.issue} ${stageJqPath(args.target_stage) + ".done"}`
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
- reason: `Stage '${args.target_stage}' is already done=true. ` +
1628
- `Re-dispatching a completed stage causes sub-agent tool-call loops (timeout/empty output). ` +
1629
- `Call auto_advance_stage to obtain the correct next stage instead.`,
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;
@@ -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?.() === true;
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
- : ` pending permission unknown (permission.list unavailable or empty)`));
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 "대기 대상을 특정하지 못했다. 점검: opencode.json permission.external_directory 시드 존재 여부 " +
633
- "(`npx makdoong2-team doctor`).";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makdoong2-team",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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:-}"
@@ -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