elanous 0.2.8 → 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/8630-cdfc61e82c653ffc.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{8894-7317998f6cb97d8c.js → 8894-e263b332208e3957.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/9454-139040d37c79dbc9.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-f4d2ecbbae6088d6.js → page-35e2713597dd24d1.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/{page-2053e49930c82748.js → page-918b656f064577ba.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/{page-d4b6df905f185055.js → page-f972fc1c00a62e88.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-a70ba27c33003dd0.js → page-5f30e00693527ebd.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/page-7fbae173b3c103a7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/consult/{page-18166f885d831d3e.js → page-3b4edcce364062ea.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/control/{page-1c9d6cfe68b121c2.js → page-c5794f484c251f44.js} +1 -1
- 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-147a328753d983e2.js → page-016e2bda04eecb1f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-799395a9df7d95aa.js → page-b4d6d9a3b56f2940.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/{page-6a8158b71d1fe9d7.js → page-ec49a466121233ba.js} +1 -1
- 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-c0c0d19d51342d3e.js → page-8b0402a46a749a3d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-2e002ad8faa43291.js → page-037f83e2a1edc8e2.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-b672e02e4bec8deb.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-9db2226f38c542fb.js → page-0cf318e9dcee2bbf.js} +1 -1
- 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-70ee65f5f7a151dc.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-092e1bcf27d751cc.js → page-de68e2205794037d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/{page-eb52580c4b75b905.js → page-c71c33a573230672.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-7464c1987dd76e6d.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-085847b088675978.js → page-4647be5dbcbd0795.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/share/{page-9fe9d38c36999eb4.js → page-e025892bda74b743.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-a0955de73b89f078.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-369846d2921af584.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/term/{page-295a02b1026b8255.js → page-72cbc1c8cdd0f65d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-77b28bfb0996d5ea.js +1 -0
- 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-90670589b87abea8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/{page-2e367075ffdbc062.js → page-7df9243ed021a4cc.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-647ed2b66c1d49f1.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 +3 -0
- package/package.json +1 -1
- package/plugins/doc-draft/graphs/run-step.ts +1 -1
- 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 +18 -4
- package/scripts/release-loop/node-verdict.ts +8 -1
- package/scripts/test-diet/lib.ts +1 -0
- package/scripts/test-diet/node.ts +56 -14
- 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/chat/index.ts +9 -9
- package/src/cli/op-proxy-sync.ts +83 -0
- package/src/cli/pr-cli.ts +6 -2
- package/src/cli/release-cli.ts +41 -1
- package/src/dashboard/dashboard-status-lines.ts +45 -0
- package/src/dashboard/first-screen-band.ts +35 -0
- package/src/dashboard/index.ts +70 -13
- package/src/dashboard/provider-slash-runtime.ts +28 -11
- package/src/dashboard/slash-runtime/dashboard-handlers.ts +10 -0
- package/src/dashboard/turn-stream-formatter.ts +1 -1
- package/src/decisions/decision-cards.ts +1 -1
- package/src/index.ts +16 -4
- package/src/maturity/feature-maturity.ts +2 -0
- package/src/nexus/api/http-server.ts +20 -0
- 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/public-routes.ts +14 -1
- package/src/nexus/api/setup-claim.ts +21 -0
- package/src/nexus/config/secrets/file-backend.ts +56 -18
- package/src/onboarding/first-run-doctor.ts +54 -0
- package/src/release-loop/checklist.ts +5 -64
- 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/skills/tools/usage-slash.ts +8 -7
- package/src/telegram-commands.ts +16 -0
- package/src/telegram-run.ts +83 -13
- package/src/thinking-line.ts +28 -20
- package/src/tui/chat/execution-badge.ts +1 -1
- 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-3084c58c1725a40b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9454-a3e0953307216c58.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-9aa47b0bc6d570ff.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/page-186fc6952f027acc.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-cbbe03c0da7b081a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/page-c108ff1fdb665c97.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-9567560270857531.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-e7ed3d010153d818.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-1ba4a5d857276334.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-0bb8e43203efb23b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/page-e573437b0a1c728f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-294300625e49e4d3.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-d8ab4d1489223459.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-6141f67f531d8b6a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-1eb9a0e1e054209d.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-82efa177a30f3741.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-d0be51cec4f62606.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ba058c56e38f2792.js +0 -1
- package/apps/pwa/out/_next/static/css/5ef72a618a8994f3.css +0 -1
- package/apps/pwa/out/_next/static/lywc8m_HDzH9MKSDQhyEj/_buildManifest.js +0 -1
- /package/apps/pwa/out/_next/static/{lywc8m_HDzH9MKSDQhyEj → bFI5EEwg7T6aG88ibX4Zq}/_ssgManifest.js +0 -0
|
@@ -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
|
+
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
// Slash
|
|
2
|
-
//
|
|
3
|
-
// Screen does not compute. It prints the same structured report the
|
|
4
|
-
// `elanous usage` command produces.
|
|
1
|
+
// Slash commands: /usage and /remaining — compact TUI view of the unified report.
|
|
5
2
|
|
|
6
|
-
import { collectUnifiedUsage,
|
|
3
|
+
import { collectUnifiedUsage, formatUsageCompact, type UnifiedUsageDeps } from '../../budget/unified-usage.js';
|
|
4
|
+
import { visibleWidth } from '../../tui.js';
|
|
7
5
|
import type { SlashExecuteRequest, SlashExecuteResult } from './dashboard-slash.js';
|
|
8
6
|
|
|
9
7
|
export interface UsageSlashResult extends SlashExecuteResult {
|
|
@@ -12,15 +10,18 @@ export interface UsageSlashResult extends SlashExecuteResult {
|
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
export async function executeUsageSlash(
|
|
15
|
-
req: SlashExecuteRequest,
|
|
13
|
+
req: SlashExecuteRequest & { width?: number },
|
|
16
14
|
deps: UnifiedUsageDeps = {},
|
|
17
15
|
): Promise<UsageSlashResult | null> {
|
|
18
16
|
if (req.name !== 'usage' && req.name !== 'remaining') return null;
|
|
19
17
|
const report = await collectUnifiedUsage(deps);
|
|
18
|
+
const width = req.width ?? 120;
|
|
19
|
+
const hint = ['자세히: elanous usage', 'elanous usage', 'elanous…', '…']
|
|
20
|
+
.find((text) => visibleWidth(text) <= width) ?? '';
|
|
20
21
|
return {
|
|
21
22
|
ok: true,
|
|
22
23
|
name: req.name,
|
|
23
24
|
args: req.args,
|
|
24
|
-
logLines:
|
|
25
|
+
logLines: [...formatUsageCompact(report, { width, nowMs: Date.now() }), hint],
|
|
25
26
|
};
|
|
26
27
|
}
|
package/src/telegram-commands.ts
CHANGED
|
@@ -775,6 +775,22 @@ export function defaultTelegramCommands(): TgSlashCommand[] {
|
|
|
775
775
|
return service.listOpen(String(ctx.chatId));
|
|
776
776
|
},
|
|
777
777
|
},
|
|
778
|
+
...(['coo', 'cto', 'cmo', 'cxo'] as const).map((command) => ({
|
|
779
|
+
name: command,
|
|
780
|
+
description: `${command.toUpperCase()} 자리에 일을 넘깁니다 — /${command} <할 일> (조율 채널 ⊕ 자리 메시지함)`,
|
|
781
|
+
handler: async (args: string[], ctx: { chatId: number; userId: number }) => {
|
|
782
|
+
const [{ handleCeoSeatCommand }, { telegramDecisionOwner }, { runGh }, { getUserConfig }, { openMsgStore }] = await Promise.all([
|
|
783
|
+
import('./seat-dispatch/ceo-commands.js'), import('./decisions/telegram-decision-cards.js'), import('./decisions/decision-cards.js'),
|
|
784
|
+
import('./user-config.js'), import('./msg/msg-store.js'),
|
|
785
|
+
]);
|
|
786
|
+
const cfg = getUserConfig();
|
|
787
|
+
const replyTarget = (cfg.raw?.decisions as { replyGhPr?: unknown } | undefined)?.replyGhPr;
|
|
788
|
+
return handleCeoSeatCommand(command, args, ctx, {
|
|
789
|
+
ownerId: telegramDecisionOwner(cfg), replyTarget: typeof replyTarget === 'string' ? replyTarget : null, runGh,
|
|
790
|
+
append: (message) => { const store = openMsgStore(); try { return store.append(message); } finally { store.close(); } },
|
|
791
|
+
});
|
|
792
|
+
},
|
|
793
|
+
})),
|
|
778
794
|
{
|
|
779
795
|
name: 'work',
|
|
780
796
|
description: '글을 알맞은 곳(흡수·태스크·하니스)에 넣고 결과를 여기로 답합니다 — /work [absorb|tasks|graph] <글>',
|