cuelo 0.10.86 → 0.10.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +33 -33
  3. package/.next/build-manifest.json +2 -2
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/required-server-files.js +1 -1
  6. package/.next/required-server-files.json +1 -1
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/.next/server/app/_not-found.html +1 -1
  18. package/.next/server/app/_not-found.rsc +2 -2
  19. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  20. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  21. package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  23. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  24. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  25. package/.next/server/app/api/model-roles/route.js +1 -1
  26. package/.next/server/app/api/models/route.js +1 -1
  27. package/.next/server/app/api/updates/route.js +1 -1
  28. package/.next/server/app/index.html +1 -1
  29. package/.next/server/app/index.rsc +4 -4
  30. package/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
  31. package/.next/server/app/index.segments/_full.segment.rsc +4 -4
  32. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  33. package/.next/server/app/index.segments/_index.segment.rsc +2 -2
  34. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  35. package/.next/server/app/page.js +4 -4
  36. package/.next/server/app/page_client-reference-manifest.js +1 -1
  37. package/.next/server/app/recover/page_client-reference-manifest.js +1 -1
  38. package/.next/server/app/recover.html +1 -1
  39. package/.next/server/app/recover.rsc +2 -2
  40. package/.next/server/app/recover.segments/_full.segment.rsc +2 -2
  41. package/.next/server/app/recover.segments/_head.segment.rsc +1 -1
  42. package/.next/server/app/recover.segments/_index.segment.rsc +2 -2
  43. package/.next/server/app/recover.segments/_tree.segment.rsc +1 -1
  44. package/.next/server/app/recover.segments/recover/__PAGE__.segment.rsc +1 -1
  45. package/.next/server/app/recover.segments/recover.segment.rsc +1 -1
  46. package/.next/server/app-paths-manifest.json +33 -33
  47. package/.next/server/chunks/6429.js +1 -1
  48. package/.next/server/middleware-build-manifest.js +1 -1
  49. package/.next/server/pages/404.html +1 -1
  50. package/.next/server/pages/500.html +1 -1
  51. package/.next/server/server-reference-manifest.json +1 -1
  52. package/.next/static/chunks/app/{layout-ee9e4e6e861bb369.js → layout-96ff72e1112f83e3.js} +1 -1
  53. package/.next/static/chunks/app/{page-2582dffb79c07c6a.js → page-d672db1a3c9cee38.js} +4 -4
  54. package/.next/trace +108 -108
  55. package/.next/trace-build +1 -1
  56. package/README.md +4 -0
  57. package/Tools/CUELO_Setup/files/native-runtime-patch.js +1 -1
  58. package/Tools/OMP_Global_Config/agent/RULES.md +1 -1
  59. package/Tools/OMP_Global_Config/agent/agents/maker.md +16 -11
  60. package/Tools/OMP_Global_Config/agent/extensions/command-guard/task-guard.ts +2 -1
  61. package/Tools/OMP_Global_Config/agent/extensions/cross-review.ts +1 -1
  62. package/Tools/OMP_Global_Config/agent/extensions/lib/cross-review.ts +117 -18
  63. package/Tools/OMP_Global_Config/agent/extensions/lib/maker-routing.ts +55 -5
  64. package/Tools/OMP_Global_Config/agent/extensions/slop-gate/review.ts +5 -5
  65. package/Tools/OMP_Global_Config/agent/extensions/slop-gate.ts +1 -1
  66. package/Tools/OMP_Global_Config/agent/prompt-copies/RULES.en.md +2 -2
  67. package/Tools/OMP_Global_Config/agent/rules/harness-policy.json +11 -5
  68. package/Tools/OMP_Global_Config/agent/rules/omp-harness.md +2 -1
  69. package/Tools/OMP_Global_Config/agent/rules/subagent.md +13 -5
  70. package/Tools/OMP_Global_Config/agent/rules/task-guard.md +1 -0
  71. package/Tools/OMP_Global_Config/agent/sop/_writer.md +14 -9
  72. package/Tools/OMP_Global_Config/agent/sop/maker.md +1 -1
  73. package/install.mjs +11 -4
  74. package/package.json +1 -1
  75. /package/.next/static/{p6f82IjLEvp_2QDtgdnBk → nLKOmVsY9AF1w5V0Fsm8L}/_buildManifest.js +0 -0
  76. /package/.next/static/{p6f82IjLEvp_2QDtgdnBk → nLKOmVsY9AF1w5V0Fsm8L}/_ssgManifest.js +0 -0
package/.next/trace-build CHANGED
@@ -1 +1 @@
1
- [{"name":"run-webpack","duration":86418230,"timestamp":496674,"id":14,"parentId":1,"tags":{},"startTime":1793007807810,"traceId":"d9f917124ac25273"},{"name":"run-typescript","duration":19239472,"timestamp":87267424,"id":11081,"parentId":1,"tags":{},"startTime":1793007894580,"traceId":"d9f917124ac25273"},{"name":"static-check","duration":3275947,"timestamp":106613626,"id":11084,"parentId":1,"tags":{},"startTime":1793007913927,"traceId":"d9f917124ac25273"},{"name":"static-generation","duration":7612200,"timestamp":109980971,"id":11262,"parentId":1,"tags":{},"startTime":1793007917294,"traceId":"d9f917124ac25273"},{"name":"collect-build-traces","duration":19436005,"timestamp":109958657,"id":11259,"parentId":1,"tags":{},"startTime":1793007917272,"traceId":"d9f917124ac25273"},{"name":"telemetry-flush","duration":69,"timestamp":129399090,"id":11271,"parentId":1,"tags":{},"startTime":1793007936712,"traceId":"d9f917124ac25273"},{"name":"next-build","duration":128990270,"timestamp":408913,"id":1,"tags":{"buildMode":"default","version":"16.2.12","bundler":"webpack","has-custom-webpack-config":"true","use-build-worker":"false"},"startTime":1793007807722,"traceId":"d9f917124ac25273"}]
1
+ [{"name":"run-webpack","duration":81455212,"timestamp":499182,"id":14,"parentId":1,"tags":{},"startTime":1793016458531,"traceId":"90d3d8b1ba023971"},{"name":"run-typescript","duration":18043597,"timestamp":82361722,"id":11081,"parentId":1,"tags":{},"startTime":1793016540393,"traceId":"90d3d8b1ba023971"},{"name":"static-check","duration":3220865,"timestamp":100480567,"id":11084,"parentId":1,"tags":{},"startTime":1793016558512,"traceId":"90d3d8b1ba023971"},{"name":"static-generation","duration":6045597,"timestamp":103791368,"id":11262,"parentId":1,"tags":{},"startTime":1793016561823,"traceId":"90d3d8b1ba023971"},{"name":"collect-build-traces","duration":18650052,"timestamp":103770400,"id":11259,"parentId":1,"tags":{},"startTime":1793016561802,"traceId":"90d3d8b1ba023971"},{"name":"telemetry-flush","duration":82,"timestamp":122424515,"id":11271,"parentId":1,"tags":{},"startTime":1793016580456,"traceId":"90d3d8b1ba023971"},{"name":"next-build","duration":122011126,"timestamp":413498,"id":1,"tags":{"buildMode":"default","version":"16.2.12","bundler":"webpack","has-custom-webpack-config":"true","use-build-worker":"false"},"startTime":1793016458445,"traceId":"90d3d8b1ba023971"}]
package/README.md CHANGED
@@ -44,6 +44,8 @@ Requires Node.js 22.19+ and Bun 1.4.2+. Open http://127.0.0.1:30141 and sign in
44
44
 
45
45
  **한국어:** 웹 앱은 omp SDK를 서버 안에서 실행하고, `omp` CLI와 같은 `~/.omp/agent` 디렉터리를 씁니다. 하네스의 세부 동작은 [하네스 세부 동작](#하네스-세부-동작)과 [하네스 안내](./docs/harness.md)에 있습니다.
46
46
 
47
+ 설치 직후 또는 기존 프로필에서 Maker·검수·보조 도구 모델 역할을 지정하는 방법은 [설치 안내의 모델 역할 설정](docs/installation.md#기존-프로필의-역할과-jev-보완)을 참고하세요.
48
+
47
49
  ## 이 저장소로 되는 것과 따로 필요한 것
48
50
 
49
51
  | 구분 | 내용 |
@@ -177,6 +179,8 @@ CUELO의 목적은 작업 중 확인한 실패와 막힘을 기억·작업 규
177
179
 
178
180
  교차 검토를 닫을 때 검토 이후의 파일 변경·삭제를 알려 줍니다. 앞선 라운드에서 이미 검토한 삭제는 다른 파일의 후속 검토를 이어 가도 다시 경고하지 않습니다. 이 안내는 검토 통과 판정을 바꾸지 않습니다.
179
181
 
182
+ 일반 코드 변경은 발주 준비 단계가 검토 필요성과 이유, 다음 행동을 권고하고 Main이 검토 여부를 정합니다. 고위험 변경·위험 표식·표본 검토는 필수로 유지합니다. 검토는 변경된 코드와 설정을 대상으로 하며 관련 호출부나 명세는 참고 입력으로 함께 넘깁니다. 필수 반대 계열 검토가 모두 끝나면 아직 진행 중인 보조 자문은 중단하고 이미 받은 지적은 처리합니다.
183
+
180
184
  세션은 관련 작업을 시작할 때 미결 항목과 미해결 실패를 현재 근거로 분류하고, 이미 승인된 범위 안의 실행 가능한 일만 원인 확인·처리·재검증한 뒤 문서를 맞춥니다. 막힌 실패는 증거와 재검증 기준을 `HANDOFF.md`에 남겨 다음 관련 세션이 이어받습니다. 백그라운드에서 스스로 고치는 기능은 아닙니다. 자세한 내용은 [하네스 안내](./docs/harness.md#미결-작업-처리)를 참고하세요.
181
185
 
182
186
  WSL에서 작업하는 프로젝트에 기존 Windows 사본이 연결되어 있으면, 작업 완료 후 검증·게시한 소스와 문서를 Windows 사본에도 같은 커밋으로 반영합니다. Windows에 별도로 남은 변경이나 갈라진 이력은 덮어쓰지 않고 알립니다. 실시간 양방향 동기화는 아니며, 설치된 프로필·인증정보·실행 산출물·독립 CLI는 복사하지 않습니다. Windows 전용 장비 프로젝트는 기존 위치와 도구를 유지합니다.
@@ -9,7 +9,7 @@ const MEMORY_RE = /(const phase1Model = await resolveMemoryModel\(\{[^}]*?fallba
9
9
  const MEMORY_DONE = /const phase1Model = await resolveMemoryModel\(\{[^}]*?fallbackRole: "smol"/;
10
10
  const EXPECTED = {
11
11
  name: 'cuelo',
12
- version: '0.10.86',
12
+ version: '0.10.87',
13
13
  distribution: 'cuelo',
14
14
  coreVersion: '18.12.6',
15
15
  };
@@ -158,7 +158,7 @@
158
158
  `HANDOFF.md`·`doc/README.md`·`doc/history` 기록을 만들어 같은 커밋에 넣는다.
159
159
  맞출 때는 남은 행동·관찰 조건·새 경계만 남기고, 이번에 끝난 행은 지운다. 그 자리에서 끝난 작업은
160
160
  HANDOFF가 아니라 `doc/history`에 기록한다(`rule://docs-handoff`).
161
- - 코드 변경이 high-risk이거나 트리거 marker·결정적 10% 표본에 해당하면(문서·주석·테스트 제외) `git_finalize` 전에 `cross_review`로 다른 모델 계열(Anthropic 작성분은 OpenAI, OpenAI·그 밖 제공자 작성분은 Anthropic)의 검토를 받고 모든 finding을 근거와 함께 닫는다. 검토는 자문 증거이며 PASS가 아니다. 사용자의 명시 지시 없이 이 gate를 면제하지 않는다. 정본은 `harness-policy.json` `mainLane.workerReview.crossFamilyReview`다.
161
+ - 코드 변경이 high-risk이거나 트리거 marker·결정적 10% 표본에 해당하면 `git_finalize` 전에 `cross_review`로 다른 모델 계열(Anthropic 작성분은 OpenAI, OpenAI·그 밖 제공자 작성분은 Anthropic)의 검토를 받고 모든 finding을 근거와 함께 닫는다. 문서·테스트·생성물, 게이트가 주석만 바꿨다고 증명한 TS/JS 수정, git이 바이너리로 본 이미지·미디어·오피스·글꼴 자산은 제외한다. 타입만 바꾼 수정과 다른 언어의 주석 수정은 코드로 본다. 검토 대상은 바뀐 실행 코드·설정이고, 관련된 바뀌지 않은 호출부·계약은 `inputs`·`references`로 넘긴다. 그 밖의 일반 수정은 Main이 `maker_route` 검토 필요성 자문을 보고 정하며, 자문은 게이트 요구를 면제하지 못한다. 검토는 자문 증거이며 PASS가 아니다. 사용자의 명시 지시 없이 이 gate를 면제하지 않는다. 정본은 `harness-policy.json` `mainLane.workerReview.crossFamilyReview`다.
162
162
  **검토 모델은 사용자가 정한 교차검증 경로만 쓴다(2026-10-26 사용자 지적).** 코드·문서·산출물의 검토, 검증, 게이트 판정에 쓰는 모델은 `config.yml` `modelRoles`의 `crossReviewOpenAI`·`crossReviewAnthropic` 슬롯(필수, `cross_review`와 같은 계열 규칙)과 자문 슬롯 `crossReviewAdvisory`(2026-10-26 Argon에서 Fable로 교체, 저자가 그 모델 자체일 때만 빠짐)에서 읽는다. 새 검토 기능이나 Maker 지시서에 다른 모델(예: Sonnet)을 비용·속도를 이유로 직접 지정하지 않고, 그 경로 밖의 모델로 본 결과를 교차검증·검토 통과라고 부르지 않는다. 경로를 바꾸려면 먼저 사용자에게 묻는다(2026-10-26: Main이 슬롭 게이트 검토 모델을 Sonnet으로 지시서에 넣었다가 사용자가 바로잡았다).
163
163
  - `git_finalize`는 상대경로 canonical target만 받고 한 호출에서 저장소 하나만 마감한다. 경로
164
164
  해석과 거부 대상, cwd 밖 별도 Git 저장소 허용, Main의 raw Git fallback 4조건과 사전 확인,
@@ -6,7 +6,7 @@ thinking-level: auto
6
6
  tools: [read, bash, edit, write, grep, glob, skim, draft, lsp, eval, generate_image, ast_grep, ast_edit, debug, todo, web_search, windows_build, windows_control, ego_browser, checkpoint, cross_review, slop_check]
7
7
  ---
8
8
 
9
- <!-- omp-global-config:generated source-hash=98516841d639
9
+ <!-- omp-global-config:generated source-hash=95589f18419f
10
10
  이 파일은 patches/build-agents.mjs 가 만든 빌드 산출물이다. 직접 수정하지 마라.
11
11
  고칠 곳: agent/sop/maker.md · agent/sop/_writer.md · agent/sop/_common.md
12
12
  재생성: node patches/build-agents.mjs 검사: node patches/build-agents.mjs --check
@@ -17,7 +17,7 @@ is yours end to end.
17
17
 
18
18
  Publicly published/shared artifacts you write (README/CHANGELOG/docs and other public markdown, html, pptx/docx/xlsx/pdf/hwpx, report csv, UI copy packs) must pass `slop_check` before you report. Private/internal documents are excluded even when delivered as files to the user; code, tests and internal records such as `doc/history` are also excluded. Use the repository's publication declaration (`.publicignore` or `.slop-gate.json`); outside git, `public:true` may declare an otherwise unknown artifact public but never override a known private/internal exclusion. Unknown publication status does not trigger automatic review or a gate. Run the check on public files with your own model selector as `authors`, fix genuine findings and review changed content again (a pass is tied to exact bytes). Report false positives to Main with evidence; resolve is Main-only. If a reviewer response is malformed, inspect its raw response and parse error: do not retry unchanged input or alter artifact punctuation to work around the protocol failure. `git_finalize` and the outside-git turn-end gate apply only to public artifacts. Drafting and direct prose correction remain unchanged.
19
19
 
20
- Code files you changed (the same scope `git_finalize` gates: no docs, comments or tests) get a `cross_review` `review` from you before you report, whether or not the brief asks for it: run it after your focused checks pass, with your own model selector as `authors` (never `"main"`), fix or refute each finding with evidence, use the probe round when a reviewer proposes one, and run a delta round on the same chain after fixes. Report the reviewId chain and a proposed status (`fixed`/`refuted`/`limit`) with an evidence locator for every finding; `resolve` is Main-only. Main does not start a duplicate review of your code: when Main forwards findings or later rounds, you run them.
20
+ Run a `cross_review` `review` of your changed code files before you report when the review is required: the brief marks the slice high-risk or carries a plain cross-family review line, a trigger marker the `git_finalize` gate uses hits your changed lines or paths (process termination, deletion, destructive SQL, deploy/release/migration, credentials/auth, encoding, device I/O), or Main forwards a finalizer sample hit for your files. Other code changes are reviewed only when Main's brief asks for it; Main decides from the `maker_route` review-necessity advice. Targets are changed executable code and config; pass relevant unchanged callers or contracts as `inputs`/`references`, not as targets. Docs, tests, generated files, git-detected binary media/office assets and TS/JS edits the gate proves comment-only are not targets. Run it after your focused checks pass, with your own model selector as `authors` (never `"main"`), fix or refute each finding with evidence, use the probe round when a reviewer proposes one, and run a delta round on the same chain after fixes. Report the reviewId chain and a proposed status (`fixed`/`refuted`/`limit`) with an evidence locator for every finding; `resolve` is Main-only. Main does not start a duplicate review of your code: when Main forwards findings or later rounds, you run them.
21
21
 
22
22
  ## Maker Contract
23
23
 
@@ -154,14 +154,20 @@ Code files you changed (the same scope `git_finalize` gates: no docs, comments o
154
154
  drift check, so a scope Main approved here is never re-explained from scratch at the end.
155
155
  `mainLane.workerReview.steeringCheckpoint` (with `composition` and `finalScope`) and
156
156
  `routing.high-risk.mainIntervention.preEditContract` are authoritative.
157
- - When the brief's `# Acceptance` asks for a cross-family review of your code (Main asks for
158
- high-risk slices: code-nature triggers or project material risk), run `cross_review`
159
- `action:"review"` yourself after your own focused checks pass and before your completion report.
160
- `files` are your changed code files only (docs, comments-only and tests excluded - the scope
161
- `git_finalize` gates); `acceptance` is the brief's acceptance; `authors` is your own model
157
+ - Run `cross_review` `action:"review"` yourself after your own focused checks pass and before your
158
+ completion report when the review is required for your slice: the brief's `# Acceptance` asks
159
+ for a cross-family review (Main asks for declared high-risk slices: code-nature triggers or
160
+ project material risk), a `git_finalize` trigger marker hits your changed lines or paths, or
161
+ Main forwards a finalizer sample hit for your files. Any other code change is reviewed only when
162
+ the brief asks; Main decides that from the `maker_route` review-necessity advice, and a Maker
163
+ never starts a blanket review of every edit. `files` are your changed executable code and config
164
+ files only (docs, tests, generated files, git-detected binary media/office assets and TS/JS edits
165
+ the gate proves comment-only are excluded - the scope `git_finalize` gates); `acceptance` is the
166
+ brief's acceptance; `authors` is your own model
162
167
  selector (the provider/model you run as - `"main"` is refused in a subagent session, and a first
163
- round whose authors list no model of your own session's family is refused); pass
164
- `references` when the change interacts with code outside the diff. Check each finding against
168
+ round whose authors list no model of your own session's family is refused); pass relevant
169
+ unchanged callers, specs or contracts as `inputs` (bound to the pass) or `references` (context
170
+ only) instead of adding them as targets. Check each finding against
165
171
  the code: fix it and re-run the affected check, or refute it with evidence, or document a limit.
166
172
  A finding carrying a `probe` may get the chain's one probe round (`previousReviewId` +
167
173
  `probeResults`); after fixes run a delta round with `previousReviewId` so the reviewer sees the
@@ -169,8 +175,7 @@ Code files you changed (the same scope `git_finalize` gates: no docs, comments o
169
175
  chain (newest last) and, per finding id, a proposed status `fixed|refuted|limit` with its
170
176
  evidence locator. The pass binds to the exact file blobs Main resolves, so do not touch a
171
177
  reviewed file after your last round; if you must, run another round and report the new head.
172
- Without that brief line, do not start a review. `mainLane.workerReview.crossFamilyReview.makerReview`
173
- is authoritative.
178
+ `mainLane.workerReview.crossFamilyReview.makerReview` is authoritative.
174
179
  - Report `validation` as an acceptance-condition map, not only a per-file or per-command result:
175
180
  for each acceptance condition in the brief give `met` with the observed behavior and the raw
176
181
  log or screen evidence locator, or `unverified` with the blocker and the check still needed,
@@ -142,7 +142,8 @@ export function resetTaskGuardState(state: TaskGuardState): void {
142
142
  state.refunded = 0;
143
143
  }
144
144
 
145
- function normalizeDeliverable(value: string): string {
145
+ /** SideQuestGuard가 PRIMARY_DELIVERABLE을 비교하는 정규형. maker_route 준비·발주 검사도 같은 함수를 쓴다. */
146
+ export function normalizeDeliverable(value: string): string {
146
147
  return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("en-US");
147
148
  }
148
149
 
@@ -151,7 +151,7 @@ export default function crossReview(pi: ExtensionAPI): void {
151
151
  const z = pi.zod;
152
152
  pi.registerTool({
153
153
  name: "cross_review", label: "Cross Review", loadMode: "essential", approval: "read",
154
- description: "계열 교차검증. review는 Main과 Maker가 부를 수 있고 resolve는 Main 전용이다(Maker는 자기 high-risk 코드의 review 체인을 돌린 뒤 reviewId 체인과 finding별 제안 status·근거를 보고하고, Main이 확인해 resolve한다). action=review: files(cwd 기준 상대 경로)의 변경을 반대 계열 reviewer(Anthropic 저자→OpenAI 슬롯 Astra, OpenAI·그 밖의 저자→Anthropic 슬롯 Opus)와 늘 함께 도는 자문 슬롯 crossReviewAdvisory(Fable, 저자가 그 모델 자체일 때만 빠진다)가 acceptance 기준으로 적대적으로 읽고 {reviewId, reviewer 모델, round, triggers, findings[]}를 돌려준다. 자문 reviewer는 120초에서 끊기며 호출 실패·해석 불가·시간 초과는 reviewers[]와 advisoryReviewers에 기록만 하고 통과를 막지 않지만(다음 라운드가 다시 부른다), 답한 finding(R*-ADV-*, 옛 기록은 R*-GGL-*)은 다른 reviewer의 것처럼 resolve로 닫아야 한다. authors는 코드를 쓴 모델 selector이며 두 계열이면 둘 다 돌린다. Main 세션은 \"main\"(자기 현재 모델)도 쓸 수 있지만 subagent 세션에서 \"main\"은 거절되므로 자기 모델 selector를 주고, subagent 세션의 1회차 authors에 세션 자신의 모델과 같은 계열이 없으면 거절된다. previousReviewId를 주면 다음 라운드이고 체인은 최대 5회(probe 라운드 1회 + probe가 아닌 라운드 4회)다: probe가 아닌 라운드는 직전 라운드가 본 내용 이후의 delta만 보며 바뀐 것이 없으면 거절한다. finding을 고친 뒤에는 다음 라운드로 그 수정을 reviewer에게 다시 보이는 것이 정상 흐름이며, 마지막 라운드의 내용과 다른 파일이 resolve 때 있으면 결과의 unreviewedChanges로 알리며, 그 중 코드 파일(git_finalize 게이트와 같은 분류)은 통과를 막는다(문서·테스트·생성물만이면 안내일 뿐 통과 판정은 그대로다). base(선택, 1회차 전용, 빈 문자열은 주지 않은 것으로 읽는다)는 이미 커밋된 변경을 심사할 때 쓰는 HEAD 또는 그 조상 커밋이며, 그 커밋부터 HEAD까지의 변경을 보여 준다. 대상 파일의 작업 트리가 HEAD와 같아야 하고(수정·삭제·추적 안 한 새 파일은 거절, git이 무시하는 새 파일은 건너뜀) base가 없으면 HEAD와 작업 트리를 비교한다(커밋 뒤에도 같은 체인을 previousReviewId로 이어 가는 것은 base 없이 된다). reviewer는 코드만으로 가릴 수 없는 의심에 finding.probe{steps, expectIfReal, expectIfNot?}(재현 실험)를 붙일 수 있다. Main이나 Maker가 그 실험을 돌린 뒤 previousReviewId와 probeResults[{findingId, command, exitCode, output}]를 주면 probe 라운드가 되어 코드 변경이 없어도 실험 결과로 한 번 더 판단받고, reviewer가 finding마다 verdicts{confirmed|withdrawn|unresolved}를 낸다(판단이 빠지면 unresolved). 체인에 없는 findingId·중복 결과·두 번째 probe 라운드·개인키 블록이 든 결과는 거절하며, output은 발췌로 결과당 8KB(넘으면 앞뒤만 남김)·합계 40KB(넘으면 거절)이고 비밀 값은 가려 신뢰하지 않는 자료로 전달한다. verdict는 자문일 뿐 finding을 자동으로 닫거나 통과시키지 않는다. references(선택, 최대 10개)는 심사 대상이 아닌 맥락용 기존·신규 파일(예: 작업 기록, 계약 파일)의 현재 내용을 변경 뒤에 읽기 전용으로 싣는다. 명시한 경로만이며 통과 지문을 덮지 않고 references만으로는 심사하지 않는다. 비밀 경로·gitignore·심볼릭 링크·바이너리·파일당 64KB/합계 100KB 초과, 읽는 동안 열린 파일의 실제 경로가 고정한 저장소 루트 아래 요청 경로와 달라진 경우는 빠지고 skippedReferences에 이유가 나온다. 이 경로 확인은 Linux(WSL 포함)의 /proc/self/fd에 의존하므로 native Windows/macOS에서는 references를 읽지 않고 skippedReferences에 unsupported 이유를 남긴다(변경 파일 심사는 그대로). inputs(선택, 최대 10개)는 변경이 일관돼야 하는 명세·계약·스키마·호출자 파일이며, references와 같은 읽기·필터·제한으로 별도 블록에 싣고(심사 대상 아님, 빠진 것은 skippedInputs) files와 겹치면 거절한다. references와 달리 통과에 묶여서, resolve 뒤 입력이 바뀌거나 지워지면 git_finalize가 그 통과를 덮지 않는 것으로 보고 다시 검토를 요구한다(inputs 없는 검토는 그대로). 이어지는 라운드는 앞 라운드의 입력을 이어받고 더할 수 있으며, 이어받은 입력을 더 읽을 수 없으면 거절하므로 빼려면 previousReviewId 없이 새로 시작한다. 파일 내용은 reviewer 슬롯이 가리키는 provider(현재 OpenAI·Anthropic)로 전송되며 비밀 이름·내용·바이너리·큰 입력은 거절/제외한다. action=resolve(Main 전용, 다른 세션이 시작한 체인도 닫을 수 있다): reviewId 체인의 모든 finding에 {id, status fixed|refuted|limit, evidence}가 있어야 pass:true이고 그 시점 파일 내용(git blob)이 통과 지문으로 기록된다(withdrawn 판단도 자동으로 닫지 않는다). 마지막 라운드 reviewer가 본 내용과 다른 코드 파일이 resolve 시점에 남아 있으면 finding이 모두 닫혀도 pass:false(unreviewedBlocking)이고, previousReviewId로 새 라운드를 돌려 그 수정을 reviewer에게 보인 뒤 다시 resolve한다. 더 돌릴 probe가 아닌 라운드가 없는 체인(또는 마지막 라운드가 바이너리·개인키·비밀 경로로 건너뛴 파일)에서만 unreviewedAcknowledgements[{path, evidence}]로 미검토 코드 파일마다 그 내용에 한 검사를 근거로 적어 통과할 수 있고(라운드가 남았거나 근거가 비면 rejectedAcknowledgements로 거절), 받아들인 확인 응답은 resolve 기록에 경로·blob·근거로 남는다. git_finalize는 trigger·표본에 걸린 코드 파일에 이 통과 기록을 요구한다. reviewer의 PASS 표현은 근거가 아니다.",
154
+ description: "계열 교차검증. review는 Main과 Maker가 부를 수 있고 resolve는 Main 전용이다(Maker는 자기 high-risk 코드의 review 체인을 돌린 뒤 reviewId 체인과 finding별 제안 status·근거를 보고하고, Main이 확인해 resolve한다). action=review: files(cwd 기준 상대 경로)의 변경을 반대 계열 reviewer(Anthropic 저자→OpenAI 슬롯 Astra, OpenAI·그 밖의 저자→Anthropic 슬롯 Opus)와 늘 함께 도는 자문 슬롯 crossReviewAdvisory(Fable, 저자가 그 모델 자체일 때만 빠진다)가 acceptance 기준으로 적대적으로 읽고 {reviewId, reviewer 모델, round, triggers, findings[]}를 돌려준다. 자문 reviewer는 필수 reviewer와 함께 시작해 120초가 지나거나 필수 reviewer가 모두 끝나면 끊기며 호출 실패·해석 불가·시간 초과·중단은 reviewers[]와 advisoryReviewers에 기록만 하고 통과를 막지 않지만(다음 라운드가 다시 부른다), 그 전에 답한 finding(R*-ADV-*, 옛 기록은 R*-GGL-*)은 다른 reviewer의 것처럼 resolve로 닫아야 한다. authors는 코드를 쓴 모델 selector이며 두 계열이면 둘 다 돌린다. Main 세션은 \"main\"(자기 현재 모델)도 쓸 수 있지만 subagent 세션에서 \"main\"은 거절되므로 자기 모델 selector를 주고, subagent 세션의 1회차 authors에 세션 자신의 모델과 같은 계열이 없으면 거절된다. previousReviewId를 주면 다음 라운드이고 체인은 최대 5회(probe 라운드 1회 + probe가 아닌 라운드 4회)다: probe가 아닌 라운드는 직전 라운드가 본 내용 이후의 delta만 보며 바뀐 것이 없으면 거절한다. finding을 고친 뒤에는 다음 라운드로 그 수정을 reviewer에게 다시 보이는 것이 정상 흐름이며, 마지막 라운드의 내용과 다른 파일이 resolve 때 있으면 결과의 unreviewedChanges로 알리며, 그 중 코드 파일(git_finalize 게이트와 같은 분류)은 통과를 막는다(문서·테스트·생성물만이면 안내일 뿐 통과 판정은 그대로다). base(선택, 1회차 전용, 빈 문자열은 주지 않은 것으로 읽는다)는 이미 커밋된 변경을 심사할 때 쓰는 HEAD 또는 그 조상 커밋이며, 그 커밋부터 HEAD까지의 변경을 보여 준다. 대상 파일의 작업 트리가 HEAD와 같아야 하고(수정·삭제·추적 안 한 새 파일은 거절, git이 무시하는 새 파일은 건너뜀) base가 없으면 HEAD와 작업 트리를 비교한다(커밋 뒤에도 같은 체인을 previousReviewId로 이어 가는 것은 base 없이 된다). reviewer는 코드만으로 가릴 수 없는 의심에 finding.probe{steps, expectIfReal, expectIfNot?}(재현 실험)를 붙일 수 있다. Main이나 Maker가 그 실험을 돌린 뒤 previousReviewId와 probeResults[{findingId, command, exitCode, output}]를 주면 probe 라운드가 되어 코드 변경이 없어도 실험 결과로 한 번 더 판단받고, reviewer가 finding마다 verdicts{confirmed|withdrawn|unresolved}를 낸다(판단이 빠지면 unresolved). 체인에 없는 findingId·중복 결과·두 번째 probe 라운드·개인키 블록이 든 결과는 거절하며, output은 발췌로 결과당 8KB(넘으면 앞뒤만 남김)·합계 40KB(넘으면 거절)이고 비밀 값은 가려 신뢰하지 않는 자료로 전달한다. verdict는 자문일 뿐 finding을 자동으로 닫거나 통과시키지 않는다. references(선택, 최대 10개)는 심사 대상이 아닌 맥락용 기존·신규 파일(예: 작업 기록, 계약 파일)의 현재 내용을 변경 뒤에 읽기 전용으로 싣는다. 명시한 경로만이며 통과 지문을 덮지 않고 references만으로는 심사하지 않는다. 비밀 경로·gitignore·심볼릭 링크·바이너리·파일당 64KB/합계 100KB 초과, 읽는 동안 열린 파일의 실제 경로가 고정한 저장소 루트 아래 요청 경로와 달라진 경우는 빠지고 skippedReferences에 이유가 나온다. 이 경로 확인은 Linux(WSL 포함)의 /proc/self/fd에 의존하므로 native Windows/macOS에서는 references를 읽지 않고 skippedReferences에 unsupported 이유를 남긴다(변경 파일 심사는 그대로). inputs(선택, 최대 10개)는 변경이 일관돼야 하는 명세·계약·스키마·호출자 파일이며, references와 같은 읽기·필터·제한으로 별도 블록에 싣고(심사 대상 아님, 빠진 것은 skippedInputs) files와 겹치면 거절한다. references와 달리 통과에 묶여서, resolve 뒤 입력이 바뀌거나 지워지면 git_finalize가 그 통과를 덮지 않는 것으로 보고 다시 검토를 요구한다(inputs 없는 검토는 그대로). 이어지는 라운드는 앞 라운드의 입력을 이어받고 더할 수 있으며, 이어받은 입력을 더 읽을 수 없으면 거절하므로 빼려면 previousReviewId 없이 새로 시작한다. 파일 내용은 reviewer 슬롯이 가리키는 provider(현재 OpenAI·Anthropic)로 전송되며 비밀 이름·내용·바이너리·큰 입력은 거절/제외한다. action=resolve(Main 전용, 다른 세션이 시작한 체인도 닫을 수 있다): reviewId 체인의 모든 finding에 {id, status fixed|refuted|limit, evidence}가 있어야 pass:true이고 그 시점 파일 내용(git blob)이 통과 지문으로 기록된다(withdrawn 판단도 자동으로 닫지 않는다). 마지막 라운드 reviewer가 본 내용과 다른 코드 파일이 resolve 시점에 남아 있으면 finding이 모두 닫혀도 pass:false(unreviewedBlocking)이고, previousReviewId로 새 라운드를 돌려 그 수정을 reviewer에게 보인 뒤 다시 resolve한다. 더 돌릴 probe가 아닌 라운드가 없는 체인(또는 마지막 라운드가 바이너리·개인키·비밀 경로로 건너뛴 파일)에서만 unreviewedAcknowledgements[{path, evidence}]로 미검토 코드 파일마다 그 내용에 한 검사를 근거로 적어 통과할 수 있고(라운드가 남았거나 근거가 비면 rejectedAcknowledgements로 거절), 받아들인 확인 응답은 resolve 기록에 경로·blob·근거로 남는다. git_finalize는 trigger·표본에 걸린 코드 파일에 이 통과 기록을 요구한다(주석 줄만 바뀐 것이 증명된 TS/JS 수정과 git이 바이너리로 보는 미디어·문서·글꼴 자산은 대상이 아니다). reviewer의 PASS 표현은 근거가 아니다.",
155
155
  parameters: z.object({
156
156
  action: z.string(),
157
157
  files: z.array(z.string()).optional(),
@@ -168,6 +168,53 @@ export function isCodeFile(path: string): boolean {
168
168
  return !GENERATED_FILE.test(normalized);
169
169
  }
170
170
 
171
+ // git이 바이너리로 본 이 확장자의 파일은 사람이 읽는 실행 코드가 아니다(이미지·미디어·오피스·글꼴). 실행 파일·라이브러리는 그대로 코드다.
172
+ const BINARY_ASSET = /\.(?:png|jpe?g|gif|webp|bmp|ico|tiff?|psd|pdf|docx|xlsx|pptx|hwpx|odt|ods|odp|mp3|wav|ogg|flac|mp4|webm|mov|avi|woff2?|ttf|otf|eot)$/i;
173
+
174
+ /** 게이트가 보는 대상인가: 경로가 코드이고, 주석만 바뀐 TS/JS로 증명되지 않았고, 바이너리 자산이 아니다. */
175
+ export function isGateTarget(change: Pick<ChangedFile, "path" | "binary" | "commentOnly">): boolean {
176
+ if (!isCodeFile(change.path) || change.commentOnly) return false;
177
+ return !(change.binary && BINARY_ASSET.test(change.path.replaceAll("\\", "/")));
178
+ }
179
+
180
+ const TRANSPILE_LOADER: Record<string, "ts" | "tsx" | "js" | "jsx"> = { ts: "ts", mts: "ts", cts: "ts", tsx: "tsx", js: "js", mjs: "js", cjs: "js", jsx: "jsx" };
181
+
182
+ /**
183
+ * 줄 하나가 의미 없는 주석 줄인가. 줄 전체가 `// …`이거나 한 줄 `/* … *\/`(닫는 표시는 줄 끝 하나뿐)이고, 컴파일러·린트·번들러
184
+ * 지시(`@`·`#`·`///`·`/*!`)나 템플릿 보간·JSX 식이 될 수 있는 글자가 없어야 한다. `*`로 시작하는 여러 줄 주석의 가운데 줄은
185
+ * 그 줄이 주석 안이라는 것을 이 줄만으로 증명할 수 없어(`*values(v: string)` 같은 generator 메서드) 받지 않는다. 빈 줄은 된다.
186
+ */
187
+ function isPureCommentLine(text: string, jsx: boolean): boolean {
188
+ const line = text.trim();
189
+ if (line === "") return true;
190
+ if (/[`@#]|\$\{/.test(line) || (jsx && /[{}]/.test(line))) return false;
191
+ if (line.startsWith("//")) return !line.startsWith("///") && !line.includes("*/");
192
+ return line.length >= 4 && line.startsWith("/*") && !line.startsWith("/*!") && line.indexOf("*/") === line.length - 2 && line.lastIndexOf("/*") === 0;
193
+ }
194
+
195
+ // 줄을 넘는 리터럴을 만들 수 있는 글자: 템플릿 리터럴의 백틱, 문자열의 줄 잇기(줄 끝 역슬래시). 이것이 없으면 따옴표 문자열은 한 줄 안에서 끝나므로
196
+ // `//`로 시작하는 줄은 코드나 블록 주석 안에서 시작한다. 있으면 그 줄이 리터럴(타입 위치면 Bun이 통째로 지운다) 안일 수 있어 증명하지 않는다.
197
+ // git은 \n으로만 줄을 나누지만 JS는 단독 \r·U+2028·U+2029에서도 줄을 끝낸다. 그러면 git의 주석 줄 하나에 지워지는 타입 선언이 숨을 수 있어 같이 거절한다.
198
+ const MULTILINE_LITERAL = /`|\\\r?\n|\r(?!\n)|[\u2028\u2029]/;
199
+
200
+ /**
201
+ * TS/JS 수정이 주석만 바꿨음을 보수적으로 증명한다. 바뀐 줄(추가·삭제 모두)이 전부 위의 주석 줄이고, HEAD와 지금 내용 어느 쪽에도 줄을 넘는
202
+ * 리터럴을 만들 글자(백틱·줄 잇기)가 없으며, 두 내용의 Bun 변환 결과가 같아야 한다. 변환 결과가 같다는 것만으로는 부족하다(타입만 바꾼
203
+ * 수정, 여러 줄 템플릿 리터럴 타입이나 줄 잇기 문자열 타입 안의 `// …` 줄도 같게 나온다). 그런 파일, 변환에 실패한 파일, 지원하지 않는
204
+ * 확장자는 코드로 남는다. 줄 문맥을 읽는 별도 lexer는 두지 않는다.
205
+ */
206
+ export function commentOnlyEdit(path: string, before: string, after: string, lines: readonly ChangedLine[]): boolean {
207
+ const loader = TRANSPILE_LOADER[path.slice(path.lastIndexOf(".") + 1).toLowerCase()];
208
+ if (!loader || lines.length === 0 || !lines.every(({ text }) => isPureCommentLine(text, loader === "tsx" || loader === "jsx"))) return false;
209
+ if (MULTILINE_LITERAL.test(before) || MULTILINE_LITERAL.test(after)) return false;
210
+ try {
211
+ const transpiler = new Bun.Transpiler({ loader, minifyWhitespace: true, deadCodeElimination: false, trimUnusedImports: false });
212
+ return transpiler.transformSync(before) === transpiler.transformSync(after);
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
171
218
  export type TriggerId = "process-termination" | "deletion" | "destructive-sql" | "deploy-release-migration" | "credentials-auth" | "encoding" | "device-io";
172
219
 
173
220
  interface Marker { id: TriggerId; line?: RegExp; path?: RegExp }
@@ -297,6 +344,8 @@ export interface ChangedFile {
297
344
  /** git blob(작업 트리 내용을 git이 커밋할 모양으로 해시한 값). 삭제는 "deleted". */
298
345
  blob: string;
299
346
  binary: boolean;
347
+ /** HEAD 대비 주석만 바뀐 TS/JS 수정으로 증명됐을 때만 true(commentOnlyEdit). 게이트 대상이 아니다. */
348
+ commentOnly?: true;
300
349
  /** marker 판정에 쓰는 바뀐 줄. 새 파일은 전체, 수정은 추가·삭제 줄, 파일 삭제는 HEAD 내용이 삭제 줄이다. */
301
350
  changedLines: ChangedLine[];
302
351
  /** 검토 본문: 수정·삭제는 통합 diff, 새 파일은 줄 번호를 붙인 전체 내용. */
@@ -344,8 +393,20 @@ export async function collectChanges(exec: Exec, git: string, root: string, path
344
393
  // 내용은 한 번만 읽는다. blob은 그 바이트에서 내고, 검토 본문(새 파일은 전체, 수정은 diff)도 같은 내용인지 확인한다.
345
394
  const content = await readFile(join(root, ...path.split("/")));
346
395
  const blob = await hashBytes(exec, git, root, path, content);
347
- const binary = content.includes(0);
348
396
  if (kind === "added") {
397
+ // 바이너리 판정은 수정·삭제처럼 git 규칙을 따르되 이미 읽은 바이트로 한다(다시 읽으면 그 사이에 바뀐 내용을 볼 수 있다).
398
+ // git 규칙: diff 속성이 꺼져 있으면(-diff·binary) 바이너리, 켜져 있으면 텍스트, 드라이버 이름이면 diff.<이름>.binary 설정을 따르고,
399
+ // 그 설정도 속성도 없으면 앞 8000바이트에 NUL이 있을 때 바이너리다.
400
+ const attr = await run(["check-attr", "diff", "--", path]);
401
+ if (attr.code !== 0) throw new Error(`git check-attr failed for ${path}: ${(attr.stderr || attr.stdout).trim()}`);
402
+ const diffAttr = attr.stdout.trim().split(": ").at(-1) ?? "unspecified";
403
+ let forced: boolean | undefined = diffAttr === "unset" ? true : diffAttr === "set" ? false : undefined;
404
+ if (forced === undefined && diffAttr !== "unspecified") {
405
+ const driver = await exec(git, ["config", "--type=bool", "--get", `diff.${diffAttr}.binary`], { cwd: root });
406
+ if (driver.code === 0) forced = driver.stdout.trim() === "true";
407
+ else if (driver.code !== 1) throw new Error(`git config diff.${diffAttr}.binary failed for ${path}: ${(driver.stderr || driver.stdout).trim()}`);
408
+ }
409
+ const binary = forced ?? content.subarray(0, 8000).includes(0);
349
410
  const text = binary ? "" : content.toString("utf8").replace(/^\uFEFF/, "");
350
411
  changed.push({ path, status: kind, blob, binary, changedLines: binary ? [] : text.replace(/\r\n/g, "\n").split("\n").map((line, index) => ({ line: index + 1, text: line })), text: binary ? "" : numbered(text), content });
351
412
  continue;
@@ -353,9 +414,18 @@ export async function collectChanges(exec: Exec, git: string, root: string, path
353
414
  const diff = await run(["diff", "HEAD", "--full-index", "--no-color", "--no-ext-diff", "--no-renames", "--no-textconv", "-U12", "--", path]);
354
415
  if (diff.code !== 0) throw new Error(`git diff failed for ${path}: ${(diff.stderr || diff.stdout).trim()}`);
355
416
  // diff의 새 blob(index 줄)이 읽은 바이트의 blob과 다르면 diff를 만드는 동안 파일이 바뀐 것이다.
356
- const diffBlob = /^index [0-9a-f]+\.\.([0-9a-f]+)/m.exec(diff.stdout)?.[1];
417
+ const diffBlobs = /^index ([0-9a-f]+)\.\.([0-9a-f]+)/m.exec(diff.stdout);
418
+ const diffBlob = diffBlobs?.[2];
357
419
  if (diffBlob && diffBlob !== blob) throw new Error(`${path} changed while it was being read for the review; run it again once the file is stable.`);
358
- changed.push({ path, status: kind, blob, binary: /^Binary files /m.test(diff.stdout), changedLines: parseChangedLines(diff.stdout), text: diff.stdout, content });
420
+ const isBinary = /^Binary files /m.test(diff.stdout);
421
+ const changedLines = parseChangedLines(diff.stdout);
422
+ // 주석만 바뀐 TS/JS인지는 HEAD blob(diff index 줄의 이전 blob)과 지금 바이트로 증명한다. 읽지 못하면 코드로 남는다.
423
+ let commentOnly = false;
424
+ if (!isBinary && diffBlobs?.[1] && /\.[cm]?[jt]sx?$/i.test(path)) {
425
+ const head = await exec(git, ["cat-file", "blob", diffBlobs[1]], { cwd: root });
426
+ commentOnly = head.code === 0 && commentOnlyEdit(path, head.stdout, content.toString("utf8"), changedLines);
427
+ }
428
+ changed.push({ path, status: kind, blob, binary: isBinary, ...(commentOnly ? { commentOnly: true as const } : {}), changedLines, text: diff.stdout, content });
359
429
  }
360
430
  return { changed, unchanged };
361
431
  }
@@ -431,7 +501,9 @@ export async function collectChangesSince(exec: Exec, git: string, root: string,
431
501
  if (content && kind === "added") {
432
502
  const shown = await exec(git, ["cat-file", "blob", inHead], { cwd: root });
433
503
  if (shown.code !== 0) throw new Error(`git cat-file blob ${inHead} failed for ${path}: ${(shown.stderr || shown.stdout).trim()}`);
434
- const binary = shown.stdout.includes("\0");
504
+ const numstat = await exec(git, ["--literal-pathspecs", "diff", "--numstat", baseSha, headSha, "--", path], { cwd: root });
505
+ if (numstat.code !== 0) throw new Error(`git diff --numstat failed for ${path}: ${(numstat.stderr || numstat.stdout).trim()}`);
506
+ const binary = numstat.stdout.startsWith("-\t-\t");
435
507
  const text = binary ? "" : shown.stdout.replace(/^\uFEFF/, "");
436
508
  changed.push({ path, status: kind, blob, binary, changedLines: binary ? [] : text.replace(/\r\n/g, "\n").split("\n").map((line, index) => ({ line: index + 1, text: line })), text: binary ? "" : numbered(text), content });
437
509
  continue;
@@ -522,11 +594,12 @@ export async function currentInputBlobs(exec: Exec, git: string, root: string, p
522
594
  }
523
595
 
524
596
  export function decideGate(changes: readonly ChangedFile[], highRisk: boolean, records: readonly CrossReviewLedgerRecord[], inputBlobs: ReadonlyMap<string, string> = new Map()): GateDecision {
597
+ // 표본은 지금처럼 커밋의 코드 경로 전체(path+blob)로 뽑는다. 뽑힌 뒤 검토를 요구하는 것은 게이트 대상(주석만 바뀐 TS/JS·바이너리 자산 제외)뿐이다.
525
598
  const code = changes.filter((change) => isCodeFile(change.path));
526
599
  const key = sampleKey(code);
527
600
  const sampled = sampleSelected(code);
528
601
  const required: RequiredFile[] = [];
529
- for (const change of code) {
602
+ for (const change of code.filter(isGateTarget)) {
530
603
  const reasons: string[] = [];
531
604
  if (highRisk) reasons.push("declared high-risk (crossReviewHighRisk)");
532
605
  for (const hit of detectMarkers(change.path, change.changedLines).slice(0, 3)) {
@@ -631,7 +704,7 @@ export async function runGate(options: GateOptions): Promise<GateOutcome> {
631
704
  // 의존 입력이 바뀌어 덮이지 않은 파일이 있으면, 다시 검토할 때 같은 입력을 선언하도록 cwd 기준 경로로 알려 준다.
632
705
  const reviewInputs = [...new Set(decision.uncovered.flatMap((file) => file.reviewInputs ?? []))].map((path) => relative(options.cwd, join(resolved.root, ...path.split("/"))).split(sep).join("/"));
633
706
  throw new CrossReviewRefusal([
634
- `cross-family review required before this commit (docs, tests and generated files are excluded). ${decision.uncovered.length} file(s) have no passed review for their current content:`,
707
+ `cross-family review required before this commit (docs, tests, generated files, binary media/office/font assets and TS/JS edits proven comment-only are excluded). ${decision.uncovered.length} file(s) have no passed review for their current content:`,
635
708
  ...list,
636
709
  `Next: run cross_review {action:"review", files:${names}${reviewInputs.length > 0 ? `, inputs:${JSON.stringify(reviewInputs)}` : ""}, acceptance:"<criteria>", authors:[<author model selectors or "main">]${decision.uncovered.some((file) => file.reasons.some((reason) => reason.startsWith("declared"))) ? ", highRisk:true" : ""}}, fix or refute every finding, close them with cross_review {action:"resolve"}, then call git_finalize again with the same files. Do not edit these files after resolve: the pass is tied to their exact git blob${reviewInputs.length > 0 ? ", and to the exact content of the inputs it was reviewed against" : ""}.`,
637
710
  "A waiver (crossReviewWaiver) is allowed only on the user's explicit instruction and is recorded in the routing ledger.",
@@ -864,7 +937,7 @@ export interface ReviewDeps {
864
937
  newId?: () => string;
865
938
  }
866
939
 
867
- export type ReviewerProgressState = "waiting" | "answered" | "failed" | "timed out" | "cancelled";
940
+ export type ReviewerProgressState = "waiting" | "answered" | "failed" | "timed out" | "cancelled" | "stopped";
868
941
  export interface ReviewProgress {
869
942
  round: number;
870
943
  reviewers: { family: Lane; advisory: boolean; state: ReviewerProgressState; elapsedSec?: number }[];
@@ -872,19 +945,21 @@ export interface ReviewProgress {
872
945
  text: string;
873
946
  }
874
947
 
875
- /** 자문 reviewer 호출이 기한·취소로 멈춘 경우. 늦게 끝난 호출의 답·오류는 버린다. */
948
+ /** 자문 reviewer 호출이 기한·취소·필수 검토 종료로 멈춘 경우. 늦게 끝난 호출의 답·오류는 버린다. */
876
949
  class AdvisoryStopError extends ReviewerCallError {
877
- constructor(message: string, model: string, readonly reason: "timed out" | "cancelled") { super(message, model); }
950
+ constructor(message: string, model: string, readonly reason: "timed out" | "cancelled" | "stopped") { super(message, model); }
878
951
  }
879
952
 
880
953
  /**
881
- * 자문 reviewer 한 명의 대기를 ms로 묶는다. 기한이 지나거나 review가 취소되면 밑의 호출을 abort하고, 제공자가 abort를
882
- * 확인해 주기를 기다리지 않고 곧바로 AdvisoryStopError로 끝낸다. 멈춘 이유가 한 번 정해지면 같은 틱에 끝난 호출의 답·오류도,
883
- * 늦게 끝난 호출의 답·오류도 그 이유를 이기지 못한다. 이미 취소된 review는 호출하지 않는다.
954
+ * 자문 reviewer 한 명의 대기를 ms로 묶는다. 기한이 지나거나 review가 취소되거나 requiredSettled가 켜지면(필수 reviewer가 모두 끝남)
955
+ * 밑의 호출을 abort하고, 제공자가 abort를 확인해 주기를 기다리지 않고 곧바로 AdvisoryStopError로 끝낸다. 멈춘 이유가 한 번 정해지면
956
+ * 같은 틱에 끝난 호출의 답·오류도, 늦게 끝난 호출의 답·오류도 그 이유를 이기지 못한다. 이미 취소된 review는 호출하지 않는다.
884
957
  */
885
- export async function withAdvisoryDeadline<T>(call: (attempt: AbortSignal) => Promise<T>, ms: number, signal: AbortSignal, model: string): Promise<T> {
958
+ export async function withAdvisoryDeadline<T>(call: (attempt: AbortSignal) => Promise<T>, ms: number, signal: AbortSignal, model: string, requiredSettled?: AbortSignal): Promise<T> {
886
959
  const cancelled = () => new AdvisoryStopError("advisory reviewer cancelled: the review was aborted, so its call was aborted without waiting for the provider", model, "cancelled");
960
+ const settled = () => new AdvisoryStopError("advisory reviewer stopped: every required reviewer had finished, so the review did not wait for it; the call was aborted and any late answer is ignored (nonblocking, not a successful answer)", model, "stopped");
887
961
  if (signal.aborted) throw cancelled();
962
+ if (requiredSettled?.aborted) throw settled();
888
963
  const controller = new AbortController();
889
964
  const stopped = Promise.withResolvers<never>();
890
965
  let stoppedBy: AdvisoryStopError | undefined;
@@ -892,7 +967,9 @@ export async function withAdvisoryDeadline<T>(call: (attempt: AbortSignal) => Pr
892
967
  const stop = (error: AdvisoryStopError) => { stoppedBy ??= error; stopped.reject(error); controller.abort(error); };
893
968
  const timer = setTimeout(() => stop(new AdvisoryStopError(`advisory reviewer timed out: no answer within ${ms / 1000} s of its start; the call was aborted and any late answer is ignored (nonblocking)`, model, "timed out")), ms);
894
969
  const onAbort = () => stop(cancelled());
970
+ const onSettled = () => stop(settled());
895
971
  signal.addEventListener("abort", onAbort, { once: true });
972
+ requiredSettled?.addEventListener("abort", onSettled, { once: true });
896
973
  try {
897
974
  const answer = await Promise.race([stopped.promise, call(AbortSignal.any([signal, controller.signal]))]);
898
975
  if (stoppedBy) throw stoppedBy;
@@ -902,13 +979,35 @@ export async function withAdvisoryDeadline<T>(call: (attempt: AbortSignal) => Pr
902
979
  } finally {
903
980
  clearTimeout(timer);
904
981
  signal.removeEventListener("abort", onAbort);
982
+ requiredSettled?.removeEventListener("abort", onSettled);
905
983
  }
906
984
  }
907
985
 
986
+ /**
987
+ * 한 라운드의 reviewer를 모두 함께 시작하고, 필수 reviewer가 모두 끝나면(답·실패 무관) 아직 기다리는 자문 reviewer를 끊는다.
988
+ * 그 전에 끝난 자문 답은 그대로 남는다. start는 자문 자리에 requiredSettled를 withAdvisoryDeadline으로 넘겨야 한다. 모든 호출을
989
+ * 끝까지 기다린 뒤(끊긴 자문은 곧바로 끝난다) 자리 순서대로 돌려주며, start가 던진 오류는 모든 호출이 끝난 다음에 다시 던진다.
990
+ */
991
+ export async function settleReviewers<R>(lanes: readonly Lane[], start: (lane: Lane, index: number, requiredSettled: AbortSignal) => Promise<R>): Promise<R[]> {
992
+ const requiredSettled = new AbortController();
993
+ const runs = lanes.map((lane, index) => start(lane, index, requiredSettled.signal));
994
+ // 모든 호출에 곧바로 처리기를 붙인다. 필수 reviewer를 기다리는 동안 끝난 자문 실패가 처리되지 않은 거절이 되지 않게 한다.
995
+ const outcomesPending = Promise.allSettled(runs);
996
+ const required = runs.filter((_, index) => lanes[index] !== ADVISORY_LANE);
997
+ if (required.length > 0) {
998
+ await Promise.allSettled(required);
999
+ requiredSettled.abort();
1000
+ }
1001
+ const outcomes = await outcomesPending;
1002
+ const failed = outcomes.find((outcome): outcome is PromiseRejectedResult => outcome.status === "rejected");
1003
+ if (failed) throw failed.reason;
1004
+ return outcomes.map((outcome) => (outcome as PromiseFulfilledResult<R>).value);
1005
+ }
1006
+
908
1007
  function describeProgress(round: number, reviewers: ReviewProgress["reviewers"], advisoryMs: number): string {
909
1008
  const parts = reviewers.map((reviewer) => {
910
1009
  const who = reviewer.advisory ? "advisory" : `${reviewer.family} (required)`;
911
- if (reviewer.state === "waiting") return `${who} waiting${reviewer.advisory ? ` (gives up at ${advisoryMs / 1000} s, nonblocking)` : ""}`;
1010
+ if (reviewer.state === "waiting") return `${who} waiting${reviewer.advisory ? ` (gives up at ${advisoryMs / 1000} s or when every required reviewer has finished, nonblocking)` : ""}`;
912
1011
  return `${who} ${reviewer.state} after ${reviewer.elapsedSec} s`;
913
1012
  });
914
1013
  return `cross_review round ${round}: ${parts.join("; ")}`;
@@ -1394,12 +1493,12 @@ export async function runReview(input: ReviewInput, deps: ReviewDeps, signal: Ab
1394
1493
  writeFileSync(join(snapshotDir, "probe-results.json"), JSON.stringify(preparedProbes.map((result) => ({ findingId: result.finding.id, command: result.command, exitCode: result.exitCode, outputBytes: result.outputBytes, truncated: result.truncated, output: result.output }))), { mode: 0o600 });
1395
1494
  }
1396
1495
 
1397
- // 필수 reviewer는 completeReviewer의 REVIEW_TIMEOUT_MS만 따른다. 자문 reviewer는 자기 시작부터 advisoryMs에서 끊어 전체 호출을 붙잡지 않는다.
1496
+ // 필수 reviewer는 completeReviewer의 REVIEW_TIMEOUT_MS만 따른다. 자문 reviewer는 자기 시작부터 advisoryMs에서, 또는 필수 reviewer가 모두 끝나면 끊어 전체 호출을 붙잡지 않는다.
1398
1497
  const advisoryMs = deps.advisoryTimeoutMs ?? ADVISORY_TIMEOUT_MS;
1399
1498
  const progress: ReviewProgress["reviewers"] = families.map((family) => ({ family, advisory: family === ADVISORY_LANE, state: "waiting" }));
1400
1499
  const report = () => deps.onProgress?.({ round, reviewers: progress.map((item) => ({ ...item })), text: describeProgress(round, progress, advisoryMs) });
1401
1500
  report();
1402
- const settled = await Promise.all(families.map(async (family, index) => {
1501
+ const settled = await settleReviewers(families, async (family, index, requiredSettled) => {
1403
1502
  const started = Date.now();
1404
1503
  const finish = (state: ReviewerProgressState) => {
1405
1504
  progress[index] = { ...progress[index]!, state, elapsedSec: Math.round((Date.now() - started) / 1000) };
@@ -1407,7 +1506,7 @@ export async function runReview(input: ReviewInput, deps: ReviewDeps, signal: Ab
1407
1506
  };
1408
1507
  try {
1409
1508
  const completion = family === ADVISORY_LANE
1410
- ? await withAdvisoryDeadline((attempt) => deps.complete(family, prompt, attempt), advisoryMs, signal, `@${SLOT[family]}`)
1509
+ ? await withAdvisoryDeadline((attempt) => deps.complete(family, prompt, attempt), advisoryMs, signal, `@${SLOT[family]}`, requiredSettled)
1411
1510
  : await deps.complete(family, prompt, signal);
1412
1511
  finish("answered");
1413
1512
  return { family, model: completion.model, text: completion.text, elapsedMs: Date.now() - started, error: undefined as string | undefined };
@@ -1416,7 +1515,7 @@ export async function runReview(input: ReviewInput, deps: ReviewDeps, signal: Ab
1416
1515
  finish(error instanceof AdvisoryStopError ? error.reason : signal.aborted ? "cancelled" : "failed");
1417
1516
  return { family, model: error instanceof ReviewerCallError ? error.model : `@${SLOT[family]}`, text: error instanceof ReviewerCallError ? error.partial ?? "" : "", elapsedMs: Date.now() - started, error: error instanceof Error ? error.message : String(error) };
1418
1517
  }
1419
- }));
1518
+ });
1420
1519
 
1421
1520
  const reviewers: (CrossReviewReviewer & { family: Lane })[] = [];
1422
1521
  const parsedByFamily = new Map<Lane, ParsedVerdict[]>();
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@oh-my-pi/pi-coding-agent";
3
- import { extractGuardBlock, isPathOwned, isSummonCall, parseOwnedPaths, readGuardField, taskGuardLockOf, WORK_CLASSES } from "../command-guard/task-guard";
3
+ import { extractGuardBlock, isPathOwned, isSummonCall, normalizeDeliverable, parseOwnedPaths, readGuardField, taskGuardLockOf, WORK_CLASSES } from "../command-guard/task-guard";
4
4
  import { parseCharacterSummonMarker } from "../character-voice";
5
5
  import { resolvePreparedTaskInput, storePreparedTaskBatch } from "./prepared-task";
6
6
  import { assignmentsByName, summarizeHistory, type AttemptIdentity, type DispatchOwnership, type DispatchRecord, type RoutingHistory, type RoutingLedger } from "./routing-ledger";
@@ -96,6 +96,8 @@ interface RoutingPolicy {
96
96
  mainWaitsCriteria: string;
97
97
  /** 위임 판정 정본(MAIN·MAKER·UNKNOWN). 정책이 항상 싣는다. */
98
98
  delegationCriteria: Record<string, string>;
99
+ /** 교차검증 필요성 자문 기준(REQUIRED·RECOMMENDED·NOT_NEEDED·UNKNOWN). 답은 Main의 지시서 판단을 돕는 자문이며 게이트를 바꾸지 않는다. */
100
+ crossReviewNeedCriteria: Record<string, string>;
99
101
  };
100
102
  }
101
103
  export interface RoutingQuestion {
@@ -268,8 +270,31 @@ function decisionQuestions(policy: RoutingPolicy) {
268
270
  type: "noul",
269
271
  instructions: `${policy.modelSelection.mainWaitsCriteria}. facts·remainingJudgments·paths에 적힌 사실만 근거로 하고, 그런 사실이 없으면 Main이 기다린다고 본다.`,
270
272
  };
273
+ // 교차검증 필요성은 같은 판단 묶음의 typed choice로만 묻는다(추가 모델 호출 없음). 답은 자문이며 git_finalize 게이트는 이 답을 읽지 않는다.
274
+ questions.crossReviewNeed = {
275
+ type: "choice",
276
+ criteria: policy.modelSelection.crossReviewNeedCriteria,
277
+ instructions: "위 criteria를 정본으로 이 조각의 바뀔 실행 코드·설정이 다른 계열 교차검증을 필요로 하는지 facts·paths·invariants·remainingJudgments에 적힌 사실만으로 판단한다. 파일 확장자나 작업 크기만으로 정하지 않고, 위험을 적은 사실이 없으면 UNKNOWN이다. 결과는 Main의 조언이며 게이트 면제나 강제가 아니다.",
278
+ };
271
279
  return questions;
272
280
  }
281
+
282
+ /** Jev의 crossReviewNeed 선택을 Main이 읽을 자문으로 바꾼다. 이유는 정책 기준 문장, 다음 행동은 선택마다 정해진 문장이며 Jev의 자유 문장은 없다. */
283
+ export interface CrossReviewAdvice { choice: string | null; reason: string; nextAction: string; missingEvidence?: string }
284
+ const CROSS_REVIEW_NEXT: Record<string, string> = {
285
+ REQUIRED: "Put a plain cross-family review line in the brief's # Acceptance (and highRisk if Main declares it); the owning Maker runs cross_review before reporting and Main only resolves.",
286
+ RECOMMENDED: "Main decides: add the review line when the slice changes a contract or behaviour other code relies on; otherwise leave it out. The git_finalize markers and sample still apply either way.",
287
+ NOT_NEEDED: "Leave the review line out. The Maker still reviews if a git_finalize trigger marker hits its diff or Main forwards a sample hit; this advice never waives the gate.",
288
+ UNKNOWN: "Main decides from the brief; the git_finalize markers and sample still apply.",
289
+ };
290
+ const CROSS_REVIEW_MISSING = "Add to assessment facts what the changed code does (process termination, deletion, destructive SQL, deploy/release/migration, credentials/auth, encoding, device I/O, state transitions or a shared contract) and the paths it touches, then re-run maker_route.";
291
+ export function crossReviewAdviceOf(answers: Record<string, RoutingAnswer> | null, criteria: Record<string, string>): CrossReviewAdvice {
292
+ const choice = answers?.crossReviewNeed?.choice;
293
+ if (!choice || !(choice in criteria) || !(choice in CROSS_REVIEW_NEXT)) {
294
+ return { choice: null, reason: "Jev gave no usable crossReviewNeed answer (unavailable or missing).", nextAction: CROSS_REVIEW_NEXT.UNKNOWN!, missingEvidence: CROSS_REVIEW_MISSING };
295
+ }
296
+ return { choice, reason: criteria[choice]!, nextAction: CROSS_REVIEW_NEXT[choice]!, ...(choice === "UNKNOWN" ? { missingEvidence: CROSS_REVIEW_MISSING } : {}) };
297
+ }
273
298
  function placementQuestions(owners: Owner[]) {
274
299
  if (owners.length === 0) return {};
275
300
  return {
@@ -344,10 +369,25 @@ export function contractGapReason(task: string, lock: DispatchContractLock | und
344
369
  if (!field("WORK_CLASS") && !lock?.workClass) missing.push("WORK_CLASS(아직 lock이 없어 이 child에 필요)");
345
370
  if (!field("PRIMARY_DELIVERABLE") && !lock?.primaryDeliverable) missing.push("PRIMARY_DELIVERABLE(아직 lock이 없어 이 child에 필요)");
346
371
  if (parseOwnedPaths(field("OWNED_PATHS")).length === 0) missing.push("OWNED_PATHS(모든 child 필수)");
372
+ const inherit = !field("WORK_CLASS") || !field("PRIMARY_DELIVERABLE")
373
+ ? " 두 값을 생략한 child는 같은 maker_route·task batch에서 두 값을 적은 앞 child 뒤에 있거나, 이 요청의 첫 child가 이미 발주돼 TaskGuard lock이 있을 때만 상속합니다. 따로 준비한다면 요청의 WORK_CLASS·PRIMARY_DELIVERABLE을 글자 그대로 다시 적으세요."
374
+ : "";
347
375
  const detail = missing.length > 0
348
- ? `빠진 필드: ${missing.join(", ")}`
349
- : "필드가 블록 안에서 읽히지 않습니다(`TASK_GUARD:` 뒤 빈 줄이나 guard 필드 사이에 끼어든 줄이 있는지 확인)";
350
- return `task ${label}: ${detail}. ${GUARD_TEMPLATE}`;
376
+ ? `빠진 필드: ${missing.join(", ")}.${inherit}`
377
+ : "필드가 블록 안에서 읽히지 않습니다(`TASK_GUARD:` 뒤 빈 줄이나 guard 필드 사이에 끼어든 줄이 있는지 확인).";
378
+ return `task ${label}: ${detail} ${GUARD_TEMPLATE}`;
379
+ }
380
+
381
+ /**
382
+ * TaskGuard(SideQuestGuard)와 같은 비교로, 이 child의 WORK_CLASS·PRIMARY_DELIVERABLE이 요청 lock(없으면 같은 batch에서 먼저 계약을
383
+ * 정한 child)과 다르면 거절 사유를 돌려준다. 준비와 발주가 같은 함수를 써서 Jev 판단이나 spawn 전에 같은 결론을 낸다.
384
+ */
385
+ export function contractLockConflict(name: string, contract: DispatchContract, lock: DispatchContractLock | undefined, lockSource: "request" | "batch"): string | null {
386
+ if (!lock) return null;
387
+ if (contract.workClass === lock.workClass && normalizeDeliverable(contract.primaryDeliverable) === normalizeDeliverable(lock.primaryDeliverable)) return null;
388
+ const label = name.trim() ? `'${name.trim().slice(0, 60)}'` : "(이름 없음)";
389
+ const source = lockSource === "request" ? "이 요청의 TaskGuard lock" : "같은 batch에서 먼저 계약을 정한 child";
390
+ return `[SideQuestGuard] task ${label}: 한 사용자 요청의 모든 child는 같은 WORK_CLASS·PRIMARY_DELIVERABLE을 씁니다. 기준=${lock.workClass}:${lock.primaryDeliverable}(${source}), 이 child=${contract.workClass}:${contract.primaryDeliverable}. 이대로 발주하면 task 전체가 거절됩니다. 고치기: 이 child의 두 줄을 기준 값 그대로 적거나 지우고(같은 batch 안이거나 lock이 있을 때만 상속), 조각마다 다른 완료물은 TASK_TITLE·TODO_TASKS·# Acceptance로 구분하세요.`;
351
391
  }
352
392
 
353
393
  /**
@@ -839,12 +879,18 @@ export function registerMakerRouting(pi: ExtensionAPI, deps: RoutingDeps) {
839
879
  // TaskGuard는 같은 요청의 첫 child가 확립한 WORK_CLASS/PRIMARY_DELIVERABLE을 이후 child에
840
880
  // 파생한다. 준비 도구도 같은 batch와 이미 연결된 task에서 그 두 필드만 상속한다.
841
881
  let candidateLock = lockFor(sessionIdOf(ctx));
882
+ const lockSource = candidateLock ? "request" : "batch";
883
+ const conflicts: string[] = [];
842
884
  const contracts = tasks.map((task) => {
843
885
  const contract = dispatchContract(task.task, candidateLock);
844
886
  if (!contract) throw new Error(`maker_route는 TaskGuard lock으로 완성 가능한 WORK_CLASS·PRIMARY_DELIVERABLE 및 명시 OWNED_PATHS가 필요합니다. ${contractGapReason(task.task, candidateLock, task.name)}`);
887
+ // TaskGuard는 lock과 다른 child가 하나라도 있으면 batch 전체를 거절한다. Jev를 부르기 전에 같은 비교로 모두 알린다.
888
+ const conflict = contractLockConflict(task.name, contract, candidateLock, lockSource);
889
+ if (conflict) conflicts.push(conflict);
845
890
  candidateLock ??= { workClass: contract.workClass, primaryDeliverable: contract.primaryDeliverable };
846
891
  return contract;
847
892
  });
893
+ if (conflicts.length > 0) throw new Error(conflicts.join("\n"));
848
894
  const keys = tasks.map((task, index) => briefKey(task.name, contracts[index]!));
849
895
  // 준비 handle. 실제 발주 identity는 spawn에서 task 호출 id·index·session으로 캡처한다.
850
896
  // 이 값으로 실제 발주를 식별하지 않으며, 중첩 prepare가 서로를 덮지 않도록 공유 map에 두지 않는다.
@@ -1065,6 +1111,7 @@ export function registerMakerRouting(pi: ExtensionAPI, deps: RoutingDeps) {
1065
1111
  ? "기존 owner가 Opus인지 확인하세요. 비-Opus면 미완 변경·증거를 freeze하고 소유권을 넘깁니다. active owner를 동시에 새 발주하거나 실행 중 모델을 바꾸지 않습니다."
1066
1112
  : null,
1067
1113
  history: historyOf(publication.prepared.answers),
1114
+ crossReviewAdvice: crossReviewAdviceOf(publication.prepared.answers, current.modelSelection.crossReviewNeedCriteria),
1068
1115
  preparedId: preparedIds[index],
1069
1116
  plan: plans[index] ?? { sessionId, planId: null },
1070
1117
  })),
@@ -1095,6 +1142,7 @@ export function registerMakerRouting(pi: ExtensionAPI, deps: RoutingDeps) {
1095
1142
  // 준비에서 시작한 online provider 시도만 공유하며, 다음 발주는 새로 해석한다.
1096
1143
  const refreshAttempts: RefreshAttempts = new Map();
1097
1144
  let candidateLock = lockFor(sessionIdOf(ctx));
1145
+ const lockSource = candidateLock ? "request" : "batch";
1098
1146
  const locksBefore: (DispatchContractLock | undefined)[] = [];
1099
1147
  const contracts = tasks.map((item) => {
1100
1148
  const task = typeof item.task === "string" ? item.task : "";
@@ -1117,6 +1165,8 @@ export function registerMakerRouting(pi: ExtensionAPI, deps: RoutingDeps) {
1117
1165
  if (parseCharacterSummonMarker(task) !== undefined || isSummonCall(sessionIdOf(ctx), callId)) continue;
1118
1166
  const contract = contracts[itemIndex];
1119
1167
  if (!contract) return { block: true, reason: `task는 TaskGuard lock으로 완성 가능한 WORK_CLASS·PRIMARY_DELIVERABLE 및 명시 OWNED_PATHS가 필요합니다. ${contractGapReason(task, locksBefore[itemIndex], String(item.name ?? ""))}` };
1168
+ const conflict = contractLockConflict(String(item.name ?? ""), contract, locksBefore[itemIndex], lockSource);
1169
+ if (conflict) return { block: true, reason: conflict };
1120
1170
  const name = String(item.name ?? "").trim();
1121
1171
  const key = briefKey(String(item.name ?? ""), contract);
1122
1172
  const pending = bindings.get(key);
@@ -1309,7 +1359,7 @@ export function registerMakerRouting(pi: ExtensionAPI, deps: RoutingDeps) {
1309
1359
  const toolParameters = parameters as unknown as ToolDefinition["parameters"];
1310
1360
  pi.registerTool({
1311
1361
  name: "maker_route", label: "Maker Route", loadMode: "essential", approval: "read",
1312
- description: `Main 전용 발주 준비. Jev가 위임 적합성·NORMAL/HARD 난이도·UI/UX 경계·HARD 지배 분야·owner 중복·Main의 결과 대기 여부(mainWaits)를 기존 한 배치에서 독립 판단한다. NORMAL UI/UX는 NORMAL_OPUS, 비-UI NORMAL의 기본 추천은 NORMAL_SONNET이며 ${DEEPSEEK_TRIGGER_NOTE}. 설정된 코드 후보 NORMAL_SOL(호환 식별자, 실제 모델은 후보 selector를 따름)은 NORMAL_SONNET과 NORMAL_DEEPSEEK를 모두 쓸 수 없을 때만 추천하고, Sonnet이 사용 불가·관측 소진이면 NORMAL_DEEPSEEK를 먼저 추천한다. 한도 미관측은 소진이 아니다. Sonnet 또는 NORMAL_DEEPSEEK가 사용 가능한데 NORMAL_SOL을 고르는 것은 추천 변경이라 ROUTING_REASON이 필요하다. 한도 여유·과거 성공률로 우월성을 추정하지 않는다. HARD_UI_OPUS·HARD_CODE_OPUS는 분야에 따른다. HARD_CODE_SONNET·HARD_CODE_ASTRA·명시 선택 전용 Daybreak Blue 후보(NORMAL_DAYBREAK·HARD_CODE_DAYBREAK, 자동 추천·폴백 순서에는 없음)·명시 선택 전용 Gemini·GLM 후보(NORMAL_GEMINI·HARD_CODE_GEMINI·NORMAL_GLM·HARD_CODE_GLM, 같은 방식)·명시 NORMAL Opus와 추천 변경은 ROUTING_REASON이 필요하다. Maker는 '<후보 selector>:auto'로만 발주하며 강도는 child auto가 solutionSpace와 이후 Main 지시로 그 모델의 전체 단계(Opus는 max까지) 안에서 고른다. 새 UI/UX 경계는 기존 비-Opus owner를 freeze해 이관하되 실행 중 모델을 바꾸지 않는다. 소유권·warm/exact pin을 보존한다. Opus unavailable을 조용히 대체하지 않는다. 난이도를 Opus 선택 수단으로 부풀리지 않는다. history는 advisory이고 routing_verdict는 실제 spawn identity를 쓴다. task 원문을 Jev에 보내거나 동일 브리프를 중복 판단하지 않는다.`,
1362
+ description: `Main 전용 발주 준비. Jev가 위임 적합성·NORMAL/HARD 난이도·UI/UX 경계·HARD 지배 분야·owner 중복·Main의 결과 대기 여부(mainWaits)·교차검증 필요성(crossReviewNeed)을 기존 한 배치에서 독립 판단한다. crossReviewAdvice{choice, reason, nextAction, missingEvidence?}는 브리프에 review 줄을 넣을지 정하는 Main의 자문일 뿐 git_finalize 게이트(high-risk·marker·표본)를 면제하거나 강제하지 않는다. 같은 batch의 child가 요청 lock(없으면 같은 batch에서 먼저 계약을 정한 child)과 다른 WORK_CLASS·PRIMARY_DELIVERABLE을 적으면 Jev 호출 전에 거절하며, 두 값을 생략한 child는 같은 batch 안이거나 lock이 있을 때만 상속한다. NORMAL UI/UX는 NORMAL_OPUS, 비-UI NORMAL의 기본 추천은 NORMAL_SONNET이며 ${DEEPSEEK_TRIGGER_NOTE}. 설정된 코드 후보 NORMAL_SOL(호환 식별자, 실제 모델은 후보 selector를 따름)은 NORMAL_SONNET과 NORMAL_DEEPSEEK를 모두 쓸 수 없을 때만 추천하고, Sonnet이 사용 불가·관측 소진이면 NORMAL_DEEPSEEK를 먼저 추천한다. 한도 미관측은 소진이 아니다. Sonnet 또는 NORMAL_DEEPSEEK가 사용 가능한데 NORMAL_SOL을 고르는 것은 추천 변경이라 ROUTING_REASON이 필요하다. 한도 여유·과거 성공률로 우월성을 추정하지 않는다. HARD_UI_OPUS·HARD_CODE_OPUS는 분야에 따른다. HARD_CODE_SONNET·HARD_CODE_ASTRA·명시 선택 전용 Daybreak Blue 후보(NORMAL_DAYBREAK·HARD_CODE_DAYBREAK, 자동 추천·폴백 순서에는 없음)·명시 선택 전용 Gemini·GLM 후보(NORMAL_GEMINI·HARD_CODE_GEMINI·NORMAL_GLM·HARD_CODE_GLM, 같은 방식)·명시 NORMAL Opus와 추천 변경은 ROUTING_REASON이 필요하다. Maker는 '<후보 selector>:auto'로만 발주하며 강도는 child auto가 solutionSpace와 이후 Main 지시로 그 모델의 전체 단계(Opus는 max까지) 안에서 고른다. 새 UI/UX 경계는 기존 비-Opus owner를 freeze해 이관하되 실행 중 모델을 바꾸지 않는다. 소유권·warm/exact pin을 보존한다. Opus unavailable을 조용히 대체하지 않는다. 난이도를 Opus 선택 수단으로 부풀리지 않는다. history는 advisory이고 routing_verdict는 실제 spawn identity를 쓴다. task 원문을 Jev에 보내거나 동일 브리프를 중복 판단하지 않는다.`,
1313
1363
  parameters: toolParameters,
1314
1364
  async execute(callId, params, signal, _onUpdate, ctx) {
1315
1365
  // core가 위 schema로 검증한 입력이며 SDK generic 경계에서 소실된 타입만 복원한다.