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.
- package/apps/pwa/out/404/index.html +1 -1
- package/apps/pwa/out/404.html +1 -1
- package/apps/pwa/out/_next/static/chunks/1147-466ec15afacc1a65.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{1394-556de6d71bbdb0fb.js → 1394-ee3795b112907122.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/3869-3cd46526879f454c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7277-0b2fc7c16ba2d64b.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7758-0b018a9e4fc56200.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{8605-ea5bee9dfae512c7.js → 8605-0259994816604c49.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/9920-5cfdc5e8fd209825.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-cb169620945d1952.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-6a061bd97f0b9398.js → page-cefe8169e36cf62a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/{page-cb95a7432c9ee744.js → page-d2e35228bc497119.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/{page-b12b6d2f8d2d1aea.js → page-5c05b0da4bc1392c.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-b9efe868bfd36255.js → page-e0ccdb576812b523.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/{page-7c97dcdfe2e37a60.js → page-813c4a52c9d05ca8.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/page-4731f2c215358937.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/control/{page-5721537ca8a3598c.js → page-a022b257950f39e6.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/{page-b654240a572e4c48.js → page-a2ffc717e04b8b0a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/{page-b9750abc897508c3.js → page-b59ee4e70fc18fcf.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/exec/page-a67e11ba962337b5.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/field/page-c172dabd796eb7a3.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/inside/page-1efd0cb9e0ed9d13.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/intake/{page-c8b1ccda6ba7ae98.js → page-d5dc7ff69a37b4cf.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-e71c84189e8c683e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/live/{page-2a9932ddb62d76bd.js → page-1d6a533ce5a6efcb.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/market/{page-9688f5b4f1ff950f.js → page-dfca9b7d2de0b135.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-793fa536b3a6ef37.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-35414e0a007b4082.js → page-6dd3bfbbde2a5c2c.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{not-found-e7a529e0507ac86c.js → not-found-490653e8d4cc8852.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/{page-e001823c35c9dd67.js → page-77447356baad75d2.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-29e961191f6057df.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-c822f55b884d9104.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-fa4ccfc432424e52.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/outputs/{page-3a24072b35470157.js → page-6d604227e1664b71.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/page-9c7e8f744a765daf.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/reflection/{page-0be8d4b2ced75ba0.js → page-4048543b4b7ad5e0.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-d37cb0d52f9b6bd5.js → page-4c3a3126abed551a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/{page-686da0df9bf23e5c.js → page-64b0aecb1ca75e75.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-4292d0149020e8a7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/{page-53f80edc6b8dc07c.js → page-db14d0be7f1c460c.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-e5d8072cd7cfef73.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/{page-239c17b911b84e72.js → page-9547b5cec7cf76bf.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/share/page-e6dcb83265530636.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-45248f08d6aecae5.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/{page-1218753c82d585f6.js → page-2cddeb9e2aeedc55.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/term/{page-4b5935e67531efb5.js → page-e297866051595d81.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/today/{page-3285bc87caf1eaa5.js → page-baba2e047872fb0f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/{page-ad553b0e06505ecd.js → page-e895da4ec9cac345.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-4344faf20216233b.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-698bb29f11864b89.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-bd6a6dc1ddc273f2.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workspace/page-2e9e2fb01c9da1c9.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-d62ceb13411ebba4.js +1 -0
- package/apps/pwa/out/_next/static/css/128fb7fb670b2fbf.css +1 -0
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +14 -14
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +14 -14
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +14 -14
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +14 -14
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +14 -14
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +14 -14
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +14 -14
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +14 -14
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +14 -14
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +14 -14
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +14 -14
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +14 -14
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +14 -14
- package/apps/pwa/out/inside/index.html +1 -1
- package/apps/pwa/out/inside/index.txt +14 -14
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +14 -14
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +14 -14
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +14 -14
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +14 -14
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +14 -14
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +14 -14
- package/apps/pwa/out/ops/checklist/index.html +1 -1
- package/apps/pwa/out/ops/checklist/index.txt +14 -14
- package/apps/pwa/out/ops/release/index.html +1 -1
- package/apps/pwa/out/ops/release/index.txt +14 -14
- package/apps/pwa/out/ops/seats/index.html +1 -1
- package/apps/pwa/out/ops/seats/index.txt +14 -14
- package/apps/pwa/out/outputs/index.html +1 -1
- package/apps/pwa/out/outputs/index.txt +14 -14
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +15 -15
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +14 -14
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +14 -14
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +14 -14
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +14 -14
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +14 -14
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +14 -14
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +14 -14
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +14 -14
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +14 -14
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +14 -14
- package/apps/pwa/out/today/index.html +1 -1
- package/apps/pwa/out/today/index.txt +14 -14
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +14 -14
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +14 -14
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +14 -14
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +14 -14
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +14 -14
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +14 -14
- package/graphs/context/nightly-condense.yaml +21 -0
- package/graphs/context/recipes.yaml +12 -0
- package/graphs/demo/inside-seed.yaml +20 -0
- package/graphs/demo/recipes.yaml +12 -0
- package/package.json +1 -1
- package/plugins/card-followup/examples/sample-card.png +0 -0
- package/scripts/lib/telegram-inject-args.ts +31 -0
- package/scripts/measure-pod-memory-by-goal.ts +130 -0
- package/scripts/release-loop/auto-approve-node.ts +1 -1
- package/scripts/release-loop/checklist-gate-node.ts +2 -1
- package/scripts/release-loop/gate-node.ts +8 -2
- package/scripts/release-loop/ops-upgrade-node.ts +47 -6
- package/scripts/release-loop/publish-node.ts +4 -1
- package/scripts/release-loop/version-node.ts +13 -3
- package/scripts/telegram-inject.ts +14 -6
- package/scripts/test-diet/remeasure-list.ts +112 -0
- package/scripts/ux/demo-rehearsal.ts +17 -13
- package/scripts/ux/demo-seed.ts +108 -0
- package/scripts/ux/lib/rehearsal-verdict.ts +12 -5
- package/src/agent-mission/driver.ts +138 -16
- package/src/agent-mission/pty-decision.ts +1 -1
- package/src/autopilot/pty-control-loop.ts +4 -1
- package/src/chat/index.ts +1 -0
- package/src/cli/persona-cli.ts +17 -14
- package/src/cli/pod-cli.ts +17 -0
- package/src/cli/pod-memory-advice.ts +35 -0
- package/src/cli/release-cli.ts +46 -6
- package/src/context-bus/context-now-surfaces.ts +17 -0
- package/src/context-bus/context-now.ts +2 -2
- package/src/context-bus/coord-events.ts +2 -2
- package/src/context-bus/nightly-condense.ts +153 -0
- package/src/context-bus/session-events.ts +35 -0
- package/src/dashboard/dashboard-status-lines.ts +2 -0
- package/src/dashboard/index.ts +8 -0
- package/src/dashboard/slash-runtime/dashboard-handlers.ts +24 -0
- package/src/domains/outbound-alert.ts +99 -31
- package/src/domains/outbound-log-probe.ts +8 -0
- package/src/execution-loop/launch-gate.ts +17 -8
- package/src/field-note/telegram.ts +142 -0
- package/src/graph-runner/graph-grow-llm.ts +23 -13
- package/src/graph-runner/graph-grow.ts +5 -2
- package/src/graph-runner/runner.ts +12 -8
- package/src/harness/harness-cli-command.ts +138 -16
- package/src/hooks/consult-intake.ts +153 -0
- package/src/hooks/receiver.ts +78 -1
- package/src/index.ts +33 -9
- package/src/intake-plane/discord-seat-work.ts +28 -2
- package/src/intake-plane/wish-card.ts +33 -0
- package/src/llm/difficulty.ts +1 -1
- package/src/maturity/feature-maturity.ts +2 -1
- package/src/nexus/api/http-server.ts +31 -8
- package/src/nexus/api/outbound-report.ts +40 -7
- package/src/nexus/api/personas.ts +95 -53
- package/src/nexus/api/task-cards-api.ts +19 -0
- package/src/nexus/api/workflow-history.ts +88 -0
- package/src/nexus/api/workflows.ts +52 -0
- package/src/nexus/outbound/delivery-ledger.ts +17 -8
- package/src/nexus/outbound/router.ts +8 -3
- package/src/persona/presets.ts +8 -1
- package/src/plugins/maker/market-bundle.ts +2 -1
- package/src/release-loop/checklist.ts +15 -3
- package/src/release-loop/feature-store.ts +24 -10
- package/src/release-loop/landed-but-yellow.ts +48 -0
- package/src/seat-loop/seat-loop.ts +168 -7
- package/src/self-dev/draft-sweep.ts +44 -10
- package/src/self-dev/draft-triage-rules.ts +54 -2
- package/src/self-implement/budget-gate.ts +29 -12
- package/src/self-implement/gate-candidate-ranking.ts +60 -0
- package/src/self-implement/gate-scope.ts +9 -8
- package/src/self-implement/headless-elanous-driver.ts +7 -2
- package/src/self-implement/orchestrator.ts +15 -0
- package/src/self-implement/test-seams.ts +1 -0
- package/src/task-orchestrator/surfaces/pod-command-job.ts +14 -10
- package/src/task-orchestrator/surfaces/self-implement-pod.ts +8 -0
- package/src/telegram-commands.ts +21 -0
- package/src/telegram-project-command.ts +78 -4
- package/src/telegram.ts +32 -5
- package/src/user-config.ts +8 -4
- package/src/version/packed-revision.json +1 -1
- package/apps/pwa/out/_next/static/chunks/2310-7a59f043f6db207e.js +0 -1
- package/apps/pwa/out/_next/static/chunks/2548-6e316138d347cefe.js +0 -1
- package/apps/pwa/out/_next/static/chunks/3869-a39d1d9921e10d43.js +0 -1
- package/apps/pwa/out/_next/static/chunks/507-5acd98bd6dab2d8a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5445-745eb795c5963d79.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-1dcdc58fc2de2454.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/page-8fd36984f549270a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/exec/page-4cd1de371f8ad048.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/field/page-d99f89657828421c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/page-50cb2a0eb15e6c83.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-d6b1d156fb95e74b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-6b15dd6456c5df09.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-371423d1a573d64b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-4b764b125339ac17.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-cdf8e4b01067bcf1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/page-94c82eb0ae5abb32.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-4dc614f3ce043e63.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d02c782fc792e2af.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/share/page-dc6a79fb003f746c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-0fffb84cafdc9da6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-fca096e3e860306f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-9ebfa78f0d9885c3.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-3dc5f21f7e39622d.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workspace/page-c6da2b468454532d.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-93bc70e36f595414.js +0 -1
- package/apps/pwa/out/_next/static/css/543f56bcb1f79fca.css +0 -1
- /package/apps/pwa/out/_next/static/{Y4zKzL62sakG5momz3SoQ → -6XrAoD3eU7NyMHAE4xPS}/_buildManifest.js +0 -0
- /package/apps/pwa/out/_next/static/{Y4zKzL62sakG5momz3SoQ → -6XrAoD3eU7NyMHAE4xPS}/_ssgManifest.js +0 -0
|
@@ -69,7 +69,7 @@ function safeSegment(value: string): string {
|
|
|
69
69
|
return value;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
type CommandRecipe = { command: string; timeout_ms?: number };
|
|
72
|
+
type CommandRecipe = { command: string; dry_run_command?: string; timeout_ms?: number };
|
|
73
73
|
type Recipe = CommandRecipe | { approval: string };
|
|
74
74
|
|
|
75
75
|
/** 접두 없는 recipe 가 카탈로그 역할이고, 그래프 옆 recipes.yaml 에 같은 키의 `{ command }` 가 있을 때만 cmd 처럼 실행한다. */
|
|
@@ -117,10 +117,13 @@ function recipesFor(path: string, recipeSource: string): Record<string, Recipe>
|
|
|
117
117
|
recipes[id] = { approval: item.approval };
|
|
118
118
|
} else {
|
|
119
119
|
if (typeof item.command !== 'string' || !item.command.trim() ||
|
|
120
|
-
(item.timeout_ms !== undefined && (!Number.isSafeInteger(item.timeout_ms) || (item.timeout_ms as number) <= 0))
|
|
120
|
+
(item.timeout_ms !== undefined && (!Number.isSafeInteger(item.timeout_ms) || (item.timeout_ms as number) <= 0)) ||
|
|
121
|
+
(item.dry_run_command !== undefined && (typeof item.dry_run_command !== 'string' || !item.dry_run_command.trim()))) {
|
|
121
122
|
throw new Error(`invalid recipe: ${id}`);
|
|
122
123
|
}
|
|
123
|
-
recipes[id] = { command: item.command,
|
|
124
|
+
recipes[id] = { command: item.command,
|
|
125
|
+
...(item.dry_run_command === undefined ? {} : { dry_run_command: item.dry_run_command as string }),
|
|
126
|
+
...(item.timeout_ms === undefined ? {} : { timeout_ms: item.timeout_ms as number }) };
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
129
|
return recipes;
|
|
@@ -590,7 +593,7 @@ export async function runGraph(path: string, options: GraphRunOptions = {}): Pro
|
|
|
590
593
|
let exit: number | null = null;
|
|
591
594
|
let error: string | undefined;
|
|
592
595
|
let output: unknown;
|
|
593
|
-
if (command && !state.dryRun && !resumingCompleted) {
|
|
596
|
+
if (command && (!state.dryRun || command.dry_run_command) && !resumingCompleted) {
|
|
594
597
|
const outputs: Record<string, unknown> = Object.create(null);
|
|
595
598
|
// 구조 산출(마지막 JSON 줄)이 있으면 그것을, 없으면 원문을 준다 — 다음 노드가 판단을 «값»으로 받는다.
|
|
596
599
|
for (const previous of state.nodes) outputs[previous.nodeId] = lastJsonObject(previous.output) ?? previous.output ?? null;
|
|
@@ -635,9 +638,10 @@ export async function runGraph(path: string, options: GraphRunOptions = {}): Pro
|
|
|
635
638
|
debug.log('graph-runner', 'child-universe-pinned', { graphId, runId, node: current, configDir: env.ELANOUS_CONFIG_DIR, stateDir: env.ELANOUS_STATE_DIR });
|
|
636
639
|
}
|
|
637
640
|
env.ELANOUS_GRAPH_CONTEXT = contextPath;
|
|
641
|
+
env.ELANOUS_GRAPH_DRY_RUN = state.dryRun ? '1' : '0';
|
|
638
642
|
env.ELANOUS_GRAPH_DIR = dirname(resolve(path));
|
|
639
643
|
const ctx = { arguments: '', artifactsDir: dirname(statePath), outputs: {}, resolvedProvider: undefined, resolvedModel: undefined, toolPolicy: {}, env } as NodeExecContext;
|
|
640
|
-
const result = await executeBashNode({ id: current, type: 'bash', bash: command.command, ...(command.timeout_ms ? { idle_timeout: command.timeout_ms } : {}) } as BashNode, ctx, { runBash } as WorkflowDeps);
|
|
644
|
+
const result = await executeBashNode({ id: current, type: 'bash', bash: state.dryRun ? command.dry_run_command! : command.command, ...(command.timeout_ms ? { idle_timeout: command.timeout_ms } : {}) } as BashNode, ctx, { runBash } as WorkflowDeps);
|
|
641
645
|
output = result.output;
|
|
642
646
|
ok = result.ok;
|
|
643
647
|
exit = code;
|
|
@@ -654,14 +658,14 @@ export async function runGraph(path: string, options: GraphRunOptions = {}): Pro
|
|
|
654
658
|
if (approval && !state.dryRun && !command) {
|
|
655
659
|
output = JSON.stringify({ outcome: ok ? 'approved' : 'rejected', ...(state.pending?.decidedBy === undefined ? {} : { decidedBy: state.pending.decidedBy }), decidedAt: state.pending?.decidedAt ?? null });
|
|
656
660
|
}
|
|
657
|
-
state.nodes.push({ nodeId: current, ok, exit, executed: !!command && !state.dryRun, ...((command || approval) && !state.dryRun && output !== undefined ? { output } : {}), ...(error ? { error } : {}),
|
|
661
|
+
state.nodes.push({ nodeId: current, ok, exit, executed: !!command && (!state.dryRun || !!command.dry_run_command), ...((command || approval) && (!state.dryRun || !!command?.dry_run_command) && output !== undefined ? { output } : {}), ...(error ? { error } : {}),
|
|
658
662
|
...(approval && state.pending ? { ...(state.pending.decidedBy === undefined ? {} : { decidedBy: state.pending.decidedBy }), decidedAt: state.pending.decidedAt } : {}) });
|
|
659
663
|
}
|
|
660
664
|
if (approval) {
|
|
661
665
|
delete state.pending;
|
|
662
666
|
delete state.approvalSourceHash;
|
|
663
667
|
}
|
|
664
|
-
const reported = command && !state.dryRun ? lastJsonObject(resumingCompleted ? completed?.output : output)?.outcome : undefined;
|
|
668
|
+
const reported = command && (!state.dryRun || command.dry_run_command) ? lastJsonObject(resumingCompleted ? completed?.output : output)?.outcome : undefined;
|
|
665
669
|
const namedOutcome = typeof reported === 'string' ? reported : undefined;
|
|
666
670
|
const seconds = Number(((performance.now() - startedAt) / 1000).toFixed(2));
|
|
667
671
|
console.error(`[graph] ${current} ${ok ? 'ok' : 'fail'} (${seconds.toFixed(2)}s)`);
|
|
@@ -709,7 +713,7 @@ export async function runGraph(path: string, options: GraphRunOptions = {}): Pro
|
|
|
709
713
|
}
|
|
710
714
|
persist();
|
|
711
715
|
}
|
|
712
|
-
current = nextNode(graph.edges, current, state.dryRun ? 'ok' : ok ? 'ok' : 'fail',
|
|
716
|
+
current = nextNode(graph.edges, current, state.dryRun && !command?.dry_run_command ? 'ok' : ok ? 'ok' : 'fail', namedOutcome);
|
|
713
717
|
if (current !== undefined) publishInsideEvent({ kind: 'edge', graphId, runId, from: fromNodeId, to: current });
|
|
714
718
|
if (current === undefined) {
|
|
715
719
|
state.status = 'failed';
|
|
@@ -24,7 +24,7 @@ import { debug } from '../debug/log.js';
|
|
|
24
24
|
import { dispatchTask, type DispatchTaskInput, type DispatchTaskDeps } from '../execution-loop/dispatch-task.js';
|
|
25
25
|
import { launchRequestId, preLaunchGate, type PreLaunchGateDeps } from '../execution-loop/launch-gate.js';
|
|
26
26
|
import { PR_LABELS } from '../github/pr-labels.js';
|
|
27
|
-
import { CODEX_PROVIDER, GROK_PROVIDER, decideBudget, readBudgetInputsLive, type BudgetDecision, type BudgetInputs } from '../self-implement/budget-gate.js';
|
|
27
|
+
import { CODEX_PROVIDER, GROK_PROVIDER, decideBudget, decideLaunchBudget, readBudgetInputsLive, type BudgetDecision, type BudgetInputs } from '../self-implement/budget-gate.js';
|
|
28
28
|
import { DEFAULT_FALLBACK_CHAIN } from '../oauth/fallback-chain.js';
|
|
29
29
|
import { installDeliverableVerifyCliCommand, type InstallDeliverableVerifyCliDeps } from './deliverable-verify-cli.js';
|
|
30
30
|
import { getUserConfig } from '../user-config.js';
|
|
@@ -338,6 +338,8 @@ function resolveLaunchSubstrate(opts: HarnessSubstrateOpts): ResolvedHarnessSubs
|
|
|
338
338
|
}
|
|
339
339
|
type PodExit = { status: number | null; signal?: string | null };
|
|
340
340
|
type PodExitReason = 'signal' | 'human-stop' | 'supervisor' | 'no-launch' | 'unknown';
|
|
341
|
+
/** GitHub lists at most this many files for one pull request; a list that reaches it is not provably complete. */
|
|
342
|
+
export const GITHUB_PR_FILES_LIST_CAP = 3000;
|
|
341
343
|
const STOPPING_SUPERVISOR_VERDICTS = new Set(['UNCONVERGEABLE', 'CONTRACT-CONFLICT']);
|
|
342
344
|
// Ledger events that only exist once the child actually ran — any of them rules out «never reached the Pod».
|
|
343
345
|
const LAUNCH_EVIDENCE_EVENTS = new Set(['job-applied', 'pod-child-run', 'reviewed', 'rework-budget', 'pr-opened']);
|
|
@@ -578,8 +580,12 @@ const LAUNCH_BUDGET_PROVIDER: Readonly<Record<string, string>> = { 'codex-rotate
|
|
|
578
580
|
export function harnessLaunchBudgetDecision(
|
|
579
581
|
inputs: BudgetInputs,
|
|
580
582
|
opts: { readonly childLlmProvider?: string; readonly childLlmModel?: string } = {},
|
|
581
|
-
): { readonly decision: BudgetDecision; readonly warning?: string } {
|
|
583
|
+
): { readonly decision: BudgetDecision; readonly warning?: string; readonly unmeasuredProvider?: string } {
|
|
582
584
|
const explicit = opts.childLlmProvider?.trim();
|
|
585
|
+
const judged = (selected: BudgetInputs) => {
|
|
586
|
+
const { decision, unmeasuredProvider } = decideLaunchBudget(selected);
|
|
587
|
+
return { decision, ...(unmeasuredProvider ? { unmeasuredProvider, warning: `budget: ${unmeasuredProvider} usage unmeasured — launching` } : {}) };
|
|
588
|
+
};
|
|
583
589
|
if (explicit) {
|
|
584
590
|
if (explicit !== CODEX_PROVIDER && explicit !== GROK_PROVIDER) {
|
|
585
591
|
return {
|
|
@@ -588,13 +594,14 @@ export function harnessLaunchBudgetDecision(
|
|
|
588
594
|
};
|
|
589
595
|
}
|
|
590
596
|
const chain = [{ provider: explicit, ...(opts.childLlmModel ? { model: opts.childLlmModel } : {}) }];
|
|
591
|
-
return
|
|
597
|
+
return judged({ ...inputs, preference: { ...inputs.preference, chain } });
|
|
592
598
|
}
|
|
593
|
-
if (inputs.preference.chain.length > 0) return
|
|
599
|
+
if (inputs.preference.chain.length > 0) return judged(inputs);
|
|
594
600
|
const chain = DEFAULT_FALLBACK_CHAIN.map((step) => ({ provider: LAUNCH_BUDGET_PROVIDER[step] ?? step }));
|
|
601
|
+
const result = judged({ ...inputs, preference: { ...inputs.preference, chain } });
|
|
595
602
|
return {
|
|
596
|
-
|
|
597
|
-
warning: `budget: no chain in this universe's config — judged with the code default ${DEFAULT_FALLBACK_CHAIN.join(',')}`,
|
|
603
|
+
...result,
|
|
604
|
+
warning: `budget: no chain in this universe's config — judged with the code default ${DEFAULT_FALLBACK_CHAIN.join(',')}${result.warning ? ` · ${result.warning}` : ''}`,
|
|
598
605
|
};
|
|
599
606
|
}
|
|
600
607
|
|
|
@@ -602,10 +609,13 @@ async function readHarnessLaunchBudget(
|
|
|
602
609
|
opts: { readonly childLlmProvider?: string; readonly childLlmModel?: string } = {},
|
|
603
610
|
): Promise<BudgetDecision | 'unknown'> {
|
|
604
611
|
try {
|
|
605
|
-
const { decision, warning } = harnessLaunchBudgetDecision(await readBudgetInputsLive(), opts);
|
|
612
|
+
const { decision, warning, unmeasuredProvider } = harnessLaunchBudgetDecision(await readBudgetInputsLive(), opts);
|
|
606
613
|
if (warning) {
|
|
607
614
|
console.error(`⚠️ launch gate: ${warning}`);
|
|
608
|
-
try {
|
|
615
|
+
try {
|
|
616
|
+
if (unmeasuredProvider) debug.log('execution-loop.launch-gate', 'budget-unmeasured', { provider: unmeasuredProvider });
|
|
617
|
+
if (!opts.childLlmProvider?.trim() && warning.includes('code default')) debug.log('execution-loop.launch-gate', 'budget-chain-defaulted', { warning, action: decision.action, provider: decision.provider });
|
|
618
|
+
} catch { /* observation is fail-soft */ }
|
|
609
619
|
}
|
|
610
620
|
return decision;
|
|
611
621
|
}
|
|
@@ -644,8 +654,8 @@ async function dispatchHarnessAskSay(
|
|
|
644
654
|
}
|
|
645
655
|
goalId ??= launchRequestId(identity);
|
|
646
656
|
// Under bun test with no injected gate deps, skip the live reads (≈5 s of codex rotation + run scans) —
|
|
647
|
-
// CLI wiring tests time out on them. Gate tests inject deps
|
|
648
|
-
if (process.env.NODE_ENV === 'test' && Object.keys(gate).length === 0) {
|
|
657
|
+
// CLI wiring tests time out on them. Gate tests inject deps or opt in with ELANOUS_LAUNCH_GATE_LIVE=1.
|
|
658
|
+
if (process.env.NODE_ENV === 'test' && Object.keys(gate).length === 0 && process.env.ELANOUS_LAUNCH_GATE_LIVE !== '1') {
|
|
649
659
|
try { debug.log('execution-loop.launch-gate', 'skipped-test-env', { goalId }); } catch { /* observation is fail-soft */ }
|
|
650
660
|
await dispatch(resolved);
|
|
651
661
|
return;
|
|
@@ -1577,10 +1587,13 @@ type GithubPull = {
|
|
|
1577
1587
|
draft: boolean;
|
|
1578
1588
|
state: 'open' | 'closed';
|
|
1579
1589
|
head: { ref: string };
|
|
1590
|
+
base?: { ref: string };
|
|
1580
1591
|
labels: Array<{ name: string }>;
|
|
1581
1592
|
created_at: string;
|
|
1582
1593
|
updated_at?: string;
|
|
1583
1594
|
merged_at: string | null;
|
|
1595
|
+
body?: string | null;
|
|
1596
|
+
merge_commit_sha?: string | null;
|
|
1584
1597
|
};
|
|
1585
1598
|
|
|
1586
1599
|
type GhExecute = (args: string[]) => string;
|
|
@@ -1629,9 +1642,25 @@ export function draftSweepRunStatus(ledgerMatches: readonly RunLedgerMatch[], ru
|
|
|
1629
1642
|
['completed', 'failed', 'cancelled', 'abandoned'].includes(terminal) ? terminal : undefined);
|
|
1630
1643
|
if (status && status !== 'unknown') statuses.add(status);
|
|
1631
1644
|
}
|
|
1645
|
+
if (statuses.has('running')) return 'running';
|
|
1646
|
+
if (statuses.has('probable-running')) return 'probable-running';
|
|
1632
1647
|
return statuses.size === 1 ? [...statuses][0] : undefined;
|
|
1633
1648
|
}
|
|
1634
1649
|
|
|
1650
|
+
function currentDraftSweepRunId(matches: readonly RunLedgerMatch[], repository: string, number: number): string | undefined {
|
|
1651
|
+
const owners = matches.flatMap((match) => {
|
|
1652
|
+
const events = match.entries.filter((entry) => entry.event === 'pr-opened' && entry.data.number === number);
|
|
1653
|
+
if (events.length === 0 || events.some((entry) => ledgerPrRepository(entry.data, number) !== repository)) return [];
|
|
1654
|
+
const times = events.map((entry) => Date.parse(entry.timestamp ?? ''));
|
|
1655
|
+
return [{ runId: match.runId, time: times.every(Number.isFinite) ? Math.max(...times) : NaN }];
|
|
1656
|
+
});
|
|
1657
|
+
if (owners.length === 1) return owners[0]!.runId;
|
|
1658
|
+
if (owners.some((owner) => !Number.isFinite(owner.time))) return undefined;
|
|
1659
|
+
owners.sort((a, b) => b.time - a.time);
|
|
1660
|
+
if (owners.length > 1 && owners[0]!.time === owners[1]!.time) return undefined;
|
|
1661
|
+
return owners[0]?.runId;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1635
1664
|
type DraftSweepGitExecute = (cwd: string, args: string[]) => Pick<ReturnType<typeof runGitCommand>, 'status' | 'stdout' | 'stderr'>;
|
|
1636
1665
|
|
|
1637
1666
|
function githubRemoteRepository(url: string): string | undefined {
|
|
@@ -1669,24 +1698,117 @@ export function githubDraftSweepAdapters(execute: GhExecute = executeGh, git: Dr
|
|
|
1669
1698
|
if (!cache.has(repository)) cache.set(repository, inventory(repository, state));
|
|
1670
1699
|
return cache.get(repository)!;
|
|
1671
1700
|
};
|
|
1701
|
+
// GitHub stops listing a pull request's files at 3,000, and the cut looks like a normal last page.
|
|
1702
|
+
// A list that reaches the cap cannot prove it is complete, so it is reported as unknown (undefined):
|
|
1703
|
+
// a draft without a provable file list never qualifies for the all-files-landed close.
|
|
1704
|
+
const filesFor = (repository: string, number: number): string[] | undefined => {
|
|
1705
|
+
const files: string[] = [];
|
|
1706
|
+
for (let page = 1; page <= 100; page++) {
|
|
1707
|
+
const batch = ghJson<Array<{ filename: string }>>(['api', `repos/${repository}/pulls/${number}/files?per_page=100&page=${page}`], execute);
|
|
1708
|
+
if (!Array.isArray(batch) || batch.length > 100 || batch.some((file) => typeof file.filename !== 'string')) throw new Error('Incomplete PR file inventory');
|
|
1709
|
+
files.push(...batch.map((file) => file.filename));
|
|
1710
|
+
if (files.length >= GITHUB_PR_FILES_LIST_CAP) {
|
|
1711
|
+
debug.log('drafts.cleanup', 'file-inventory-capped', { number, listed: files.length });
|
|
1712
|
+
return undefined;
|
|
1713
|
+
}
|
|
1714
|
+
if (batch.length < 100) return files;
|
|
1715
|
+
}
|
|
1716
|
+
throw new Error('PR file inventory exceeded pagination limit');
|
|
1717
|
+
};
|
|
1718
|
+
const latestFileChangesFor = (draft: SweepDraft, repository: string): Record<string, string> | undefined => {
|
|
1719
|
+
if (!draft.changedFiles?.length) return undefined;
|
|
1720
|
+
const changes: Record<string, string> = {};
|
|
1721
|
+
for (let page = 1; page <= 100; page++) {
|
|
1722
|
+
const batch = ghJson<Array<{ sha: string; commit: { committer: { date: string } } }>>(
|
|
1723
|
+
['api', `repos/${repository}/pulls/${draft.number}/commits?per_page=100&page=${page}`], execute);
|
|
1724
|
+
if (!Array.isArray(batch) || batch.length > 100) throw new Error('Incomplete draft commit inventory');
|
|
1725
|
+
for (const commit of batch) {
|
|
1726
|
+
const changedAt = commit.commit?.committer?.date;
|
|
1727
|
+
if (!commit.sha || !changedAt || !Number.isFinite(Date.parse(changedAt))) return undefined;
|
|
1728
|
+
const detail = ghJson<{ files?: Array<{ filename: string; previous_filename?: string }> }>(
|
|
1729
|
+
['api', `repos/${repository}/commits/${commit.sha}?per_page=100`], execute);
|
|
1730
|
+
// GitHub truncates very large commit file lists; never infer coverage from a partial list.
|
|
1731
|
+
if (!Array.isArray(detail.files) || detail.files.length >= 100) return undefined;
|
|
1732
|
+
for (const file of detail.files) {
|
|
1733
|
+
for (const name of [file.filename, file.previous_filename]) {
|
|
1734
|
+
if (!name || !draft.changedFiles.includes(name)) continue;
|
|
1735
|
+
if (!changes[name] || Date.parse(changes[name]) < Date.parse(changedAt)) changes[name] = changedAt;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
if (batch.length < 100) return draft.changedFiles.every((file) => changes[file]) ? changes : undefined;
|
|
1740
|
+
}
|
|
1741
|
+
return undefined;
|
|
1742
|
+
};
|
|
1743
|
+
const landingCommentsFor = (repository: string, number: number): string[] => {
|
|
1744
|
+
const comments: string[] = [];
|
|
1745
|
+
for (let page = 1; page <= 100; page++) {
|
|
1746
|
+
const batch = ghJson<Array<{ body?: string }>>(['api', `repos/${repository}/issues/${number}/comments?per_page=100&page=${page}`], execute);
|
|
1747
|
+
if (!Array.isArray(batch) || batch.length > 100) throw new Error('Incomplete landing comment inventory');
|
|
1748
|
+
comments.push(...batch.filter((comment) => /\blanding-verified\b/i.test(comment.body ?? '')).map((comment) => comment.body!));
|
|
1749
|
+
if (batch.length < 100) return comments;
|
|
1750
|
+
}
|
|
1751
|
+
throw new Error('Landing comment inventory exceeded pagination limit');
|
|
1752
|
+
};
|
|
1672
1753
|
let ledgerMatches: readonly RunLedgerMatch[] | undefined;
|
|
1673
1754
|
let running: ReadonlyMap<string, string> | undefined;
|
|
1674
|
-
const runStatusFor = (
|
|
1755
|
+
const runStatusFor = (draft: SweepDraft, repository: string): string | undefined => {
|
|
1675
1756
|
ledgerMatches ??= listRunLedgers().matches;
|
|
1676
1757
|
running ??= new Map(queryRunningRuns().entries.map((entry) => [entry.runId, entry.status]));
|
|
1677
|
-
|
|
1758
|
+
if (draft.runId) {
|
|
1759
|
+
const match = ledgerMatches.find((entry) => entry.runId === draft.runId);
|
|
1760
|
+
return match ? draftSweepRunStatus([match], running, repository, draft.number) : undefined;
|
|
1761
|
+
}
|
|
1762
|
+
return draftSweepRunStatus(ledgerMatches, running, repository, draft.number);
|
|
1678
1763
|
};
|
|
1679
1764
|
return {
|
|
1680
1765
|
listDrafts: async (page, perPage, repository): Promise<SweepDraft[]> =>
|
|
1681
1766
|
(await listed(open, repository, 'open')).filter((pr) => pr.draft)
|
|
1682
1767
|
.slice((page - 1) * perPage, page * perPage)
|
|
1683
1768
|
.map((pr) => ({ number: pr.number, title: pr.title, branch: pr.head.ref,
|
|
1684
|
-
labels: pr.labels.map((item) => item.name), createdAt: pr.created_at,
|
|
1769
|
+
labels: pr.labels.map((item) => item.name), createdAt: pr.created_at, body: pr.body ?? undefined,
|
|
1770
|
+
changedFiles: filesFor(repository, pr.number), ...(pr.updated_at ? { updatedAt: pr.updated_at } : {}),
|
|
1771
|
+
runId: currentDraftSweepRunId(ledgerMatches ??= listRunLedgers().matches, repository, pr.number) })),
|
|
1685
1772
|
listMerged: async (page, perPage, repository): Promise<SweepMergedPr[]> =>
|
|
1686
|
-
(await listed(closed, repository, 'closed')).filter((pr) => pr.merged_at !== null)
|
|
1773
|
+
(await listed(closed, repository, 'closed')).filter((pr) => pr.merged_at !== null && pr.base?.ref === 'main')
|
|
1687
1774
|
.slice((page - 1) * perPage, page * perPage)
|
|
1688
|
-
.map((pr) => ({ number: pr.number, title: pr.title, branch: pr.head.ref
|
|
1689
|
-
|
|
1775
|
+
.map((pr) => ({ number: pr.number, title: pr.title, branch: pr.head.ref, body: pr.body ?? undefined,
|
|
1776
|
+
mergedAt: pr.merged_at ?? undefined, changedFiles: filesFor(repository, pr.number),
|
|
1777
|
+
mergeCommitMessage: pr.merge_commit_sha ? ghJson<{ commit: { message: string } }>(
|
|
1778
|
+
['api', `repos/${repository}/commits/${pr.merge_commit_sha}`], execute).commit.message : undefined,
|
|
1779
|
+
landingVerifiedComments: landingCommentsFor(repository, pr.number) })),
|
|
1780
|
+
getRunStatus: async (draft, repository) => runStatusFor(draft, repository),
|
|
1781
|
+
getLatestFileChanges: async (draft, repository) => latestFileChangesFor(draft, repository),
|
|
1782
|
+
hasFinalRunResult: async (draft, repository) => {
|
|
1783
|
+
ledgerMatches ??= listRunLedgers().matches;
|
|
1784
|
+
const ownedRuns = new Set(ledgerMatches.filter((match) => {
|
|
1785
|
+
const prEvents = match.entries.filter((entry) => entry.event === 'pr-opened' && entry.data.number === draft.number);
|
|
1786
|
+
return prEvents.length > 0 && prEvents.every((entry) => ledgerPrRepository(entry.data, draft.number) === repository);
|
|
1787
|
+
}).map((match) => match.runId));
|
|
1788
|
+
if (ownedRuns.size === 0) return false;
|
|
1789
|
+
// A previous owner's final does not establish finality for a later owner of this PR.
|
|
1790
|
+
if (draft.runId && !ownedRuns.has(draft.runId)) return false;
|
|
1791
|
+
if (!draft.runId && ownedRuns.size > 1) return false;
|
|
1792
|
+
const currentOwners = draft.runId ? new Set([draft.runId]) : ownedRuns;
|
|
1793
|
+
const path = logsDbPath();
|
|
1794
|
+
if (!existsSync(path)) return false;
|
|
1795
|
+
const store = LogStore.openReadOnly(path);
|
|
1796
|
+
try {
|
|
1797
|
+
let beforeId: number | undefined;
|
|
1798
|
+
for (;;) {
|
|
1799
|
+
const rows = store.query({ exactCategories: ['self-implement.result'], events: ['final'], grep: String(draft.number),
|
|
1800
|
+
limit: 1_000, ...(beforeId === undefined ? {} : { beforeId }) });
|
|
1801
|
+
for (const row of rows) {
|
|
1802
|
+
try {
|
|
1803
|
+
const data = JSON.parse(row.data ?? '{}') as { runId?: string; prNumber?: number };
|
|
1804
|
+
if (data.prNumber === draft.number && data.runId && currentOwners.has(data.runId)) return true;
|
|
1805
|
+
} catch { /* A malformed result cannot establish finality. */ }
|
|
1806
|
+
}
|
|
1807
|
+
if (rows.length < 1_000) return false;
|
|
1808
|
+
beforeId = rows.at(-1)!.id;
|
|
1809
|
+
}
|
|
1810
|
+
} finally { store.close(); }
|
|
1811
|
+
},
|
|
1690
1812
|
listLiveBranches: async (repository) => {
|
|
1691
1813
|
const cwd = process.cwd();
|
|
1692
1814
|
const remote = git(cwd, ['config', '--get', 'remote.origin.url']);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const CONSULT_MAX_BYTES = 8 * 1024;
|
|
6
|
+
export const CONSULT_QUEUE_MAX_AGE_MS = 7 * 24 * 3600_000;
|
|
7
|
+
|
|
8
|
+
export interface ConsultInput {
|
|
9
|
+
name: string;
|
|
10
|
+
org?: string;
|
|
11
|
+
kind: 'company' | 'personal';
|
|
12
|
+
interest: 'A' | 'B';
|
|
13
|
+
contact: string;
|
|
14
|
+
consent: true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface QueuedConsult { id: string; receivedAt: string; consult: ConsultInput }
|
|
18
|
+
|
|
19
|
+
const singleLine = (value: string): string => value.trim().replace(/[\r\n\u2028\u2029]+/g, ' ');
|
|
20
|
+
|
|
21
|
+
/** The Primary accepts optional org and requires nonblank name/contact, kind, interest and explicit consent. */
|
|
22
|
+
export function validateConsult(body: unknown): { ok: true; consult: ConsultInput } | { ok: false; field: string } {
|
|
23
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { ok: false, field: 'body' };
|
|
24
|
+
const b = body as Record<string, unknown>;
|
|
25
|
+
if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 256) return { ok: false, field: 'name' };
|
|
26
|
+
if (b.org !== undefined && (typeof b.org !== 'string' || !b.org.trim() || b.org.length > 256)) return { ok: false, field: 'org' };
|
|
27
|
+
if (b.kind !== 'company' && b.kind !== 'personal') return { ok: false, field: 'kind' };
|
|
28
|
+
if (b.interest !== 'A' && b.interest !== 'B') return { ok: false, field: 'interest' };
|
|
29
|
+
if (typeof b.contact !== 'string' || !b.contact.trim() || b.contact.length > 512) return { ok: false, field: 'contact' };
|
|
30
|
+
if (b.consent !== true) return { ok: false, field: 'consent' };
|
|
31
|
+
return { ok: true, consult: { name: singleLine(b.name), ...(typeof b.org === 'string' ? { org: singleLine(b.org) } : {}),
|
|
32
|
+
kind: b.kind, interest: b.interest, contact: b.contact.trim(), consent: true } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Sliding windows; neither rejected hits nor invalid submissions consume quota. */
|
|
36
|
+
export class ConsultLimiter {
|
|
37
|
+
private readonly ips = new Map<string, number[]>();
|
|
38
|
+
private daily: number[] = [];
|
|
39
|
+
constructor(private readonly now: () => number = Date.now) {}
|
|
40
|
+
|
|
41
|
+
admit(ip: string): number | null {
|
|
42
|
+
const t = this.now();
|
|
43
|
+
const hits = (this.ips.get(ip) ?? []).filter(x => t - x < 600_000);
|
|
44
|
+
this.daily = this.daily.filter(x => t - x < 86_400_000);
|
|
45
|
+
const wait = hits.length >= 3 ? 600_000 - (t - hits[0]!) :
|
|
46
|
+
this.daily.length >= 200 ? 86_400_000 - (t - this.daily[0]!) : null;
|
|
47
|
+
if (wait !== null) { this.ips.set(ip, hits); return Math.max(1, Math.ceil(wait / 1000)); }
|
|
48
|
+
hits.push(t);
|
|
49
|
+
this.ips.set(ip, hits);
|
|
50
|
+
this.daily.push(t);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Atomic JSONL rewrite on every mutation, with a private file and directory. */
|
|
56
|
+
export class ConsultQueue {
|
|
57
|
+
readonly path: string;
|
|
58
|
+
constructor(root: string, private readonly log: (event: string, data: Record<string, unknown>) => void = () => {}) {
|
|
59
|
+
this.path = join(root, 'hooks', 'consult-queue.jsonl');
|
|
60
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private read(): QueuedConsult[] {
|
|
64
|
+
try { return readFileSync(this.path, 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line) as QueuedConsult); }
|
|
65
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private write(items: QueuedConsult[]): void {
|
|
69
|
+
const temp = `${this.path}.${randomUUID()}.tmp`;
|
|
70
|
+
try {
|
|
71
|
+
const fd = openSync(temp, 'wx', 0o600);
|
|
72
|
+
try { writeSync(fd, items.map(item => JSON.stringify(item)).join('\n') + (items.length ? '\n' : '')); fsyncSync(fd); }
|
|
73
|
+
finally { closeSync(fd); }
|
|
74
|
+
renameSync(temp, this.path);
|
|
75
|
+
} finally { rmSync(temp, { force: true }); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
enqueue(item: QueuedConsult): void { this.write([...this.read(), item]); }
|
|
79
|
+
entries(now: number = Date.now()): QueuedConsult[] {
|
|
80
|
+
const items = this.read();
|
|
81
|
+
const kept = items.filter(item => Number.isFinite(Date.parse(item.receivedAt)) && now - Date.parse(item.receivedAt) <= CONSULT_QUEUE_MAX_AGE_MS);
|
|
82
|
+
if (kept.length !== items.length) { this.write(kept); this.log('dropped', { expired: items.length - kept.length }); }
|
|
83
|
+
return kept;
|
|
84
|
+
}
|
|
85
|
+
count(): number { return existsSync(this.path) ? this.read().length : 0; }
|
|
86
|
+
delivered(id: string): void { this.write(this.read().filter(item => item.id !== id)); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Accept only a validated body; responses and logs never contain user-provided values. */
|
|
90
|
+
export async function handleConsultPost(request: Request, deps: {
|
|
91
|
+
limiter: ConsultLimiter; queue: ConsultQueue; now?: () => number; ip?: string;
|
|
92
|
+
log?: (event: string, data: Record<string, unknown>) => void; wake?: () => void;
|
|
93
|
+
}): Promise<Response> {
|
|
94
|
+
const log = deps.log ?? (() => {});
|
|
95
|
+
const declared = Number(request.headers.get('content-length') ?? '0');
|
|
96
|
+
if (declared > CONSULT_MAX_BYTES) { log('rejected', { reason: 'too-large' }); return Response.json({ error: 'too-large' }, { status: 413 }); }
|
|
97
|
+
const chunks: Uint8Array[] = [];
|
|
98
|
+
let size = 0;
|
|
99
|
+
const reader = request.body?.getReader();
|
|
100
|
+
try {
|
|
101
|
+
if (reader) {
|
|
102
|
+
while (true) {
|
|
103
|
+
const { done, value } = await reader.read();
|
|
104
|
+
if (done) break;
|
|
105
|
+
size += value.byteLength;
|
|
106
|
+
if (size > CONSULT_MAX_BYTES) {
|
|
107
|
+
await reader.cancel().catch(() => {});
|
|
108
|
+
log('rejected', { reason: 'too-large' });
|
|
109
|
+
return Response.json({ error: 'too-large' }, { status: 413 });
|
|
110
|
+
}
|
|
111
|
+
chunks.push(value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
log('rejected', { reason: 'body' });
|
|
116
|
+
return Response.json({ error: 'invalid', field: 'body' }, { status: 400 });
|
|
117
|
+
} finally { reader?.releaseLock(); }
|
|
118
|
+
let body: unknown;
|
|
119
|
+
try {
|
|
120
|
+
const raw = new Uint8Array(size);
|
|
121
|
+
let offset = 0;
|
|
122
|
+
for (const chunk of chunks) { raw.set(chunk, offset); offset += chunk.byteLength; }
|
|
123
|
+
body = JSON.parse(new TextDecoder().decode(raw));
|
|
124
|
+
}
|
|
125
|
+
catch { log('rejected', { reason: 'body' }); return Response.json({ error: 'invalid', field: 'body' }, { status: 400 }); }
|
|
126
|
+
const checked = validateConsult(body);
|
|
127
|
+
if (!checked.ok) { log('rejected', { reason: checked.field }); return Response.json({ error: 'invalid', field: checked.field }, { status: 400 }); }
|
|
128
|
+
const ip = deps.ip ?? 'unknown';
|
|
129
|
+
const wait = deps.limiter.admit(ip);
|
|
130
|
+
if (wait !== null) {
|
|
131
|
+
log('rejected', { reason: 'rate-limited' });
|
|
132
|
+
return Response.json({ error: 'rate-limited', retryAfterSec: wait }, { status: 429, headers: { 'Retry-After': String(wait) } });
|
|
133
|
+
}
|
|
134
|
+
const id = randomUUID();
|
|
135
|
+
try { deps.queue.enqueue({ id, receivedAt: new Date((deps.now ?? Date.now)()).toISOString(), consult: checked.consult }); }
|
|
136
|
+
catch { log('rejected', { reason: 'queue-unavailable' }); return Response.json({ error: 'queue-unavailable' }, { status: 503 }); }
|
|
137
|
+
log('received', { queued: true, kind: checked.consult.kind, interest: checked.consult.interest,
|
|
138
|
+
nameLength: checked.consult.name.length, contactLength: checked.consult.contact.length, orgLength: checked.consult.org?.length ?? 0 });
|
|
139
|
+
deps.wake?.();
|
|
140
|
+
return Response.json({ id }, { status: 202 });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Forward in arrival order; a failure leaves that item and all following items queued. */
|
|
144
|
+
export async function drainConsults(queue: ConsultQueue, forward: (item: QueuedConsult) => Promise<boolean>, now: number = Date.now()): Promise<{ delivered: number; failed: number }> {
|
|
145
|
+
let delivered = 0;
|
|
146
|
+
let failed = 0;
|
|
147
|
+
for (const item of queue.entries(now)) {
|
|
148
|
+
let ok = false;
|
|
149
|
+
try { ok = await forward(item); } catch { ok = false; }
|
|
150
|
+
if (ok) { queue.delivered(item.id); delivered++; } else { failed++; break; }
|
|
151
|
+
}
|
|
152
|
+
return { delivered, failed };
|
|
153
|
+
}
|
package/src/hooks/receiver.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { HookQueue, type QueuedHook } from './queue.js';
|
|
|
7
7
|
import { recordGithubShadow } from './github-shadow.js';
|
|
8
8
|
import { toExternalTask, verifyWebhook, type HookProvider } from './providers.js';
|
|
9
9
|
import { drainReports, handleReportPost, ReportLimiter, ReportQueue, type ReceivedReport } from './error-report.js';
|
|
10
|
+
import { ConsultLimiter, ConsultQueue, drainConsults, handleConsultPost, type QueuedConsult } from './consult-intake.js';
|
|
10
11
|
import { effectiveInstanceRoot } from '../instance/resolve.js';
|
|
11
12
|
|
|
12
13
|
export interface HookSecrets {
|
|
@@ -27,6 +28,11 @@ export interface HookReceiverOptions {
|
|
|
27
28
|
wakeSeat?: HookWake;
|
|
28
29
|
/** ER2 — forward one queued error report to the Primary; default = POST /v1/reports/ingest over the tailnet. */
|
|
29
30
|
forwardReport?: (item: ReceivedReport) => Promise<boolean>;
|
|
31
|
+
/** CS1 — forward a queued consultation to the Primary's consult ledger. */
|
|
32
|
+
forwardConsult?: (item: QueuedConsult) => Promise<boolean>;
|
|
33
|
+
/** CS1 — the public consult route is a write path from the open internet, so it stays closed (404) until
|
|
34
|
+
* `hooks.consult.enabled === true` in user config (대표 decision D-20261003-09). Tests pass it directly. */
|
|
35
|
+
consultEnabled?: boolean;
|
|
30
36
|
/** GET-only public metadata lookup; failure forces human review. */
|
|
31
37
|
githubFetch?: typeof fetch;
|
|
32
38
|
}
|
|
@@ -53,11 +59,66 @@ async function forwardReportToPrimary(item: ReceivedReport): Promise<boolean> {
|
|
|
53
59
|
return response.ok;
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
async function forwardConsultToPrimary(item: QueuedConsult): Promise<boolean> {
|
|
63
|
+
const config = JSON.parse(readFileSync(userConfigPath(), 'utf8')) as { hooks?: { primaryTokenRef?: string; primaryUrl?: unknown } };
|
|
64
|
+
const ref = config.hooks?.primaryTokenRef;
|
|
65
|
+
if (!ref) throw new Error('primary token reference missing');
|
|
66
|
+
const token = await getSecretAsync(ref);
|
|
67
|
+
if (!token) throw new Error('primary token missing');
|
|
68
|
+
const response = await fetch(new URL('/v1/consult-requests', hooksPrimaryUrl(config)), {
|
|
69
|
+
method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
70
|
+
body: JSON.stringify(item.consult), signal: AbortSignal.timeout(10_000),
|
|
71
|
+
});
|
|
72
|
+
return response.ok;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Raw read like the report forwarder: only an explicit `hooks.consult.enabled: true` opens the route. */
|
|
76
|
+
function consultRouteEnabled(): boolean {
|
|
77
|
+
try {
|
|
78
|
+
const config = JSON.parse(readFileSync(userConfigPath(), 'utf8')) as { hooks?: { consult?: { enabled?: unknown } } };
|
|
79
|
+
return config.hooks?.consult?.enabled === true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function consultOrigin(origin: string | null): string | null {
|
|
86
|
+
if (!origin) return null;
|
|
87
|
+
try {
|
|
88
|
+
const url = new URL(origin);
|
|
89
|
+
if (url.protocol !== 'https:' || url.origin !== origin) return null;
|
|
90
|
+
return url.hostname === 'elanous.ai' || url.hostname === 'www.elanous.ai' ||
|
|
91
|
+
/^[a-z0-9-]+\.vercel\.app$/i.test(url.hostname) ? origin : null;
|
|
92
|
+
} catch { return null; }
|
|
93
|
+
}
|
|
94
|
+
|
|
56
95
|
export function startHookReceiver(options: HookReceiverOptions): { url: string; stop: () => void; queue: HookQueue } {
|
|
57
96
|
const queue = new HookQueue(options.root);
|
|
58
97
|
const reportQueue = new ReportQueue(options.root ?? effectiveInstanceRoot(), (event, data) => debug.log('hooks.reports', event, data));
|
|
59
98
|
const reportLimiter = new ReportLimiter(options.now ?? Date.now);
|
|
60
99
|
const forwardReport = options.forwardReport ?? forwardReportToPrimary;
|
|
100
|
+
const consultEnabled = options.consultEnabled ?? consultRouteEnabled();
|
|
101
|
+
debug.log('hooks.consult', consultEnabled ? 'route-open' : 'route-closed', {});
|
|
102
|
+
const consultQueue = new ConsultQueue(options.root ?? effectiveInstanceRoot(), (_event, data) =>
|
|
103
|
+
debug.log('hooks.consult', 'rejected', { reason: 'expired', ...data }));
|
|
104
|
+
const consultLimiter = new ConsultLimiter(options.now ?? Date.now);
|
|
105
|
+
const forwardConsult = options.forwardConsult ?? forwardConsultToPrimary;
|
|
106
|
+
let consultFailures = 0;
|
|
107
|
+
let consultNextAt = 0;
|
|
108
|
+
let consultsDraining = false;
|
|
109
|
+
async function drainConsultQueue(): Promise<void> {
|
|
110
|
+
if (stopped || consultsDraining || Date.now() < consultNextAt) return;
|
|
111
|
+
consultsDraining = true;
|
|
112
|
+
try {
|
|
113
|
+
const result = await drainConsults(consultQueue, forwardConsult, (options.now ?? Date.now)());
|
|
114
|
+
if (result.delivered) debug.log('hooks.consult', 'forwarded', { queued: consultQueue.count(), delivered: result.delivered });
|
|
115
|
+
if (result.failed) {
|
|
116
|
+
consultFailures++;
|
|
117
|
+
consultNextAt = Date.now() + Math.min(300_000, Math.max(1, options.retryBaseMs ?? 1000) * 2 ** Math.min(consultFailures - 1, 20));
|
|
118
|
+
debug.log('hooks.consult', 'forward-failed', { queued: consultQueue.count(), reason: 'delivery-failed' });
|
|
119
|
+
} else consultFailures = 0;
|
|
120
|
+
} finally { consultsDraining = false; }
|
|
121
|
+
}
|
|
61
122
|
let reportFailures = 0;
|
|
62
123
|
let reportNextAt = 0;
|
|
63
124
|
let reportsDraining = false;
|
|
@@ -111,18 +172,34 @@ export function startHookReceiver(options: HookReceiverOptions): { url: string;
|
|
|
111
172
|
}
|
|
112
173
|
} finally {
|
|
113
174
|
void drainReportQueue();
|
|
175
|
+
void drainConsultQueue();
|
|
114
176
|
draining = false;
|
|
115
177
|
if (!stopped) timer = setTimeout(() => { void drain(); }, Math.min(1000, options.retryBaseMs ?? 1000));
|
|
116
178
|
}
|
|
117
179
|
}
|
|
118
180
|
const server = Bun.serve({ hostname: options.host ?? '127.0.0.1', port: options.port,
|
|
119
|
-
async fetch(request) {
|
|
181
|
+
async fetch(request, server) {
|
|
120
182
|
const url = new URL(request.url);
|
|
121
183
|
if (request.method === 'GET' && url.pathname === '/hooks/health')
|
|
122
184
|
return Response.json({ ok: true, queued: queue.count(), reportsQueued: reportQueue.count() });
|
|
123
185
|
if (request.method === 'POST' && url.pathname === '/v1/reports')
|
|
124
186
|
return handleReportPost(request, { limiter: reportLimiter, queue: reportQueue, ...(options.now ? { now: options.now } : {}),
|
|
125
187
|
log: (event, data) => debug.log('hooks.reports', event, data), wake: () => { reportNextAt = 0; void drainReportQueue(); } });
|
|
188
|
+
if (consultEnabled && url.pathname === '/v1/consult' && (request.method === 'POST' || request.method === 'OPTIONS')) {
|
|
189
|
+
const origin = consultOrigin(request.headers.get('origin'));
|
|
190
|
+
if (!origin) {
|
|
191
|
+
debug.log('hooks.consult', 'rejected', { reason: 'origin' });
|
|
192
|
+
return Response.json({ error: 'forbidden' }, { status: 403 });
|
|
193
|
+
}
|
|
194
|
+
const headers = { 'Access-Control-Allow-Origin': origin, Vary: 'Origin',
|
|
195
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' };
|
|
196
|
+
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers });
|
|
197
|
+
const response = await handleConsultPost(request, { limiter: consultLimiter, queue: consultQueue, ...(options.now ? { now: options.now } : {}),
|
|
198
|
+
ip: server.requestIP(request)?.address ?? 'unknown',
|
|
199
|
+
log: (event, data) => debug.log('hooks.consult', event, data), wake: () => { consultNextAt = 0; void drainConsultQueue(); } });
|
|
200
|
+
for (const [key, value] of Object.entries(headers)) response.headers.set(key, value);
|
|
201
|
+
return response;
|
|
202
|
+
}
|
|
126
203
|
if (request.method !== 'POST' || !/^\/hooks\/(linear|asana|github)$/.test(url.pathname)) return new Response('Not Found', { status: 404 });
|
|
127
204
|
const provider = url.pathname.slice('/hooks/'.length) as HookProvider;
|
|
128
205
|
const reject = (reason: string) => {
|
package/src/index.ts
CHANGED
|
@@ -3035,15 +3035,39 @@ contextCmd.command('hooks').description('Claude Code 맥락 훅 설정').command
|
|
|
3035
3035
|
const { claudeHooksSettings } = await import('./context-bus/claude-hooks-install.js');
|
|
3036
3036
|
console.log(claudeHooksSettings(REPOSITORY_ROOT));
|
|
3037
3037
|
});
|
|
3038
|
-
contextCmd.command('emit')
|
|
3039
|
-
.
|
|
3040
|
-
.
|
|
3041
|
-
.
|
|
3042
|
-
.
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3038
|
+
contextCmd.command('emit [sessionKind]')
|
|
3039
|
+
.description('외부 에이전트 세션 이벤트: emit <kind> --seat <자리> --text <한 줄> [--ref <PR|칸>]')
|
|
3040
|
+
.option('--seat <seat>', 'OP|TC|MK|UX')
|
|
3041
|
+
.option('--text <line>', '한 줄 이벤트')
|
|
3042
|
+
.option('--ref <PR|cell>', '연결 PR 또는 칸')
|
|
3043
|
+
.option('--kind <kind>', '기존 Claude Code 훅 이벤트 종류')
|
|
3044
|
+
.option('--summary <line>', '기존 훅 한 줄 요약')
|
|
3045
|
+
.option('--source <link>', '기존 훅 출처 링크')
|
|
3046
|
+
.action(async (sessionKind: string | undefined, opts: { seat?: string; text?: string; ref?: string; kind?: string; summary?: string; source?: string }) => {
|
|
3047
|
+
try {
|
|
3048
|
+
if (sessionKind !== undefined) {
|
|
3049
|
+
if (opts.seat === undefined || opts.text === undefined || opts.kind !== undefined || opts.summary !== undefined || opts.source !== undefined) {
|
|
3050
|
+
throw new Error('context emit <kind> requires --seat and --text (not hook options)');
|
|
3051
|
+
}
|
|
3052
|
+
const { emitSessionEvent, SESSION_EVENT_KINDS } = await import('./context-bus/session-events.js');
|
|
3053
|
+
if (!SESSION_EVENT_KINDS.includes(sessionKind as import('./context-bus/session-events.js').SessionEventKind)) {
|
|
3054
|
+
throw new Error(`unknown context session kind: ${sessionKind}`);
|
|
3055
|
+
}
|
|
3056
|
+
emitSessionEvent({ kind: sessionKind as import('./context-bus/session-events.js').SessionEventKind,
|
|
3057
|
+
seat: opts.seat, text: opts.text, ...(opts.ref === undefined ? {} : { ref: opts.ref }) });
|
|
3058
|
+
} else {
|
|
3059
|
+
if (opts.seat !== undefined || opts.text !== undefined || opts.ref !== undefined || !opts.kind || opts.summary === undefined || opts.source === undefined) {
|
|
3060
|
+
throw new Error('context emit hook requires --kind, --summary and --source');
|
|
3061
|
+
}
|
|
3062
|
+
if (!['claimed', 'done', 'asked', 'guide-changed'].includes(opts.kind)) throw new Error(`unknown context hook kind: ${opts.kind}`);
|
|
3063
|
+
const { recordExternalEvent } = await import('./context-bus/external-events.js');
|
|
3064
|
+
recordExternalEvent({ origin: 'claude-code', kind: opts.kind as import('./context-bus/external-events.js').ContextHookKind,
|
|
3065
|
+
summary: opts.summary, source: opts.source });
|
|
3066
|
+
}
|
|
3067
|
+
} catch (error) {
|
|
3068
|
+
console.error(`context emit: ${error instanceof Error ? error.message : String(error)}`);
|
|
3069
|
+
process.exitCode = 2;
|
|
3070
|
+
}
|
|
3047
3071
|
});
|
|
3048
3072
|
|
|
3049
3073
|
const coordCmd = program.command('coord').description('조율 채널 맥락 원장');
|