elanous 0.2.16 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/pwa/out/404/index.html +1 -1
- package/apps/pwa/out/404.html +1 -1
- package/apps/pwa/out/_next/static/chunks/{1307-b46d45b705e8c9ae.js → 1307-aac7332576c4d6fc.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/{6877-7c6208705c284124.js → 6877-fe94e76a5841ee9b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/8401-4ce1c42c8d89903f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/899-f41be85e8a680651.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9247-269e4464cc4b491c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-3143b07a817d1da9.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-3cb52e5ea8fc2fa5.js → page-17ba086ef82d339b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-7eb661ff23af7da2.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/botlab/page-6b8444914da74ca6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-482c5dbde699947c.js → page-6e5c3a867557298b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/{page-1e962b31d1f11081.js → page-7110f732f45bf944.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/{page-8c2d9526164f5d09.js → page-9a41d65355fc25b9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/page-5e77cd1802567c89.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/control/page-41afec0b255ed2bb.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/design-check/{page-c53598cfce6156f2.js → page-4187666132fc98c4.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/{page-13a63953f53c6a23.js → page-09328d499162bae4.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/exec/{page-5d3c8429d04f3831.js → page-29ff3a8f84f03b6a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-3193bdc0bbab047a.js → page-80ec7401bc83f511.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/{page-76270efaee638f17.js → page-ab93b240f687d69a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/{page-6107ca8ddd5e3bdd.js → page-fc7d86467291ae38.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{layout-dce686e582c23937.js → layout-de43a186e5ba5683.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/live/{page-d3d53b146416e422.js → page-8b08cafad27759c9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/loops/page-0e2d6985fb42cd2f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/page-13e4108999f58d13.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-e8296ee284aa1293.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-4167348049166852.js → page-043a9111e74f6b24.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{not-found-de52e8fdf9095f7a.js → not-found-23a4601ceb7f452e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-1ed1324b1dc36754.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/{page-ca3952cd78a83ca2.js → page-10d958605a77b3bd.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/{page-5b042736aa113756.js → page-166745e51ac0c7b0.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/{page-acb749f1e432bcf3.js → page-a04260b8ccc968d4.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-7a1d85e121065cc8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/page-1f50e3d4e13435f4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/reflection/{page-1ebfe049c640e171.js → page-12916918988ac97e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-d8a3347a9d0c3f4a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/sessions/page-a9117d2cd3093d36.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-31fa1ce4c4aff9ae.js → page-5a64cb5208c6a8fd.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/{page-0f5cd57ca928f42b.js → page-1407ec83ea64c1f9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/{page-abe8e3b74bea1ffc.js → page-1d824510df025d18.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/page-6e878ebbe1737d00.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/share/{page-d28573d6792b4275.js → page-7f49fe645d5132f7.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/{page-59f03fda76c03d22.js → page-81a4e7f8dbfe74cb.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/tasks/{page-d751de5f8062ff5e.js → page-27d71f131acd9320.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-3ab59d19913b200e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/today/page-bf85b2f5bf077925.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/trace/page-424659bacfb8cdd7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/vault/page-ab32a8f522a60371.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/{page-d4aef67abf264874.js → page-fc2df261f8a2c7df.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/{page-1ea64acadd704ace.js → page-09461366af82ef13.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-3544f154c87b43ee.js → page-5d4fef9ccd7d406b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/{page-30c2073bfc21c004.js → page-ea90a8ac73ce2749.js} +1 -1
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +13 -13
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +13 -13
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +13 -13
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +13 -13
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +13 -13
- package/apps/pwa/out/ceo/index.html +1 -1
- package/apps/pwa/out/ceo/index.txt +13 -13
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +13 -13
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +13 -13
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +13 -13
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +13 -13
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +13 -13
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +13 -13
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +13 -13
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +13 -13
- package/apps/pwa/out/inside/index.html +1 -1
- package/apps/pwa/out/inside/index.txt +13 -13
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +13 -13
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +13 -13
- package/apps/pwa/out/loops/index.html +1 -1
- package/apps/pwa/out/loops/index.txt +13 -13
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +13 -13
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +13 -13
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +13 -13
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +13 -13
- package/apps/pwa/out/ops/checklist/index.html +1 -1
- package/apps/pwa/out/ops/checklist/index.txt +13 -13
- package/apps/pwa/out/ops/release/index.html +1 -1
- package/apps/pwa/out/ops/release/index.txt +13 -13
- package/apps/pwa/out/ops/seats/index.html +1 -1
- package/apps/pwa/out/ops/seats/index.txt +13 -13
- package/apps/pwa/out/outputs/index.html +1 -1
- package/apps/pwa/out/outputs/index.txt +13 -13
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +14 -14
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +13 -13
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +13 -13
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +13 -13
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +13 -13
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +13 -13
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +13 -13
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +13 -13
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +13 -13
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +13 -13
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +13 -13
- package/apps/pwa/out/today/index.html +1 -1
- package/apps/pwa/out/today/index.txt +13 -13
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +13 -13
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +13 -13
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +13 -13
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +13 -13
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +13 -13
- package/graphs/intake/intake-daily.yaml +9 -2
- package/graphs/intake/news-sources.yaml +4 -0
- package/graphs/intake/recipes.yaml +6 -1
- package/package.json +1 -1
- package/scripts/__fixtures__/intake-news.xml +7 -0
- package/scripts/intake-news.ts +169 -0
- package/scripts/release-loop/docs-land-node.ts +69 -10
- package/scripts/release-loop/publish-node.ts +2 -2
- package/scripts/release-loop/release-docs.ts +134 -0
- package/scripts/release-loop/version-node.ts +7 -4
- package/scripts/release-story/draft.ts +42 -14
- package/src/agent-mission/driver.ts +53 -10
- package/src/autopilot/track-agent.ts +5 -3
- package/src/cli/autopilot-cli.ts +66 -0
- package/src/cli/decisions-cli.ts +4 -1
- package/src/cli/intake-cli.ts +1 -1
- package/src/cli/log-event-names.ts +1 -0
- package/src/cli/pr-cli.ts +12 -1
- package/src/cli/provider-cli.ts +14 -0
- package/src/cli/self-update-canary.ts +46 -0
- package/src/cli/self-update.ts +200 -1
- package/src/context-card/index.ts +45 -0
- package/src/context-card/task.ts +76 -0
- package/src/dashboard/slash-runtime/dashboard-handlers.ts +2 -0
- package/src/decisions/decision-ledger.ts +10 -1
- package/src/harness/harness-cli-command.ts +142 -10
- package/src/harness/harness-pod-dispatch.ts +11 -0
- package/src/harness/harness-queue.ts +86 -7
- package/src/harness/nested-elanous-policy.ts +23 -4
- package/src/hq/fence-wrapper.ts +123 -0
- package/src/hq/hq.ts +8 -0
- package/src/index.ts +57 -8
- package/src/intake-plane/digest.ts +39 -4
- package/src/nexus/api/grid.ts +61 -0
- package/src/nexus/api/http-server.ts +19 -1
- package/src/nexus/api/task-cards-api.ts +19 -0
- package/src/oauth/codex.ts +82 -2
- package/src/release-loop/landing-freeze.ts +18 -10
- package/src/seat-loop/seat-loop.ts +95 -31
- package/src/self-dev/ask-launch-flow.ts +26 -7
- package/src/self-dev/run-store.ts +5 -1
- package/src/self-dev/run-supervisor.ts +7 -1
- package/src/self-dev/supervisor-verdict-edges.ts +46 -0
- package/src/self-implement/frozen-merges.ts +25 -20
- package/src/self-implement/goal-author-clarification.ts +81 -0
- package/src/self-implement/goal-author-cli.ts +33 -5
- package/src/self-implement/goal-author.ts +88 -12
- package/src/self-implement/goal-clarification-escalation.ts +37 -1
- package/src/self-implement/goal-document-write.ts +28 -0
- package/src/self-implement/heal-intake.ts +4 -0
- package/src/self-implement/host-regate.ts +3 -1
- package/src/self-implement/orchestrator.ts +19 -1
- package/src/task-agent/actions.ts +118 -0
- package/src/task-agent/judge.ts +96 -0
- package/src/task-cards/card-store.ts +4 -1
- package/src/task-orchestrator/surfaces/pod-failure-reason.ts +33 -4
- package/src/task-orchestrator/surfaces/pod-lease.ts +5 -2
- package/src/task-orchestrator/surfaces/pod-pool.ts +81 -13
- package/src/task-orchestrator/surfaces/self-implement-pod.ts +151 -29
- package/src/telegram-commands.ts +1 -1
- package/src/telegram-loops-command.ts +21 -14
- package/src/user-config.ts +25 -3
- package/src/version/packed-revision.json +1 -1
- package/apps/pwa/out/_next/static/chunks/8827-e2f5b09a8d37e83b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/899-46946fe8363e1262.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9247-29d889b809288e65.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-79c3ed84baaedd03.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-a424ef80885d1743.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/page-c1e20d7d338713f1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/page-ffff6d819fbc6e0f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-a0e2a6d1bfb52fc5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/loops/page-1a61c7ca1ceb3ae7.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/market/page-51f811fa119308b6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-dacff46049e1557e.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-36a7d994f4ca0299.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-cd78862707999911.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/page-54b9ef8afe6278fd.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-57924393e3e4e539.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/page-9e0ce6c8129fa641.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/page-4364ef9f4e7c7f0f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-fe1759f94003b32b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/today/page-d64c984e855c8c68.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-86914b0dee8ab7f8.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-9afdee66b820c76e.js +0 -1
- /package/apps/pwa/out/_next/static/{rv9cierV0jSOvDBm1g0XB → yRA5ur6DcPl0YHSRheH2O}/_buildManifest.js +0 -0
- /package/apps/pwa/out/_next/static/{rv9cierV0jSOvDBm1g0XB → yRA5ur6DcPl0YHSRheH2O}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
|
+
import { judge } from '../src/llm/judge-layer.js';
|
|
7
|
+
import { effectiveInstanceRoot } from '../src/instance/resolve.js';
|
|
8
|
+
import { canonicalUrl } from '../src/intake-plane/items.js';
|
|
9
|
+
|
|
10
|
+
export interface NewsSource { name: string; rss: string }
|
|
11
|
+
export interface NewsArticle { title: string; description: string; url: string; published: string }
|
|
12
|
+
export interface NewsCheck { items: { fact: string; line: string; verdict: string; current: string }[] }
|
|
13
|
+
export interface NewsDeps {
|
|
14
|
+
fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
15
|
+
now?: () => Date;
|
|
16
|
+
check?: (facts: readonly string[]) => Promise<NewsCheck>;
|
|
17
|
+
classify?: (article: NewsArticle) => Promise<boolean>;
|
|
18
|
+
log?: (message: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const AXES = /하니스|harness|에이전트|agent|코딩\s*(?:에이전트|agent)|coding\s*agent|로컬\s*(?:llm|언어모델)|local\s*llm|기업.{0,12}ai\s*도입|enterprise\s*ai|ai\s*투자|인공지능\s*투자|ai\s*investment/i;
|
|
22
|
+
const AMBIGUOUS = /\bAI\b|인공지능|생성형|\bLLM\b/i;
|
|
23
|
+
const entity = (text: string): string => text.replace(/&#(x[\da-f]+|\d+);|&(amp|lt|gt|quot|apos|nbsp|lsquo|rsquo|ldquo|rdquo|middot|hellip|ndash|mdash);/gi, (match, numeric: string | undefined, named: string | undefined) => {
|
|
24
|
+
if (numeric) {
|
|
25
|
+
const code = numeric[0]?.toLowerCase() === 'x' ? parseInt(numeric.slice(1), 16) : parseInt(numeric, 10);
|
|
26
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
|
27
|
+
}
|
|
28
|
+
return ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”', middot: '·', hellip: '…', ndash: '–', mdash: '—' } as Record<string, string>)[named?.toLowerCase() ?? ''] ?? match;
|
|
29
|
+
});
|
|
30
|
+
const plain = (text: string): string => entity(text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim();
|
|
31
|
+
const field = (xml: string, tag: string): string => plain(xml.match(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i'))?.[1] ?? '');
|
|
32
|
+
|
|
33
|
+
/** RSS 2.0 items, including CDATA, escaped HTML and pubDate. */
|
|
34
|
+
export function parseNewsRss(xml: string): NewsArticle[] {
|
|
35
|
+
if (!/<rss\b|<rdf:RDF\b/i.test(xml)) throw new Error('RSS 응답이 아님');
|
|
36
|
+
return [...xml.matchAll(/<item(?:\s[^>]*)?>([\s\S]*?)<\/item>/gi)].flatMap((match) => {
|
|
37
|
+
const item = match[1] ?? '';
|
|
38
|
+
const url = field(item, 'link');
|
|
39
|
+
const published = field(item, 'pubDate');
|
|
40
|
+
return /^https?:\/\//i.test(url) && Number.isFinite(Date.parse(published))
|
|
41
|
+
? [{ title: field(item, 'title'), description: field(item, 'description'), url, published }]
|
|
42
|
+
: [];
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function newsInterest(article: NewsArticle): 'yes' | 'no' | 'ambiguous' {
|
|
47
|
+
const text = `${article.title} ${article.description}`;
|
|
48
|
+
if (AXES.test(text)) return 'yes';
|
|
49
|
+
return AMBIGUOUS.test(text) ? 'ambiguous' : 'no';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function classifyAmbiguous(article: NewsArticle): Promise<boolean> {
|
|
53
|
+
const decision = await judge({
|
|
54
|
+
site: 'intake.news.interest',
|
|
55
|
+
prompt: `제목·요약만 보고 하니스·에이전트·코딩 에이전트·로컬 LLM·기업 AI 도입·AI 투자 관련 기사인지 판정한다. JSON {"relevant":true|false} 만 답하라.\n제목: ${article.title}\n요약: ${article.description}`,
|
|
56
|
+
schema: (value) => value && typeof value === 'object' && typeof (value as { relevant?: unknown }).relevant === 'boolean'
|
|
57
|
+
? (value as { relevant: boolean }).relevant : null,
|
|
58
|
+
});
|
|
59
|
+
if (!decision.ok) throw new Error('관심 축 판정 실패');
|
|
60
|
+
return decision.value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The CLI, not a local imitation of the checker, is the implication authority. No --author or release mutation. */
|
|
64
|
+
async function checkWithCli(facts: readonly string[]): Promise<NewsCheck> {
|
|
65
|
+
const result = spawnSync('bun', ['bin/elanous.mjs', '--test', 'intake', 'check', ...facts.flatMap((fact) => ['--fact', fact]), '--json'], {
|
|
66
|
+
cwd: resolve(import.meta.dir, '..'), encoding: 'utf8', timeout: 120_000, maxBuffer: 4 * 1024 * 1024,
|
|
67
|
+
});
|
|
68
|
+
if (result.error || result.status !== 0) throw new Error(result.error?.message ?? result.stderr?.trim() ?? `intake check exited ${result.status}`);
|
|
69
|
+
// Standalone notifications may precede JSON on stdout.
|
|
70
|
+
const start = result.stdout.indexOf('{');
|
|
71
|
+
if (start < 0) throw new Error('intake check JSON 없음');
|
|
72
|
+
const parsed = JSON.parse(result.stdout.slice(start)) as NewsCheck;
|
|
73
|
+
if (!Array.isArray(parsed.items)) throw new Error('intake check items 없음');
|
|
74
|
+
return parsed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function summarize(html: string): string[] {
|
|
78
|
+
const body = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>|<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ');
|
|
79
|
+
const paragraphs = [...body.matchAll(/<(?:p|h[1-4])\b[^>]*>([\s\S]*?)<\/(?:p|h[1-4])>/gi)]
|
|
80
|
+
.map((match) => plain(match[1] ?? '')).filter((text) => text.length >= 25 && !/무단전재|저작권|기자\s*=/.test(text));
|
|
81
|
+
if (!paragraphs.length) throw new Error('본문을 읽지 못함');
|
|
82
|
+
const text = paragraphs.join(' ');
|
|
83
|
+
const sentences = text.split(/(?<=[.!?。])\s+/).map((s) => s.trim()).filter(Boolean);
|
|
84
|
+
// 앞 문장 요약(최대 다섯 줄) — 짧은 기사는 있는 문장만 남긴다. 낱말을 잘라 줄 수를 채우지 않는다(사실을 지어내지 않는다).
|
|
85
|
+
return sentences.slice(0, 5).map((sentence) => sentence.slice(0, 240));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function runIntakeNews(options: { root: string; sources: readonly NewsSource[]; deps?: NewsDeps }): Promise<{ written: number; failed: number }> {
|
|
89
|
+
const { root, sources, deps = {} } = options;
|
|
90
|
+
const request = deps.fetch ?? fetch;
|
|
91
|
+
const log = deps.log ?? ((message: string) => console.error(message));
|
|
92
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
93
|
+
const day = new Date(now.getTime() + 9 * 3600_000).toISOString().slice(0, 10);
|
|
94
|
+
const state = join(root, 'intake', 'news-seen.jsonl');
|
|
95
|
+
const lens = join(root, 'intake', 'outbox', 'lens', `${day}.jsonl`);
|
|
96
|
+
const seen = new Set(existsSync(state) ? readFileSync(state, 'utf8').split('\n').filter(Boolean) : []);
|
|
97
|
+
// 렌즈에 이미 쓴 기사도 «본 것» — 렌즈 기록과 상태 기록 사이에서 죽어도 재실행이 같은 기사를 다시 쓰지 않는다(24h 창이라 어제·오늘 파일).
|
|
98
|
+
const yesterday = new Date(now.getTime() + 9 * 3600_000 - 86_400_000).toISOString().slice(0, 10);
|
|
99
|
+
for (const lensDay of [yesterday, day]) {
|
|
100
|
+
const file = join(root, 'intake', 'outbox', 'lens', `${lensDay}.jsonl`);
|
|
101
|
+
if (!existsSync(file)) continue;
|
|
102
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
103
|
+
try { const id = (JSON.parse(line) as { id?: unknown }).id; if (typeof id === 'string') seen.add(id); } catch { /* 깨진 줄은 건너뛴다 */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
let written = 0;
|
|
107
|
+
let failed = 0;
|
|
108
|
+
for (const source of sources) {
|
|
109
|
+
try {
|
|
110
|
+
const response = await request(source.rss, { signal: AbortSignal.timeout(30_000) });
|
|
111
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
112
|
+
for (const article of parseNewsRss(await response.text())) {
|
|
113
|
+
const published = Date.parse(article.published);
|
|
114
|
+
if (published > now.getTime() || published < now.getTime() - 86_400_000) continue;
|
|
115
|
+
const id = createHash('sha256').update(canonicalUrl(article.url) ?? article.url).digest('hex').slice(0, 16);
|
|
116
|
+
if (seen.has(id)) continue;
|
|
117
|
+
const interest = newsInterest(article);
|
|
118
|
+
if (interest === 'no') continue;
|
|
119
|
+
try {
|
|
120
|
+
if (interest === 'ambiguous' && !(await (deps.classify ?? classifyAmbiguous)(article))) continue;
|
|
121
|
+
const page = await request(article.url, { signal: AbortSignal.timeout(30_000) });
|
|
122
|
+
if (!page.ok) throw new Error(`본문 HTTP ${page.status}`);
|
|
123
|
+
const summary = summarize(await page.text());
|
|
124
|
+
const name = article.title.match(/\b[A-Z][A-Z0-9]{2,}\b/)?.[0] ?? article.title;
|
|
125
|
+
// 함의는 기사 하나에서 셋까지 — 제목 ⊕ 요약 앞 두 문장을 각각 «엘라누스가 이걸 하나»로 대조한다(판정선: 함의 3).
|
|
126
|
+
const facts = [
|
|
127
|
+
`elanous는 \`${name}\` 관련 ${article.title} 기능을 지원한다`,
|
|
128
|
+
...summary.slice(0, 2).map((line) => `elanous는 다음 일을 한다: ${line.slice(0, 160)}`),
|
|
129
|
+
];
|
|
130
|
+
const check = await (deps.check ?? checkWithCli)(facts);
|
|
131
|
+
if (!check.items.length) throw new Error('intake check 항목 없음');
|
|
132
|
+
// «판단 필요» is a review suggestion, not an asserted absence; keep the check verdict attached.
|
|
133
|
+
const suggestions = check.items.filter((item) => item.verdict === '없음' || item.verdict === '판단 필요')
|
|
134
|
+
.map((item) => ({ kind: '칸 제안', fact: item.fact, status: '제안', verdict: item.verdict }));
|
|
135
|
+
const verdict = suggestions.length ? '보강'
|
|
136
|
+
: check.items.every((item) => item.verdict === '있음') ? '참고' : '경쟁 대조';
|
|
137
|
+
const record = {
|
|
138
|
+
id, at: now.toISOString(), source: source.name, url: article.url, title: article.title,
|
|
139
|
+
summary, implication: check.items.map((item) => item.line), suggestions,
|
|
140
|
+
lensVerdict: verdict, why: `엘라누스 대조: ${check.items.map((item) => item.line).join(' · ')}`.slice(0, 500), target: name,
|
|
141
|
+
};
|
|
142
|
+
mkdirSync(dirname(lens), { recursive: true });
|
|
143
|
+
appendFileSync(lens, `${JSON.stringify(record)}\n`);
|
|
144
|
+
mkdirSync(dirname(state), { recursive: true });
|
|
145
|
+
appendFileSync(state, `${id}\n`);
|
|
146
|
+
seen.add(id);
|
|
147
|
+
written++;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
failed++;
|
|
150
|
+
log(`못 모음: ${article.url} — ${error instanceof Error ? error.message : String(error)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
failed++;
|
|
155
|
+
log(`못 모음: ${source.name} — ${error instanceof Error ? error.message : String(error)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { written, failed };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (import.meta.main) {
|
|
162
|
+
const config = parseYaml(readFileSync(resolve(import.meta.dir, '../graphs/intake/news-sources.yaml'), 'utf8')) as { sources?: NewsSource[] };
|
|
163
|
+
if (!Array.isArray(config.sources) || !config.sources.every((source) => source && typeof source.name === 'string' && source.name.trim() && typeof source.rss === 'string' && /^https?:\/\//.test(source.rss))) {
|
|
164
|
+
throw new Error('news-sources.yaml: sources 배열 필요');
|
|
165
|
+
}
|
|
166
|
+
const result = await runIntakeNews({ root: effectiveInstanceRoot(), sources: config.sources });
|
|
167
|
+
console.log(`intake-news: ${result.written}건 · 못 모음 ${result.failed}건`);
|
|
168
|
+
if (result.failed) process.exitCode = 1;
|
|
169
|
+
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { effectiveInstanceRoot, releaseLedgerRoot } from '../../src/instance/resolve.js';
|
|
4
|
+
import { listChecklist } from '../../src/release-loop/checklist.js';
|
|
5
|
+
import type { ReleaseManifest } from '../../src/release-loop/manifest.js';
|
|
6
|
+
import { recordDocsFollow, type DocsFollowItem } from './release-docs.js';
|
|
3
7
|
import { dirname, join } from 'node:path';
|
|
4
8
|
import { tmpdir } from 'node:os';
|
|
5
9
|
import { errorResult, finishNode, nodeOutput, readGraphContext, runCommand, type CommandRunner } from './node-verdict.js';
|
|
6
10
|
|
|
7
|
-
export function runDocsLand(run: CommandRunner = runCommand) {
|
|
11
|
+
export function runDocsLand(run: CommandRunner = runCommand, stateRoot = effectiveInstanceRoot(), ledgerRoot = releaseLedgerRoot()) {
|
|
8
12
|
const context = readGraphContext();
|
|
9
13
|
const version = context.input.version;
|
|
10
14
|
if (context.outputs.publish?.outcome !== 'ok') throw new Error('publish must succeed before docs land');
|
|
@@ -14,6 +18,37 @@ export function runDocsLand(run: CommandRunner = runCommand) {
|
|
|
14
18
|
const worktree = nodeOutput(context, 'docs', 'worktree');
|
|
15
19
|
const scratch = dirname(worktree);
|
|
16
20
|
if (dirname(scratch) !== tmpdir() || !/^release-docs-[A-Za-z0-9]+$/.test(scratch.split('/').at(-1) ?? '') || worktree !== join(scratch, 'tree')) throw new Error('invalid docs worktree');
|
|
21
|
+
const recordFollow = (deployed: boolean) => {
|
|
22
|
+
const releaseDir = join(stateRoot, 'release', version);
|
|
23
|
+
const manifest: ReleaseManifest = JSON.parse(readFileSync(join(releaseDir, 'manifest.json'), 'utf8'));
|
|
24
|
+
if (manifest.version !== version || !Array.isArray(manifest.in)) throw new Error('docs-follow manifest/version mismatch');
|
|
25
|
+
// MK attestations are independent of generated notes; missing proof is unmeasured.
|
|
26
|
+
const evidencePath = join(releaseDir, 'docs-follow-evidence.json');
|
|
27
|
+
// 증거 파일이 깨졌으면 «증거 없음»으로 센다 — 보고서·MK 대기열은 그래도 남긴다(미측정으로).
|
|
28
|
+
let raw: unknown = { items: [] };
|
|
29
|
+
try { if (existsSync(evidencePath)) raw = JSON.parse(readFileSync(evidencePath, 'utf8')); }
|
|
30
|
+
catch (error) { process.stderr.write(`docs-follow evidence unreadable — counted as no evidence: ${String(error)}\n`); }
|
|
31
|
+
const valid = !!raw && typeof raw === 'object' && 'items' in raw && Array.isArray((raw as { items: unknown }).items);
|
|
32
|
+
if (!valid) process.stderr.write('docs-follow evidence malformed — counted as no evidence\n');
|
|
33
|
+
// 항목도 하나씩 본다 — 객체이고 문자열 id 가 있는 것만 증거로 센다(나머지는 «증거 없음»).
|
|
34
|
+
const evidence: { items: unknown[] } = { items: valid ? (raw as { items: unknown[] }).items
|
|
35
|
+
.filter((item) => !!item && typeof item === 'object' && typeof (item as { id?: unknown }).id === 'string') : [] };
|
|
36
|
+
const checklist = listChecklist(version, ledgerRoot);
|
|
37
|
+
const attestations = new Map((evidence.items as DocsFollowItem[]).map((item) => [item.id, item]));
|
|
38
|
+
const items = checklist.items.map((item) => ({ ...attestations.get(item.id), id: item.id, status: item.status,
|
|
39
|
+
...(deployed ? {} : { docs: undefined }) }));
|
|
40
|
+
const publicationFile = join(ledgerRoot, 'release', version, 'release.json');
|
|
41
|
+
const publication: unknown = existsSync(publicationFile) ? JSON.parse(readFileSync(publicationFile, 'utf8')) : null;
|
|
42
|
+
const publishedAt = publication && typeof publication === 'object' && 'publishedAt' in publication
|
|
43
|
+
&& typeof publication.publishedAt === 'string' ? publication.publishedAt : undefined;
|
|
44
|
+
const follow = recordDocsFollow(stateRoot, manifest, items, new Date(), publishedAt);
|
|
45
|
+
return { pending: follow.pending.length, unassessed: follow.unassessed.length, ratio: follow.ratio, verdict: follow.verdict };
|
|
46
|
+
};
|
|
47
|
+
// The deployment error is authoritative even if the follow-up ledger cannot be written.
|
|
48
|
+
const recordFailure = () => {
|
|
49
|
+
try { return recordFollow(false); }
|
|
50
|
+
catch (error) { process.stderr.write(`docs-follow recording failed: ${String(error)}\n`); return undefined; }
|
|
51
|
+
};
|
|
17
52
|
// 재시도 멱등: 노트가 이미 main 에 있으면(앞 판에서 머지됐고 배포만 실패) 착지를 건너뛰고 배포부터.
|
|
18
53
|
const before = run('git', ['fetch', 'origin', 'main']);
|
|
19
54
|
if (before.status !== 0) throw new Error(`docs landing fetch failed: ${before.stderr}`);
|
|
@@ -22,16 +57,20 @@ export function runDocsLand(run: CommandRunner = runCommand) {
|
|
|
22
57
|
if (!existsSync(worktree)) throw new Error(`docs worktree missing and notes not on main: ${worktree}`);
|
|
23
58
|
const land = run('bun', ['bin/elanous.mjs', 'pr', 'land', '--cwd', worktree]);
|
|
24
59
|
if (land.stderr) process.stderr.write(land.stderr);
|
|
25
|
-
if (land.status === 1) return { outcome: 'fail' as const, verdict: 'fail' as const, summary: `docs land failed: ${land.stderr || land.stdout}
|
|
60
|
+
if (land.status === 1) return { outcome: 'fail' as const, verdict: 'fail' as const, summary: `docs land failed: ${land.stderr || land.stdout}`, docsFollow: recordFailure() };
|
|
26
61
|
if (land.status !== 0 || !land.stdout.includes('✓ merge:')) throw new Error(`docs land incomplete (rc=${land.status}): ${land.stderr || land.stdout}`);
|
|
27
62
|
}
|
|
63
|
+
// 착지(병합)는 이미 끝났다 — 이 뒤의 모든 실패는 후속 기록을 시도하고 원래 오류를 그대로 던진다.
|
|
28
64
|
const fetch = run('git', ['fetch', 'origin', 'main']);
|
|
29
|
-
if (fetch.status !== 0) throw new Error(`docs landing fetch failed: ${fetch.stderr}`);
|
|
65
|
+
if (fetch.status !== 0) { recordFailure(); throw new Error(`docs landing fetch failed: ${fetch.stderr}`); }
|
|
30
66
|
const merged = run('git', ['rev-parse', 'origin/main']);
|
|
31
|
-
if (merged.status !== 0 || !/^[0-9a-f]{40}\s*$/i.test(merged.stdout)) throw new Error(`merged main commit unavailable: ${merged.stderr}`);
|
|
32
|
-
|
|
67
|
+
if (merged.status !== 0 || !/^[0-9a-f]{40}\s*$/i.test(merged.stdout)) { recordFailure(); throw new Error(`merged main commit unavailable: ${merged.stderr}`); }
|
|
68
|
+
let deployDir: string;
|
|
69
|
+
try { deployDir = mkdtempSync(join(tmpdir(), 'release-docs-deploy-')); }
|
|
70
|
+
catch (error) { recordFailure(); throw error; }
|
|
33
71
|
const deployTree = join(deployDir, 'tree');
|
|
34
72
|
let added = false;
|
|
73
|
+
let deployed = false;
|
|
35
74
|
try {
|
|
36
75
|
const add = run('git', ['worktree', 'add', '--detach', deployTree, merged.stdout.trim()]);
|
|
37
76
|
if (add.status !== 0) throw new Error(`docs deploy worktree failed: ${add.stderr}`);
|
|
@@ -40,21 +79,41 @@ export function runDocsLand(run: CommandRunner = runCommand) {
|
|
|
40
79
|
if (install.status !== 0) throw new Error(`docs deploy install failed: ${install.stderr}`);
|
|
41
80
|
const deploy = run('bun', ['website/scripts/deploy-pages.ts', '--remote', 'node-b', '--yes'], deployTree);
|
|
42
81
|
if (deploy.stderr) process.stderr.write(deploy.stderr);
|
|
43
|
-
if (deploy.status === 1) return { outcome: 'fail' as const, verdict: 'fail' as const,
|
|
82
|
+
if (deploy.status === 1) return { outcome: 'fail' as const, verdict: 'fail' as const,
|
|
83
|
+
summary: `docs deploy failed: ${deploy.stderr || deploy.stdout}`, docsFollow: recordFailure() };
|
|
44
84
|
if (deploy.status !== 0) throw new Error(`docs deploy incomplete (rc=${deploy.status}): ${deploy.stderr || deploy.stdout}`);
|
|
85
|
+
deployed = true;
|
|
45
86
|
if (existsSync(worktree)) {
|
|
46
87
|
const remove = run('git', ['worktree', 'remove', '--force', worktree]);
|
|
47
88
|
if (remove.status !== 0) throw new Error(`docs worktree cleanup failed: ${remove.stderr}`);
|
|
48
89
|
}
|
|
49
90
|
rmSync(scratch, { recursive: true, force: true });
|
|
50
|
-
|
|
91
|
+
// 배포 성공 — 후속 기록 실패(manifest 없음 등)는 결과를 «실패»로 바꾸지 않는다. 알리고 넘어간다.
|
|
92
|
+
let follow: ReturnType<typeof recordFollow> | undefined;
|
|
93
|
+
try { follow = recordFollow(true); }
|
|
94
|
+
catch (error) { process.stderr.write(`docs-follow recording failed after deploy: ${String(error)}\n`); }
|
|
95
|
+
return { outcome: 'ok' as const, verdict: 'pass' as const,
|
|
96
|
+
summary: `docs landed and deployed v${version}${follow ? ` · docs-follow pending=${follow.pending} unassessed=${follow.unassessed}` : ' · docs-follow not recorded'}`,
|
|
97
|
+
docsFollow: follow };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!deployed) recordFailure();
|
|
100
|
+
else {
|
|
101
|
+
// 배포는 됐는데 정리에서 실패 — 보고서·MK 대기열은 «배포됨»으로 남기고 원래 오류를 보존한다.
|
|
102
|
+
try { recordFollow(true); }
|
|
103
|
+
catch (recordError) { process.stderr.write(`docs-follow recording failed: ${String(recordError)}\n`); }
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
51
106
|
} finally {
|
|
52
107
|
try {
|
|
53
108
|
if (added) {
|
|
54
109
|
const remove = run('git', ['worktree', 'remove', '--force', deployTree]);
|
|
55
|
-
|
|
110
|
+
// 배포 임시 트리 정리 실패는 앞선 결과(반환·원래 오류)를 덮지 않는다 — 알리고 넘어간다.
|
|
111
|
+
if (remove.status !== 0) process.stderr.write(`docs deploy cleanup failed (kept the original result): ${remove.stderr}\n`);
|
|
56
112
|
}
|
|
57
|
-
} finally {
|
|
113
|
+
} finally {
|
|
114
|
+
try { rmSync(deployDir, { recursive: true, force: true }); }
|
|
115
|
+
catch (error) { process.stderr.write(`docs deploy temp cleanup failed (kept the original result): ${String(error)}\n`); }
|
|
116
|
+
}
|
|
58
117
|
}
|
|
59
118
|
}
|
|
60
119
|
|
|
@@ -36,7 +36,7 @@ export function waitForAssets(run: CommandRunner, repo: string, tag: string, nam
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export function runPublish(run: CommandRunner = runCommand) {
|
|
39
|
+
export function runPublish(run: CommandRunner = runCommand, prodFreezeRoot?: string) {
|
|
40
40
|
const context = readGraphContext();
|
|
41
41
|
const version = context.input.version;
|
|
42
42
|
// A measured automatic decision or the existing human approval must authorize publication.
|
|
@@ -68,7 +68,7 @@ export function runPublish(run: CommandRunner = runCommand) {
|
|
|
68
68
|
writeFileSync(file, body);
|
|
69
69
|
// Same in-flight marker as merges: `freeze on` waits for a publication that already passed this check.
|
|
70
70
|
// Unlike a merge, a frozen publication is not queued: it fails, and the release run is run again after `freeze off`.
|
|
71
|
-
const landing = beginLandingMerge();
|
|
71
|
+
const landing = beginLandingMerge(undefined, undefined, prodFreezeRoot);
|
|
72
72
|
const frozen = landing.frozen;
|
|
73
73
|
if (frozen) {
|
|
74
74
|
landing.end(); // already released by beginLandingMerge when frozen; kept explicit so no path leaves a marker
|
|
@@ -1,6 +1,140 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { appendSeatRequestRows, listSeatRequests, withSeatRequestLedgerLock } from '../../src/seat-dispatch/seat-request-ledger.js';
|
|
1
5
|
import { debug } from '../../src/debug/log.js';
|
|
2
6
|
import type { ReleaseManifest } from '../../src/release-loop/manifest.js';
|
|
3
7
|
|
|
8
|
+
type DocsFollowEvidence = { location: string; verifiedAt: string; publishedAt: string };
|
|
9
|
+
type DocsFollowExposure = {
|
|
10
|
+
verdict: 'public' | 'beta' | 'internal';
|
|
11
|
+
judge: 'MK';
|
|
12
|
+
rubric: string;
|
|
13
|
+
verifiedAt: string;
|
|
14
|
+
};
|
|
15
|
+
export type DocsFollowItem = {
|
|
16
|
+
id: string;
|
|
17
|
+
status: string;
|
|
18
|
+
sha?: string;
|
|
19
|
+
exposure?: DocsFollowExposure;
|
|
20
|
+
docs?: DocsFollowEvidence;
|
|
21
|
+
homepage?: { elanous: DocsFollowEvidence; elanvital: DocsFollowEvidence };
|
|
22
|
+
readme?: DocsFollowEvidence;
|
|
23
|
+
};
|
|
24
|
+
type DocsFollowCoverage = {
|
|
25
|
+
id: string;
|
|
26
|
+
sha: string | null;
|
|
27
|
+
docs: boolean;
|
|
28
|
+
homepage: boolean;
|
|
29
|
+
readme: boolean;
|
|
30
|
+
missing: Array<'docs' | 'homepage' | 'readme'>;
|
|
31
|
+
};
|
|
32
|
+
type DocsFollowReport = {
|
|
33
|
+
coverage: DocsFollowCoverage[];
|
|
34
|
+
pending: DocsFollowCoverage[];
|
|
35
|
+
unassessed: string[];
|
|
36
|
+
ratio: number | null;
|
|
37
|
+
verdict: 'pass' | 'fail' | 'unmeasured';
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** An attestation is not coverage unless it points to a measured, timestamped surface. */
|
|
41
|
+
function evidenced(value: DocsFollowEvidence | undefined, measuredAt?: string): boolean {
|
|
42
|
+
const at = value?.verifiedAt ? Date.parse(value.verifiedAt) : NaN;
|
|
43
|
+
const published = value?.publishedAt ? Date.parse(value.publishedAt) : NaN;
|
|
44
|
+
return !!value?.location?.trim() && Number.isFinite(at) && Number.isFinite(published)
|
|
45
|
+
&& /(?:Z|[+-]\d\d:\d\d)$/.test(value.verifiedAt)
|
|
46
|
+
&& /(?:Z|[+-]\d\d:\d\d)$/.test(value.publishedAt)
|
|
47
|
+
&& published <= at && (measuredAt === undefined || at <= Date.parse(measuredAt));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Unknown exposure/landing never becomes a published claim; it remains visible as an unassessed item. */
|
|
51
|
+
export function calculateDocsFollow(manifest: ReleaseManifest, items: readonly DocsFollowItem[], publishedAt?: string, measuredAt?: string): DocsFollowReport {
|
|
52
|
+
const landed = new Set(manifest.in.map((entry) => entry.sha).filter(Boolean));
|
|
53
|
+
const green = items.filter((item) => item.status === 'green');
|
|
54
|
+
const ids = new Set<string>();
|
|
55
|
+
for (const item of items) {
|
|
56
|
+
if (!item.id?.trim() || ids.has(item.id)) throw new Error('invalid or duplicate docs-follow item');
|
|
57
|
+
ids.add(item.id);
|
|
58
|
+
}
|
|
59
|
+
const judged = (item: DocsFollowItem) => item.exposure?.judge === 'MK'
|
|
60
|
+
&& !!item.exposure.rubric?.trim() && evidenced({ location: item.exposure.rubric, verifiedAt: item.exposure.verifiedAt,
|
|
61
|
+
publishedAt: item.exposure.verifiedAt }, measuredAt)
|
|
62
|
+
&& ['public', 'beta', 'internal'].includes(item.exposure.verdict);
|
|
63
|
+
const unassessed = green.filter((item) => !judged(item) || !item.sha || !landed.has(item.sha))
|
|
64
|
+
.map((item) => item.id);
|
|
65
|
+
const supplied = new Set(items.map((item) => item.sha));
|
|
66
|
+
for (const entry of manifest.in) {
|
|
67
|
+
if (entry.sha && (entry.kind === 'feat' || entry.kind === 'fix' || entry.kind === 'security') && entry.line && !supplied.has(entry.sha)) {
|
|
68
|
+
unassessed.push(`landing:${entry.sha}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const visible = green.filter((item) => judged(item) && (item.exposure?.verdict === 'public' || item.exposure?.verdict === 'beta') && item.sha && landed.has(item.sha));
|
|
72
|
+
const coverage: DocsFollowCoverage[] = visible.map((item) => {
|
|
73
|
+
const docs = evidenced(item.docs, measuredAt);
|
|
74
|
+
const homepage = evidenced(item.homepage?.elanous, measuredAt) && evidenced(item.homepage?.elanvital, measuredAt);
|
|
75
|
+
const readme = evidenced(item.readme, measuredAt);
|
|
76
|
+
return { id: item.id, sha: item.sha!, docs, homepage, readme,
|
|
77
|
+
missing: ([['docs', docs], ['homepage', homepage], ['readme', readme]] as const)
|
|
78
|
+
.filter(([, present]) => !present).map(([surface]) => surface) };
|
|
79
|
+
});
|
|
80
|
+
const pending = coverage.filter((item) => !item.docs || !item.homepage);
|
|
81
|
+
const covered = coverage.filter((item) => item.docs && item.homepage && item.readme).length;
|
|
82
|
+
const start = publishedAt ? Date.parse(publishedAt) : NaN;
|
|
83
|
+
const end = measuredAt ? Date.parse(measuredAt) : NaN;
|
|
84
|
+
const deadline = start + 24 * 60 * 60 * 1000;
|
|
85
|
+
const validWindow = Number.isFinite(start) && Number.isFinite(end) && end >= start
|
|
86
|
+
&& /(?:Z|[+-]\d\d:\d\d)$/.test(publishedAt ?? '')
|
|
87
|
+
&& /(?:Z|[+-]\d\d:\d\d)$/.test(measuredAt ?? '');
|
|
88
|
+
const atDeadline = validWindow && end > deadline
|
|
89
|
+
? calculateDocsFollow(manifest, items, publishedAt, new Date(deadline).toISOString()) : null;
|
|
90
|
+
const timely = atDeadline ?? { coverage, pending, unassessed,
|
|
91
|
+
ratio: coverage.length && !unassessed.length ? covered / coverage.length : null };
|
|
92
|
+
const pass = validWindow && timely.coverage.length > 0 && !timely.unassessed.length
|
|
93
|
+
&& timely.ratio !== null && timely.ratio >= 0.8 && !timely.pending.length;
|
|
94
|
+
// Missing proof may still establish timely coverage; only a verified late publication rules it out.
|
|
95
|
+
const lateProof = (proof: DocsFollowEvidence | undefined) => evidenced(proof, measuredAt)
|
|
96
|
+
&& Date.parse(proof!.publishedAt) > deadline;
|
|
97
|
+
const impossible = validWindow && end > deadline && !pass && !timely.unassessed.length
|
|
98
|
+
&& visible.length > 0 && (
|
|
99
|
+
visible.some((item) => lateProof(item.docs) || lateProof(item.homepage?.elanous) || lateProof(item.homepage?.elanvital))
|
|
100
|
+
|| visible.filter((item) => ![item.docs, item.homepage?.elanous, item.homepage?.elanvital, item.readme]
|
|
101
|
+
.some(lateProof)).length / visible.length < 0.8
|
|
102
|
+
);
|
|
103
|
+
return { coverage, pending, unassessed,
|
|
104
|
+
ratio: coverage.length && !unassessed.length ? covered / coverage.length : null,
|
|
105
|
+
verdict: pass ? 'pass' as const : impossible ? 'fail' as const : 'unmeasured' as const };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Persist a per-release report and queue each missing docs/home item once for MK. */
|
|
109
|
+
export function recordDocsFollow(root: string, manifest: ReleaseManifest, items: readonly DocsFollowItem[], now: Date = new Date(), publishedAt?: string) {
|
|
110
|
+
if (!Number.isFinite(now.getTime())) throw new Error('invalid docs-follow time');
|
|
111
|
+
if (!/^\d+\.\d+\.\d+$/.test(manifest.version)) throw new Error('invalid docs-follow version');
|
|
112
|
+
const report = calculateDocsFollow(manifest, items, publishedAt, now.toISOString());
|
|
113
|
+
const path = join(root, 'release', manifest.version, 'docs-follow.json');
|
|
114
|
+
const journal = join(root, 'seat-requests', 'requests.jsonl');
|
|
115
|
+
withSeatRequestLedgerLock(journal, () => {
|
|
116
|
+
const known = new Set(listSeatRequests(root).map((row) => row.key));
|
|
117
|
+
const missing = [
|
|
118
|
+
...report.pending.map((item) => ({ id: item.id, text: `문서 반영 대기 목록 v${manifest.version} · ${item.id}: ${item.missing.join(', ')} — 착지 ${item.sha}; docs·홈 실측 뒤 닫기` })),
|
|
119
|
+
...report.unassessed.map((id) => ({ id, text: `문서 반영 대기 목록 v${manifest.version} · ${id}: 착지·green·EXPOSE-RUBRIC 근거 미판정 — 확인 전 공개 문면 금지` })),
|
|
120
|
+
];
|
|
121
|
+
const rows = missing.filter((item) => !known.has(`docs-follow:${manifest.version}:${item.id}`)).map((item) => ({
|
|
122
|
+
key: `docs-follow:${manifest.version}:${item.id}`, seat: 'MK', status: 'queued' as const,
|
|
123
|
+
text: item.text, queuedAt: now.toISOString(), ref: `release:${manifest.version}:docs-follow`,
|
|
124
|
+
}));
|
|
125
|
+
mkdirSync(join(root, 'release', manifest.version), { recursive: true });
|
|
126
|
+
const temp = `${path}.${createHash('sha256').update(now.toISOString()).digest('hex').slice(0, 12)}.tmp`;
|
|
127
|
+
try {
|
|
128
|
+
writeFileSync(temp, JSON.stringify({ version: manifest.version, measuredAt: now.toISOString(), ...report }, null, 2) + '\n');
|
|
129
|
+
renameSync(temp, path);
|
|
130
|
+
} finally {
|
|
131
|
+
if (existsSync(temp)) unlinkSync(temp);
|
|
132
|
+
}
|
|
133
|
+
appendSeatRequestRows(journal, rows);
|
|
134
|
+
});
|
|
135
|
+
return report;
|
|
136
|
+
}
|
|
137
|
+
|
|
4
138
|
/** Render public IN lines only; keep private titles and PR numbers out of release notes. */
|
|
5
139
|
export function renderReleaseNotes(manifest: ReleaseManifest, version: string): string {
|
|
6
140
|
const sections = [
|
|
@@ -64,7 +64,7 @@ function shippedNextMdLines(repo: string, cut: string | undefined): { lines: Set
|
|
|
64
64
|
return { lines: new Set(shown.stdout.split('\n').map((line) => line.trim()).filter((line) => line.startsWith('- '))) };
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
function landing(repo: string, kind: Kind, releaseVersion: string, cut?: string, cutCommit?: string): Output {
|
|
67
|
+
function landing(repo: string, kind: Kind, releaseVersion: string, cut?: string, cutCommit?: string, forceFreeze = false): Output {
|
|
68
68
|
const target = kind === 'release' ? releaseVersion : nextDevVersion(releaseVersion);
|
|
69
69
|
const source = kind === 'release' ? `${releaseVersion}-dev.N` : releaseVersion;
|
|
70
70
|
fetchMain(repo);
|
|
@@ -183,7 +183,8 @@ function landing(repo: string, kind: Kind, releaseVersion: string, cut?: string,
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
const title = kind === 'release' ? `release: ${target}` : `version: ${target}`;
|
|
186
|
-
const land = spawnSync('bun', ['bin/elanous.mjs', 'pr', 'land', '--commit-message', title, '--title', title, '--body', `Set package.json version to ${target}
|
|
186
|
+
const land = spawnSync('bun', ['bin/elanous.mjs', 'pr', 'land', '--commit-message', title, '--title', title, '--body', `Set package.json version to ${target}.`,
|
|
187
|
+
...(forceFreeze ? ['--force-freeze', `release run version bump ${target}`] : [])], {
|
|
187
188
|
cwd: worktree, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024,
|
|
188
189
|
});
|
|
189
190
|
const transcript = `${land.stdout ?? ''}\n${land.stderr ?? ''}`;
|
|
@@ -211,6 +212,7 @@ function main(args: string[] = process.argv.slice(2), repo = process.cwd()): Out
|
|
|
211
212
|
let version: string | null = null;
|
|
212
213
|
let cut: string | undefined;
|
|
213
214
|
let cutCommit: string | undefined;
|
|
215
|
+
let forceFreeze = false;
|
|
214
216
|
try {
|
|
215
217
|
if (args[0] !== 'release' && args[0] !== 'dev-bump') throw new Error('usage: version-node.ts <release|dev-bump> [--version <v>] [--cut <sha>] [--cut-commit <sha>] --json');
|
|
216
218
|
kind = args[0];
|
|
@@ -224,8 +226,9 @@ function main(args: string[] = process.argv.slice(2), repo = process.cwd()): Out
|
|
|
224
226
|
if (process.env.ELANOUS_GRAPH_CONTEXT) {
|
|
225
227
|
const location = process.env.ELANOUS_GRAPH_CONTEXT;
|
|
226
228
|
const context = JSON.parse(location.trimStart().startsWith('{') ? location : readFileSync(location, 'utf8')) as {
|
|
227
|
-
input?: { version?: unknown; cutCommit?: unknown }; outputs?: Record<string, { commit?: unknown } | undefined> };
|
|
229
|
+
input?: { version?: unknown; cutCommit?: unknown; forceFreeze?: unknown }; outputs?: Record<string, { commit?: unknown } | undefined> };
|
|
228
230
|
if (!version && typeof context.input?.version === 'string') version = context.input.version;
|
|
231
|
+
forceFreeze = context.input?.forceFreeze === true;
|
|
229
232
|
if (kind === 'release' && cutCommit === undefined && context.input?.cutCommit !== undefined) {
|
|
230
233
|
if (typeof context.input.cutCommit !== 'string') throw new Error('cutCommit must be a commit SHA');
|
|
231
234
|
cutCommit = context.input.cutCommit;
|
|
@@ -237,7 +240,7 @@ function main(args: string[] = process.argv.slice(2), repo = process.cwd()): Out
|
|
|
237
240
|
if (!version) throw new Error('version required (--version or ELANOUS_GRAPH_CONTEXT.input.version)');
|
|
238
241
|
nextDevVersion(version);
|
|
239
242
|
if (kind === 'dev-bump' && cutCommit !== undefined) throw new Error('--cut-commit is only valid for release');
|
|
240
|
-
return landing(resolve(repo), kind, version, cut, cutCommit);
|
|
243
|
+
return landing(resolve(repo), kind, version, cut, cutCommit, forceFreeze);
|
|
241
244
|
} catch (error) {
|
|
242
245
|
const message = error instanceof Error ? error.message : String(error);
|
|
243
246
|
const worktree = /\[worktree: ([^\]]+)\]$/.exec(message)?.[1];
|
|
@@ -86,18 +86,25 @@ function manualTexts(dir: string): string[] {
|
|
|
86
86
|
return texts;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
type StoryClaim = { id: string; sentence: string; command: string | null; firstPublicCandidate: boolean };
|
|
90
|
+
|
|
91
|
+
function linkedClaims(stateDir: string, version: string, ids: Set<string>): { claims: StoryClaim[]; error?: string } {
|
|
90
92
|
const ledger = new ClaimsLedger({ stateDir });
|
|
91
93
|
try {
|
|
92
|
-
if (!existsSync(stateDir)) return {
|
|
93
|
-
if (!statSync(stateDir).isDirectory()) return {
|
|
94
|
-
if (!existsSync(ledger.path)) return {
|
|
95
|
-
const
|
|
96
|
-
.
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
if (!existsSync(stateDir)) return { claims: [], error: '상태 경로 없음' };
|
|
95
|
+
if (!statSync(stateDir).isDirectory()) return { claims: [], error: '상태 경로가 폴더가 아님' };
|
|
96
|
+
if (!existsSync(ledger.path)) return { claims: [], error: '원장 없음' };
|
|
97
|
+
const claims = [...ledger.list({ status: 'verified' }), ...ledger.list({ status: 'public' })]
|
|
98
|
+
.map((row): StoryClaim | null => {
|
|
99
|
+
const detail = ledger.get(row.id);
|
|
100
|
+
if (!detail.links.some((link) => link.version === version && ids.has(link.cell))) return null;
|
|
101
|
+
return { id: row.id, sentence: row.claim, command: detail.evidence.at(-1)?.command ?? null,
|
|
102
|
+
firstPublicCandidate: !detail.history.some((entry) => entry.event === 'publish')
|
|
103
|
+
&& !detail.links.some((link) => link.version !== version && link.version !== null) };
|
|
104
|
+
}).filter((claim): claim is StoryClaim => claim !== null);
|
|
105
|
+
return { claims };
|
|
99
106
|
} catch (error) {
|
|
100
|
-
return {
|
|
107
|
+
return { claims: [], error: error instanceof Error ? error.message : String(error) };
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
|
|
@@ -117,15 +124,34 @@ export function draftReleaseStory(options: DraftOptions): DraftResult {
|
|
|
117
124
|
const candidates = green.filter((item) => !mentions.some((text) => text.includes(item.id)));
|
|
118
125
|
const evidence = linkedClaims(options.stateDir ?? effectiveInstanceRoot(), version, new Set(publicCells.map((item) => item.id)));
|
|
119
126
|
const changes = [...userLines, ...publicCells.map((item) => firstPhrase(item.title))];
|
|
127
|
+
const numeric = evidence.claims.find((claim) => claim.firstPublicCandidate && /\d/.test(claim.sentence) && claim.command);
|
|
128
|
+
const kindPattern = /\bnew\b|새로운|신규|새 종류/i;
|
|
129
|
+
const kindFromNext = userLines.find((line) => kindPattern.test(line));
|
|
130
|
+
const kindFromCell = publicCells.map((item) => firstPhrase(item.title)).find((line) => kindPattern.test(line));
|
|
131
|
+
const kindFromClaim = evidence.claims.find((claim) => kindPattern.test(claim.sentence));
|
|
132
|
+
const newKind = kindFromNext ? { text: kindFromNext, source: 'release/next.md', command: null }
|
|
133
|
+
: kindFromCell ? { text: kindFromCell, source: 'green 칸', command: null }
|
|
134
|
+
: kindFromClaim ? { text: kindFromClaim.sentence, source: `소구점 ${kindFromClaim.id}`, command: kindFromClaim.command } : null;
|
|
135
|
+
const card = numeric ? { kind: '첫 공개 숫자 후보', text: numeric.sentence, source: `소구점 ${numeric.id}`, command: numeric.command }
|
|
136
|
+
: newKind ? { kind: '새 종류 후보', ...newKind } : null;
|
|
120
137
|
const announcement = [
|
|
121
|
-
`# ${version} 판 공지 초안`, '', '##
|
|
138
|
+
`# ${version} 판 공지 초안`, '', '## 대표 카드 · CMO 확인 전',
|
|
139
|
+
...(card ? [`- ${card.kind}: ${card.text}`, `- 출처: ${card.source}`, `- 재측정: ${card.command ?? '확인 명령 없음 — 게시 전 확인'}`]
|
|
140
|
+
: ['- 후보 없음 — CMO가 이번 판의 대표 문장을 고른다']),
|
|
141
|
+
'- 첫 공개 여부·새 종류 여부는 앞 판의 공개 문장과 대조한 뒤 CMO가 확정한다.',
|
|
142
|
+
'', '## 무엇이 달라졌나',
|
|
122
143
|
...(changes.length ? changes.map((line) => `- ${line}`) : ['- 사용자 쪽 변경 없음']),
|
|
123
144
|
'', '## 근거',
|
|
124
|
-
...(evidence.error ? [`근거: 소구점 원장 못 읽음(${evidence.error})`] : evidence.
|
|
145
|
+
...(evidence.error ? [`근거: 소구점 원장 못 읽음(${evidence.error})`] : evidence.claims.length ? evidence.claims.map((claim) => `- ${claim.sentence}`) : ['근거: 연결된 소구점 없음']),
|
|
146
|
+
'', '## CMO 게시 판단 · 초안은 게시가 아니다',
|
|
147
|
+
'- 착지·출처: release/next.md와 green 칸의 공개 상태, 연결된 검증 소구점을 대조한다. 근거가 없거나 원장을 못 읽었으면 보류한다.',
|
|
148
|
+
'- 사실·톤: 숫자는 근거 명령으로 재측정하고 첫 공개·새 종류는 앞 판 공개 문장과 대조한다. 브랜드 규칙과 금지 표현을 확인한다.',
|
|
149
|
+
'- 채널·결정: 공지·사이트·매뉴얼 문장의 대상과 영어 원문을 대조하고 CMO가 게시/수정/보류와 이유를 기록한다. 실제 바깥 게시는 역할 파일의 사람 결정 관문을 따른다.',
|
|
125
150
|
'',
|
|
126
151
|
].join('\n');
|
|
127
152
|
const siteChanges = [...userLines, ...publicCells.map((item) => firstPhrase(item.title)).filter((title) => !/[가-힣]/.test(title))];
|
|
128
|
-
const
|
|
153
|
+
const siteCard = card && !/[가-힣]/.test(card.text) ? [`## Lead card candidate (CMO review required)`, card.text, ''] : [];
|
|
154
|
+
const siteNews = [`# What's new in ${version}`, '', ...siteCard, ...siteChanges.slice(0, 3).map((line) => `- ${line}`), '', `Release: ${version}`, ''].join('\n');
|
|
129
155
|
const manual = [`# ${version} 매뉴얼 갱신 후보`, '', ...candidates.map((item: ChecklistItem) => `- ${item.id} · ${firstPhrase(item.title)} · 매뉴얼 언급 없음`), ''].join('\n');
|
|
130
156
|
const outDir = options.outDir ?? join(effectiveInstanceRoot(), 'release', version, 'story');
|
|
131
157
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -150,7 +176,7 @@ export function draftReleaseStory(options: DraftOptions): DraftResult {
|
|
|
150
176
|
files.push(file);
|
|
151
177
|
}
|
|
152
178
|
const result: DraftResult = { version, status: 'drafted', userLines: userLines.length, internalDropped, greenCells: green.length,
|
|
153
|
-
claims: evidence.
|
|
179
|
+
claims: evidence.claims.length, manualCandidates: candidates.length, brandFindings, ...(brandUnmeasured ? { brandUnmeasured } : {}), files };
|
|
154
180
|
debug.log('release.story', 'drafted', { version, userLines: result.userLines, internalDropped, greenCells: result.greenCells,
|
|
155
181
|
claims: result.claims, manualCandidates: result.manualCandidates, brandFindings, ...(brandUnmeasured ? { brandUnmeasured } : {}) });
|
|
156
182
|
return result;
|
|
@@ -161,7 +187,9 @@ export function runReleaseStory(context: GraphContext = readGraphContext(), draf
|
|
|
161
187
|
try {
|
|
162
188
|
if (context.outputs.publish?.outcome !== 'ok' || context.outputs.verify?.outcome !== 'ok')
|
|
163
189
|
throw new Error('published release and verification required');
|
|
164
|
-
|
|
190
|
+
// 판 노트 원천은 기본이 저장소의 release/next.md — 시험·재현은 env 로 다른 파일을 준다(실제 next.md 는 판마다 비워진다).
|
|
191
|
+
const nextPath = process.env.ELANOUS_RELEASE_NEXT_PATH?.trim();
|
|
192
|
+
const result = draft({ version, ...(nextPath ? { nextPath } : {}) });
|
|
165
193
|
if (result.status === 'drafted') {
|
|
166
194
|
const root = effectiveInstanceRoot();
|
|
167
195
|
const requestsPath = join(root, 'seat-requests', 'requests.jsonl');
|