elanous 0.2.11 → 0.2.12

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 (246) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/chunks/1147-466ec15afacc1a65.js +1 -0
  4. package/apps/pwa/out/_next/static/chunks/{1394-556de6d71bbdb0fb.js → 1394-ee3795b112907122.js} +1 -1
  5. package/apps/pwa/out/_next/static/chunks/3869-3cd46526879f454c.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/7277-0b2fc7c16ba2d64b.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/7758-0b018a9e4fc56200.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/{8605-ea5bee9dfae512c7.js → 8605-0259994816604c49.js} +1 -1
  9. package/apps/pwa/out/_next/static/chunks/9920-5cfdc5e8fd209825.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/app/approvals/page-cb169620945d1952.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-6a061bd97f0b9398.js → page-cefe8169e36cf62a.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/app/board/{page-cb95a7432c9ee744.js → page-d2e35228bc497119.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/botlab/{page-b12b6d2f8d2d1aea.js → page-5c05b0da4bc1392c.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/app/bots/{page-b9efe868bfd36255.js → page-e0ccdb576812b523.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/chat/{page-7c97dcdfe2e37a60.js → page-813c4a52c9d05ca8.js} +1 -1
  16. package/apps/pwa/out/_next/static/chunks/app/consult/page-4731f2c215358937.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/control/{page-5721537ca8a3598c.js → page-a022b257950f39e6.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/design-check/{page-b654240a572e4c48.js → page-a2ffc717e04b8b0a.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/editor/{page-b9750abc897508c3.js → page-b59ee4e70fc18fcf.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/exec/page-a67e11ba962337b5.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/app/field/page-c172dabd796eb7a3.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/app/inside/page-1efd0cb9e0ed9d13.js +1 -0
  23. package/apps/pwa/out/_next/static/chunks/app/intake/{page-c8b1ccda6ba7ae98.js → page-d5dc7ff69a37b4cf.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/layout-e71c84189e8c683e.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/app/live/{page-2a9932ddb62d76bd.js → page-1d6a533ce5a6efcb.js} +1 -1
  26. package/apps/pwa/out/_next/static/chunks/app/market/{page-9688f5b4f1ff950f.js → page-dfca9b7d2de0b135.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-793fa536b3a6ef37.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/app/morning/{page-35414e0a007b4082.js → page-6dd3bfbbde2a5c2c.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/app/{not-found-e7a529e0507ac86c.js → not-found-490653e8d4cc8852.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/observatory/{page-e001823c35c9dd67.js → page-77447356baad75d2.js} +1 -1
  31. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-29e961191f6057df.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-c822f55b884d9104.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-fa4ccfc432424e52.js +1 -0
  34. package/apps/pwa/out/_next/static/chunks/app/outputs/{page-3a24072b35470157.js → page-6d604227e1664b71.js} +1 -1
  35. package/apps/pwa/out/_next/static/chunks/app/page-9c7e8f744a765daf.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/reflection/{page-0be8d4b2ced75ba0.js → page-4048543b4b7ad5e0.js} +1 -1
  37. package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-d37cb0d52f9b6bd5.js → page-4c3a3126abed551a.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-686da0df9bf23e5c.js → page-64b0aecb1ca75e75.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-4292d0149020e8a7.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/app/settings/{page-53f80edc6b8dc07c.js → page-db14d0be7f1c460c.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-e5d8072cd7cfef73.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/app/setup/{page-239c17b911b84e72.js → page-9547b5cec7cf76bf.js} +1 -1
  43. package/apps/pwa/out/_next/static/chunks/app/share/page-e6dcb83265530636.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/app/showroom/page-45248f08d6aecae5.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/tasks/{page-1218753c82d585f6.js → page-2cddeb9e2aeedc55.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/term/{page-4b5935e67531efb5.js → page-e297866051595d81.js} +1 -1
  47. package/apps/pwa/out/_next/static/chunks/app/today/{page-3285bc87caf1eaa5.js → page-baba2e047872fb0f.js} +1 -1
  48. package/apps/pwa/out/_next/static/chunks/app/trace/{page-ad553b0e06505ecd.js → page-e895da4ec9cac345.js} +1 -1
  49. package/apps/pwa/out/_next/static/chunks/app/vault/page-4344faf20216233b.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-698bb29f11864b89.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/app/workflows/page-bd6a6dc1ddc273f2.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/app/workspace/page-2e9e2fb01c9da1c9.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-d62ceb13411ebba4.js +1 -0
  54. package/apps/pwa/out/_next/static/css/128fb7fb670b2fbf.css +1 -0
  55. package/apps/pwa/out/approvals/index.html +1 -1
  56. package/apps/pwa/out/approvals/index.txt +14 -14
  57. package/apps/pwa/out/autopilot/index.html +1 -1
  58. package/apps/pwa/out/autopilot/index.txt +14 -14
  59. package/apps/pwa/out/board/index.html +1 -1
  60. package/apps/pwa/out/board/index.txt +14 -14
  61. package/apps/pwa/out/botlab/index.html +1 -1
  62. package/apps/pwa/out/botlab/index.txt +14 -14
  63. package/apps/pwa/out/bots/index.html +1 -1
  64. package/apps/pwa/out/bots/index.txt +14 -14
  65. package/apps/pwa/out/chat/index.html +1 -1
  66. package/apps/pwa/out/chat/index.txt +14 -14
  67. package/apps/pwa/out/consult/index.html +1 -1
  68. package/apps/pwa/out/consult/index.txt +14 -14
  69. package/apps/pwa/out/control/index.html +1 -1
  70. package/apps/pwa/out/control/index.txt +14 -14
  71. package/apps/pwa/out/design-check/index.html +1 -1
  72. package/apps/pwa/out/design-check/index.txt +14 -14
  73. package/apps/pwa/out/editor/index.html +1 -1
  74. package/apps/pwa/out/editor/index.txt +14 -14
  75. package/apps/pwa/out/exec/index.html +1 -1
  76. package/apps/pwa/out/exec/index.txt +14 -14
  77. package/apps/pwa/out/field/index.html +1 -1
  78. package/apps/pwa/out/field/index.txt +14 -14
  79. package/apps/pwa/out/healthz +1 -1
  80. package/apps/pwa/out/index.html +1 -1
  81. package/apps/pwa/out/index.txt +14 -14
  82. package/apps/pwa/out/inside/index.html +1 -1
  83. package/apps/pwa/out/inside/index.txt +14 -14
  84. package/apps/pwa/out/intake/index.html +1 -1
  85. package/apps/pwa/out/intake/index.txt +14 -14
  86. package/apps/pwa/out/live/index.html +1 -1
  87. package/apps/pwa/out/live/index.txt +14 -14
  88. package/apps/pwa/out/market/index.html +1 -1
  89. package/apps/pwa/out/market/index.txt +14 -14
  90. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  91. package/apps/pwa/out/missions/placeholder/index.txt +14 -14
  92. package/apps/pwa/out/morning/index.html +1 -1
  93. package/apps/pwa/out/morning/index.txt +14 -14
  94. package/apps/pwa/out/observatory/index.html +1 -1
  95. package/apps/pwa/out/observatory/index.txt +14 -14
  96. package/apps/pwa/out/ops/checklist/index.html +1 -1
  97. package/apps/pwa/out/ops/checklist/index.txt +14 -14
  98. package/apps/pwa/out/ops/release/index.html +1 -1
  99. package/apps/pwa/out/ops/release/index.txt +14 -14
  100. package/apps/pwa/out/ops/seats/index.html +1 -1
  101. package/apps/pwa/out/ops/seats/index.txt +14 -14
  102. package/apps/pwa/out/outputs/index.html +1 -1
  103. package/apps/pwa/out/outputs/index.txt +14 -14
  104. package/apps/pwa/out/reflection/index.html +1 -1
  105. package/apps/pwa/out/reflection/index.txt +15 -15
  106. package/apps/pwa/out/scheduler/index.html +1 -1
  107. package/apps/pwa/out/scheduler/index.txt +14 -14
  108. package/apps/pwa/out/sessions/index.html +1 -1
  109. package/apps/pwa/out/sessions/index.txt +14 -14
  110. package/apps/pwa/out/settings/devices/index.html +1 -1
  111. package/apps/pwa/out/settings/devices/index.txt +14 -14
  112. package/apps/pwa/out/settings/index.html +1 -1
  113. package/apps/pwa/out/settings/index.txt +14 -14
  114. package/apps/pwa/out/setup/done/index.html +1 -1
  115. package/apps/pwa/out/setup/done/index.txt +14 -14
  116. package/apps/pwa/out/setup/index.html +1 -1
  117. package/apps/pwa/out/setup/index.txt +14 -14
  118. package/apps/pwa/out/share/index.html +1 -1
  119. package/apps/pwa/out/share/index.txt +14 -14
  120. package/apps/pwa/out/showroom/index.html +1 -1
  121. package/apps/pwa/out/showroom/index.txt +14 -14
  122. package/apps/pwa/out/tasks/index.html +1 -1
  123. package/apps/pwa/out/tasks/index.txt +14 -14
  124. package/apps/pwa/out/term/index.html +1 -1
  125. package/apps/pwa/out/term/index.txt +14 -14
  126. package/apps/pwa/out/today/index.html +1 -1
  127. package/apps/pwa/out/today/index.txt +14 -14
  128. package/apps/pwa/out/trace/index.html +1 -1
  129. package/apps/pwa/out/trace/index.txt +14 -14
  130. package/apps/pwa/out/vault/index.html +1 -1
  131. package/apps/pwa/out/vault/index.txt +14 -14
  132. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  133. package/apps/pwa/out/workflows/chat-ui/index.txt +14 -14
  134. package/apps/pwa/out/workflows/index.html +1 -1
  135. package/apps/pwa/out/workflows/index.txt +14 -14
  136. package/apps/pwa/out/workspace/index.html +1 -1
  137. package/apps/pwa/out/workspace/index.txt +14 -14
  138. package/apps/pwa/out/worktrees/index.html +1 -1
  139. package/apps/pwa/out/worktrees/index.txt +14 -14
  140. package/graphs/context/nightly-condense.yaml +21 -0
  141. package/graphs/context/recipes.yaml +12 -0
  142. package/graphs/demo/inside-seed.yaml +20 -0
  143. package/graphs/demo/recipes.yaml +12 -0
  144. package/package.json +1 -1
  145. package/plugins/card-followup/examples/sample-card.png +0 -0
  146. package/scripts/lib/telegram-inject-args.ts +31 -0
  147. package/scripts/measure-pod-memory-by-goal.ts +130 -0
  148. package/scripts/release-loop/auto-approve-node.ts +1 -1
  149. package/scripts/release-loop/checklist-gate-node.ts +2 -1
  150. package/scripts/release-loop/gate-node.ts +8 -2
  151. package/scripts/release-loop/ops-upgrade-node.ts +47 -6
  152. package/scripts/release-loop/publish-node.ts +4 -1
  153. package/scripts/release-loop/version-node.ts +13 -3
  154. package/scripts/telegram-inject.ts +14 -6
  155. package/scripts/test-diet/remeasure-list.ts +112 -0
  156. package/scripts/ux/demo-rehearsal.ts +17 -13
  157. package/scripts/ux/demo-seed.ts +108 -0
  158. package/scripts/ux/lib/rehearsal-verdict.ts +12 -5
  159. package/src/agent-mission/driver.ts +138 -16
  160. package/src/agent-mission/pty-decision.ts +1 -1
  161. package/src/autopilot/pty-control-loop.ts +4 -1
  162. package/src/chat/index.ts +1 -0
  163. package/src/cli/persona-cli.ts +17 -14
  164. package/src/cli/pod-cli.ts +17 -0
  165. package/src/cli/pod-memory-advice.ts +35 -0
  166. package/src/cli/release-cli.ts +46 -6
  167. package/src/context-bus/context-now-surfaces.ts +17 -0
  168. package/src/context-bus/context-now.ts +2 -2
  169. package/src/context-bus/coord-events.ts +2 -2
  170. package/src/context-bus/nightly-condense.ts +153 -0
  171. package/src/context-bus/session-events.ts +35 -0
  172. package/src/dashboard/dashboard-status-lines.ts +2 -0
  173. package/src/dashboard/index.ts +8 -0
  174. package/src/dashboard/slash-runtime/dashboard-handlers.ts +24 -0
  175. package/src/domains/outbound-alert.ts +99 -31
  176. package/src/domains/outbound-log-probe.ts +8 -0
  177. package/src/execution-loop/launch-gate.ts +17 -8
  178. package/src/field-note/telegram.ts +142 -0
  179. package/src/graph-runner/graph-grow-llm.ts +23 -13
  180. package/src/graph-runner/graph-grow.ts +5 -2
  181. package/src/graph-runner/runner.ts +12 -8
  182. package/src/harness/harness-cli-command.ts +138 -16
  183. package/src/hooks/consult-intake.ts +153 -0
  184. package/src/hooks/receiver.ts +78 -1
  185. package/src/index.ts +33 -9
  186. package/src/intake-plane/discord-seat-work.ts +28 -2
  187. package/src/intake-plane/wish-card.ts +33 -0
  188. package/src/llm/difficulty.ts +1 -1
  189. package/src/maturity/feature-maturity.ts +2 -1
  190. package/src/nexus/api/http-server.ts +31 -8
  191. package/src/nexus/api/outbound-report.ts +40 -7
  192. package/src/nexus/api/personas.ts +95 -53
  193. package/src/nexus/api/task-cards-api.ts +19 -0
  194. package/src/nexus/api/workflow-history.ts +88 -0
  195. package/src/nexus/api/workflows.ts +52 -0
  196. package/src/nexus/outbound/delivery-ledger.ts +17 -8
  197. package/src/nexus/outbound/router.ts +8 -3
  198. package/src/persona/presets.ts +8 -1
  199. package/src/plugins/maker/market-bundle.ts +2 -1
  200. package/src/release-loop/checklist.ts +15 -3
  201. package/src/release-loop/feature-store.ts +24 -10
  202. package/src/release-loop/landed-but-yellow.ts +48 -0
  203. package/src/seat-loop/seat-loop.ts +168 -7
  204. package/src/self-dev/draft-sweep.ts +44 -10
  205. package/src/self-dev/draft-triage-rules.ts +54 -2
  206. package/src/self-implement/budget-gate.ts +29 -12
  207. package/src/self-implement/gate-candidate-ranking.ts +60 -0
  208. package/src/self-implement/gate-scope.ts +9 -8
  209. package/src/self-implement/headless-elanous-driver.ts +7 -2
  210. package/src/self-implement/orchestrator.ts +15 -0
  211. package/src/self-implement/test-seams.ts +1 -0
  212. package/src/task-orchestrator/surfaces/pod-command-job.ts +14 -10
  213. package/src/task-orchestrator/surfaces/self-implement-pod.ts +8 -0
  214. package/src/telegram-commands.ts +21 -0
  215. package/src/telegram-project-command.ts +78 -4
  216. package/src/telegram.ts +32 -5
  217. package/src/user-config.ts +8 -4
  218. package/src/version/packed-revision.json +1 -1
  219. package/apps/pwa/out/_next/static/chunks/2310-7a59f043f6db207e.js +0 -1
  220. package/apps/pwa/out/_next/static/chunks/2548-6e316138d347cefe.js +0 -1
  221. package/apps/pwa/out/_next/static/chunks/3869-a39d1d9921e10d43.js +0 -1
  222. package/apps/pwa/out/_next/static/chunks/507-5acd98bd6dab2d8a.js +0 -1
  223. package/apps/pwa/out/_next/static/chunks/5445-745eb795c5963d79.js +0 -1
  224. package/apps/pwa/out/_next/static/chunks/app/approvals/page-1dcdc58fc2de2454.js +0 -1
  225. package/apps/pwa/out/_next/static/chunks/app/consult/page-8fd36984f549270a.js +0 -1
  226. package/apps/pwa/out/_next/static/chunks/app/exec/page-4cd1de371f8ad048.js +0 -1
  227. package/apps/pwa/out/_next/static/chunks/app/field/page-d99f89657828421c.js +0 -1
  228. package/apps/pwa/out/_next/static/chunks/app/inside/page-50cb2a0eb15e6c83.js +0 -1
  229. package/apps/pwa/out/_next/static/chunks/app/layout-d6b1d156fb95e74b.js +0 -1
  230. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-6b15dd6456c5df09.js +0 -1
  231. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-371423d1a573d64b.js +0 -1
  232. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-4b764b125339ac17.js +0 -1
  233. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-cdf8e4b01067bcf1.js +0 -1
  234. package/apps/pwa/out/_next/static/chunks/app/page-94c82eb0ae5abb32.js +0 -1
  235. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-4dc614f3ce043e63.js +0 -1
  236. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d02c782fc792e2af.js +0 -1
  237. package/apps/pwa/out/_next/static/chunks/app/share/page-dc6a79fb003f746c.js +0 -1
  238. package/apps/pwa/out/_next/static/chunks/app/showroom/page-0fffb84cafdc9da6.js +0 -1
  239. package/apps/pwa/out/_next/static/chunks/app/vault/page-fca096e3e860306f.js +0 -1
  240. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-9ebfa78f0d9885c3.js +0 -1
  241. package/apps/pwa/out/_next/static/chunks/app/workflows/page-3dc5f21f7e39622d.js +0 -1
  242. package/apps/pwa/out/_next/static/chunks/app/workspace/page-c6da2b468454532d.js +0 -1
  243. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-93bc70e36f595414.js +0 -1
  244. package/apps/pwa/out/_next/static/css/543f56bcb1f79fca.css +0 -1
  245. /package/apps/pwa/out/_next/static/{Y4zKzL62sakG5momz3SoQ → -6XrAoD3eU7NyMHAE4xPS}/_buildManifest.js +0 -0
  246. /package/apps/pwa/out/_next/static/{Y4zKzL62sakG5momz3SoQ → -6XrAoD3eU7NyMHAE4xPS}/_ssgManifest.js +0 -0
@@ -27,6 +27,10 @@ export interface DraftSweepAdapters {
27
27
  closeDraft(repository: string, number: number, comment: string): Promise<void>;
28
28
  /** Resolve the latest claim comment owner; a failed lookup must not authorize a close. */
29
29
  getClaimOwner?(repository: string, number: number): Promise<string | undefined>;
30
+ /** Whether the current owned run has a self-implement.result final observation, even if its worktree remains. */
31
+ hasFinalRunResult?(draft: SweepDraft, repository: string): Promise<boolean | undefined>;
32
+ /** Per-file last change on the draft branch; undefined means file coverage is unverified. */
33
+ getLatestFileChanges?(draft: SweepDraft, repository: string): Promise<Readonly<Record<string, string>> | undefined>;
30
34
  }
31
35
 
32
36
  export interface DraftSweepOptions {
@@ -120,9 +124,23 @@ export async function runDraftSweep({ repository, adapters, apply = false, now =
120
124
  return finish();
121
125
  }
122
126
  let statuses: Map<number, string | undefined>;
127
+ let finality: Map<number, boolean | undefined>;
128
+ let latestFileChanges: Map<number, Readonly<Record<string, string>> | undefined>;
123
129
  try {
124
130
  statuses = new Map<number, string | undefined>();
125
- for (const draft of drafts) statuses.set(draft.number, await adapters.getRunStatus(draft, repository));
131
+ finality = new Map<number, boolean | undefined>();
132
+ latestFileChanges = new Map<number, Readonly<Record<string, string>> | undefined>();
133
+ for (const draft of drafts) {
134
+ const status = await adapters.getRunStatus(draft, repository);
135
+ // A prior final cannot terminate a new active run on the same draft.
136
+ const active = status === 'running' || status === 'probable-running';
137
+ const final = active ? false : await adapters.hasFinalRunResult?.(draft, repository);
138
+ finality.set(draft.number, final);
139
+ statuses.set(draft.number, final ? 'self-implement.result final' : status);
140
+ if (draft.changedFiles?.length && adapters.getLatestFileChanges) {
141
+ latestFileChanges.set(draft.number, await adapters.getLatestFileChanges(draft, repository));
142
+ }
143
+ }
126
144
  } catch (error) {
127
145
  result.error = String(error);
128
146
  return finish();
@@ -132,8 +150,10 @@ export async function runDraftSweep({ repository, adapters, apply = false, now =
132
150
  let unobserved = 0;
133
151
  let claimed = 0;
134
152
  let claimExpired = 0;
135
- for (const draft of drafts) {
153
+ for (const listedDraft of drafts) {
154
+ const draft = { ...listedDraft, latestFileChanges: latestFileChanges.get(listedDraft.number) };
136
155
  const runStatus = statuses.get(draft.number);
156
+ const finalRunResult = finality.get(draft.number);
137
157
  const states = stateLabels.filter((state) => draft.labels.includes(state.name));
138
158
  const contradiction = states.length > 1 || draft.labels.includes(approvalLabel ?? '');
139
159
  if (draft.labels.includes(releaseHoldLabel ?? '') || (!contradiction && draft.labels.includes(keepLabel ?? ''))) {
@@ -147,8 +167,14 @@ export async function runDraftSweep({ repository, adapters, apply = false, now =
147
167
  ? (now.getTime() - Date.parse(draft.updatedAt)) / 3_600_000 : NaN;
148
168
  // An unobserved claim may still have a merged twin: liveness/holds retain their priority,
149
169
  // but a superseded draft must not be hidden by the six-hour claim window.
150
- const mergedDecision = isClaimed ? decideDraft({ draft, runStatus, mergedTwins: merged, liveBranches, ageHours: idleHours }) : undefined;
151
- const supersededClaim = mergedDecision?.reason.startsWith('superseded-by #') === true;
170
+ const mergedDecision = isClaimed ? decideDraft({ draft, runStatus, mergedTwins: merged, openDrafts: drafts, liveBranches,
171
+ finalRunResult, ageHours: idleHours }) : undefined;
172
+ const supersededClaim = mergedDecision?.reason.startsWith('superseded-by #') === true
173
+ || mergedDecision?.reason.startsWith('duplicate-of-open #') === true;
174
+ if (!contradiction && mergedDecision?.reason === 'branch-finality-unobserved') {
175
+ record({ number: draft.number, action: 'keep', reason: mergedDecision.reason, applied: false });
176
+ continue;
177
+ }
152
178
  // Without an observed update time, neither expiration nor a fallback to creation time can authorize mutation —
153
179
  // but a merged twin does not need the update time, so superseded keeps its priority over the unobserved claim.
154
180
  if (isClaimed && !supersededClaim && (typeof draft.updatedAt !== 'string' || !Number.isFinite(Date.parse(draft.updatedAt)))) {
@@ -162,13 +188,21 @@ export async function runDraftSweep({ repository, adapters, apply = false, now =
162
188
  }
163
189
  if (isClaimed && !supersededClaim && Number.isFinite(idleHours) && idleHours >= CLAIM_IDLE_HOURS) {
164
190
  claimExpired += 1;
165
- if (runStatus === 'running' || runStatus === 'probable-running' || liveBranches.has(draft.branch)) {
191
+ if (liveBranches.has(draft.branch) && finalRunResult === undefined
192
+ && runStatus !== 'running' && runStatus !== 'probable-running') {
193
+ record({ number: draft.number, action: 'keep', reason: 'branch-finality-unobserved', applied: false });
194
+ continue;
195
+ }
196
+ if (runStatus === 'running' || runStatus === 'probable-running' ||
197
+ (runStatus !== 'self-implement.result final' && liveBranches.has(draft.branch))) {
166
198
  record({ number: draft.number, action: 'keep', reason: 'claim-expired-but-live', applied: false });
167
199
  continue;
168
200
  }
169
201
  const action = closes >= DRAFT_SWEEP_CLOSE_CAP ? 'keep' : 'close';
170
202
  if (action === 'close') closes += 1;
171
- const entry: DraftSweepEntry = { number: draft.number, action, reason: action === 'close' ? 'claim-expired' : 'close-cap',
203
+ const entry: DraftSweepEntry = { number: draft.number, action,
204
+ reason: action === 'close' ? runStatus === 'self-implement.result final'
205
+ ? 'claim-expired (self-implement.result final; worktree is not live)' : 'claim-expired' : 'close-cap',
172
206
  ...(action === 'close' ? { statusLabel: stalledLabel } : {}), applied: false };
173
207
  record(entry);
174
208
  if (!apply || action !== 'close') continue;
@@ -178,7 +212,7 @@ export async function runDraftSweep({ repository, adapters, apply = false, now =
178
212
  await adapters.setLabels(repository, draft.number, { add: stalledLabel, remove: [runningLabel] });
179
213
  labelsChanged = true;
180
214
  await adapters.closeDraft(repository, draft.number,
181
- `Draft sweep: 처리 중 표식이 ${CLAIM_IDLE_HOURS}시간 갱신 없음 — 런·워크트리 없음 · 주인 ${owner}. Closed as stalled. Branch preserved; reopen to restore.`);
215
+ `Draft sweep: 처리 중 표식이 ${CLAIM_IDLE_HOURS}시간 갱신 없음 — ${runStatus === 'self-implement.result final' ? 'self-implement.result final · 남은 워크트리는 live 아님' : '런·워크트리 없음'} · 주인 ${owner}. Closed as stalled. Branch preserved; reopen to restore.`);
182
216
  entry.applied = true;
183
217
  } catch (error) {
184
218
  if (labelsChanged) entry.partialApplied = true;
@@ -191,8 +225,8 @@ export async function runDraftSweep({ repository, adapters, apply = false, now =
191
225
  record({ number: draft.number, action: 'keep', reason: 'outside-harness', applied: false });
192
226
  continue;
193
227
  }
194
- let decision = supersededClaim ? mergedDecision! : decideDraft({ draft, runStatus, mergedTwins: merged, liveBranches,
195
- ageHours: runStatus ? ageHours : idleHours });
228
+ let decision = supersededClaim ? mergedDecision! : decideDraft({ draft, runStatus, mergedTwins: merged, openDrafts: drafts, liveBranches,
229
+ finalRunResult, ageHours: runStatus ? ageHours : idleHours });
196
230
  if (!runStatus && (decision.reason === 'unobserved' || decision.reason === 'stale-unobserved')) unobserved += 1;
197
231
  if (!contradiction && decision.action === 'close') {
198
232
  if (closes >= DRAFT_SWEEP_CLOSE_CAP) decision = { action: 'keep', reason: 'close-cap' };
@@ -204,7 +238,7 @@ export async function runDraftSweep({ repository, adapters, apply = false, now =
204
238
  : decision.reason;
205
239
  const isLive = decision.reason === 'live';
206
240
  const target = action === 'report' ? undefined : action === 'close'
207
- ? decision.reason.startsWith('superseded-by #') ? supersededLabel : stalledLabel
241
+ ? decision.reason.startsWith('superseded-by #') || decision.reason.startsWith('duplicate-of-open #') ? supersededLabel : stalledLabel
208
242
  : isLive ? (states.length ? runningLabel : undefined) : runStatus && (states[0]?.name === runningLabel || states.length === 0) ? stalledLabel : undefined;
209
243
  const entry: DraftSweepEntry = { number: draft.number, action, reason, ...(target ? { statusLabel: target } : {}), applied: false };
210
244
  record(entry);
@@ -6,14 +6,25 @@ export interface DraftTriagePr {
6
6
  title?: string;
7
7
  branch: string;
8
8
  labels?: readonly string[];
9
+ body?: string;
10
+ mergeCommitMessage?: string;
11
+ landingVerifiedComments?: readonly string[];
12
+ createdAt?: string;
13
+ mergedAt?: string;
14
+ changedFiles?: readonly string[];
15
+ /** Latest change to each draft file, from the draft branch's commit history. Missing is not creation time. */
16
+ latestFileChanges?: Readonly<Record<string, string>>;
9
17
  }
10
18
 
11
19
  export interface DraftTriageInput {
12
20
  draft: DraftTriagePr;
13
21
  runStatus: string | undefined;
14
22
  mergedTwins: readonly DraftTriagePr[];
23
+ openDrafts?: readonly DraftTriagePr[];
15
24
  ageHours: number;
16
25
  liveBranches: ReadonlySet<string>;
26
+ /** false only when the current run's final result has actually been checked and ruled out. */
27
+ finalRunResult?: boolean;
17
28
  }
18
29
 
19
30
  export type DraftDecision = { action: 'close' | 'keep'; reason: string };
@@ -25,11 +36,22 @@ const PROTECTED_LABELS: ReadonlySet<string> = new Set<string>([...PR_LABELS
25
36
  .map((label) => label.name), RELEASE_PATH_LABEL]);
26
37
 
27
38
  const goalId = (branch: string): string | undefined => /(?:^|[-/])goalid-([a-f0-9]+)(?=-|\/|$)/i.exec(branch)?.[1]?.toLowerCase();
39
+ export const branchStem = (branch: string): string | undefined => /^self-impl\/(.+)-[a-f0-9]{8}-r[a-f0-9]+$/i.exec(branch)?.[1];
40
+ const slot = (body?: string): string | undefined => /^칸:\s*(.+?)\s*$/m.exec(body ?? '')?.[1];
41
+ const referencesDraft = (text: string, number: number): boolean =>
42
+ new RegExp(`(?:\\(수확\\s*#${number}\\s*\\)|\\bsuperseded\\s*#${number}(?!\\d))`, 'i').test(text);
43
+ const landingCommentReferencesDraft = (text: string, number: number): boolean =>
44
+ new RegExp(`\\blanding-verified\\s*:\\s*(?:draft|superseded|수확)\\s*#${number}(?!\\d)\\b`, 'i').test(text);
28
45
 
29
- export function decideDraft({ draft, runStatus, mergedTwins, ageHours, liveBranches }: DraftTriageInput): DraftDecision {
46
+ export function decideDraft({ draft, runStatus, mergedTwins, openDrafts, ageHours, liveBranches, finalRunResult }: DraftTriageInput): DraftDecision {
30
47
  const held = draft.labels?.find((label) => PROTECTED_LABELS.has(label));
31
48
  if (held) return { action: 'keep', reason: `label:${held}` };
32
- if (runStatus === 'running' || runStatus === 'probable-running' || (draft.branch && liveBranches.has(draft.branch))) {
49
+ if (runStatus !== 'self-implement.result final' && draft.branch && liveBranches.has(draft.branch)
50
+ && finalRunResult === undefined && runStatus !== 'running' && runStatus !== 'probable-running') {
51
+ return { action: 'keep', reason: 'branch-finality-unobserved' };
52
+ }
53
+ if (runStatus === 'running' || runStatus === 'probable-running' ||
54
+ (runStatus !== 'self-implement.result final' && draft.branch && liveBranches.has(draft.branch))) {
33
55
  return { action: 'keep', reason: 'live' };
34
56
  }
35
57
  const lineage = goalId(draft.branch);
@@ -37,9 +59,39 @@ export function decideDraft({ draft, runStatus, mergedTwins, ageHours, liveBranc
37
59
  (draft.title && pr.title === draft.title) || (lineage && goalId(pr.branch) === lineage)
38
60
  ));
39
61
  if (twin) return { action: 'close', reason: `superseded-by #${twin.number}` };
62
+ const stem = branchStem(draft.branch);
63
+ const createdAt = Date.parse(draft.createdAt ?? '');
64
+ const newer = stem && draft.title && Number.isFinite(createdAt) ? openDrafts
65
+ ?.filter((pr) => pr.number !== draft.number && pr.title === draft.title && branchStem(pr.branch) === stem
66
+ && Number.isFinite(Date.parse(pr.createdAt ?? '')) && Date.parse(pr.createdAt!) > createdAt)
67
+ .sort((a, b) => Date.parse(b.createdAt!) - Date.parse(a.createdAt!) || b.number - a.number)[0] : undefined;
68
+ if (newer) return { action: 'close', reason: `duplicate-of-open #${newer.number}` };
69
+ const harvest = mergedTwins.find((pr) => pr.number !== draft.number && (
70
+ [pr.mergeCommitMessage, pr.body].some((text) => text && referencesDraft(text, draft.number))
71
+ || pr.landingVerifiedComments?.some((text) => landingCommentReferencesDraft(text, draft.number))
72
+ ));
73
+ if (harvest) return { action: 'close', reason: `superseded-by #${harvest.number} (harvest #${draft.number})` };
74
+ const created = Date.parse(draft.createdAt ?? '');
75
+ const files = draft.changedFiles;
76
+ const draftSlot = slot(draft.body);
77
+ const later = mergedTwins.filter((pr) => pr.number !== draft.number && Number.isFinite(created)
78
+ && Number.isFinite(Date.parse(pr.mergedAt ?? '')) && Date.parse(pr.mergedAt!) > created
79
+ && ((lineage && goalId(pr.branch) === lineage) || (draftSlot && slot(pr.body) === draftSlot)));
80
+ if (files?.length && files.every((file) => {
81
+ const changed = Date.parse(draft.latestFileChanges?.[file] ?? '');
82
+ return Number.isFinite(changed) && changed >= created && later.some((pr) =>
83
+ pr.changedFiles?.includes(file) && Date.parse(pr.mergedAt!) > changed);
84
+ })) {
85
+ const covering = later.filter((pr) => pr.changedFiles?.some((file) => files.includes(file)
86
+ && Date.parse(pr.mergedAt!) > Date.parse(draft.latestFileChanges![file]!)));
87
+ return { action: 'close', reason: `superseded-by #${covering.map((pr) => pr.number).join(', #')} (all-files-landed)` };
88
+ }
40
89
  if (!runStatus) return Number.isFinite(ageHours) && ageHours >= STALLED_DRAFT_HOURS
41
90
  ? { action: 'close', reason: 'stale-unobserved' }
42
91
  : { action: 'keep', reason: 'unobserved' };
92
+ if (runStatus === 'self-implement.result final') return Number.isFinite(ageHours) && ageHours >= STALLED_DRAFT_HOURS
93
+ ? { action: 'close', reason: 'stale-ended-run (self-implement.result final; worktree is not live)' }
94
+ : { action: 'keep', reason: 'recent (self-implement.result final; worktree is not live)' };
43
95
  if (Number.isFinite(ageHours) && ageHours >= STALLED_DRAFT_HOURS) return { action: 'close', reason: 'stale-ended-run' };
44
96
  return { action: 'keep', reason: 'recent' };
45
97
  }
@@ -101,21 +101,27 @@ function codexCandidateUsable(candidate: BudgetCodexCandidate, cap: number | und
101
101
  function slotUsable(
102
102
  provider: string,
103
103
  input: DecideBudgetInput,
104
- ): { usable: boolean; reason: string } {
104
+ allowUnmeasured = false,
105
+ ): { usable: boolean; reason: string; unmeasured?: boolean } {
105
106
  if (provider === CODEX_PROVIDER) {
106
107
  const cap = capFor(input.maxUsedPercent, CODEX_PROVIDER);
107
- const usable = input.codexCandidates.some((candidate) => codexCandidateUsable(candidate, cap));
108
- return { usable, reason: formatCodexReason(input.codexCandidates, cap) };
108
+ const measuredUsable = input.codexCandidates.some((candidate) => codexCandidateUsable(candidate, cap));
109
+ const unmeasured = allowUnmeasured && !measuredUsable && (input.codexCandidates.length === 0
110
+ || input.codexCandidates.some((candidate) => candidate.reached !== true
111
+ && (typeof candidate.usedPercent !== 'number' || !Number.isFinite(candidate.usedPercent))));
112
+ return { usable: measuredUsable || unmeasured, reason: formatCodexReason(input.codexCandidates, cap), ...(unmeasured ? { unmeasured: true } : {}) };
109
113
  }
110
114
  if (provider === GROK_PROVIDER) {
111
115
  const cap = capFor(input.maxUsedPercent, GROK_PROVIDER);
112
116
  const known = typeof input.grokUsedPercent === 'number' && Number.isFinite(input.grokUsedPercent);
113
- const usable = cap === undefined
117
+ const unmeasured = allowUnmeasured && !known;
118
+ const usable = unmeasured || (cap === undefined
114
119
  ? known
115
- : known && (input.grokUsedPercent as number) < cap;
120
+ : known && (input.grokUsedPercent as number) < cap);
116
121
  return {
117
122
  usable,
118
123
  reason: formatGrokReason(input.grokUsedPercent, cap),
124
+ ...(unmeasured ? { unmeasured: true } : {}),
119
125
  };
120
126
  }
121
127
  return { usable: false, reason: `${provider}: 예산 판정 없음` };
@@ -127,6 +133,15 @@ function slotUsable(
127
133
  * 아무것도 못 쓰면 onShortfall 이 wait-reset 일 때만 wait-reset, 아니면 stop.
128
134
  */
129
135
  export function decideBudget(input: DecideBudgetInput): BudgetDecision {
136
+ return decideBudgetWithPolicy(input, false).decision;
137
+ }
138
+
139
+ /** Only the harness launch gate treats an unmeasured provider as launchable. */
140
+ export function decideLaunchBudget(input: DecideBudgetInput): { decision: BudgetDecision; unmeasuredProvider?: string } {
141
+ return decideBudgetWithPolicy(input, true);
142
+ }
143
+
144
+ function decideBudgetWithPolicy(input: DecideBudgetInput, allowUnmeasured: boolean): { decision: BudgetDecision; unmeasuredProvider?: string } {
130
145
  const reasons: string[] = [];
131
146
  if (input.preference.chain.length === 0) {
132
147
  reasons.push('chain: 비었음 — tools.selfImplement.childLlm.chain 도 llm.fallbackChain 도 없다');
@@ -134,7 +149,7 @@ export function decideBudget(input: DecideBudgetInput): BudgetDecision {
134
149
  let firstUnusable = false;
135
150
  for (let index = 0; index < input.preference.chain.length; index++) {
136
151
  const slot = input.preference.chain[index]!;
137
- const judged = slotUsable(slot.provider, input);
152
+ const judged = slotUsable(slot.provider, input, allowUnmeasured);
138
153
  reasons.push(judged.reason);
139
154
  if (!judged.usable) {
140
155
  if (index === 0) firstUnusable = true;
@@ -142,16 +157,18 @@ export function decideBudget(input: DecideBudgetInput): BudgetDecision {
142
157
  }
143
158
  const action: BudgetAction = index === 0 || !firstUnusable ? 'proceed' : 'next-provider';
144
159
  return {
145
- action,
146
- provider: slot.provider,
147
- ...(slot.model !== undefined ? { model: slot.model } : {}),
148
- reasons,
160
+ decision: {
161
+ action,
162
+ provider: slot.provider,
163
+ ...(slot.model !== undefined ? { model: slot.model } : {}),
164
+ reasons,
165
+ },
166
+ ...(judged.unmeasured ? { unmeasuredProvider: slot.provider } : {}),
149
167
  };
150
168
  }
151
169
  const onShortfall = input.preference.budgetGate.onShortfall;
152
170
  return {
153
- action: onShortfall === 'wait-reset' ? 'wait-reset' : 'stop',
154
- reasons,
171
+ decision: { action: onShortfall === 'wait-reset' ? 'wait-reset' : 'stop', reasons },
155
172
  };
156
173
  }
157
174
 
@@ -0,0 +1,60 @@
1
+ import type { GateCallerReason } from './gate-scope.js';
2
+
3
+ export interface GateCallerCandidate {
4
+ readonly file: string;
5
+ readonly reasons: readonly GateCallerReason[];
6
+ }
7
+
8
+ /** Path segments between `file` and the nearest changed path's directory (0 = same directory). */
9
+ function directoryDistance(file: string, changedPaths: readonly string[]): number {
10
+ const dir = file.split('/').slice(0, -1);
11
+ let best = Infinity;
12
+ for (const changed of changedPaths) {
13
+ const other = changed.split('/').slice(0, -1);
14
+ let shared = 0;
15
+ while (shared < dir.length && shared < other.length && dir[shared] === other[shared]) shared += 1;
16
+ best = Math.min(best, dir.length - shared + other.length - shared);
17
+ }
18
+ return best;
19
+ }
20
+
21
+ /**
22
+ * Rank gate caller tests by closeness to the change, nearest first (GATE-CALLERS2 — the cap keeps the head).
23
+ * A `route` candidate names a route on a changed diff line, so it ranks with touched tests (distance 0);
24
+ * otherwise the distance is the shortest reverse-import path from a changed file (direct importer = 1,
25
+ * unreachable = after every reachable candidate). Equal distances prefer the test nearest a changed file
26
+ * in the directory tree, then numeric-aware path order. Inputs are not modified.
27
+ * `importersByFile` maps each imported file to its immediate importers (tests or sources).
28
+ */
29
+ export function rankGateCallerCandidates<T extends GateCallerCandidate>(
30
+ changedPaths: readonly string[],
31
+ candidates: readonly T[],
32
+ importersByFile: ReadonlyMap<string, readonly string[]>,
33
+ ): T[] {
34
+ const distances = new Map<string, number>();
35
+ const queue: string[] = [];
36
+ for (const path of changedPaths) {
37
+ if (distances.has(path)) continue;
38
+ distances.set(path, 0);
39
+ queue.push(path);
40
+ }
41
+ for (let index = 0; index < queue.length; index += 1) {
42
+ const source = queue[index]!;
43
+ const nextDistance = distances.get(source)! + 1;
44
+ for (const importer of importersByFile.get(source) ?? []) {
45
+ if (distances.has(importer)) continue;
46
+ distances.set(importer, nextDistance);
47
+ queue.push(importer);
48
+ }
49
+ }
50
+ const keys = new Map(candidates.map((candidate) => [candidate, {
51
+ distance: candidate.reasons.includes('route') ? 0 : distances.get(candidate.file) ?? Infinity,
52
+ directory: directoryDistance(candidate.file, changedPaths),
53
+ }]));
54
+ return [...candidates].sort((left, right) => {
55
+ const a = keys.get(left)!;
56
+ const b = keys.get(right)!;
57
+ return (a.distance - b.distance || 0) || (a.directory - b.directory || 0)
58
+ || left.file.localeCompare(right.file, undefined, { numeric: true });
59
+ });
60
+ }
@@ -30,6 +30,7 @@
30
30
 
31
31
  import { importerTestsNotInRunSet, type ImporterTestIndex } from './importer-test-index.js';
32
32
  import type { RouteConsumerTestIndex } from './route-consumer-test-index.js';
33
+ import { rankGateCallerCandidates } from './gate-candidate-ranking.js';
33
34
 
34
35
  export const GATE_CALLER_TEST_LIMIT = 30;
35
36
  export type GateCallerReason = 'import' | 'route';
@@ -249,11 +250,7 @@ interface GateScopeDecision {
249
250
  readonly unverified: readonly string[];
250
251
  /** elanous 자체가 남긴 런타임 산출물. 미검증 사용자 변경에서 제외하되 경로를 보존한다. */
251
252
  readonly elanousRuntimeArtifacts: readonly string[];
252
- /** ⭐ **편집되지 않았는데 함께 끌어와 돌린** 연관 테스트(`changed-tests` 분기 전용).
253
- * ⚠️ 직전 판본은 이 자리에 `unrunRelatedTests`(=실행집합에 없는 연관 테스트)를 뒀는데,
254
- * 실행집합이 연관 테스트를 **전부 포함**하도록 바뀌었으므로 그 값은 **정의상 항상 빈 배열**이었다.
255
- * 빈 배열을 보고 *"안 돈 게 없다"* 고 읽게 되지만 그건 사실 확인이 아니라 계산의 결과였다
256
- * (관측이 사실 아닌 값을 나르는 형태). ⇒ **실제로 일어난 일**을 센다: 합집합이 구제한 파일들. */
253
+ /** ⭐ 편집되지 않았는데 함께 끌어와 돌린 연관 테스트(`changed-tests` 분기) — 합집합이 실제로 구제한 파일들. */
257
254
  readonly pulledInRelatedTests: readonly string[];
258
255
  /** 실행 집합에 없는, 변경 소스를 상대 import 하는 테스트. 색인이 주입되지 않으면 null. */
259
256
  readonly importerTestsNotRun: {
@@ -352,8 +349,9 @@ export function resolveGateScope(
352
349
  }
353
350
  }
354
351
  }
355
- const candidates = [...callers].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))
356
- .map(([file, reasons]) => ({ file, reasons: [...reasons] }));
352
+ // GATE-CALLERS2 — the cap keeps the candidates closest to the change, not the alphabetically first.
353
+ const candidates = rankGateCallerCandidates(userChanged, [...callers].map(([file, reasons]) => ({ file, reasons: [...reasons] })),
354
+ importerTestIndex?.testsBySource ?? new Map());
357
355
  const callerTests = candidates.slice(0, GATE_CALLER_TEST_LIMIT);
358
356
  const callerTestsOverflow = candidates.slice(GATE_CALLER_TEST_LIMIT);
359
357
  const runWithCallers = (tests: readonly string[]) => [...new Set([...tests, ...callerTests.map(({ file }) => file)])];
@@ -364,11 +362,14 @@ export function resolveGateScope(
364
362
  });
365
363
 
366
364
  if (testFiles.length > 0) {
365
+ // Edited tests ⊕ every related test of the changed sources ⊕ capped callers (restored 10-04: #23467 narrowed this
366
+ // to edited tests only, so a co-located sibling of a changed source stopped running — 0.2.12 gate introduced).
367
367
  const allRelatedTests = conventional;
368
368
  const runSet = runWithCallers([...testFiles, ...allRelatedTests]);
369
369
  // 편집된 테스트만 돌렸다면 빠졌을 것들 — 이 목록이 비어있지 않다는 것은 합집합이 실제로 구제했다는 뜻이다.
370
370
  const pulledInRelatedTests = allRelatedTests.filter((f) => !testFiles.includes(f));
371
- return { ...base, ...withDocumentNonContribution(runSet), testArgs: runSet, unverified: unverifiedFor(runSet), pulledInRelatedTests, importerTestsNotRun: observationFor(runSet), skipTestStep: false, reason: 'changed-tests' };
371
+ return { ...base, ...withDocumentNonContribution(runSet), testArgs: runSet, unverified: unverifiedFor(runSet), pulledInRelatedTests,
372
+ importerTestsNotRun: observationFor(runSet), skipTestStep: false, reason: 'changed-tests' };
372
373
  }
373
374
  if (userChanged.length === 0) {
374
375
  // postsync 빈 범위는 기존 no-changes(깨끗함)와 다른 값이다. 일반 호출자는 계속 no-changes를 읽는다.
@@ -1170,11 +1170,16 @@ export interface HeadlessGoalLoopPtyResult {
1170
1170
 
1171
1171
  export function pickImplementRoleForGoal(document: string): ReturnType<typeof resolveRoleLlm> | undefined {
1172
1172
  try {
1173
- const role = resolveRoleLlm('implement');
1173
+ const config = getUserConfig();
1174
+ const role = resolveRoleLlm('implement', { config });
1175
+ if (config.harness?.difficultyPlacement !== true || role.source !== 'default') return role;
1174
1176
  const signals = goalDifficultySignals(document);
1175
1177
  const level = classifyGoalDifficulty(signals);
1176
1178
  const picked = resolveImplementDifficulty(role, level);
1177
- try { debug.log('llm.difficulty', 'pick', { level, tier: picked.tier ?? null, signals }); } catch { /* observation is fail-soft */ }
1179
+ try {
1180
+ debug.log('llm.difficulty', 'pick', { level, tier: picked.tier ?? null, signals });
1181
+ debug.log('harness.role-llm', 'difficulty-placement', { difficulty: level, tier: picked.tier, source: role.source });
1182
+ } catch { /* observation is fail-soft */ }
1178
1183
  return picked;
1179
1184
  } catch { return undefined; }
1180
1185
  }
@@ -63,6 +63,7 @@ import { GoalRunStore, insertGoalRunRecord, type GoalPriorRuns } from './goal-ru
63
63
  import { writeGoalRunRecordFragment } from './goal-execution-records.js';
64
64
  import { getUserConfig } from '../user-config.js';
65
65
  import { appendRunLedgerEntry, loadRunLedger, parseRunShardIdentity, queryRunChain, type RunChainShardSibling, type RunLedgerEntry, type RunLedgerWriter, type RunOriginData, type RunShardIdentity } from './run-ledger.js';
66
+ import { emitSessionEvent, type SessionEventInput } from '../context-bus/session-events.js';
66
67
  import { decideLineageSupersede, lineageSupersedeCloseComment, type LineageSupersedeOpenDraft } from './lineage-supersede.js';
67
68
  import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
68
69
  import { enqueueControlMemo, readSoftStopRequestStatus, type SoftStopRequestRead } from '../harness/control-inbox.js';
@@ -874,6 +875,8 @@ export type ImplementTerminalStatus = {
874
875
  export interface SelfImplementSeams {
875
876
  /** Run-observer ledger sink. Tests inject an in-memory sink to avoid touching user state. */
876
877
  writeRunLedger?: RunLedgerWriter;
878
+ /** Optional context-journal sink for isolated runs. */
879
+ emitContextEvent?: (input: SessionEventInput) => void;
877
880
  /** Registers this run as an ephemeral loop-agent asset; failures are observed but never affect execution. */
878
881
  registerLoopAgent?: (input: LoopAgentInput) => void;
879
882
  /** Wraps implementation and gate promises with their effective wall-clock timeout; tests observe the actual stage budgets. */
@@ -3384,6 +3387,16 @@ export async function runSelfImplement(opts: SelfImplementOptions): Promise<Self
3384
3387
  detail: 'observe-only: SelfImplement execution skipped before child boot',
3385
3388
  };
3386
3389
  }
3390
+ const emitContext = (kind: 'task-claimed' | 'task-done', text: string, pr?: number): void => {
3391
+ try {
3392
+ (opts.seams.emitContextEvent ?? emitSessionEvent)({ kind, seat: 'TC', text,
3393
+ ref: `runId=${runId}${pr === undefined ? '' : ` PR=#${pr}`}` });
3394
+ } catch (error) {
3395
+ try { logRunAwareFailSoft(runId, 'context-event-failed', { kind, error: safeErrorDescription(error) }); }
3396
+ catch { /* context publication never changes the run result */ }
3397
+ }
3398
+ };
3399
+ emitContext('task-claimed', `Self-implement claimed: ${opts.feature.split(/\r?\n/, 1)[0]}`);
3387
3400
  const parentHarnessSpaceId = getHarnessSpace()?.id || undefined;
3388
3401
  const configRoot = getElanousConfigDir() || undefined;
3389
3402
  const stateRoot = elanousStateRoot() || undefined;
@@ -4091,6 +4104,8 @@ export async function runSelfImplement(opts: SelfImplementOptions): Promise<Self
4091
4104
  }, { level: 'warn' });
4092
4105
  } catch { /* fail-soft — 종결 기록 실패가 원래 예외를 덮으면 안 된다 */ }
4093
4106
  }
4107
+ emitContext('task-done', `Self-implement ${terminalResult?.stage ?? 'crashed'}: ${opts.feature.split(/\r?\n/, 1)[0]}`,
4108
+ terminalResult?.prNumber ?? capturedPrNumber);
4094
4109
  finalizeTraversal();
4095
4110
  try { unregisterProviderErrorSink(); } catch { /* fail-soft — cleanup must not change the terminal result */ }
4096
4111
  }
@@ -21,6 +21,7 @@ export function seams(over: Partial<SelfImplementSeams> & { gateResults?: boolea
21
21
  // ⭐ 실제 경로는 그것을 «의도한» 전용 테스트에서만 탄다(그 테스트가 심을 명시로 준다).
22
22
  refreshCodexQuotaSignals: async () => ({ accounts: [] }),
23
23
  writeRunLedger: () => {},
24
+ emitContextEvent: () => {},
24
25
  registerLoopAgent: () => {},
25
26
  createWorktree: async ({ branch, base }) => ({ path: `/wt/${branch}`, branch, base, resolvedBase: 'a'.repeat(40), invokedHead: 'a'.repeat(40) }),
26
27
  implement: async ({ feature }) => { features.push(feature); return { ok: true, summary: 'impl' }; },
@@ -304,13 +304,15 @@ export async function runPodCommand(options: RunPodCommandOptions): Promise<PodC
304
304
 
305
305
  let member: PodPoolMember | null = null;
306
306
  let pool = options.poolScheduler;
307
- if (!pool && options.pool) {
308
- const spec = resolvePodPoolSpec(options.pool, env);
309
- if (!spec) throw new Error('pod command: 풀 스펙이 비었다');
310
- const members = parsePodPool(spec);
311
- const checked = (options.checkPool ?? checkPodPool)(members, baseKubectl as PoolKubectl);
312
- if (!checked.ok) throw new Error('pod command: 풀의 노드가 하나도 준비되지 않았다');
313
- pool = new PodPoolScheduler(checked.ready);
307
+ if (!pool) {
308
+ const spec = resolvePodPoolSpec(options.pool, env, () => options.kubectl ? undefined : getUserConfig().pod?.pool);
309
+ if (options.pool && !spec) throw new Error('pod command: 풀 스펙이 비었다');
310
+ if (spec) {
311
+ const members = parsePodPool(spec);
312
+ const checked = (options.checkPool ?? checkPodPool)(members, baseKubectl as PoolKubectl);
313
+ if (!checked.ok) throw new Error('pod command: 풀의 노드가 하나도 준비되지 않았다');
314
+ pool = new PodPoolScheduler(checked.ready);
315
+ }
314
316
  }
315
317
  if (pool) {
316
318
  for (;;) {
@@ -357,6 +359,8 @@ export async function runPodCommand(options: RunPodCommandOptions): Promise<PodC
357
359
  if (synced && !synced.ok) throw new Error(`pod command: 이미지 판을 못 맞췄다: ${synced.detail}`);
358
360
  if (synced?.imageRef) member = { ...member, imageRef: synced.imageRef };
359
361
  }
362
+ const imageRef = member?.imageRef;
363
+ if (!imageRef) log('pod.command-job', 'image-fallback-local', { reason: !member ? 'no-pool-member' : !imageCommit ? 'image-commit-unavailable' : 'registry-tag-unavailable' });
360
364
  const ghToken = options.clone ? (options.ghToken ?? defaultGhToken)() : undefined;
361
365
  const secret = commandJobSecret({ name, namespace, launch, skills, skillEnvText, ...(grokAuth !== undefined ? { grokAuth } : {}), ...(ghToken !== undefined ? { ghToken } : {}) });
362
366
  if (secret) {
@@ -364,8 +368,8 @@ export async function runPodCommand(options: RunPodCommandOptions): Promise<PodC
364
368
  if (applied.status !== 0) throw new Error(applied.stderr.trim() || 'pod command: Secret 적용 실패');
365
369
  }
366
370
  const manifest = podCommandJobManifest({
367
- name, namespace, launch, image: member?.imageRef ?? image,
368
- ...(member?.imageRef ? { imagePullPolicy: 'IfNotPresent' as const } : {}),
371
+ name, namespace, launch, image: imageRef ?? image,
372
+ ...(imageRef ? { imagePullPolicy: 'IfNotPresent' as const } : {}),
369
373
  repoUrl, command, skills, ...(llm ? { llm } : {}), ...(options.clone ? { clone: true } : {}),
370
374
  ...(options.source ? { source: options.source } : {}), ...(hostMirror ? { hostMirror } : {}), ...(bunCache ? { bunCache } : {}), ...(memoryLimit ? { memoryLimit } : {}), deadlineSeconds,
371
375
  ...(options.runId ? { runId: options.runId } : {}),
@@ -398,7 +402,7 @@ export async function runPodCommand(options: RunPodCommandOptions): Promise<PodC
398
402
  llm: llm ?? null,
399
403
  command: command.join(' ').slice(0, COMMAND_LOG_CHARS),
400
404
  });
401
- return { exitCode, artifactsDir, job: name, image: member?.imageRef ?? image };
405
+ return { exitCode, artifactsDir, job: name, image: imageRef ?? image };
402
406
  } catch (err) {
403
407
  cleanupSecret();
404
408
  throw err;
@@ -436,6 +436,12 @@ export function goalTouchesPwa(feature: string): boolean {
436
436
  return PWA_PATH.test(feature);
437
437
  }
438
438
 
439
+ /** A launch-time warning, not a memory-tier override or a test-command parser. */
440
+ export function broadPodTestWarning(goal: string): string | null {
441
+ const broad = /(?:전체\s*(?:시험|테스트)|(?:full|whole|entire|all)\s+(?:test|suite)|(?:전체|모든)\s*(?:test|suite)|\bbun\s+test\s+(?:[\w./-]+\/|[\w./-]+(?<!\.[cm]?[jt]sx?))(?:\s|$)|\bbun\s+test\s*(?:$|[;&|]))/imu.test(goal);
442
+ return broad ? '[pod] 넓은 시험 골 경고: 10-04 W10b 32Gi OOM — 넓은 bun test 한 프로세스가 16GB+ 사용; 바꾼 시험 파일만 지정해 실행하세요.' : null;
443
+ }
444
+
439
445
  export function parsePodMemoryTier(v: string | undefined): PodMemoryTier | null {
440
446
  const t = v?.trim().toLowerCase();
441
447
  return t && (POD_MEMORY_TIERS as readonly string[]).includes(t) ? (t as PodMemoryTier) : null;
@@ -660,6 +666,8 @@ export function podSelfImplementSpawn(options: PodSpawnOptions = {}): SelfImplem
660
666
  debug.log('self-implement.pod', 'memory-directive-ignored', { spaceId: input.spaceId, where, reason }, { level: 'warn' });
661
667
  }
662
668
  }
669
+ const broadTestWarning = broadPodTestWarning(`${input.feature}\n${parentGoal ?? ''}`);
670
+ if (broadTestWarning) console.warn(broadTestWarning);
663
671
  let memoryLimit = selectedMemoryLimit;
664
672
  const after = input.after ?? podPredecessorFor(input.feature, env, parentGoal);
665
673
  if (after !== undefined) debug.log('self-implement.pod', 'predecessor', { spaceId: input.spaceId, after, source: input.after !== undefined ? 'input' : 'goal-or-option' });
@@ -46,6 +46,9 @@ import { classifyIntake } from './ad-pipeline/intake.js';
46
46
  import { createAdPipelineDeps, runAdPipeline } from './ad-pipeline/run.js';
47
47
  import { projectCommand } from './telegram-project-command.js';
48
48
  import { telegramNowSlash } from './context-bus/context-now-surfaces.js';
49
+ import { createWishCard } from './intake-plane/wish-card.js';
50
+ import { telegramDecisionOwner } from './decisions/telegram-decision-cards.js';
51
+ import { CardStore } from './task-cards/card-store.js';
49
52
  import type { ContextNowDeps } from './context-bus/context-now.js';
50
53
 
51
54
  /** How many prior user/assistant turns to pass into `executeSkill` as
@@ -155,6 +158,7 @@ export interface TgCommandContext {
155
158
  userConfig: UserConfig;
156
159
  /** All registered commands — used by `/help` to enumerate. */
157
160
  allCommands: TgSlashCommand[];
161
+ sendButtons?: (text: string, buttons: Array<Array<{ text: string; data: string }>>) => Promise<void>;
158
162
  /** When the caller posted a placeholder for a streaming command,
159
163
  * the streamer edits it in-place as the handler accumulates
160
164
  * output. Undefined for instant commands. Handlers must tolerate
@@ -590,6 +594,23 @@ export function defaultTelegramCommands(nowDeps?: ContextNowDeps): TgSlashComman
590
594
  },
591
595
  },
592
596
  projectCommand(),
597
+ {
598
+ name: 'wish',
599
+ description: '소원을 카드로 남기기',
600
+ handler: async (args, ctx, { userConfig }) => {
601
+ if (String(ctx.userId) !== telegramDecisionOwner(userConfig) || ctx.chatId !== ctx.userId || !ctx.isDm) {
602
+ return '대표만 쓸 수 있습니다';
603
+ }
604
+ if (args.length === 0) return '/wish <소원 한 줄>';
605
+ const store = new CardStore();
606
+ try {
607
+ const card = createWishCard({ text: args.join(' '), source: 'telegram', ref: `${ctx.chatId}:${ctx.messageId}` }, store);
608
+ return `소원 카드로 남겼습니다 — ${card.title} (카드 ${card.cardId.slice(0, 8)})`;
609
+ } finally {
610
+ store.close();
611
+ }
612
+ },
613
+ },
593
614
  {
594
615
  name: 'fork',
595
616
  description: 'Fork the current chat session (or /fork <prefix>) and continue HERE on the fork · time-travel: /fork before:N',