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
|
@@ -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
|
-
/** ① 중복 발사 제어 —
|
|
56
|
-
*
|
|
57
|
-
|
|
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
|
-
):
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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,
|
|
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
|
-
|
|
213
|
+
const suppressedBy = successfulDeliveryId(ledger, dedupKey);
|
|
214
|
+
if (suppressedBy) {
|
|
211
215
|
if (ownLedger) ledger.close();
|
|
212
|
-
|
|
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
|
}
|
package/src/persona/presets.ts
CHANGED
|
@@ -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
|
|
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 === '
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 -
|
|
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,
|
|
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.
|