elanous 0.2.7 → 0.2.9
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/bFI5EEwg7T6aG88ibX4Zq/_buildManifest.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7032-f4ef698518fba61c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8067-d976b441a104ab74.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8519-5632377cce994f86.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8630-cdfc61e82c653ffc.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8894-e263b332208e3957.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9454-139040d37c79dbc9.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9740-8f5ff96c522b5f55.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-fb7c2d458f32fcad.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/autopilot/page-35e2713597dd24d1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/board/page-918b656f064577ba.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/botlab/{page-624f7a2f89b60c8f.js → page-f972fc1c00a62e88.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-5a5fb385a4df0ffe.js → page-5f30e00693527ebd.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/{page-80497e93a878ddc5.js → page-7fbae173b3c103a7.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/{page-7457f52683562161.js → page-3b4edcce364062ea.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-c5794f484c251f44.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-1130d8eefcd87e76.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/editor/page-973996d584f008c1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/exec/{page-b7dcf5d48a854b76.js → page-016e2bda04eecb1f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-51480757e7d68b92.js → page-b4d6d9a3b56f2940.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/page-ec49a466121233ba.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/layout-f72db356744ceade.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/live/page-a6fd9b50d1b85378.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/page-8b0402a46a749a3d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-07267f1391b96aba.js → page-037f83e2a1edc8e2.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-55d50e9b0006a91d.js → page-adfbd2ba9598e59a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-46fedff57d4bec67.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-0cf318e9dcee2bbf.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-899b327cb8b6845c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-6c1e6ba743a29dfe.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/{page-a7e37af7d0399cb8.js → page-1a8e4807292f6b17.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-28deba6bcb834916.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-de68e2205794037d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/sessions/{page-15e5a15db84c56aa.js → page-c71c33a573230672.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-a2e572e4511f2a16.js → page-0df30e0c7e50148a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/page-4fc56790fe0d2682.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-59df2aebb38a7c1e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/page-4647be5dbcbd0795.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/share/page-e025892bda74b743.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-a0955de73b89f078.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/{page-c1c95739c0bff3b8.js → page-369846d2921af584.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-72cbc1c8cdd0f65d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/trace/{page-71d18eec33b9c667.js → page-77b28bfb0996d5ea.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-3220c4934f49c6c2.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/{page-2c4e59079ee357ac.js → page-90670589b87abea8.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-7df9243ed021a4cc.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-5d422f38c052967e.js → page-4498c3853f4f688d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-35bedde6d885f08f.js +1 -0
- package/apps/pwa/out/_next/static/css/f92f63a5df0ba229.css +1 -0
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +30 -29
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +30 -29
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +30 -29
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +27 -26
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +27 -26
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +27 -26
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +30 -29
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +30 -29
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +30 -29
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +30 -29
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +30 -29
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +30 -29
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +27 -26
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +30 -29
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +30 -29
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +27 -26
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +27 -26
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +30 -29
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +30 -29
- package/apps/pwa/out/ops/checklist/index.html +1 -0
- package/apps/pwa/out/ops/checklist/index.txt +30 -0
- package/apps/pwa/out/ops/release/index.html +1 -0
- package/apps/pwa/out/ops/release/index.txt +30 -0
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +28 -27
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +30 -29
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +27 -26
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +30 -29
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +30 -29
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +30 -29
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +30 -29
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +30 -29
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +27 -26
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +30 -29
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +30 -29
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +30 -29
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +30 -29
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +30 -29
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +30 -29
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +30 -29
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +30 -29
- package/graphs/release/recipes.yaml +3 -0
- package/graphs/release/release-loop.yaml +3 -1
- package/graphs/test-diet/nightly-audit.yaml +18 -0
- package/graphs/test-diet/recipes.yaml +12 -0
- package/graphs/test-diet/test-diet.yaml +22 -0
- package/package.json +2 -1
- package/persona-presets/_index.yaml +10 -0
- package/persona-presets/_schema.json +146 -0
- package/persona-presets/dana.yaml +26 -0
- package/persona-presets/haru.yaml +27 -0
- package/persona-presets/kai.yaml +27 -0
- package/persona-presets/leo.yaml +27 -0
- package/persona-presets/mira.yaml +30 -0
- package/persona-presets/nari.yaml +27 -0
- package/persona-presets/sora.yaml +27 -0
- package/persona-presets/yuna.yaml +27 -0
- package/plugins/doc-draft/graphs/run-step.ts +1 -1
- package/plugins/geo-check/graphs/run-step.ts +34 -10
- package/scripts/field-feed/deliver-node.ts +8 -3
- package/scripts/field-feed/lib.ts +2 -0
- package/scripts/install.sh +20 -11
- package/scripts/persona-presets/lint.ts +79 -0
- package/scripts/release-loop/checklist-gate-node.ts +2 -2
- package/scripts/release-loop/cutoff.ts +20 -2
- package/scripts/release-loop/export-check-node.ts +85 -0
- package/scripts/release-loop/gate-node.ts +45 -7
- package/scripts/release-loop/node-verdict.ts +8 -1
- package/scripts/release-loop/npm-publish-node.ts +10 -4
- package/scripts/release-loop/ops-upgrade-node.ts +11 -3
- package/scripts/release-loop/publish-node.ts +23 -1
- package/scripts/release-loop/shard-plan.ts +17 -0
- package/scripts/storyboard/storyboard.ts +167 -0
- package/scripts/test-diet/lib.ts +89 -0
- package/scripts/test-diet/node.ts +144 -0
- package/src/acp/auth-methods.ts +30 -0
- package/src/acp/server.ts +5 -5
- package/src/auth/setup-link-tokens.fixture.ts +19 -0
- package/src/auth/setup-link-tokens.ts +153 -0
- package/src/budget/unified-usage.ts +75 -0
- package/src/channel-bot-token.ts +37 -10
- package/src/channel-import/detect.ts +191 -0
- package/src/channel-import/run.ts +111 -0
- package/src/chat/index.ts +9 -9
- package/src/cli/config-test-sync.ts +4 -0
- package/src/cli/decisions-cli.ts +13 -3
- package/src/cli/doctor-bundle.ts +128 -0
- package/src/cli/doctor-cli.ts +36 -1
- package/src/cli/doctor-local-llm.ts +95 -0
- package/src/cli/import-cli.ts +60 -0
- package/src/cli/mcp-login.ts +13 -4
- package/src/cli/op-proxy-sync.ts +83 -0
- package/src/cli/persona-cli.ts +116 -0
- package/src/cli/pr-cli.ts +6 -2
- package/src/cli/release-cli.ts +49 -8
- package/src/cli/skills-cli.ts +173 -0
- package/src/cli/start-cli.ts +13 -4
- package/src/codex/setup.ts +15 -13
- package/src/dashboard/dashboard-status-lines.ts +45 -0
- package/src/dashboard/first-screen-band.ts +35 -0
- package/src/dashboard/index.ts +87 -14
- package/src/dashboard/provider-slash-runtime.ts +28 -11
- package/src/dashboard/slash-runtime/dashboard-handlers.ts +12 -2
- package/src/dashboard/slash-runtime/help-from-registry.ts +4 -2
- package/src/dashboard/turn-stream-formatter.ts +1 -1
- package/src/decisions/decision-cards.ts +291 -0
- package/src/decisions/decision-ledger.ts +5 -2
- package/src/decisions/discord-decision-cards.ts +133 -0
- package/src/decisions/telegram-decision-cards.ts +100 -0
- package/src/directives/version-at.ts +77 -0
- package/src/discord.ts +17 -0
- package/src/domains/outbound-alert.ts +44 -1
- package/src/domains/repeated-failure.ts +1 -1
- package/src/error-report/report.ts +200 -0
- package/src/exec-requests/planner.ts +8 -2
- package/src/exec-requests/runner.ts +64 -24
- package/src/exec-requests/store.ts +2 -0
- package/src/field/field-feed-auto.ts +48 -0
- package/src/field/field-reel.ts +8 -1
- package/src/hooks/error-report.ts +210 -0
- package/src/hooks/receiver.ts +41 -1
- package/src/import/apply.ts +128 -0
- package/src/import/plan.ts +197 -0
- package/src/index.ts +98 -11
- package/src/input-core/text-entry.ts +13 -5
- package/src/intake-plane/seat-answer.ts +96 -0
- package/src/intake-plane/seat-doc-route.ts +17 -3
- package/src/maturity/feature-maturity.ts +2 -0
- package/src/nexus/api/consult-requests.ts +19 -8
- package/src/nexus/api/error-reports.ts +108 -0
- package/src/nexus/api/http-server.ts +41 -1
- package/src/nexus/api/meta-api.ts +27 -0
- package/src/nexus/api/operator.ts +61 -0
- package/src/nexus/api/ops-api.ts +139 -0
- package/src/nexus/api/ops-seats-sources.ts +65 -0
- package/src/nexus/api/ops-seats.ts +114 -0
- package/src/nexus/api/outbound-report.ts +7 -0
- package/src/nexus/api/public-routes.ts +14 -1
- package/src/nexus/api/running-runs-background.ts +68 -0
- package/src/nexus/api/running-runs-child.ts +6 -0
- package/src/nexus/api/setup-claim.ts +21 -0
- package/src/nexus/api/share-captures.ts +91 -0
- package/src/nexus/api/skill-problems.ts +56 -0
- package/src/nexus/api/terminals.ts +24 -8
- package/src/nexus/config/secrets/file-backend.ts +56 -18
- package/src/nexus/index.ts +25 -1
- package/src/nexus/setup-status.ts +7 -2
- package/src/oauth/browser-availability.ts +14 -0
- package/src/oauth/codex-quota-policy.ts +16 -0
- package/src/onboarding/continue-here.ts +13 -8
- package/src/onboarding/first-run-doctor.ts +54 -0
- package/src/onboarding/web-first.ts +8 -1
- package/src/onboarding.ts +66 -12
- package/src/persona/presets.ts +105 -0
- package/src/release-loop/checklist.ts +33 -69
- package/src/release-loop/feature-store.ts +327 -0
- package/src/release-loop/manifest.ts +59 -5
- package/src/seat-dispatch/ceo-commands.ts +65 -0
- package/src/self-dev/orchestrate.ts +18 -11
- package/src/shell-env-bootstrap.ts +44 -7
- package/src/skills/connect-claude-plugin.ts +142 -0
- package/src/skills/connect.ts +70 -0
- package/src/skills/index.ts +29 -1
- package/src/skills/repair.ts +124 -0
- package/src/skills/runner.ts +11 -2
- package/src/skills/tools/usage-slash.ts +8 -7
- package/src/skills/trigger-extract.ts +1 -1
- package/src/task-orchestrator/surfaces/pod-command-job.ts +16 -8
- package/src/telegram-commands.ts +29 -0
- package/src/telegram-run.ts +83 -13
- package/src/telegram.ts +34 -0
- package/src/thinking-line.ts +28 -20
- package/src/tui/chat/execution-badge.ts +1 -1
- package/src/user-config.ts +111 -13
- package/src/ux-sim/turn-states.ts +1 -1
- package/src/version/packed-revision.json +1 -1
- package/apps/pwa/out/_next/static/chunks/4836-2eba6079f31b88d4.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8067-f74bf14d67ad98f1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8519-f6b5d8a5833a7603.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8894-e92491a164192cc1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9454-a3e0953307216c58.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9740-67051141115ce775.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9804-64c02c3c127d5014.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-d5ebde04844689e6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/autopilot/page-10ed24f413ed66e9.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-211810b6c93ab66a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-cee86394de407aa6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-4ff8edab44196b8c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/page-92e4f44428d5e290.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/page-2a05dfe80c8bdf16.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-d9979633aa82f8e6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-30533117beb53625.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/market/page-f7857826c2f97ad7.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-9699b1786be6171d.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-43dd772aee8af75f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-973ab7af59c718b7.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a9f8d3865b91e436.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/page-dd107bf1c7d52a90.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d42b99cdfb9f27c6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/page-2764ff40dad5a995.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/share/page-c5f1152db116ff96.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-f933bca81ea38a6f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-a7aaffcf771662d8.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-56adbe1a1f1379bd.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-a81f113f3ef89d72.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-fc27b00e20733146.js +0 -1
- package/apps/pwa/out/_next/static/css/f537bdb19c69306a.css +0 -1
- package/apps/pwa/out/_next/static/keajW7DDyghEs1y8oJe6T/_buildManifest.js +0 -1
- /package/apps/pwa/out/_next/static/{keajW7DDyghEs1y8oJe6T → bFI5EEwg7T6aG88ibX4Zq}/_ssgManifest.js +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import * as store from './feature-store.js';
|
|
5
4
|
import { debug } from '../debug/log.js';
|
|
6
|
-
import { releaseLedgerRoot } from '../instance/resolve.js';
|
|
7
5
|
import { CliUserError } from '../cli/cli-user-error.js';
|
|
8
6
|
|
|
9
7
|
export type ChecklistStatus = 'green' | 'yellow' | 'red' | 'done';
|
|
10
8
|
export type ChecklistDisposition = 'move' | 'known-issue' | 'block';
|
|
9
|
+
/** `screen` = a five-surface feature cell — its evidence carries a `짝:` line (MANUAL-five-surface-parity §A). */
|
|
10
|
+
export type ChecklistKind = 'screen';
|
|
11
11
|
export interface ChecklistItem {
|
|
12
12
|
id: string;
|
|
13
13
|
title: string;
|
|
@@ -15,6 +15,7 @@ export interface ChecklistItem {
|
|
|
15
15
|
owner?: string;
|
|
16
16
|
evidence?: string;
|
|
17
17
|
disposition?: ChecklistDisposition;
|
|
18
|
+
kind?: ChecklistKind;
|
|
18
19
|
updatedAt: string;
|
|
19
20
|
updatedBy: string;
|
|
20
21
|
}
|
|
@@ -40,67 +41,12 @@ export function devVersion(): string {
|
|
|
40
41
|
return (JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf8')) as { version: string }).version;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
function releasedVersion(): string {
|
|
44
|
-
const dir = join(releaseLedgerRoot(), 'release');
|
|
45
|
-
if (!existsSync(dir)) return '';
|
|
46
|
-
return readdirSync(dir).filter((v) => /^\d+\.\d+\.\d+$/.test(v) && existsSync(join(dir, v, 'release.json')))
|
|
47
|
-
.filter((v) => {
|
|
48
|
-
try { const record = JSON.parse(readFileSync(join(dir, v, 'release.json'), 'utf8')) as { version?: string; publishedAt?: string }; return record.version === v && typeof record.publishedAt === 'string'; }
|
|
49
|
-
catch { return false; }
|
|
50
|
-
})
|
|
51
|
-
.sort((a, b) => {
|
|
52
|
-
const aa = a.split('.').map(Number), bb = b.split('.').map(Number);
|
|
53
|
-
return (bb[0]! - aa[0]!) || (bb[1]! - aa[1]!) || (bb[2]! - aa[2]!);
|
|
54
|
-
})[0] ?? '';
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function pathFor(v: string): string {
|
|
58
|
-
if (!/^\d+\.\d+\.\d+(?:-(?:rc|alpha|beta)\.\d+)?$/.test(v)) throw new CliUserError(`체크리스트 판이 아니다: ${v}`);
|
|
59
|
-
return join(releaseLedgerRoot(), 'release', v, 'checklist.json');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
44
|
export function listChecklist(v: string): Checklist {
|
|
63
|
-
|
|
64
|
-
if (existsSync(path)) {
|
|
65
|
-
const data = JSON.parse(readFileSync(path, 'utf8')) as Checklist;
|
|
66
|
-
refresh(data);
|
|
67
|
-
return data;
|
|
68
|
-
}
|
|
69
|
-
return { version: v, released: releasedVersion(), dev: devVersion(), items: [], history: [] };
|
|
45
|
+
return store.list(v);
|
|
70
46
|
}
|
|
71
47
|
|
|
72
|
-
// SQLite's OS-backed write lock is released when the owning process exits, even without a finally block.
|
|
73
|
-
// Hold it across the JSON read/modify/atomic-rename sequence so writers never use stale snapshots.
|
|
74
48
|
function mutate(v: string, apply: (data: Checklist) => boolean): Checklist {
|
|
75
|
-
|
|
76
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
77
|
-
const lock = `${path}.mutex.sqlite`;
|
|
78
|
-
const db = new Database(lock, { create: true, strict: true });
|
|
79
|
-
try {
|
|
80
|
-
chmodSync(lock, 0o600);
|
|
81
|
-
db.exec('PRAGMA busy_timeout = 10000');
|
|
82
|
-
db.exec('BEGIN IMMEDIATE');
|
|
83
|
-
try {
|
|
84
|
-
const data = listChecklist(v);
|
|
85
|
-
if (apply(data)) save(v, data);
|
|
86
|
-
db.exec('COMMIT');
|
|
87
|
-
return data;
|
|
88
|
-
} catch (error) {
|
|
89
|
-
db.exec('ROLLBACK');
|
|
90
|
-
throw error;
|
|
91
|
-
}
|
|
92
|
-
} finally { db.close(); }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function save(v: string, data: Checklist): void {
|
|
96
|
-
const path = pathFor(v);
|
|
97
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
98
|
-
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
99
|
-
try {
|
|
100
|
-
writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
101
|
-
chmodSync(temp, 0o600);
|
|
102
|
-
renameSync(temp, path);
|
|
103
|
-
} finally { if (existsSync(temp)) rmSync(temp); }
|
|
49
|
+
return store.mutate(v, store.releasedVersion(), devVersion(), apply);
|
|
104
50
|
}
|
|
105
51
|
|
|
106
52
|
function change(data: Checklist, id: string, field: string, from: unknown, to: unknown, by: string, at: string): void {
|
|
@@ -108,29 +54,29 @@ function change(data: Checklist, id: string, field: string, from: unknown, to: u
|
|
|
108
54
|
debug.log('release-loop.checklist', 'change', { version: data.version, id, field, from: from ?? null, to: to ?? null, by });
|
|
109
55
|
}
|
|
110
56
|
|
|
111
|
-
function
|
|
112
|
-
|
|
113
|
-
export function addItem(v: string, input: { id: string; title: string; owner?: string }): Checklist {
|
|
57
|
+
export function addItem(v: string, input: { id: string; title: string; owner?: string; kind?: ChecklistKind }): Checklist {
|
|
114
58
|
if (!input.id.trim()) throw new CliUserError('칸 id 가 비었다');
|
|
115
59
|
if (!input.title.trim()) throw new CliUserError('칸 제목이 비었다');
|
|
60
|
+
if (input.kind !== undefined && input.kind !== 'screen') throw new CliUserError(`잘못된 종류: ${input.kind}`, 'screen');
|
|
116
61
|
return mutate(v, (data) => {
|
|
117
62
|
if (data.items.some((item) => item.id === input.id)) throw new CliUserError(`이미 있는 칸: ${input.id}`, 'set <id> 로 고친다');
|
|
118
63
|
const by = process.env.ELANOUS_TRACK || 'cli';
|
|
119
64
|
const at = new Date().toISOString();
|
|
120
|
-
const item: ChecklistItem = { id: input.id, title: input.title, status: 'yellow', ...(input.owner !== undefined ? { owner: input.owner } : {}), updatedAt: at, updatedBy: by };
|
|
65
|
+
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 };
|
|
121
66
|
data.items.push(item);
|
|
122
67
|
change(data, item.id, 'add', null, item, by, at);
|
|
123
68
|
return true;
|
|
124
69
|
});
|
|
125
70
|
}
|
|
126
71
|
|
|
127
|
-
export function setItem(v: string, id: string, patch: { status?: ChecklistStatus; evidence?: string; owner?: string; disposition?: ChecklistDisposition }, by: string): Checklist {
|
|
72
|
+
export function setItem(v: string, id: string, patch: { status?: ChecklistStatus; evidence?: string; owner?: string; disposition?: ChecklistDisposition; kind?: ChecklistKind }, by: string): Checklist {
|
|
128
73
|
return mutate(v, (data) => {
|
|
129
74
|
const item = data.items.find((i) => i.id === id);
|
|
130
75
|
if (!item) throw new CliUserError(`없는 칸: ${id}`, 'list 로 칸 목록을 본다');
|
|
131
76
|
if (patch.status !== undefined && !['green', 'yellow', 'red', 'done'].includes(patch.status)) throw new CliUserError(`잘못된 상태: ${patch.status}`);
|
|
132
77
|
if (patch.disposition !== undefined && !['move', 'known-issue', 'block'].includes(patch.disposition)) throw new CliUserError(`잘못된 처분: ${patch.disposition}`);
|
|
133
|
-
|
|
78
|
+
if (patch.kind !== undefined && patch.kind !== 'screen') throw new CliUserError(`잘못된 종류: ${patch.kind}`, 'screen');
|
|
79
|
+
const fields = (['evidence', 'owner', 'status', 'disposition', 'kind'] as const).filter((field) => patch[field] !== undefined && patch[field] !== item[field]);
|
|
134
80
|
if (fields.length === 0) return false;
|
|
135
81
|
const at = new Date().toISOString();
|
|
136
82
|
for (const field of fields) {
|
|
@@ -180,11 +126,29 @@ export interface ChecklistGate {
|
|
|
180
126
|
blocked: string[];
|
|
181
127
|
moved: string[];
|
|
182
128
|
knownIssues: Array<{ id: string; title: string; evidence: string }>;
|
|
129
|
+
/** Screen cells whose `짝:` line is missing or has an untracked ⏳ — a warning; it never changes `ok`. */
|
|
130
|
+
parity?: Array<{ id: string; why: string }>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const PARITY_LINE = /짝:\s*PWA\s*(.+?)\s*·\s*데스크톱\s*(.+?)\s*·\s*폴드\s*(.+?)\s*·\s*아이폰\s*(.+?)\s*·\s*아이패드\s*(.+?)\s*$/m;
|
|
134
|
+
const PARITY_SURFACES = ['PWA', '데스크톱', '폴드', '아이폰', '아이패드'] as const;
|
|
135
|
+
|
|
136
|
+
/** Why a screen cell's evidence fails the five-surface rule, or null when it passes. */
|
|
137
|
+
export function parityGap(evidence: string | undefined): string | null {
|
|
138
|
+
if (!evidence || !/짝:/.test(evidence)) return '근거에 짝: 줄이 없다';
|
|
139
|
+
const match = PARITY_LINE.exec(evidence);
|
|
140
|
+
if (!match) return '짝: 줄에 다섯 열(PWA · 데스크톱 · 폴드 · 아이폰 · 아이패드)이 다 없다';
|
|
141
|
+
const untracked = PARITY_SURFACES.filter((_, i) => match[i + 1]!.includes('⏳') && !/\(칸 /.test(match[i + 1]!));
|
|
142
|
+
return untracked.length ? `⏳ 에 (칸 …) 번호가 없다: ${untracked.join(', ')}` : null;
|
|
183
143
|
}
|
|
184
144
|
|
|
185
145
|
export function checklistGate(v: string): ChecklistGate {
|
|
186
|
-
const result: ChecklistGate = { ok: true, red: [], undecided: [], blocked: [], moved: [], knownIssues: [] };
|
|
146
|
+
const result: ChecklistGate = { ok: true, red: [], undecided: [], blocked: [], moved: [], knownIssues: [], parity: [] };
|
|
187
147
|
for (const item of listChecklist(v).items) {
|
|
148
|
+
if (item.kind === 'screen') {
|
|
149
|
+
const why = parityGap(item.evidence);
|
|
150
|
+
if (why) result.parity!.push({ id: item.id, why });
|
|
151
|
+
}
|
|
188
152
|
if (item.status === 'red') result.red.push(item.id);
|
|
189
153
|
if (item.status !== 'yellow') continue;
|
|
190
154
|
if (item.disposition === 'block') result.blocked.push(item.id);
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { Database } from 'bun:sqlite';
|
|
5
|
+
import { releaseLedgerRoot } from '../instance/resolve.js';
|
|
6
|
+
import { CliUserError } from '../cli/cli-user-error.js';
|
|
7
|
+
import { debug } from '../debug/log.js';
|
|
8
|
+
import { devVersion, type Checklist, type ChecklistHistory, type ChecklistItem } from './checklist.js';
|
|
9
|
+
|
|
10
|
+
export function validateVersion(version: string): void {
|
|
11
|
+
if (!/^\d+\.\d+\.\d+(?:-(?:rc|alpha|beta)\.\d+)?$/.test(version)) throw new CliUserError(`체크리스트 판이 아니다: ${version}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function jsonPath(version: string): string { validateVersion(version); return join(releaseLedgerRoot(), 'release', version, 'checklist.json'); }
|
|
15
|
+
function legacyVersions(): string[] {
|
|
16
|
+
const root = join(releaseLedgerRoot(), 'release');
|
|
17
|
+
if (!existsSync(root)) return [];
|
|
18
|
+
return readdirSync(root).filter((version) => /^\d+\.\d+\.\d+(?:-(?:rc|alpha|beta)\.\d+)?$/.test(version) && existsSync(jsonPath(version)));
|
|
19
|
+
}
|
|
20
|
+
export function releasedVersion(): string {
|
|
21
|
+
const root = join(releaseLedgerRoot(), 'release');
|
|
22
|
+
if (!existsSync(root)) return '';
|
|
23
|
+
return readdirSync(root).filter((version) => /^\d+\.\d+\.\d+$/.test(version) && existsSync(join(root, version, 'release.json')))
|
|
24
|
+
.filter((version) => {
|
|
25
|
+
try { const record = JSON.parse(readFileSync(join(root, version, 'release.json'), 'utf8')) as { version?: string; publishedAt?: string }; return record.version === version && typeof record.publishedAt === 'string'; }
|
|
26
|
+
catch { return false; }
|
|
27
|
+
}).sort((a, b) => {
|
|
28
|
+
const aa = a.split('.').map(Number), bb = b.split('.').map(Number);
|
|
29
|
+
return (bb[0]! - aa[0]!) || (bb[1]! - aa[1]!) || (bb[2]! - aa[2]!);
|
|
30
|
+
})[0] ?? '';
|
|
31
|
+
}
|
|
32
|
+
const walWait = new Int32Array(new SharedArrayBuffer(4));
|
|
33
|
+
function open(): Database {
|
|
34
|
+
const path = join(releaseLedgerRoot(), 'release', 'features.sqlite');
|
|
35
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
36
|
+
const db = new Database(path, { create: true, strict: true });
|
|
37
|
+
try {
|
|
38
|
+
chmodSync(path, 0o600);
|
|
39
|
+
db.exec('PRAGMA busy_timeout = 10000');
|
|
40
|
+
if ((db.query('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode !== 'wal') {
|
|
41
|
+
// First open may race another process initializing the same database; journal_mode needs an exclusive lock.
|
|
42
|
+
for (let attempt = 0; ; attempt++) {
|
|
43
|
+
try { db.exec('PRAGMA journal_mode = WAL'); break; }
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (!(error instanceof Error) || !/database is locked/.test(error.message) || attempt >= 500) throw error;
|
|
46
|
+
Atomics.wait(walWait, 0, 0, 20);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
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
|
+
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);
|
|
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
|
+
CREATE TABLE IF NOT EXISTS imported_versions (version TEXT PRIMARY KEY);`);
|
|
56
|
+
return db;
|
|
57
|
+
} catch (error) { db.close(); throw error; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function transaction<T>(db: Database, work: () => T): T {
|
|
61
|
+
db.exec('BEGIN IMMEDIATE');
|
|
62
|
+
try { const result = work(); db.exec('COMMIT'); return result; }
|
|
63
|
+
catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
64
|
+
}
|
|
65
|
+
/** Read-only counterpart: one deferred transaction so a snapshot never mixes rows from before and after a concurrent write. */
|
|
66
|
+
function readTransaction<T>(db: Database, work: () => T): T {
|
|
67
|
+
db.exec('BEGIN DEFERRED');
|
|
68
|
+
try { const result = work(); db.exec('COMMIT'); return result; }
|
|
69
|
+
catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type EventRow = { seq: number; at: string; by: string; feature_id: string; version: string; field: string; from: string; to: string; released: string; dev: string };
|
|
73
|
+
function decode(value: string | null): unknown { return value === null ? null : JSON.parse(value); }
|
|
74
|
+
function historyRow(row: EventRow): ChecklistHistory {
|
|
75
|
+
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 };
|
|
76
|
+
}
|
|
77
|
+
function record(db: Database, version: string, entry: ChecklistHistory): void {
|
|
78
|
+
db.query('INSERT INTO events (at, by, feature_id, version, field, "from", "to", released, dev) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
79
|
+
.run(entry.at, entry.by, entry.id, version, entry.field, JSON.stringify(entry.from ?? null), JSON.stringify(entry.to ?? null), entry.released, entry.dev);
|
|
80
|
+
}
|
|
81
|
+
function evidenceRefs(db: Database, version: string, id: string): string[] {
|
|
82
|
+
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);
|
|
83
|
+
}
|
|
84
|
+
function items(db: Database, version: string): ChecklistItem[] {
|
|
85
|
+
const rows = db.query(`SELECT f.id, COALESCE(a.title_override, f.title) AS title, a.owner, a.kind, a.status, a.disposition, a.evidence, a.updated_at, a.updated_by
|
|
86
|
+
FROM assignments a JOIN features f ON f.id = a.feature_id WHERE a.version = ? ORDER BY a.rowid`).all(version) as Array<Record<string, string | null>>;
|
|
87
|
+
return rows.map((r) => {
|
|
88
|
+
const evidence = [r.evidence, ...evidenceRefs(db, version, r.id!)].filter((value) => value !== null).join('\n');
|
|
89
|
+
return { id: r.id!, title: r.title!, status: r.status as ChecklistItem['status'],
|
|
90
|
+
...(r.owner !== null ? { owner: r.owner! } : {}), ...(r.kind !== null ? { kind: r.kind as ChecklistItem['kind'] } : {}),
|
|
91
|
+
...(r.evidence !== null || evidence ? { evidence } : {}), ...(r.disposition !== null ? { disposition: r.disposition as ChecklistItem['disposition'] } : {}),
|
|
92
|
+
updatedAt: r.updated_at!, updatedBy: r.updated_by! };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function snapshot(db: Database, version: string, released: string, dev: string): Checklist {
|
|
96
|
+
const rows = db.query('SELECT * FROM events WHERE version = ? ORDER BY seq').all(version) as EventRow[];
|
|
97
|
+
return { version, released, dev, items: items(db, version), history: rows.map(historyRow) };
|
|
98
|
+
}
|
|
99
|
+
function putItem(db: Database, version: string, item: ChecklistItem): void {
|
|
100
|
+
const feature = db.query('SELECT title FROM features WHERE id = ?').get(item.id) as { title: string } | null;
|
|
101
|
+
if (!feature) db.query('INSERT INTO features (id, title, owner, kind, created_at) VALUES (?, ?, ?, ?, ?)').run(item.id, item.title, item.owner ?? null, item.kind ?? null, item.updatedAt);
|
|
102
|
+
// features.owner/kind follow the latest assignment so details() and the checklist never disagree after a set.
|
|
103
|
+
else if (item.owner !== undefined || item.kind !== undefined) db.query('UPDATE features SET owner = COALESCE(?, owner), kind = COALESCE(?, kind) WHERE id = ?').run(item.owner ?? null, item.kind ?? null, item.id);
|
|
104
|
+
db.query(`INSERT INTO assignments (feature_id, version, status, disposition, evidence, title_override, owner, kind, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
105
|
+
ON CONFLICT(feature_id, version) DO UPDATE SET status=excluded.status, disposition=excluded.disposition, evidence=excluded.evidence, owner=excluded.owner, kind=excluded.kind, updated_at=excluded.updated_at, updated_by=excluded.updated_by`)
|
|
106
|
+
.run(item.id, version, item.status, item.disposition ?? null, item.evidence ?? null, feature && feature.title !== item.title ? item.title : null, item.owner ?? null, item.kind ?? null, item.updatedAt, item.updatedBy);
|
|
107
|
+
}
|
|
108
|
+
function importOnDb(db: Database, version: string): boolean {
|
|
109
|
+
if (db.query('SELECT 1 FROM imported_versions WHERE version = ?').get(version)) return false;
|
|
110
|
+
const path = jsonPath(version);
|
|
111
|
+
if (!existsSync(path)) {
|
|
112
|
+
db.query('INSERT INTO imported_versions (version) VALUES (?)').run(version);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const data = JSON.parse(readFileSync(path, 'utf8')) as Checklist;
|
|
116
|
+
if (data.version !== version) throw new CliUserError(`체크리스트 판 불일치: ${path}`);
|
|
117
|
+
if (!Array.isArray(data.items) || !Array.isArray(data.history)) throw new CliUserError(`잘못된 체크리스트: ${path}`);
|
|
118
|
+
const duplicate = new Set<string>();
|
|
119
|
+
for (const item of data.items) {
|
|
120
|
+
if (!item.id?.trim() || !item.title?.trim() || duplicate.has(item.id) || !['green', 'yellow', 'red', 'done'].includes(item.status)) throw new CliUserError(`잘못된 체크리스트 칸: ${version} ${item.id}`);
|
|
121
|
+
duplicate.add(item.id);
|
|
122
|
+
}
|
|
123
|
+
for (const item of data.items) {
|
|
124
|
+
const existing = db.query('SELECT title FROM features WHERE id = ?').get(item.id) as { title: string } | null;
|
|
125
|
+
// Keep a legacy per-version title until an explicit retitle unifies the feature identity.
|
|
126
|
+
if (!existing) putItem(db, version, item);
|
|
127
|
+
else {
|
|
128
|
+
db.query('INSERT OR IGNORE INTO assignments (feature_id, version, status, disposition, evidence, title_override, owner, kind, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
129
|
+
.run(item.id, version, item.status, item.disposition ?? null, item.evidence ?? null, item.title === existing.title ? null : item.title, item.owner ?? null, item.kind ?? null, item.updatedAt, item.updatedBy);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const entry of data.history) record(db, version, entry);
|
|
133
|
+
db.query('INSERT INTO imported_versions (version) VALUES (?)').run(version);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
export function importJson(version: string): boolean {
|
|
137
|
+
validateVersion(version);
|
|
138
|
+
if (!existsSync(jsonPath(version))) return false;
|
|
139
|
+
const db = open();
|
|
140
|
+
try {
|
|
141
|
+
if (db.query('SELECT 1 FROM imported_versions WHERE version = ?').get(version)) return false;
|
|
142
|
+
const imported = transaction(db, () => importOnDb(db, version));
|
|
143
|
+
if (imported) debug.log('release.features', 'imported', { version });
|
|
144
|
+
return imported;
|
|
145
|
+
} finally { db.close(); }
|
|
146
|
+
}
|
|
147
|
+
function importLegacyAll(): void {
|
|
148
|
+
const db = open();
|
|
149
|
+
try {
|
|
150
|
+
const imported = transaction(db, () => legacyVersions().filter((version) => importOnDb(db, version)));
|
|
151
|
+
for (const version of imported) debug.log('release.features', 'imported', { version });
|
|
152
|
+
} finally { db.close(); }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function list(version: string, released = releasedVersion(), dev = devVersion()): Checklist {
|
|
156
|
+
validateVersion(version);
|
|
157
|
+
importJson(version);
|
|
158
|
+
const db = open();
|
|
159
|
+
try { return readTransaction(db, () => snapshot(db, version, released, dev)); } finally { db.close(); }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The checklist API supplies its existing validation, field ordering and legacy history shape. */
|
|
163
|
+
export function mutate(version: string, released: string, dev: string, apply: (data: Checklist) => boolean): Checklist {
|
|
164
|
+
validateVersion(version);
|
|
165
|
+
const db = open();
|
|
166
|
+
try {
|
|
167
|
+
let imported = false;
|
|
168
|
+
const result = transaction(db, () => {
|
|
169
|
+
imported = importOnDb(db, version);
|
|
170
|
+
const data = snapshot(db, version, released, dev);
|
|
171
|
+
const before = new Map(data.items.map((item) => [item.id, JSON.stringify(item)]));
|
|
172
|
+
const count = data.history.length;
|
|
173
|
+
if (apply(data)) {
|
|
174
|
+
for (const item of data.items) if (before.get(item.id) !== JSON.stringify(item)) {
|
|
175
|
+
const previous = before.get(item.id);
|
|
176
|
+
const evidenceChanged = previous && (JSON.parse(previous) as ChecklistItem).evidence !== item.evidence;
|
|
177
|
+
if (evidenceChanged) db.query('DELETE FROM evidence WHERE feature_id = ? AND version = ?').run(item.id, version);
|
|
178
|
+
const persisted = previous && !evidenceChanged
|
|
179
|
+
? { ...item, evidence: (db.query('SELECT evidence FROM assignments WHERE feature_id = ? AND version = ?').get(item.id, version) as { evidence: string | null }).evidence ?? undefined }
|
|
180
|
+
: item;
|
|
181
|
+
putItem(db, version, persisted);
|
|
182
|
+
}
|
|
183
|
+
for (const id of before.keys()) if (!data.items.some((item) => item.id === id)) {
|
|
184
|
+
db.query('DELETE FROM evidence WHERE feature_id = ? AND version = ?').run(id, version);
|
|
185
|
+
db.query('DELETE FROM assignments WHERE feature_id = ? AND version = ?').run(id, version);
|
|
186
|
+
}
|
|
187
|
+
for (const entry of data.history.slice(count)) record(db, version, entry);
|
|
188
|
+
}
|
|
189
|
+
return data;
|
|
190
|
+
});
|
|
191
|
+
if (imported) debug.log('release.features', 'imported', { version });
|
|
192
|
+
return result;
|
|
193
|
+
} finally { db.close(); }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function add(version: string, item: ChecklistItem, released = releasedVersion(), dev = devVersion()): Checklist {
|
|
197
|
+
if (!item.id.trim()) throw new CliUserError('칸 id 가 비었다');
|
|
198
|
+
if (!item.title.trim()) throw new CliUserError('칸 제목이 비었다');
|
|
199
|
+
return mutate(version, released, dev, (data) => {
|
|
200
|
+
if (data.items.some((i) => i.id === item.id)) throw new CliUserError(`이미 있는 칸: ${item.id}`, 'set <id> 로 고친다');
|
|
201
|
+
data.items.push(item);
|
|
202
|
+
data.history.push({ at: item.updatedAt, by: item.updatedBy, id: item.id, field: 'add', from: null, to: item, released, dev });
|
|
203
|
+
return true;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
export function set(version: string, id: string, patch: Partial<ChecklistItem>, by: string, released = releasedVersion(), dev = devVersion()): Checklist {
|
|
207
|
+
return mutate(version, released, dev, (data) => {
|
|
208
|
+
const item = data.items.find((i) => i.id === id);
|
|
209
|
+
if (!item) throw new CliUserError(`없는 칸: ${id}`, 'list 로 칸 목록을 본다');
|
|
210
|
+
const fields = (['evidence', 'owner', 'status', 'disposition', 'kind'] as const).filter((field) => patch[field] !== undefined && patch[field] !== item[field]);
|
|
211
|
+
if (!fields.length) return false;
|
|
212
|
+
const at = new Date().toISOString();
|
|
213
|
+
for (const field of fields) {
|
|
214
|
+
const from = item[field]; const to = patch[field]!;
|
|
215
|
+
(item as unknown as Record<string, unknown>)[field] = to;
|
|
216
|
+
item.updatedAt = at; item.updatedBy = by;
|
|
217
|
+
data.history.push({ at, by, id, field, from: from ?? null, to, released, dev });
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
export function remove(version: string, id: string, by: string, released = releasedVersion(), dev = devVersion()): Checklist {
|
|
223
|
+
return mutate(version, released, dev, (data) => {
|
|
224
|
+
const index = data.items.findIndex((i) => i.id === id);
|
|
225
|
+
if (index < 0) throw new CliUserError(`없는 칸: ${id}`, 'list 로 칸 목록을 본다');
|
|
226
|
+
const [item] = data.items.splice(index, 1);
|
|
227
|
+
data.history.push({ at: new Date().toISOString(), by, id, field: 'remove', from: item, to: null, released, dev });
|
|
228
|
+
return true;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
export function move(id: string, from: string, to: string, by: string, released = releasedVersion(), dev = devVersion()): Checklist {
|
|
232
|
+
validateVersion(from); validateVersion(to);
|
|
233
|
+
if (from === to) throw new CliUserError('같은 판으로 옮길 수 없다');
|
|
234
|
+
const db = open();
|
|
235
|
+
try {
|
|
236
|
+
let importedFrom = false; let importedTo = false;
|
|
237
|
+
const result = transaction(db, () => {
|
|
238
|
+
importedFrom = importOnDb(db, from); importedTo = importOnDb(db, to);
|
|
239
|
+
const item = items(db, from).find((i) => i.id === id);
|
|
240
|
+
if (!item) throw new CliUserError(`없는 칸: ${id}`, 'list 로 칸 목록을 본다');
|
|
241
|
+
if (db.query('SELECT 1 FROM assignments WHERE feature_id = ? AND version = ?').get(id, to)) throw new CliUserError(`이미 있는 칸: ${id}`);
|
|
242
|
+
const at = new Date().toISOString();
|
|
243
|
+
db.query('UPDATE assignments SET version = ?, updated_at = ?, updated_by = ? WHERE feature_id = ? AND version = ?').run(to, at, by, id, from);
|
|
244
|
+
db.query('UPDATE evidence SET version = ? WHERE feature_id = ? AND version = ?').run(to, id, from);
|
|
245
|
+
record(db, to, { at, by, id, field: 'move', from, to, released, dev });
|
|
246
|
+
return snapshot(db, to, released, dev);
|
|
247
|
+
});
|
|
248
|
+
if (importedFrom) debug.log('release.features', 'imported', { version: from });
|
|
249
|
+
if (importedTo) debug.log('release.features', 'imported', { version: to });
|
|
250
|
+
debug.log('release.features', 'moved', { id, from, to, by });
|
|
251
|
+
return result;
|
|
252
|
+
} finally { db.close(); }
|
|
253
|
+
}
|
|
254
|
+
export function retitle(id: string, title: string, by: string, released = releasedVersion(), dev = devVersion()): void {
|
|
255
|
+
if (!title.trim()) throw new CliUserError('칸 제목이 비었다');
|
|
256
|
+
importLegacyAll();
|
|
257
|
+
const db = open();
|
|
258
|
+
try {
|
|
259
|
+
const changed = transaction(db, () => {
|
|
260
|
+
const old = db.query('SELECT title FROM features WHERE id = ?').get(id) as { title: string } | null;
|
|
261
|
+
if (!old) throw new CliUserError(`없는 칸: ${id}`);
|
|
262
|
+
const versions = db.query('SELECT version, COALESCE(title_override, ?) AS title FROM assignments WHERE feature_id = ? ORDER BY version')
|
|
263
|
+
.all(old.title, id) as Array<{ version: string; title: string }>;
|
|
264
|
+
if (!versions.length) throw new CliUserError(`없는 칸: ${id}`);
|
|
265
|
+
const changed = versions.filter((row) => row.title !== title);
|
|
266
|
+
if (!changed.length && old.title === title) return false;
|
|
267
|
+
const at = new Date().toISOString();
|
|
268
|
+
db.query('UPDATE features SET title = ? WHERE id = ?').run(title, id);
|
|
269
|
+
db.query('UPDATE assignments SET title_override = NULL WHERE feature_id = ?').run(id);
|
|
270
|
+
for (const { version, title: previousTitle } of changed) {
|
|
271
|
+
db.query('UPDATE assignments SET updated_at = ?, updated_by = ? WHERE feature_id = ? AND version = ?').run(at, by, id, version);
|
|
272
|
+
record(db, version, { at, by, id, field: 'title', from: previousTitle, to: title, released, dev });
|
|
273
|
+
}
|
|
274
|
+
return true;
|
|
275
|
+
});
|
|
276
|
+
if (changed) debug.log('release.features', 'retitled', { id, title, by });
|
|
277
|
+
} finally { db.close(); }
|
|
278
|
+
}
|
|
279
|
+
export function evidenceAdd(id: string, version: string, ref: string, by: string, released = releasedVersion(), dev = devVersion()): void {
|
|
280
|
+
validateVersion(version);
|
|
281
|
+
if (!ref.trim()) throw new CliUserError('근거 ref 가 비었다');
|
|
282
|
+
const db = open();
|
|
283
|
+
try {
|
|
284
|
+
const imported = transaction(db, () => {
|
|
285
|
+
const loaded = importOnDb(db, version);
|
|
286
|
+
if (!db.query('SELECT 1 FROM assignments WHERE feature_id = ? AND version = ?').get(id, version)) throw new CliUserError(`없는 칸: ${id}`);
|
|
287
|
+
const at = new Date().toISOString();
|
|
288
|
+
db.query('INSERT INTO evidence (feature_id, version, ref, at, by) VALUES (?, ?, ?, ?, ?)').run(id, version, ref, at, by);
|
|
289
|
+
record(db, version, { at, by, id, field: 'evidence.add', from: null, to: ref, released, dev });
|
|
290
|
+
return loaded;
|
|
291
|
+
});
|
|
292
|
+
if (imported) debug.log('release.features', 'imported', { version });
|
|
293
|
+
} finally { db.close(); }
|
|
294
|
+
}
|
|
295
|
+
export function history(id: string): Array<ChecklistHistory & { version: string; seq: number }> {
|
|
296
|
+
importLegacyAll();
|
|
297
|
+
const db = open();
|
|
298
|
+
try { return (db.query('SELECT * FROM events WHERE feature_id = ? ORDER BY at, seq').all(id) as EventRow[]).map((row) => ({ ...historyRow(row), version: row.version, seq: row.seq })); }
|
|
299
|
+
finally { db.close(); }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function details(id: string): { id: string; title: string; owner: string | null; kind: string | null; createdAt: string; evidence: Array<{ version: string; ref: string; at: string; by: string }> } | null {
|
|
303
|
+
importLegacyAll();
|
|
304
|
+
const db = open();
|
|
305
|
+
try {
|
|
306
|
+
const row = db.query('SELECT id, title, owner, kind, created_at FROM features WHERE id = ?').get(id) as { id: string; title: string; owner: string | null; kind: string | null; created_at: string } | null;
|
|
307
|
+
if (!row) return null;
|
|
308
|
+
const evidence = db.query('SELECT version, ref, at, by FROM evidence WHERE feature_id = ? ORDER BY at, rowid').all(id) as Array<{ version: string; ref: string; at: string; by: string }>;
|
|
309
|
+
return { id: row.id, title: row.title, owner: row.owner, kind: row.kind, createdAt: row.created_at, evidence };
|
|
310
|
+
} finally { db.close(); }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function exportJson(version: string, released = releasedVersion(), dev = devVersion()): Checklist {
|
|
314
|
+
const data = list(version, released, dev);
|
|
315
|
+
const path = jsonPath(version);
|
|
316
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
317
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
318
|
+
try {
|
|
319
|
+
writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
320
|
+
chmodSync(temp, 0o600);
|
|
321
|
+
renameSync(temp, path);
|
|
322
|
+
} finally { if (existsSync(temp)) rmSync(temp); }
|
|
323
|
+
const db = open();
|
|
324
|
+
try { transaction(db, () => { db.query('INSERT OR IGNORE INTO imported_versions (version) VALUES (?)').run(version); }); }
|
|
325
|
+
finally { db.close(); }
|
|
326
|
+
return data;
|
|
327
|
+
}
|
|
@@ -15,6 +15,8 @@ export interface ReleaseManifestInput {
|
|
|
15
15
|
cutoff: { sha: string };
|
|
16
16
|
landings: ReleaseLanding[];
|
|
17
17
|
notes?: Map<number, ReleaseNoteFragment>;
|
|
18
|
+
/** Note lines from release/next.md at the cutoff, each tied to the landing that added it when known. */
|
|
19
|
+
nextMd?: NextMdNote[];
|
|
18
20
|
runningGoalPaths: string[];
|
|
19
21
|
publicCommandsBefore: string[];
|
|
20
22
|
publicCommandsAfter: string[];
|
|
@@ -24,9 +26,41 @@ export interface ReleaseManifest {
|
|
|
24
26
|
version: string;
|
|
25
27
|
baseline: ReleaseManifestInput['baseline'];
|
|
26
28
|
cutoff: ReleaseManifestInput['cutoff'];
|
|
27
|
-
in: Array<{ sha: string; title: string; prNumber?: number; docs: 'present' | 'missing' | 'n/a'; line?: string; kind?: ReleaseNoteFragment['kind'] | 'unknown'; note?: 'missing' }>;
|
|
29
|
+
in: Array<{ sha: string; title: string; prNumber?: number; docs: 'present' | 'missing' | 'n/a'; line?: string; kind?: ReleaseNoteFragment['kind'] | NextMdKind | 'unknown'; note?: 'missing' }>;
|
|
28
30
|
deferred: Array<{ sha: string; reason: 'release-target-later' | 'run-in-flight' }>;
|
|
29
|
-
escalate: Array<{ kind: 'command-removed'; command: string }>;
|
|
31
|
+
escalate: Array<{ kind: 'command-removed'; command: string } | { kind: 'notes-empty'; featFixLandings: number; nextMdLines: number }>;
|
|
32
|
+
/** Where each IN line came from — present when next.md was read. */
|
|
33
|
+
fragments?: { byPr: number; byNextMd: number; unlinked: number; unknown: number };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type NextMdKind = 'feat' | 'fix' | 'security' | 'perf' | 'docs' | 'internal';
|
|
37
|
+
export interface NextMdNote { kind: NextMdKind; line: string; sha?: string; /** The list line as written, to find the landing that added it. */ raw?: string }
|
|
38
|
+
|
|
39
|
+
const NEXT_MD_KINDS: readonly NextMdKind[] = ['feat', 'fix', 'security', 'perf', 'docs', 'internal'];
|
|
40
|
+
const USER_KINDS = new Set(['feat', 'fix', 'security']);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read `release/next.md` note lines: the `## Feat` / `## Fix` … heading gives the kind, an explicit `- <kind> — ` prefix wins.
|
|
44
|
+
* The trailing `Documentation: … Target: …` fields are dropped from the sentence; `Target: later` lines are skipped.
|
|
45
|
+
*/
|
|
46
|
+
export function parseNextMdNotes(text: string): NextMdNote[] {
|
|
47
|
+
const notes: NextMdNote[] = [];
|
|
48
|
+
let section: NextMdKind | null = null;
|
|
49
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
50
|
+
const heading = /^##\s+(\S+)\s*$/.exec(raw.trim());
|
|
51
|
+
if (heading) { const kind = heading[1]!.toLowerCase(); section = (NEXT_MD_KINDS as readonly string[]).includes(kind) ? kind as NextMdKind : null; continue; }
|
|
52
|
+
const item = /^-\s+(.*\S)\s*$/.exec(raw);
|
|
53
|
+
if (!item) continue;
|
|
54
|
+
let body = item[1]!;
|
|
55
|
+
if (/\bTarget:\s*later\b/i.test(body)) continue;
|
|
56
|
+
const prefix = /^(feat|fix|security|perf|docs|internal)\s+[—-]\s+/i.exec(body);
|
|
57
|
+
const kind = prefix ? prefix[1]!.toLowerCase() as NextMdKind : section;
|
|
58
|
+
if (!kind) continue;
|
|
59
|
+
if (prefix) body = body.slice(prefix[0].length);
|
|
60
|
+
body = body.replace(/\s+Documentation:.*$/s, '').replace(/\s+Target:\s*\S+\.?\s*$/, '').trim();
|
|
61
|
+
if (body) notes.push({ kind, line: body, raw: raw.trim() });
|
|
62
|
+
}
|
|
63
|
+
return notes;
|
|
30
64
|
}
|
|
31
65
|
|
|
32
66
|
/** Classify the already-observed landings without reading git, run ledgers or the filesystem. */
|
|
@@ -41,6 +75,14 @@ export function buildReleaseManifest(input: ReleaseManifestInput): ReleaseManife
|
|
|
41
75
|
deferred: [],
|
|
42
76
|
escalate: [],
|
|
43
77
|
};
|
|
78
|
+
const nextBySha = new Map<string, NextMdNote[]>();
|
|
79
|
+
const landingShas = new Set(input.landings.map((landing) => landing.sha));
|
|
80
|
+
const unlinked: NextMdNote[] = [];
|
|
81
|
+
for (const note of input.nextMd ?? []) {
|
|
82
|
+
if (note.sha && landingShas.has(note.sha)) nextBySha.set(note.sha, [...(nextBySha.get(note.sha) ?? []), note]);
|
|
83
|
+
else unlinked.push(note);
|
|
84
|
+
}
|
|
85
|
+
const counts = { byPr: 0, byNextMd: 0, unlinked: 0, unknown: 0 };
|
|
44
86
|
for (const landing of input.landings) {
|
|
45
87
|
const fragment = landing.prNumber === undefined ? undefined : input.notes?.get(landing.prNumber);
|
|
46
88
|
if (landing.goalPath && running.has(landing.goalPath)) {
|
|
@@ -53,11 +95,23 @@ export function buildReleaseManifest(input: ReleaseManifestInput): ReleaseManife
|
|
|
53
95
|
: landing.changedFiles.some((file) => file.startsWith('release/public/docs/')) ? 'present'
|
|
54
96
|
: landing.changedFiles.some((file) => file.startsWith('src/cli/') || file === 'src/index.ts') ? 'missing'
|
|
55
97
|
: 'n/a';
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
98
|
+
const base: ReleaseManifest['in'][number] = { sha: landing.sha, title: landing.title, ...(landing.prNumber === undefined ? {} : { prNumber: landing.prNumber }), docs };
|
|
99
|
+
// PR fragment first, then the next.md lines this landing added; only with neither is the line unknown.
|
|
100
|
+
const fromNext = fragment ? [] : nextBySha.get(landing.sha) ?? [];
|
|
101
|
+
if (fragment) { counts.byPr++; result.in.push({ ...base, line: fragment.line, kind: fragment.kind }); }
|
|
102
|
+
else if (fromNext.length) for (const note of fromNext) { counts.byNextMd++; result.in.push({ ...base, line: note.line, kind: note.kind }); }
|
|
103
|
+
else { if (input.notes) counts.unknown++; result.in.push({ ...base, ...(input.notes ? { line: landing.title, kind: 'unknown' as const, note: 'missing' as const } : {}) }); }
|
|
59
104
|
}
|
|
60
105
|
}
|
|
106
|
+
// A next.md line no landing in range claims still belongs to this release.
|
|
107
|
+
for (const note of unlinked) { counts.unlinked++; result.in.push({ sha: note.sha ?? '', title: note.line, docs: 'n/a', line: note.line, kind: note.kind }); }
|
|
108
|
+
if (input.nextMd) {
|
|
109
|
+
result.fragments = counts;
|
|
110
|
+
const shown = result.in.filter((entry) => entry.line && entry.kind && USER_KINDS.has(entry.kind)).length;
|
|
111
|
+
const featFixLandings = input.landings.filter((landing) => /^(feat|fix)\b/i.test(landing.title)).length;
|
|
112
|
+
const nextMdLines = input.nextMd.filter((note) => USER_KINDS.has(note.kind)).length;
|
|
113
|
+
if (!shown && (featFixLandings || nextMdLines)) result.escalate.push({ kind: 'notes-empty', featFixLandings, nextMdLines });
|
|
114
|
+
}
|
|
61
115
|
for (const command of input.publicCommandsBefore) {
|
|
62
116
|
if (!after.has(command)) result.escalate.push({ kind: 'command-removed', command });
|
|
63
117
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The owner hands a seat a task from Telegram: /coo /cto /cmo /cxo → one «[대표] → <seat>» line on the coordination PR,
|
|
2
|
+
// one 'ceo-task' message in the seat store, and a «받음» reply. Nothing is written unless it is the owner in a private chat.
|
|
3
|
+
// Goal: 내부 문서 `ASK-the-ceo-cannot-hand-a-seat-a-task-from-telegram-2026-10-02` (C2 phase 1).
|
|
4
|
+
import { debug } from '../debug/log.js';
|
|
5
|
+
import type { MessageEnvelope } from '../msg/msg-store.js';
|
|
6
|
+
|
|
7
|
+
export const CEO_SEAT_COMMANDS = { coo: 'OP', cto: 'TC', cmo: 'MK', cxo: 'UX' } as const;
|
|
8
|
+
export type CeoSeatCommand = keyof typeof CEO_SEAT_COMMANDS;
|
|
9
|
+
|
|
10
|
+
export interface CeoCommandContext { chatId: number | string; userId: number | string }
|
|
11
|
+
|
|
12
|
+
export interface CeoCommandDeps {
|
|
13
|
+
/** Telegram user id of the owner (`telegramDecisionOwner`), null when none is configured. */
|
|
14
|
+
ownerId: string | null;
|
|
15
|
+
/** `decisions.replyGhPr` — "owner/repo#N". */
|
|
16
|
+
replyTarget: string | null;
|
|
17
|
+
runGh: (args: string[], stdin: string) => Promise<number>;
|
|
18
|
+
append: (message: MessageEnvelope) => unknown;
|
|
19
|
+
now?: () => Date;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function kstStamp(at: Date): string {
|
|
23
|
+
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Seoul', dateStyle: 'short', timeStyle: 'short' }).format(at);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ceoTaskLine(seat: string, task: string, at: Date): string {
|
|
27
|
+
return `**[대표]** ${kstStamp(at)} KST → ${seat} · ${task}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function handleCeoSeatCommand(command: CeoSeatCommand, args: string[], ctx: CeoCommandContext, deps: CeoCommandDeps): Promise<string> {
|
|
31
|
+
const seat = CEO_SEAT_COMMANDS[command];
|
|
32
|
+
if (!deps.ownerId || String(ctx.userId) !== deps.ownerId) {
|
|
33
|
+
debug.log('seat.dispatch', 'refused', { seat, reason: 'not-owner' });
|
|
34
|
+
return '소유자만 쓸 수 있습니다.';
|
|
35
|
+
}
|
|
36
|
+
// A group would show the task to every member — private chat only (same rule as /decisions).
|
|
37
|
+
if (String(ctx.chatId) !== String(ctx.userId)) {
|
|
38
|
+
debug.log('seat.dispatch', 'refused', { seat, reason: 'not-private' });
|
|
39
|
+
return '개인 대화에서만 쓸 수 있습니다.';
|
|
40
|
+
}
|
|
41
|
+
const task = args.join(' ').replace(/\s+/g, ' ').trim();
|
|
42
|
+
if (!task) {
|
|
43
|
+
debug.log('seat.dispatch', 'refused', { seat, reason: 'empty' });
|
|
44
|
+
return `사용법: /${command} <할 일>`;
|
|
45
|
+
}
|
|
46
|
+
deps.append({ from: 'CEO', to: seat, body: task, kind: 'ceo-task' });
|
|
47
|
+
const channelError = await postToChannel(seat, task, deps);
|
|
48
|
+
if (channelError) {
|
|
49
|
+
debug.log('seat.dispatch', 'channel-failed', { seat, reason: channelError }, { level: 'warn' });
|
|
50
|
+
return `받음 — ${seat} 메시지함에는 넣었습니다. 다만 조율 채널에 못 남겼습니다(${channelError}).`;
|
|
51
|
+
}
|
|
52
|
+
debug.log('seat.dispatch', 'ceo-task', { seat });
|
|
53
|
+
return `받음 — ${seat}에 전했습니다.`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function postToChannel(seat: string, task: string, deps: CeoCommandDeps): Promise<string | null> {
|
|
57
|
+
const target = /^([\w.-]+\/[\w.-]+)#(\d+)$/.exec(deps.replyTarget?.trim() ?? '');
|
|
58
|
+
if (!target) return deps.replyTarget ? 'decisions.replyGhPr 형식이 owner/repo#N 이 아님' : 'decisions.replyGhPr 미설정';
|
|
59
|
+
try {
|
|
60
|
+
const code = await deps.runGh(['pr', 'comment', target[2]!, '--repo', target[1]!, '--body-file', '-'], ceoTaskLine(seat, task, deps.now?.() ?? new Date()));
|
|
61
|
+
return code === 0 ? null : `gh 종료 코드 ${code}`;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return error instanceof Error ? error.message.slice(0, 80) : 'gh 실행 실패';
|
|
64
|
+
}
|
|
65
|
+
}
|