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
@@ -32,7 +32,9 @@ import { requirePosixShellCommand } from '../../platform/default-shell.js';
32
32
  import { spawn } from 'child_process';
33
33
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
34
34
  import { join } from 'path';
35
+ import { debug } from '../../debug/log.js';
35
36
  import { elanousStateRoot } from '../../autopilot/state-paths.js';
37
+ import { getWorkflowHistoryVersion, listWorkflowHistory, snapshotBeforeSave } from './workflow-history.js';
36
38
  import {
37
39
  deleteWorkflow,
38
40
  discoverWorkflows,
@@ -232,6 +234,45 @@ export function handleWorkflowGet(
232
234
  );
233
235
  }
234
236
 
237
+ export function handleWorkflowHistoryList(
238
+ req: Request,
239
+ name: string,
240
+ opts: MetaApiOpts,
241
+ ): Response {
242
+ if (!checkAuth(req, opts)) return jsonResponse({ error: 'unauthorized' }, 401);
243
+ if (req.method !== 'GET') return jsonResponse({ error: 'method-not-allowed', method: req.method }, 405);
244
+ const scope = new URL(req.url).searchParams.get('scope') === 'global' ? 'global' : 'project';
245
+ try {
246
+ return jsonResponse({ versions: listWorkflowHistory(name, { scope }) }, 200);
247
+ } catch (err) {
248
+ if (err instanceof Error && err.message === 'invalid workflow name') {
249
+ return jsonResponse({ error: 'bad_request', reason: err.message }, 400);
250
+ }
251
+ throw err;
252
+ }
253
+ }
254
+
255
+ export function handleWorkflowHistoryVersionGet(
256
+ req: Request,
257
+ name: string,
258
+ id: string,
259
+ opts: MetaApiOpts,
260
+ ): Response {
261
+ if (!checkAuth(req, opts)) return jsonResponse({ error: 'unauthorized' }, 401);
262
+ if (req.method !== 'GET') return jsonResponse({ error: 'method-not-allowed', method: req.method }, 405);
263
+ const scope = new URL(req.url).searchParams.get('scope') === 'global' ? 'global' : 'project';
264
+ try {
265
+ const yaml = getWorkflowHistoryVersion(name, id, { scope });
266
+ if (yaml === null) return jsonResponse({ error: 'not_found', name, id }, 404);
267
+ return jsonResponse({ yaml }, 200);
268
+ } catch (err) {
269
+ if (err instanceof Error && (err.message === 'invalid workflow name' || err.message === 'invalid workflow history version id')) {
270
+ return jsonResponse({ error: 'bad_request', reason: err.message }, 400);
271
+ }
272
+ throw err;
273
+ }
274
+ }
275
+
235
276
  export async function handleWorkflowPut(
236
277
  req: Request,
237
278
  name: string,
@@ -248,6 +289,17 @@ export async function handleWorkflowPut(
248
289
  return jsonResponse({ error: 'bad_request', reason: '`yaml` must be a string' }, 400);
249
290
  }
250
291
  const scope = scopeRaw === 'global' ? 'global' : 'project';
292
+ const parsed = parseWorkflowYaml(yamlText);
293
+ if (parsed.ok && parsed.workflow?.name === name) {
294
+ // Capture the existing file before saveWorkflow overwrites it; a failed snapshot is non-fatal.
295
+ try {
296
+ snapshotBeforeSave(name, { scope });
297
+ } catch (err) {
298
+ debug.log('nexus.workflow-history', 'snapshot-failed', {
299
+ name, scope, error: err instanceof Error ? err.message : String(err),
300
+ }, { level: 'warn' });
301
+ }
302
+ }
251
303
  const r = saveWorkflow(name, yamlText, { scope });
252
304
  if (!r.ok) {
253
305
  return jsonResponse(
@@ -52,20 +52,29 @@ export function deliveryDedupKey(kind: string, text: string): string {
52
52
  return createHash('sha1').update(`${kind}\n${norm}`).digest('hex').slice(0, 16);
53
53
  }
54
54
 
55
- /** ① 중복 발사 제어 — 같은 dedupKey가 windowSec 내에 이미 발송됐나(재팬아웃 억제).
56
- * 보수적 짧은 창(기본 120s) — 크론 재시도·이중호출 같은 즉시 중복만 잡고,
57
- * 시간 지난 정당한 재발송은 통과. (의미 중복은 상류 signal-dedup 6h 담당.) */
58
- export function recentlyDelivered(
55
+ /** ① 중복 발사 제어 — windowSec 내 동일 메시지가 한 채널 이상 성공했으면 그 messageId.
56
+ * 실패 기록은 재시도를 막지 않는다. 짧은 창(기본 120s) 밖의 재발송은 통과. */
57
+ export function successfulDeliveryId(
59
58
  db: Database, dedupKey: string, windowSec = 120, nowMs: number = Date.now(),
60
- ): boolean {
59
+ ): string | null {
61
60
  // ⚠️ 축 ②(JS 임계 · db-window 참조) — 같은 파일의 `recentDeliveryCount` 와 동형이다.
62
61
  // SQL 안의 `datetime('now')` 를 쓰면 **시계를 주입할 수 없어** 테스트가 실제 시각에
63
62
  // 매달린다. 실제로 이 함수의 테스트가 고정 날짜로 seed 한 채 시간이 흘러 어느 날 빨개진
64
63
  // 시한폭탄이었다. `deliveries.ts` 는 ISO 로만 쓰므로 raw 비교가 옳고 인덱스도 산다.
65
64
  const row = db.prepare(
66
- `SELECT 1 FROM deliveries WHERE dedup_key = ? AND ts >= ? LIMIT 1`,
67
- ).get(dedupKey, sinceTs(SECONDS(windowSec), nowMs));
68
- return !!row;
65
+ `SELECT message_id FROM deliveries
66
+ WHERE dedup_key = ? AND ts >= ?
67
+ AND EXISTS (SELECT 1 FROM json_each(deliveries.channels) WHERE json_extract(value, '$.ok') = 1)
68
+ ORDER BY ts DESC LIMIT 1`,
69
+ ).get(dedupKey, sinceTs(SECONDS(windowSec), nowMs)) as { message_id: string } | null;
70
+ return row?.message_id ?? null;
71
+ }
72
+
73
+ /** 기존 boolean 호출부 호환 — 성공 배송 기록만 중복으로 센다. */
74
+ export function recentlyDelivered(
75
+ db: Database, dedupKey: string, windowSec = 120, nowMs: number = Date.now(),
76
+ ): boolean {
77
+ return successfulDeliveryId(db, dedupKey, windowSec, nowMs) !== null;
69
78
  }
70
79
 
71
80
  /** 최근 창(windowSec) 내 총 발송 수 — 버스트(밀림) 판정용. 전 kind 합산.
@@ -27,13 +27,14 @@
27
27
  // that boolean before falling back to direct Telegram).
28
28
 
29
29
  import { createHash } from 'node:crypto';
30
+ import { debug } from '../../debug/log.js';
30
31
  import { sendTelegramReport } from '../../telegram-report.js';
31
32
  import { getPushcutClient } from '../../pushcut/client.js';
32
33
  import type { UserConfig } from '../../user-config.js';
33
34
  import { formatForChannel, type OutboundChannelType, type OutboundMsg } from './format.js';
34
35
  import { spillLongContent } from '../../storage/content-spill.js';
35
36
  import {
36
- openDeliveryDb, deliveryDedupKey, recentlyDelivered, recordDelivery, type ChannelDelivery,
37
+ openDeliveryDb, deliveryDedupKey, successfulDeliveryId, recordDelivery, type ChannelDelivery,
37
38
  } from './delivery-ledger.js';
38
39
 
39
40
  // 채널 타입·메시지·분할기는 format.ts가 단일 출처 — 기존 소비처(test 등) 호환 재export.
@@ -71,6 +72,8 @@ export interface RouteResult {
71
72
  channels: ChannelResult[];
72
73
  /** 중복 발사 제어(dedup)로 재팬아웃이 억제됨 — 최근 동일 발송 존재. */
73
74
  suppressed?: boolean;
75
+ /** 억제 근거가 된 앞선 성공 발송의 식별자. */
76
+ suppressedBy?: string;
74
77
  /** 이 발송의 식별자(리드 동기화·회상 교차참조용). */
75
78
  messageId?: string;
76
79
  }
@@ -207,9 +210,11 @@ export async function routeOutbound(cfg: UserConfig, msg: OutboundMsg, deps: Rou
207
210
  // ① 중복 발사 제어 — 최근(120s) 동일 발송이면 재팬아웃 억제.
208
211
  if (ledger && deps.dedup !== false) {
209
212
  try {
210
- if (recentlyDelivered(ledger, dedupKey)) {
213
+ const suppressedBy = successfulDeliveryId(ledger, dedupKey);
214
+ if (suppressedBy) {
211
215
  if (ownLedger) ledger.close();
212
- return { delivered: true, channels: [], suppressed: true, messageId };
216
+ try { debug.log('outbound.send', 'suppressed', { kind: msg.kind, suppressedBy }); } catch { /* 관측 실패는 발송 결과에 영향 없음 */ }
217
+ return { delivered: true, channels: [], suppressed: true, suppressedBy, messageId };
213
218
  }
214
219
  } catch { /* 원장 조회 실패 — 억제 없이 진행 */ }
215
220
  }
@@ -111,11 +111,18 @@ function mapping(value: unknown): value is Record<string, unknown> {
111
111
  return value !== null && typeof value === 'object' && !Array.isArray(value);
112
112
  }
113
113
 
114
+ export class UnknownPersonaSchemaKeyError extends Error {
115
+ constructor(key: string) {
116
+ super(`unknown persona schema key: ${key}`);
117
+ this.name = 'UnknownPersonaSchemaKeyError';
118
+ }
119
+ }
120
+
114
121
  function assertSchemaKeys(value: Record<string, unknown>, parent = ''): void {
115
122
  const allowed: ReadonlySet<string> = parent ? new Set(NESTED_KEYS[parent] ?? []) : PROFILE_KEYS;
116
123
  for (const [key, child] of Object.entries(value)) {
117
124
  if (!allowed.has(key)) {
118
- throw new Error(`unknown persona schema key: ${parent ? `${parent}.` : ''}${key}`);
125
+ throw new UnknownPersonaSchemaKeyError(`${parent ? `${parent}.` : ''}${key}`);
119
126
  }
120
127
  const path = parent ? `${parent}.${key}` : key;
121
128
  if (NESTED_KEYS[path] && mapping(child)) assertSchemaKeys(child, path);
@@ -17,7 +17,8 @@ function packageFiles(dir: string, prefix = ''): string[] {
17
17
  const files: string[] = [];
18
18
  for (const entry of readdirSync(join(dir, prefix), { withFileTypes: true })) {
19
19
  const path = prefix ? `${prefix}/${entry.name}` : entry.name;
20
- if (entry.name === '.git' || entry.name === '.elanous' || entry.name === 'node_modules') continue;
20
+ if (entry.name === '.git' || entry.name === '.elanous' || entry.name === '.elanous-local' ||
21
+ entry.name === '.elanous-test' || entry.name === 'graph-runs' || entry.name === 'node_modules') continue;
21
22
  if (entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory())) throw new Error(`unsafe market bundle entry: ${path}`);
22
23
  if (entry.isDirectory()) files.push(...packageFiles(dir, path));
23
24
  else files.push(path);
@@ -29,6 +29,7 @@ export interface ChecklistHistory {
29
29
  released: string;
30
30
  dev: string;
31
31
  force?: true;
32
+ reason?: string;
32
33
  }
33
34
  export interface Checklist {
34
35
  version: string;
@@ -72,13 +73,18 @@ export function listChecklist(v: string, root?: string): Checklist {
72
73
  return decodeClaimHistory(root === undefined ? store.list(v) : store.list(v, undefined, undefined, root));
73
74
  }
74
75
 
76
+ /** Events for this id across every release, ordered by event time (then ledger sequence). */
77
+ export function checklistHistory(id: string): Array<ChecklistHistory & { version: string; seq: number }> {
78
+ return store.history(id).map(decodeClaimHistoryEntry);
79
+ }
80
+
75
81
  /** Status events in the requested window; other ledger fields never become status changes. */
76
82
  export function statusChangesSince(history: readonly ChecklistHistory[], sinceIso: string): ChecklistHistory[] {
77
83
  const since = Date.parse(sinceIso);
78
84
  return history.filter((entry) => entry.field === 'status' && Date.parse(entry.at) >= since);
79
85
  }
80
86
 
81
- function mutate(v: string, apply: (data: Checklist) => boolean): Checklist {
87
+ function mutate(v: string, apply: (data: Checklist, otherItems: (id: string) => store.ChecklistCollision[]) => boolean): Checklist {
82
88
  return decodeClaimHistory(store.mutate(v, store.releasedVersion(), devVersion(), apply));
83
89
  }
84
90
 
@@ -87,13 +93,19 @@ function change(data: Checklist, id: string, field: string, from: unknown, to: u
87
93
  debug.log('release-loop.checklist', 'change', { version: data.version, id, field, from: from ?? null, to: to ?? null, by });
88
94
  }
89
95
 
90
- export function addItem(v: string, input: { id: string; title: string; owner?: string; kind?: ChecklistKind }): Checklist {
96
+ export function addItem(v: string, input: { id: string; title: string; owner?: string; kind?: ChecklistKind }, options: { allowDuplicateId?: boolean } = {}): Checklist {
91
97
  if (!input.id.trim()) throw new CliUserError('칸 id 가 비었다');
92
98
  if (!input.title.trim()) throw new CliUserError('칸 제목이 비었다');
93
99
  if (input.kind !== undefined && input.kind !== 'screen') throw new CliUserError(`잘못된 종류: ${input.kind}`, 'screen');
94
100
  if (input.owner !== undefined) parseOwner(input.owner);
95
- return mutate(v, (data) => {
101
+ return mutate(v, (data, otherItems) => {
96
102
  if (data.items.some((item) => item.id === input.id)) throw new CliUserError(`이미 있는 칸: ${input.id}`, 'set <id> 로 고친다');
103
+ const collisions = otherItems(input.id);
104
+ if (collisions.length) {
105
+ const displayedId = JSON.stringify(input.id).slice(1, -1);
106
+ console.error(`⚠ 중복 id: ${displayedId} — ${collisions.map((item) => `${item.version} · 담당 ${JSON.stringify(item.owner ?? '-').slice(1, -1)} · ${item.title.replace(/\s+/g, ' ').slice(0, 40)}`).join(' / ')}`);
107
+ if (!options.allowDuplicateId) throw new CliUserError(`다른 판에 이미 있는 칸: ${displayedId}`, '--allow-duplicate-id 로 허용');
108
+ }
97
109
  const by = process.env.ELANOUS_TRACK || 'cli';
98
110
  const at = new Date().toISOString();
99
111
  const item: ChecklistItem = { id: input.id, title: input.title, status: 'yellow', ...(input.owner !== undefined ? { owner: input.owner } : {}), ...(input.kind !== undefined ? { kind: input.kind } : {}), updatedAt: at, updatedBy: by };
@@ -50,12 +50,13 @@ function open(root = releaseLedgerRoot()): Database {
50
50
  db.exec('PRAGMA foreign_keys = ON');
51
51
  if (!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'imported_versions'").get()) db.exec(`CREATE TABLE IF NOT EXISTS features (id TEXT PRIMARY KEY, title TEXT NOT NULL, owner TEXT, kind TEXT, created_at TEXT NOT NULL);
52
52
  CREATE TABLE IF NOT EXISTS assignments (feature_id TEXT NOT NULL REFERENCES features(id), version TEXT NOT NULL, status TEXT NOT NULL, disposition TEXT, evidence TEXT, title_override TEXT, owner TEXT, kind TEXT, updated_at TEXT NOT NULL, updated_by TEXT NOT NULL, PRIMARY KEY(feature_id, version));
53
- CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, at TEXT NOT NULL, by TEXT NOT NULL, feature_id TEXT NOT NULL, version TEXT NOT NULL, field TEXT NOT NULL, "from" TEXT, "to" TEXT, released TEXT NOT NULL, dev TEXT NOT NULL);
53
+ CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, at TEXT NOT NULL, by TEXT NOT NULL, feature_id TEXT NOT NULL, version TEXT NOT NULL, field TEXT NOT NULL, "from" TEXT, "to" TEXT, released TEXT NOT NULL, dev TEXT NOT NULL, reason TEXT);
54
54
  CREATE TABLE IF NOT EXISTS evidence (feature_id TEXT NOT NULL, version TEXT NOT NULL, ref TEXT NOT NULL, at TEXT NOT NULL, by TEXT NOT NULL);
55
55
  CREATE TABLE IF NOT EXISTS imported_versions (version TEXT PRIMARY KEY, json_hash TEXT, imported_at TEXT);`);
56
56
  const columns = db.query('PRAGMA table_info(imported_versions)').all() as Array<{ name: string }>;
57
57
  if (!columns.some((column) => column.name === 'json_hash')) db.exec('ALTER TABLE imported_versions ADD COLUMN json_hash TEXT');
58
58
  if (!columns.some((column) => column.name === 'imported_at')) db.exec('ALTER TABLE imported_versions ADD COLUMN imported_at TEXT');
59
+ if (!(db.query('PRAGMA table_info(events)').all() as Array<{ name: string }>).some((column) => column.name === 'reason')) db.exec('ALTER TABLE events ADD COLUMN reason TEXT');
59
60
  db.exec('CREATE TABLE IF NOT EXISTS release_schedules (version TEXT PRIMARY KEY, cut_at TEXT NOT NULL, land_by TEXT, updated_at TEXT NOT NULL, updated_by TEXT NOT NULL)');
60
61
  return db;
61
62
  } catch (error) { db.close(); throw error; }
@@ -119,14 +120,15 @@ function readTransaction<T>(db: Database, work: () => T): T {
119
120
  catch (error) { db.exec('ROLLBACK'); throw error; }
120
121
  }
121
122
 
122
- type EventRow = { seq: number; at: string; by: string; feature_id: string; version: string; field: string; from: string; to: string; released: string; dev: string };
123
+ type EventRow = { seq: number; at: string; by: string; feature_id: string; version: string; field: string; from: string; to: string; released: string; dev: string; reason: string | null };
123
124
  function decode(value: string | null): unknown { return value === null ? null : JSON.parse(value); }
124
125
  function historyRow(row: EventRow): ChecklistHistory {
125
- return { at: row.at, by: row.by, id: row.feature_id, field: row.field, from: decode(row.from), to: decode(row.to), released: row.released, dev: row.dev };
126
+ return { at: row.at, by: row.by, id: row.feature_id, field: row.field, from: decode(row.from), to: decode(row.to), released: row.released, dev: row.dev,
127
+ ...(row.reason !== null ? { reason: row.reason } : {}) };
126
128
  }
127
129
  function record(db: Database, version: string, entry: ChecklistHistory): void {
128
- db.query('INSERT INTO events (at, by, feature_id, version, field, "from", "to", released, dev) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
129
- .run(entry.at, entry.by, entry.id, version, entry.field, JSON.stringify(entry.from ?? null), JSON.stringify(entry.to ?? null), entry.released, entry.dev);
130
+ db.query('INSERT INTO events (at, by, feature_id, version, field, "from", "to", released, dev, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
131
+ .run(entry.at, entry.by, entry.id, version, entry.field, JSON.stringify(entry.from ?? null), JSON.stringify(entry.to ?? null), entry.released, entry.dev, entry.reason ?? null);
130
132
  }
131
133
  function evidenceRefs(db: Database, version: string, id: string): string[] {
132
134
  return (db.query('SELECT ref FROM evidence WHERE feature_id = ? AND version = ? ORDER BY rowid').all(id, version) as Array<{ ref: string }>).map((row) => row.ref);
@@ -288,7 +290,9 @@ export function list(version: string, released?: string, dev = devVersion(), roo
288
290
  }
289
291
 
290
292
  /** The checklist API supplies its existing validation, field ordering and legacy history shape. */
291
- export function mutate(version: string, released: string, dev: string, apply: (data: Checklist) => boolean): Checklist {
293
+ export interface ChecklistCollision { version: string; owner?: string; title: string }
294
+
295
+ export function mutate(version: string, released: string, dev: string, apply: (data: Checklist, otherItems: (id: string) => ChecklistCollision[]) => boolean): Checklist {
292
296
  validateVersion(version);
293
297
  const db = open();
294
298
  try {
@@ -298,7 +302,16 @@ export function mutate(version: string, released: string, dev: string, apply: (d
298
302
  const data = snapshot(db, version, released, dev);
299
303
  const before = new Map(data.items.map((item) => [item.id, JSON.stringify(item)]));
300
304
  const count = data.history.length;
301
- if (apply(data)) {
305
+ if (apply(data, (id) => {
306
+ for (const other of legacyVersions()) if (other !== version) importOnDb(db, other);
307
+ return db.query(`SELECT a.version, a.owner, COALESCE(a.title_override, f.title) AS title
308
+ FROM assignments a JOIN features f ON f.id = a.feature_id
309
+ WHERE a.feature_id = ? AND a.version <> ? ORDER BY a.version`).all(id, version)
310
+ .map((row) => {
311
+ const item = row as { version: string; owner: string | null; title: string };
312
+ return { version: item.version, ...(item.owner !== null ? { owner: item.owner } : {}), title: item.title };
313
+ });
314
+ })) {
302
315
  for (const item of data.items) if (before.get(item.id) !== JSON.stringify(item)) {
303
316
  const previous = before.get(item.id);
304
317
  const evidenceChanged = previous && (JSON.parse(previous) as ChecklistItem).evidence !== item.evidence;
@@ -356,7 +369,7 @@ export function remove(version: string, id: string, by: string, released = relea
356
369
  return true;
357
370
  });
358
371
  }
359
- export function move(id: string, from: string, to: string, by: string, released = releasedVersion(), dev = devVersion()): Checklist {
372
+ export function move(id: string, from: string, to: string, by: string, released = releasedVersion(), dev = devVersion(), reason?: string): Checklist {
360
373
  validateVersion(from); validateVersion(to);
361
374
  if (from === to) throw new CliUserError('같은 판으로 옮길 수 없다');
362
375
  const db = open();
@@ -366,11 +379,12 @@ export function move(id: string, from: string, to: string, by: string, released
366
379
  importedFrom = importOnDb(db, from); importedTo = importOnDb(db, to);
367
380
  const item = items(db, from).find((i) => i.id === id);
368
381
  if (!item) throw new CliUserError(`없는 칸: ${id}`, 'list 로 칸 목록을 본다');
369
- if (db.query('SELECT 1 FROM assignments WHERE feature_id = ? AND version = ?').get(id, to)) throw new CliUserError(`이미 있는 칸: ${id}`);
382
+ const collision = items(db, to).find((i) => i.id === id);
383
+ if (collision) throw new CliUserError(`이미 있는 칸: ${JSON.stringify(id).slice(1, -1)} — ${to} · 담당 ${JSON.stringify(collision.owner ?? '-').slice(1, -1)} · ${collision.title.replace(/\s+/g, ' ').slice(0, 40)}`);
370
384
  const at = new Date().toISOString();
371
385
  db.query('UPDATE assignments SET version = ?, updated_at = ?, updated_by = ? WHERE feature_id = ? AND version = ?').run(to, at, by, id, from);
372
386
  db.query('UPDATE evidence SET version = ? WHERE feature_id = ? AND version = ?').run(to, id, from);
373
- record(db, to, { at, by, id, field: 'move', from, to, released, dev });
387
+ record(db, to, { at, by, id, field: 'move', from, to, released, dev, ...(reason !== undefined ? { reason } : {}) });
374
388
  return snapshot(db, to, released, dev);
375
389
  });
376
390
  if (importedFrom) debug.log('release.features', 'imported', { version: from });
@@ -0,0 +1,48 @@
1
+ import { ownerMatches, type ChecklistItem } from './checklist.js';
2
+
3
+ export interface MergedChecklistPr { number: number; title: string; body: string | null; mergedAt: string }
4
+ type PrMatchBasis = 'cell-line' | 'title-strong' | 'mention';
5
+ export interface LandedButYellowRow {
6
+ id: string;
7
+ owner: string | undefined;
8
+ status: 'yellow' | 'red';
9
+ prs: Array<Pick<MergedChecklistPr, 'number' | 'title' | 'mergedAt'> & { basis: PrMatchBasis }>;
10
+ alreadyInEvidence: boolean[];
11
+ newer: boolean;
12
+ }
13
+
14
+ /** Prefer a dedicated body cell line over title evidence; incidental mentions only qualify for long ids. */
15
+ export function landedButYellow(items: readonly ChecklistItem[], prs: readonly MergedChecklistPr[], { owner }: { owner?: string } = {}): LandedButYellowRow[] {
16
+ const rows: LandedButYellowRow[] = [];
17
+ for (const item of items) {
18
+ if (item.status !== 'yellow' && item.status !== 'red') continue;
19
+ if (owner !== undefined && !ownerMatches(item.owner, owner)) continue;
20
+ const id = item.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
+ const word = new RegExp(`(?<![A-Za-z0-9])${id}(?![A-Za-z0-9])`);
22
+ const shortId = /^[A-Z]{1,2}\d{1,2}[a-z]?$/.test(item.id);
23
+ const titleStart = shortId
24
+ ? new RegExp(`^${id}(?:$|\\s+(?:수확|(?:첫|둘째|셋째)\\s*조각|fixed|new|old)(?=\\s|:|$))`)
25
+ : new RegExp(`^${id}(?:\\s|$)`);
26
+ const explicitTitle = new RegExp(`(?<![A-Za-z0-9])${id}\\s+(?:수확|(?:첫|둘째|셋째)\\s*조각)(?![A-Za-z0-9가-힣])|\\(${id}\\)`);
27
+ const rank: Record<PrMatchBasis, number> = { 'cell-line': 0, 'title-strong': 1, mention: 2 };
28
+ const matched = prs.flatMap((pr) => {
29
+ const cellLine = (pr.body ?? '').split('\n').some((line) => {
30
+ const match = /^[ \t]*칸(?:[ \t]*:[ \t]*|[ \t]+)([A-Za-z0-9_-]+(?:[ \t]*,[ \t]*[A-Za-z0-9_-]+)*)[ \t]*$/.exec(line.endsWith('\r') ? line.slice(0, -1) : line);
31
+ return match?.[1]?.split(',').some((candidate) => candidate.trim() === item.id) ?? false;
32
+ });
33
+ const basis: PrMatchBasis | undefined = cellLine ? 'cell-line'
34
+ : titleStart.test(pr.title) || explicitTitle.test(pr.title) ? 'title-strong'
35
+ : !shortId && (word.test(pr.title) || word.test(pr.body ?? '')) ? 'mention' : undefined;
36
+ return basis ? [{ number: pr.number, title: pr.title, mergedAt: pr.mergedAt, basis }] : [];
37
+ }).sort((a, b) => rank[a.basis] - rank[b.basis] || b.mergedAt.localeCompare(a.mergedAt) || b.number - a.number);
38
+ if (!matched.length) continue;
39
+ const shown = matched.slice(0, 5);
40
+ rows.push({
41
+ id: item.id, owner: item.owner, status: item.status,
42
+ prs: shown,
43
+ alreadyInEvidence: shown.map(({ number }) => new RegExp(`(?<![A-Za-z0-9_#])#${number}(?![A-Za-z0-9_])`).test(item.evidence ?? '')),
44
+ newer: matched.some((pr) => pr.mergedAt > item.updatedAt),
45
+ });
46
+ }
47
+ return rows.sort((a, b) => Number(b.newer) - Number(a.newer));
48
+ }
@@ -17,7 +17,7 @@ import { nextPersonaTodo, personaTodoPath, readPersonaTodos, type PersonaTodo }
17
17
  import { PersonaRegistry } from '../persona/registry.js';
18
18
  import { answerSeat, askSeat, deliveredSeatQuestion, hasSeatAnswer, isSeatId, seatQuestions, type SeatId } from '../seat-dispatch/seat-questions.js';
19
19
  import { queryRunningRuns } from '../self-implement/running-runs.js';
20
- import { listChecklist, type Checklist } from '../release-loop/checklist.js';
20
+ import { checklistHistory, listChecklist, type Checklist } from '../release-loop/checklist.js';
21
21
  import { readSchedules, releasedVersion } from '../release-loop/feature-store.js';
22
22
 
23
23
  // `kind`/`createdAt` are the V3 shadow-compare keys (내부 문서 `METHOD-v3-shadow-compare-2026-10-02` · MK 10-02 18:54).
@@ -31,7 +31,11 @@ export type OpCandidate =
31
31
  | { kind: 'release-readiness'; version: string | null; verdict: 'ready' | 'not-ready' | 'no-open-release'; cutAt?: string; landBy?: string | null; red: string[]; undecided: string[]; blocked: string[] }
32
32
  | { kind: 'unassigned-cell'; version: string | null; id: string | null; title?: string; status?: string; verdict: 'assign' | 'none' }
33
33
  | { kind: 'decision-delegation'; id: string | null; title?: string; category?: DecisionEntry['category']; dueAt?: string; verdict: 'review-delegation' | 'none' };
34
- export type SeatEntry = { seat: string; ts?: string; at: string; status: 'shadow' | 'attempting' | 'outcome-unknown' | 'launched' | 'hitl' | 'wait' | 'asked' | 'answered' | 'awaiting-answer' | 'skipped-budget' | 'skipped-empty'; item?: SeatItem; candidate?: OpCandidate; action?: SeatAction; reason?: string; runId?: string; inquiry?: SeatInquiry; answer?: string; escalated?: boolean };
34
+ export type TcCandidate =
35
+ | { kind: 'merged-pr-yellow-cell'; version: string; id: string; title: string; pr: number; mergedAt: string }
36
+ | { kind: 'publication-pr-approval-wait'; pr: number; title: string; readyAt: string; approvalWaitAt: string; paths: string[] };
37
+ export type TcPullRequest = { number: number; title: string; body?: string; state: 'OPEN' | 'MERGED' | 'CLOSED'; isDraft: boolean; createdAt: string; updatedAt?: string; mergedAt?: string | null; readyAt?: string; approvalWaitAt?: string; paths?: string[]; reviewDecision?: string };
38
+ export type SeatEntry = { seat: string; ts?: string; at: string; status: 'shadow' | 'attempting' | 'outcome-unknown' | 'launched' | 'hitl' | 'wait' | 'asked' | 'answered' | 'awaiting-answer' | 'skipped-budget' | 'skipped-empty'; item?: SeatItem; candidate?: OpCandidate | TcCandidate; action?: SeatAction; reason?: string; runId?: string; inquiry?: SeatInquiry; answer?: string; escalated?: boolean };
35
39
  export type SeatLoopResult = SeatEntry | { seat: string; status: 'skipped-off' };
36
40
  export type PersonaShadowEntry = { personaId: string; ts: string; status: 'shadow' | 'skipped-empty'; todo: PersonaTodo | null };
37
41
  export type SeatDeps = {
@@ -54,12 +58,16 @@ export type SeatDeps = {
54
58
  schedules?: () => Array<{ version: string; cutAt: string; landBy?: string | null }>;
55
59
  /** Checklist items of one version — default reads the release ledger DB (checklist.json is only a legacy import source since REL5b). */
56
60
  checklistItems?: (version: string) => Array<{ id: string; title: string; status: string; owner?: string | null; disposition?: string; evidence?: string }>;
61
+ /** Chronological release-ledger events for a checklist id; injected for isolated release fixtures. */
62
+ checklistHistory?: (id: string) => ReturnType<typeof checklistHistory>;
57
63
  /** Full checklist snapshot including history, for stall detection. */
58
64
  stallChecklist?: (version: string) => Checklist;
59
65
  /** Inject a mailbox write for isolated failure tests; production uses the seat message store. */
60
66
  stallDelivery?: (message: MessageEnvelope, key: string, root: string) => boolean;
61
67
  /** Open cards from the decision ledger; injected only for isolated ledger tests. */
62
68
  pendingDecisions?: () => Array<Pick<DecisionEntry, 'id' | 'title' | 'category' | 'dueAt'>>;
69
+ /** Read-only PR snapshot for TC judgment; defaults to GitHub CLI reads. */
70
+ pullRequests?: () => Promise<TcPullRequest[]>;
63
71
  ledgerFiles?: (directory: string) => string[];
64
72
  lockContended?: () => void;
65
73
  /** Supply a question only when this seat lacks evidence for the selected work item. */
@@ -211,17 +219,19 @@ function eligibleItem(item: SeatItem, ledger: readonly SeatEntry[], handled: Rea
211
219
  return !lastWait?.item || itemSnapshot(lastWait.item) !== itemSnapshot(item);
212
220
  }
213
221
 
214
- // Compare checklist snapshots without consulting release history or treating a version move as new work.
222
+ const CHECKLIST_HANDLED_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
223
+
224
+ // Same-version snapshots are handled here; carried work requires an explicit release-ledger move.
215
225
  export function alreadyHandled(item: SeatItem, ledger: readonly SeatEntry[]): boolean {
216
226
  if (item.source !== 'checklist' || !item.seat || !item.asOf || item.evidenceHash === undefined) return false;
217
227
  const now = Date.parse(item.asOf);
218
228
  if (!Number.isFinite(now)) return false;
219
229
  return ledger.some((entry) => {
220
230
  const at = Date.parse(entry.at || entry.ts || '');
221
- return entry.seat === item.seat && at >= now - 7 * 24 * 60 * 60 * 1000 && at <= now
231
+ return entry.seat === item.seat && at >= now - CHECKLIST_HANDLED_WINDOW_MS && at <= now
222
232
  // A shadowed seat question was never delivered — it must be picked again once delivery is on (RM3 · TC merge 10-03).
223
233
  && (entry.status === 'launched' || (entry.status === 'shadow' && !entry.inquiry))
224
- && entry.item?.source === 'checklist' && entry.item.id === item.id
234
+ && entry.item?.source === 'checklist' && entry.item.id === item.id && entry.item.version === item.version
225
235
  && entry.item.title === item.title && entry.item.evidenceHash === item.evidenceHash;
226
236
  });
227
237
  }
@@ -357,6 +367,126 @@ function opCandidates(deps: SeatDeps, now: Date): OpCandidate[] {
357
367
  ];
358
368
  }
359
369
 
370
+ const TC_APPROVAL_WAIT_MS = 2 * 60 * 60 * 1000;
371
+
372
+ function readTcChecklist(root: string): Array<{ version: string; id: string; title: string; status: string; owner: string | null }> {
373
+ const path = join(root, 'release', 'features.sqlite');
374
+ if (!existsSync(path)) return [];
375
+ const db = new Database(path, { readonly: true, strict: true });
376
+ try {
377
+ return db.query(`SELECT a.version, f.id, COALESCE(a.title_override, f.title) AS title, a.status, a.owner
378
+ FROM assignments a JOIN features f ON f.id = a.feature_id WHERE a.status = 'yellow' AND a.owner = 'TC'
379
+ ORDER BY a.version, f.id`).all() as Array<{ version: string; id: string; title: string; status: string; owner: string | null }>;
380
+ } finally { db.close(); }
381
+ }
382
+
383
+ type TcTimelineEvent = { event: string; created_at: string };
384
+
385
+ export function tcApprovalWaitAt(pr: TcPullRequest, timeline: readonly TcTimelineEvent[]): { readyAt: string; approvalWaitAt: string } | undefined {
386
+ if (pr.state !== 'OPEN' || pr.isDraft || pr.reviewDecision !== 'REVIEW_REQUIRED') return undefined;
387
+ const events = timeline.filter((event) => typeof event.created_at === 'string' && Number.isFinite(Date.parse(event.created_at)))
388
+ .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
389
+ let readyAt: string | undefined = pr.createdAt;
390
+ let waitAt: string | undefined = pr.createdAt;
391
+ for (const event of events) {
392
+ if (event.event === 'converted_to_draft') { readyAt = undefined; waitAt = undefined; }
393
+ if (event.event === 'ready_for_review') { readyAt = event.created_at; waitAt = event.created_at; }
394
+ if (event.event === 'review_requested' && readyAt && Date.parse(event.created_at) >= Date.parse(readyAt)) waitAt = event.created_at;
395
+ }
396
+ return readyAt && waitAt && Number.isFinite(Date.parse(readyAt)) && Date.parse(waitAt) >= Date.parse(readyAt)
397
+ ? { readyAt, approvalWaitAt: waitAt } : undefined;
398
+ }
399
+
400
+ export async function readTcPullRequests(query: (args: string[]) => Promise<string> = async (args) => {
401
+ const { stdout } = await exec('gh', args, { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024 });
402
+ return stdout;
403
+ }): Promise<TcPullRequest[]> {
404
+ const pages: unknown = JSON.parse(await query(['api', '--paginate', '--slurp', 'repos/{owner}/{repo}/pulls?state=all&per_page=100']));
405
+ if (!Array.isArray(pages) || !pages.every((page) => Array.isArray(page))) throw new Error('PR list is not an array of pages');
406
+ const prs = pages.flatMap((page: unknown[]) => page).filter((pr) => pr && typeof pr === 'object'
407
+ && ((pr as Record<string, unknown>).state === 'open' || typeof (pr as Record<string, unknown>).merged_at === 'string'));
408
+ const results: TcPullRequest[] = [];
409
+ for (let offset = 0; offset < prs.length; offset += 10) {
410
+ results.push(...await Promise.all(prs.slice(offset, offset + 10).map(async (pr): Promise<TcPullRequest> => {
411
+ const listing = pr as Record<string, unknown>;
412
+ if (typeof listing.number !== 'number' || typeof listing.title !== 'string' || typeof listing.created_at !== 'string'
413
+ || typeof listing.draft !== 'boolean' || (listing.state !== 'open' && listing.state !== 'closed')) {
414
+ throw new Error('incomplete PR record');
415
+ }
416
+ const details: unknown = listing.state === 'open'
417
+ ? JSON.parse(await query(['pr', 'view', String(listing.number), '--json', 'number,title,body,state,isDraft,createdAt,mergedAt,files,reviewDecision']))
418
+ : { number: listing.number, title: listing.title, body: listing.body, state: 'MERGED', isDraft: listing.draft,
419
+ createdAt: listing.created_at, mergedAt: listing.merged_at, files: [] };
420
+ if (!details || typeof details !== 'object' || Array.isArray(details)) throw new Error('invalid PR record');
421
+ const row = details as Record<string, unknown>;
422
+ if (typeof row.number !== 'number' || typeof row.title !== 'string' || typeof row.createdAt !== 'string'
423
+ || typeof row.isDraft !== 'boolean' || (row.state !== 'OPEN' && row.state !== 'MERGED' && row.state !== 'CLOSED')) {
424
+ throw new Error('incomplete PR record');
425
+ }
426
+ const paths = Array.isArray(row.files) ? row.files.flatMap((file: unknown) =>
427
+ file && typeof file === 'object' && typeof (file as { path?: unknown }).path === 'string' ? [(file as { path: string }).path] : []) : [];
428
+ const result: TcPullRequest = { number: row.number, title: row.title, body: typeof row.body === 'string' ? row.body : '',
429
+ state: row.state, isDraft: row.isDraft, createdAt: row.createdAt,
430
+ mergedAt: typeof row.mergedAt === 'string' ? row.mergedAt : null, paths,
431
+ reviewDecision: typeof row.reviewDecision === 'string' ? row.reviewDecision : undefined };
432
+ if (result.state === 'OPEN' && !result.isDraft && result.reviewDecision === 'REVIEW_REQUIRED'
433
+ && paths.some((path) => path.startsWith('release/public/') || path.startsWith('scripts/public-export') || path.startsWith('src/market/'))) {
434
+ try {
435
+ const pages: unknown = JSON.parse(await query(['api', '--paginate', '--slurp', `repos/{owner}/{repo}/issues/${result.number}/timeline`]));
436
+ if (!Array.isArray(pages) || !pages.every((page) => Array.isArray(page))) throw new Error('PR timeline is not an array of pages');
437
+ const timeline: unknown[] = pages.flatMap((page: unknown[]) => page);
438
+ const transitions = timeline.filter((event): event is Record<string, unknown> => event !== null && typeof event === 'object'
439
+ && ['converted_to_draft', 'ready_for_review', 'review_requested'].includes((event as Record<string, unknown>).event as string));
440
+ if (!transitions.every((event) => typeof event.created_at === 'string' && Number.isFinite(Date.parse(event.created_at)))) {
441
+ throw new Error('invalid PR timeline transition date');
442
+ }
443
+ Object.assign(result, tcApprovalWaitAt(result, transitions as TcTimelineEvent[]));
444
+ } catch (error) { observe('tc-pr-timeline-unreadable', { pr: result.number, error: String(error).slice(0, 200) }); }
445
+ }
446
+ return result;
447
+ })));
448
+ }
449
+ return results;
450
+ }
451
+
452
+ async function tcCandidates(deps: SeatDeps, now: Date): Promise<TcCandidate[]> {
453
+ let prs: TcPullRequest[];
454
+ // An injected instance root must not fetch unrelated live GitHub state.
455
+ try { prs = await (deps.pullRequests ?? (deps.root ? async () => [] : readTcPullRequests))(); }
456
+ catch (error) {
457
+ observe('tc-pr-unreadable', { seat: 'TC', error: String(error).slice(0, 200) });
458
+ return [];
459
+ }
460
+ const merged = prs.filter((pr) => pr.state === 'MERGED' && pr.mergedAt && Number.isFinite(Date.parse(pr.mergedAt)) && Date.parse(pr.mergedAt) <= now.getTime());
461
+ const candidates: TcCandidate[] = [];
462
+ const releaseRoot = deps.root ?? releaseLedgerRoot();
463
+ try {
464
+ const cells = merged.length === 0 ? [] : deps.checklistItems && deps.schedules
465
+ ? deps.schedules().flatMap(({ version }) => deps.checklistItems!(version).map((cell) => ({ ...cell, version })))
466
+ : readTcChecklist(releaseRoot);
467
+ for (const cell of cells) {
468
+ if (cell.owner !== 'TC' || cell.status !== 'yellow') continue;
469
+ for (const pr of merged) {
470
+ const id = cell.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
471
+ const reference = new RegExp(`(?<![A-Za-z0-9])${id}(?![A-Za-z0-9])`);
472
+ if (reference.test(pr.title) || reference.test(pr.body ?? '')) {
473
+ candidates.push({ kind: 'merged-pr-yellow-cell', version: cell.version, id: cell.id, title: cell.title, pr: pr.number, mergedAt: pr.mergedAt! });
474
+ }
475
+ }
476
+ }
477
+ } catch (error) { observe('tc-checklist-unreadable', { seat: 'TC', error: String(error).slice(0, 200) }); }
478
+ for (const pr of prs) {
479
+ if (pr.state !== 'OPEN' || pr.isDraft || pr.reviewDecision !== 'REVIEW_REQUIRED'
480
+ || !pr.paths?.some((path) => path.startsWith('release/public/') || path.startsWith('scripts/public-export') || path.startsWith('src/market/'))) continue;
481
+ const readyAt = pr.readyAt;
482
+ const age = now.getTime() - Date.parse(pr.approvalWaitAt ?? '');
483
+ if (!readyAt || !Number.isFinite(Date.parse(readyAt)) || Date.parse(readyAt) > now.getTime()
484
+ || !Number.isFinite(age) || age < TC_APPROVAL_WAIT_MS) continue;
485
+ candidates.push({ kind: 'publication-pr-approval-wait', pr: pr.number, title: pr.title, readyAt, approvalWaitAt: pr.approvalWaitAt!, paths: pr.paths });
486
+ }
487
+ return candidates;
488
+ }
489
+
360
490
  function deliverStall(message: MessageEnvelope, key: string, root: string): boolean {
361
491
  const store = openMsgStore(join(root, 'msg', 'messages.db'));
362
492
  try {
@@ -411,13 +541,34 @@ function escalateStalls(seat: string, deps: SeatDeps, config: SeatLoopConfig, no
411
541
  }
412
542
 
413
543
  // In shadow mode a shadowed item counts as handled, so a rehearsal day walks the queue like the seat would.
414
- export function pickNext(inputs: SeatInputs, ledger: readonly SeatEntry[], opts: { shadow?: boolean; root?: string } = {}): SeatItem | null {
544
+ export function pickNext(inputs: SeatInputs, ledger: readonly SeatEntry[], opts: { shadow?: boolean; root?: string; history?: SeatDeps['checklistHistory'] } = {}): SeatItem | null {
415
545
  const handled = handledKeys(ledger, opts.shadow === true, opts.root);
416
546
  return [...inputs.requests, ...inputs.checklist].find((item) => {
417
547
  if (alreadyHandled(item, ledger)) {
418
548
  debug.log('seat.loop', 'skip-handled', { id: item.id });
419
549
  return false;
420
550
  }
551
+ if (item.source === 'checklist' && item.version && item.seat && item.asOf && item.evidenceHash !== undefined && opts.history
552
+ && ledger.some((entry) => entry.seat === item.seat && entry.status === 'shadow'
553
+ && entry.item?.source === 'checklist' && entry.item.id === item.id && entry.item.version !== item.version)) {
554
+ // Only an actual move into this version identifies a carried cell; unrelated releases may reuse an id.
555
+ const moves = opts.history(item.id).filter((event) => event.field === 'move'
556
+ && typeof event.from === 'string' && typeof event.to === 'string' && Date.parse(event.at) <= Date.parse(item.asOf!));
557
+ const latest = moves.at(-1);
558
+ if (latest?.to === item.version) {
559
+ const processed = [...ledger].reverse().find((entry) => entry.seat === item.seat
560
+ && entry.status === 'shadow' && !entry.inquiry && entry.action !== 'wait'
561
+ && entry.item?.source === 'checklist' && entry.item.id === item.id);
562
+ if (processed && processed.item && processed.item.version === latest.from && processed.item.title === item.title
563
+ && processed.item.evidenceHash === item.evidenceHash
564
+ && Date.parse(processed.at || processed.ts || '') >= Date.parse(item.asOf) - CHECKLIST_HANDLED_WINDOW_MS
565
+ && Date.parse(processed.at || processed.ts || '') <= Date.parse(item.asOf)
566
+ && Date.parse(processed.at || processed.ts || '') <= Date.parse(latest.at)) {
567
+ debug.log('seat.loop', 'skip-carried', { id: item.id, from: latest.from, version: item.version });
568
+ return false;
569
+ }
570
+ }
571
+ }
421
572
  return eligibleItem(item, ledger, handled, opts.shadow === true);
422
573
  }) ?? null;
423
574
  }
@@ -470,6 +621,15 @@ export async function runSeatLoopOnce(seat: string, deps: SeatDeps = {}): Promis
470
621
  const stateRoot = deps.root ?? effectiveInstanceRoot();
471
622
  let turnError: unknown;
472
623
  try {
624
+ if (seat === 'TC') {
625
+ const candidates = await tcCandidates(deps, now);
626
+ for (const candidate of candidates) {
627
+ if (ledger.some((row) => row.status === 'shadow' && JSON.stringify(row.candidate) === JSON.stringify(candidate))) continue;
628
+ const entry: SeatEntry = { seat, ts: now.toISOString(), at: now.toISOString(), status: 'shadow', candidate };
629
+ (deps.append ?? defaultAppend)(path, entry);
630
+ observe('tc-shadow-candidate', { seat, candidate });
631
+ }
632
+ }
473
633
  if (seat === 'OP' && !seatQuestions(stateRoot, 'OP').some((question) =>
474
634
  !hasSeatAnswer(stateRoot, question)
475
635
  && !handledKeys(ledger, config.mode !== 'on' || (config.questions ?? 'shadow') !== 'on', stateRoot).has(`seat-question::${question.id}`))) {
@@ -526,7 +686,8 @@ export async function runSeatLoopOnce(seat: string, deps: SeatDeps = {}): Promis
526
686
  const questionsMode = config.mode === 'on' ? config.questions ?? 'shadow' : 'shadow';
527
687
  const questionHandled = handledKeys(ledger, questionsMode !== 'on', stateRoot);
528
688
  const pendingQuestion = inputs.requests.find((candidate) => candidate.source === 'seat-question' && !questionHandled.has(itemKey(candidate)));
529
- const ordinary = pickNext({ ...inputs, requests: inputs.requests.filter((candidate) => candidate.source !== 'seat-question') }, ledger, { shadow: config.mode === 'shadow', root: stateRoot });
689
+ const ordinary = pickNext({ ...inputs, requests: inputs.requests.filter((candidate) => candidate.source !== 'seat-question') }, ledger,
690
+ { shadow: config.mode === 'shadow', root: stateRoot, history: deps.checklistHistory ?? checklistHistory });
530
691
  const item = pendingQuestion ?? ordinary;
531
692
  const append = deps.append ?? defaultAppend;
532
693
  // RM3 review must-fix: `questions` now has a default, so it can't be the trigger — a shadow seat loop never calls the model on its own.