elanous 0.2.15 → 0.2.16
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-33ffcc49ab45123b.js → 1307-b46d45b705e8c9ae.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/{9103-7a3ae7f60af3b99f.js → 9103-60fbef33e66f0a46.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-79c3ed84baaedd03.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-e05a77df7e005b04.js → page-3cb52e5ea8fc2fa5.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-a424ef80885d1743.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/botlab/{page-5491c115b1f9a923.js → page-c1e20d7d338713f1.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-b8c49f6b4fa2df91.js → page-482c5dbde699947c.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-1e962b31d1f11081.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/chat/{page-976813c91d7394ea.js → page-8c2d9526164f5d09.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/page-ffff6d819fbc6e0f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/control/page-a0e2a6d1bfb52fc5.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/design-check/{page-72d3e7ff35a8c954.js → page-c53598cfce6156f2.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/{page-60c7a8e1a4fe62ce.js → page-13a63953f53c6a23.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/exec/{page-2a52b316cb143d9c.js → page-5d3c8429d04f3831.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-c3ce483c8d5db370.js → page-3193bdc0bbab047a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/page-76270efaee638f17.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/intake/{page-96f074f6538e8a2c.js → page-6107ca8ddd5e3bdd.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{layout-e97be5334d83a9f5.js → layout-dce686e582c23937.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-d3d53b146416e422.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/loops/page-1a61c7ca1ceb3ae7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/{page-955ec2da3be4c560.js → page-51f811fa119308b6.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-9f88fbc15d9058d1.js → page-dacff46049e1557e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-c1343f9499d86f3f.js → page-4167348049166852.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{not-found-8739a2e0ae793130.js → not-found-de52e8fdf9095f7a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/{page-c9fc5706b8abaa11.js → page-36a7d994f4ca0299.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/{page-d23b12bb92242558.js → page-ca3952cd78a83ca2.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-5b042736aa113756.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/{page-c7fea475488201a8.js → page-acb749f1e432bcf3.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-cd78862707999911.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/page-54b9ef8afe6278fd.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/reflection/{page-5efbf81124c474ae.js → page-1ebfe049c640e171.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-85f1a1fc0a82738c.js → page-57924393e3e4e539.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/page-9e0ce6c8129fa641.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-06ee266953d9c879.js → page-31fa1ce4c4aff9ae.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/{page-153c2b3bcc89e8de.js → page-0f5cd57ca928f42b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-abe8e3b74bea1ffc.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/page-4364ef9f4e7c7f0f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/share/{page-0d6d23b40263a7ea.js → page-d28573d6792b4275.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-59f03fda76c03d22.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-d751de5f8062ff5e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/term/{page-f5597e894c02cd33.js → page-fe1759f94003b32b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/today/{page-0337fdf7fb5e37b6.js → page-d64c984e855c8c68.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-86914b0dee8ab7f8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/vault/{page-8fcec4c9ea2e7f70.js → page-9afdee66b820c76e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/{page-f7e631e9b4daf66e.js → page-d4aef67abf264874.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-1ea64acadd704ace.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-9817c7f9007466cc.js → page-3544f154c87b43ee.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-30c2073bfc21c004.js +1 -0
- package/apps/pwa/out/_next/static/css/001d5fbaee388a25.css +1 -0
- package/apps/pwa/out/_next/static/css/{04dabcbcc4bfa6db.css → 085673d826b98b62.css} +1 -1
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +15 -15
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +15 -15
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +15 -15
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +15 -15
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +15 -15
- package/apps/pwa/out/ceo/index.html +1 -1
- package/apps/pwa/out/ceo/index.txt +15 -15
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +15 -15
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +15 -15
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +15 -15
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +15 -15
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +15 -15
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +15 -15
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +15 -15
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +15 -15
- package/apps/pwa/out/inside/index.html +1 -1
- package/apps/pwa/out/inside/index.txt +15 -15
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +15 -15
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +15 -15
- package/apps/pwa/out/loops/index.html +1 -1
- package/apps/pwa/out/loops/index.txt +15 -15
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +15 -15
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +15 -15
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +15 -15
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +15 -15
- package/apps/pwa/out/ops/checklist/index.html +1 -1
- package/apps/pwa/out/ops/checklist/index.txt +15 -15
- package/apps/pwa/out/ops/release/index.html +1 -1
- package/apps/pwa/out/ops/release/index.txt +15 -15
- package/apps/pwa/out/ops/seats/index.html +1 -1
- package/apps/pwa/out/ops/seats/index.txt +15 -15
- package/apps/pwa/out/outputs/index.html +1 -1
- package/apps/pwa/out/outputs/index.txt +15 -15
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +16 -16
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +15 -15
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +15 -15
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +15 -15
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +15 -15
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +15 -15
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +15 -15
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +15 -15
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +15 -15
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +15 -15
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +15 -15
- package/apps/pwa/out/today/index.html +1 -1
- package/apps/pwa/out/today/index.txt +15 -15
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +15 -15
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +15 -15
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +15 -15
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +15 -15
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +15 -15
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +15 -15
- package/graphs/doc-gen/doc-gen.yaml +24 -0
- package/graphs/doc-gen/recipes.yaml +3 -0
- package/graphs/orchestrator/orchestrator.yaml +7 -0
- package/graphs/orchestrator/recipes.yaml +3 -0
- package/graphs/steward/recipes.yaml +2 -1
- package/package.json +1 -1
- package/scripts/ci-public-export-import-gate.ts +154 -0
- package/scripts/ci-test-interference-gate.ts +5 -1
- package/scripts/ci-typecheck-changed.ts +34 -15
- package/scripts/docs/doc-rot.ts +164 -0
- package/scripts/docs/generate-graph-anatomy.ts +131 -9
- package/scripts/docs-cli-check.ts +4 -4
- package/scripts/goals/goal-census.ts +343 -0
- package/scripts/measure/author-section-contribution.ts +84 -0
- package/scripts/release-loop/cut-branch.ts +43 -13
- package/scripts/release-loop/gate-node.ts +3 -1
- package/scripts/release-loop/vault-note-node.ts +19 -2
- package/scripts/rhythm/monthly.ts +97 -0
- package/src/acp/client.ts +17 -2
- package/src/agent-mission/driver.ts +51 -1
- package/src/agent-mission/supervisor-metrics-cli.ts +40 -0
- package/src/agent-mission/supervisor-metrics.ts +218 -0
- package/src/autopilot/track-agent-shadow.ts +2 -1
- package/src/briefing/brief-items.ts +191 -36
- package/src/budget/usage-outcomes.ts +204 -0
- package/src/cli/brief-cli.ts +73 -0
- package/src/cli/know-cli.ts +20 -0
- package/src/cli/logs-cli.ts +43 -1
- package/src/cli/pr-cli.ts +80 -12
- package/src/cli/release-cli.ts +11 -8
- package/src/cli/self-update.ts +200 -1
- package/src/cli/usage-cli.ts +26 -0
- package/src/discord-self-message.ts +9 -0
- package/src/flow-loop/split.ts +24 -0
- package/src/harness/harness-cli-command.ts +36 -2
- package/src/harness/harness-pod-dispatch.ts +25 -55
- package/src/harness/harness-seams.ts +13 -3
- package/src/harness/helper-repair.ts +139 -8
- package/src/harness/helper-scan.ts +13 -2
- package/src/harness/nested-elanous-policy.ts +114 -0
- package/src/hq/hq.ts +8 -2
- package/src/hq/lease.ts +5 -3
- package/src/hq/transfer.ts +185 -0
- package/src/index.ts +112 -6
- package/src/intake-plane/digest.ts +48 -26
- package/src/knowledge/know-find.ts +105 -0
- package/src/llm.ts +75 -19
- package/src/loops/orchestrator/finish-rate.ts +14 -3
- package/src/loops/orchestrator/tick.ts +96 -25
- package/src/maturity/bot-slash-catalog.ts +11 -2
- package/src/maturity/feature-maturity.ts +2 -2
- package/src/maturity/tui-slash-availability.ts +9 -0
- package/src/oauth/overload-failover-launch.ts +75 -0
- package/src/oauth/overload-failover.ts +137 -0
- package/src/release-loop/checklist.ts +10 -5
- package/src/release-loop/feature-store.ts +10 -9
- package/src/release-loop/placement.ts +17 -8
- package/src/seat-dispatch/seat-ask.ts +33 -6
- package/src/seat-loop/seat-loop.ts +90 -3
- package/src/self-dev/ask-launch-flow.ts +22 -1
- package/src/self-dev/authored-goal-trace.ts +64 -0
- package/src/self-dev/dev-cli.ts +19 -1
- package/src/self-dev/dev-pipeline.ts +6 -5
- package/src/self-dev/entrance-registry.ts +10 -6
- package/src/self-dev/old-door.ts +73 -0
- package/src/self-implement/app-api-budget.ts +166 -0
- package/src/self-implement/budget-gate.ts +7 -5
- package/src/self-implement/follow-up-goals.ts +289 -0
- package/src/self-implement/goal-author.ts +19 -13
- package/src/self-implement/heal-triage.ts +44 -0
- package/src/self-implement/orchestrator.ts +138 -7
- package/src/self-implement/run-ledger.ts +4 -0
- package/src/self-implement/seams.ts +44 -15
- package/src/session-runtime/index.ts +2 -0
- package/src/session-runtime/retry-api.ts +87 -0
- package/src/session-runtime/retry-policy.ts +107 -6
- package/src/steward/working-backwards.ts +16 -1
- package/src/task-cards/card-cli.ts +25 -0
- package/src/task-cards/card-store.ts +5 -3
- package/src/task-orchestrator/surfaces/pod-ledger-collect.ts +4 -3
- package/src/task-orchestrator/surfaces/pod-ledger-prod-sink.ts +75 -0
- package/src/task-orchestrator/surfaces/pod-pool.ts +14 -5
- package/src/task-orchestrator/surfaces/pod-source-receive.ts +37 -1
- package/src/task-orchestrator/surfaces/self-implement-pod.ts +19 -10
- package/src/task-orchestrator/surfaces/self-implement.ts +2 -0
- package/src/telegram-commands.ts +5 -0
- package/src/telegram.ts +47 -0
- package/src/user-config.ts +36 -3
- package/src/version/packed-revision.json +1 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-0e2c57fe6add4b2a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-f544338193a9a762.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-96398e364fbd4548.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/page-28b9448091776c06.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-1d4901eb7c2d8822.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/page-13c7b53e0c2519a9.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-65e484302921a932.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/loops/page-34d08facaf08f556.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-8d47f45a0dc044ac.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-e2ee2896811e98d0.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/page-83b19dae8619de05.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/page-da6c08aae0f92768.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-87a11e6a05f15199.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/page-7d4182964407c09c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-7e8c39241f8eb6d0.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-008c1a96618a4887.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-73735b64bfe1ae67.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-4881b8f9f63694b4.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-97f9f43172a3a90e.js +0 -1
- package/apps/pwa/out/_next/static/css/1f7c958c41ffa3b9.css +0 -1
- package/src/launch-head/seat-goal-queue-lock.fixture.ts +0 -15
- package/src/launch-head/seat-goal-queue.ts +0 -198
- /package/apps/pwa/out/_next/static/{LZcstaO9_NXypPFmujolv → rv9cierV0jSOvDBm1g0XB}/_buildManifest.js +0 -0
- /package/apps/pwa/out/_next/static/{LZcstaO9_NXypPFmujolv → rv9cierV0jSOvDBm1g0XB}/_ssgManifest.js +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 공개 문서에 적힌 `elanous …` 명령·하위 명령·플래그가 «실제 CLI» 에 있나 — 문서가 실제와 어긋나는 1순위 원인을 잡는다.
|
|
3
3
|
// bun scripts/docs-cli-check.ts [--json] [files…] (기본 = release/public/내부 문서 `*` ⊕ README.md)
|
|
4
4
|
// 🩸 계기(2026-09-25 🅢 RFC 피드백 ③): README·install.md 가 거짓이 된 원인이 전부 «설치기·doctor 가 바뀌었는데 문서가 모름»이었다.
|
|
5
|
-
// 자 = `bun bin/elanous.mjs <cmd> [<sub>] [<subsub>] --help` 산출(원천) — 문서의 코드 블록과 인라인 코드에서 `elanous ` 로 시작하는 것만 본다.
|
|
5
|
+
// 자 = `bun bin/elanous.mjs <cmd> [<sub>] [<subsub>] --help` 산출(원천) — 문서의 코드 블록과 인라인 코드에서 `elanous ` 또는 `bun bin/elanous.mjs ` 로 시작하는 것만 본다.
|
|
6
6
|
// ⛔ 못 본 것은 «없다»로 적지 않는다: `--help` 가 실패하면 그 명령은 `unmeasured` 로 따로 센다.
|
|
7
7
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
8
8
|
import { join, relative, resolve } from 'node:path';
|
|
@@ -21,10 +21,10 @@ export function extractElanousCommands(file: string, text: string): DocCommand[]
|
|
|
21
21
|
if (/^\s*(```|~~~)/.test(raw)) { inFence = !inFence; return; }
|
|
22
22
|
const candidates: string[] = [];
|
|
23
23
|
if (inFence) candidates.push(raw.replace(/#.*$/, ''));
|
|
24
|
-
for (const m of raw.matchAll(/`(elanous [^`]+)`/g)) candidates.push(m[1]!);
|
|
24
|
+
for (const m of raw.matchAll(/`((?:bun bin\/elanous\.mjs |elanous )[^`]+)`/g)) candidates.push(m[1]!);
|
|
25
25
|
for (const c of candidates) {
|
|
26
26
|
for (const seg of c.split(/&&|\|\||;|\|/)) {
|
|
27
|
-
const m = /^(?:(?:\$\s+|>\s+|sudo\s+|[A-Za-z_][A-Za-z0-9_]*=\S+\s+))*elanous\s+(.+)$/.exec(seg.trim());
|
|
27
|
+
const m = /^(?:(?:\$\s+|>\s+|sudo\s+|[A-Za-z_][A-Za-z0-9_]*=\S+\s+))*(?:bun bin\/elanous\.mjs |elanous\s+)(.+)$/.exec(seg.trim());
|
|
28
28
|
if (!m) continue;
|
|
29
29
|
const tokens = m[1]!.trim().split(/\s+/).filter(Boolean);
|
|
30
30
|
const cmd = tokens[0];
|
|
@@ -86,7 +86,7 @@ export function checkCommands(refs: readonly DocCommand[], help: HelpRunner = de
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
for (const f of ref.flags) {
|
|
89
|
-
if (!new RegExp(`(^|[\\s,])${f}(\\b|[\\s,=<\\[])`, 'm').test(flagsHelp)) findings.push({ kind: 'unknown-flag', ref, detail: `${f} (${flagsPath})` });
|
|
89
|
+
if (!new RegExp(`(^|[\\s,])${f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\b|[\\s,=<\\[])`, 'm').test(flagsHelp)) findings.push({ kind: 'unknown-flag', ref, detail: `${f} (${flagsPath})` });
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
return findings;
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* GOAL-LIFE① — docs/goals 전수 분류(이관 전 측정).
|
|
4
|
+
*
|
|
5
|
+
* GitHub API·gh 를 부르지 않는다. git 이력과 파일 시스템만 읽고,
|
|
6
|
+
* docs/goals 아래 어떤 파일도 쓰지 않는다.
|
|
7
|
+
*
|
|
8
|
+
* bun scripts/goals/goal-census.ts [--json] [--out <path>]
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join, relative, resolve } from 'node:path';
|
|
13
|
+
import { debug } from '../../src/debug/log.js';
|
|
14
|
+
|
|
15
|
+
export type GoalKind = 'GOAL' | 'ASK' | 'other';
|
|
16
|
+
export type GoalClass = 'landed' | 'untracked' | 'tracked-no-pr';
|
|
17
|
+
|
|
18
|
+
export interface GoalCensusEntry {
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly classification: GoalClass;
|
|
21
|
+
readonly kind: GoalKind;
|
|
22
|
+
readonly bytes: number;
|
|
23
|
+
/** 추가 날짜 YYYY-MM-DD (KST). 미추적이면 파일 mtime. */
|
|
24
|
+
readonly addedDateKst: string;
|
|
25
|
+
readonly prNumber?: number;
|
|
26
|
+
readonly addCommit?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GoalCensusSummary {
|
|
30
|
+
readonly total: number;
|
|
31
|
+
readonly byClass: Record<GoalClass, number>;
|
|
32
|
+
readonly byKind: Record<GoalKind, number>;
|
|
33
|
+
readonly matrix: Record<GoalClass, Record<GoalKind, number>>;
|
|
34
|
+
readonly bytes: number;
|
|
35
|
+
readonly bytesByClass: Record<GoalClass, number>;
|
|
36
|
+
/** 월별 추가 수 — YYYY-MM (KST). */
|
|
37
|
+
readonly addedByMonth: Record<string, number>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GoalCensus {
|
|
41
|
+
readonly entries: readonly GoalCensusEntry[];
|
|
42
|
+
readonly summary: GoalCensusSummary;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AddedCommit {
|
|
46
|
+
readonly hash: string;
|
|
47
|
+
readonly subject: string;
|
|
48
|
+
/** git %aI — ISO 8601 with offset. */
|
|
49
|
+
readonly authoredAt: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CensusGoalsInput {
|
|
53
|
+
readonly repoRoot: string;
|
|
54
|
+
/** 경로(repo 상대, posix) → 그 파일을 처음 추가한 커밋. 없으면 미추적. */
|
|
55
|
+
readonly gitLog: (repoRoot: string) => ReadonlyMap<string, AddedCommit>;
|
|
56
|
+
/** repo 상대 미추적 경로. */
|
|
57
|
+
readonly listUntracked: (repoRoot: string) => readonly string[];
|
|
58
|
+
/** 테스트·대체 루트. 기본 docs/goals. */
|
|
59
|
+
readonly goalsDir?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const CLASSES: readonly GoalClass[] = ['landed', 'untracked', 'tracked-no-pr'];
|
|
63
|
+
const KINDS: readonly GoalKind[] = ['GOAL', 'ASK', 'other'];
|
|
64
|
+
|
|
65
|
+
const PR_AT_END = /\(#(\d+)\)\s*$/;
|
|
66
|
+
|
|
67
|
+
export function classifyKind(relPath: string): GoalKind {
|
|
68
|
+
const base = relPath.split('/').pop() ?? relPath;
|
|
69
|
+
if (base.startsWith('GOAL')) return 'GOAL';
|
|
70
|
+
if (base.startsWith('ASK')) return 'ASK';
|
|
71
|
+
return 'other';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function prNumberFromSubject(subject: string): number | undefined {
|
|
75
|
+
const m = subject.match(PR_AT_END);
|
|
76
|
+
return m ? Number(m[1]) : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** UTC ISO(또는 offset 포함) → KST YYYY-MM-DD. */
|
|
80
|
+
export function kstDate(iso: string): string {
|
|
81
|
+
const t = Date.parse(iso);
|
|
82
|
+
if (Number.isNaN(t)) return iso.slice(0, 10);
|
|
83
|
+
const kst = new Date(t + 9 * 60 * 60 * 1000);
|
|
84
|
+
const y = kst.getUTCFullYear();
|
|
85
|
+
const m = String(kst.getUTCMonth() + 1).padStart(2, '0');
|
|
86
|
+
const d = String(kst.getUTCDate()).padStart(2, '0');
|
|
87
|
+
return `${y}-${m}-${d}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function emptyMatrix(): Record<GoalClass, Record<GoalKind, number>> {
|
|
91
|
+
const matrix = {} as Record<GoalClass, Record<GoalKind, number>>;
|
|
92
|
+
for (const c of CLASSES) {
|
|
93
|
+
matrix[c] = { GOAL: 0, ASK: 0, other: 0 };
|
|
94
|
+
}
|
|
95
|
+
return matrix;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function measureEntry(repoRoot: string, rel: string): { bytes: number; mtimeIso: string; files: string[] } {
|
|
99
|
+
const abs = resolve(repoRoot, rel);
|
|
100
|
+
const root = resolve(repoRoot);
|
|
101
|
+
try {
|
|
102
|
+
const st = statSync(abs);
|
|
103
|
+
if (!st.isDirectory()) return { bytes: st.size, mtimeIso: st.mtime.toISOString(), files: [rel] };
|
|
104
|
+
const files: string[] = [];
|
|
105
|
+
walkFiles(abs, root, files);
|
|
106
|
+
let bytes = 0;
|
|
107
|
+
for (const f of files) {
|
|
108
|
+
try { bytes += statSync(resolve(repoRoot, f)).size; } catch { /* skip missing */ }
|
|
109
|
+
}
|
|
110
|
+
return { bytes, mtimeIso: st.mtime.toISOString(), files };
|
|
111
|
+
} catch {
|
|
112
|
+
return { bytes: 0, mtimeIso: new Date(0).toISOString(), files: [] };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function earliestAddition(added: ReadonlyMap<string, AddedCommit>, prefix: string): AddedCommit | undefined {
|
|
117
|
+
let best: AddedCommit | undefined;
|
|
118
|
+
for (const [p, c] of added) {
|
|
119
|
+
if (!p.startsWith(prefix)) continue;
|
|
120
|
+
if (!best || c.authoredAt < best.authoredAt) best = c;
|
|
121
|
+
}
|
|
122
|
+
return best;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function walkFiles(dir: string, root: string, out: string[]): void {
|
|
126
|
+
let names: string[];
|
|
127
|
+
try {
|
|
128
|
+
names = readdirSync(dir);
|
|
129
|
+
} catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
if (name === '.git') continue;
|
|
134
|
+
const abs = join(dir, name);
|
|
135
|
+
let st;
|
|
136
|
+
try {
|
|
137
|
+
st = statSync(abs);
|
|
138
|
+
} catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (st.isDirectory()) walkFiles(abs, root, out);
|
|
142
|
+
else if (st.isFile()) out.push(relative(root, abs).split('\\').join('/'));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `ls docs/goals` 와 같은 최상위 이름(파일·디렉터리 각 1).
|
|
148
|
+
* 디렉터리의 크기·착지 근거는 그 아래 파일을 모아 정한다.
|
|
149
|
+
*/
|
|
150
|
+
export function listGoalFiles(repoRoot: string, goalsDir = 'docs/goals'): string[] {
|
|
151
|
+
const abs = resolve(repoRoot, goalsDir);
|
|
152
|
+
let names: string[];
|
|
153
|
+
try {
|
|
154
|
+
names = readdirSync(abs);
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
return names.filter((n) => n !== '.' && n !== '..').map((n) => `${goalsDir}/${n}`).sort();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function summarize(entries: readonly GoalCensusEntry[]): GoalCensusSummary {
|
|
162
|
+
const byClass = { landed: 0, untracked: 0, 'tracked-no-pr': 0 } as Record<GoalClass, number>;
|
|
163
|
+
const byKind = { GOAL: 0, ASK: 0, other: 0 } as Record<GoalKind, number>;
|
|
164
|
+
const matrix = emptyMatrix();
|
|
165
|
+
const bytesByClass = { landed: 0, untracked: 0, 'tracked-no-pr': 0 } as Record<GoalClass, number>;
|
|
166
|
+
const addedByMonth: Record<string, number> = {};
|
|
167
|
+
let bytes = 0;
|
|
168
|
+
for (const e of entries) {
|
|
169
|
+
byClass[e.classification] += 1;
|
|
170
|
+
byKind[e.kind] += 1;
|
|
171
|
+
matrix[e.classification][e.kind] += 1;
|
|
172
|
+
bytesByClass[e.classification] += e.bytes;
|
|
173
|
+
bytes += e.bytes;
|
|
174
|
+
const month = e.addedDateKst.slice(0, 7);
|
|
175
|
+
addedByMonth[month] = (addedByMonth[month] ?? 0) + 1;
|
|
176
|
+
}
|
|
177
|
+
return { total: entries.length, byClass, byKind, matrix, bytes, bytesByClass, addedByMonth };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function censusGoals(input: CensusGoalsInput): GoalCensus {
|
|
181
|
+
const goalsDir = input.goalsDir ?? 'docs/goals';
|
|
182
|
+
const prefix = goalsDir.endsWith('/') ? goalsDir : `${goalsDir}/`;
|
|
183
|
+
const files = listGoalFiles(input.repoRoot, goalsDir);
|
|
184
|
+
const added = input.gitLog(input.repoRoot);
|
|
185
|
+
const untracked = new Set(input.listUntracked(input.repoRoot).map((p) => p.split('\\').join('/')));
|
|
186
|
+
|
|
187
|
+
const entries: GoalCensusEntry[] = [];
|
|
188
|
+
for (const path of files) {
|
|
189
|
+
if (!path.startsWith(prefix)) continue;
|
|
190
|
+
const kind = classifyKind(path);
|
|
191
|
+
const measured = measureEntry(input.repoRoot, path);
|
|
192
|
+
const { bytes, mtimeIso } = measured;
|
|
193
|
+
const commit = added.get(path) ?? earliestAddition(added, `${path}/`);
|
|
194
|
+
const nestedUntracked = measured.files.length > 0
|
|
195
|
+
&& measured.files.every((f) => untracked.has(f) || !added.has(f));
|
|
196
|
+
if (!commit || untracked.has(path) || nestedUntracked) {
|
|
197
|
+
entries.push({
|
|
198
|
+
path,
|
|
199
|
+
classification: 'untracked',
|
|
200
|
+
kind,
|
|
201
|
+
bytes,
|
|
202
|
+
addedDateKst: kstDate(mtimeIso),
|
|
203
|
+
});
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const prNumber = prNumberFromSubject(commit.subject);
|
|
207
|
+
if (prNumber !== undefined) {
|
|
208
|
+
entries.push({
|
|
209
|
+
path,
|
|
210
|
+
classification: 'landed',
|
|
211
|
+
kind,
|
|
212
|
+
bytes,
|
|
213
|
+
addedDateKst: kstDate(commit.authoredAt),
|
|
214
|
+
prNumber,
|
|
215
|
+
addCommit: commit.hash,
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
entries.push({
|
|
219
|
+
path,
|
|
220
|
+
classification: 'tracked-no-pr',
|
|
221
|
+
kind,
|
|
222
|
+
bytes,
|
|
223
|
+
addedDateKst: kstDate(commit.authoredAt),
|
|
224
|
+
addCommit: commit.hash,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { entries, summary: summarize(entries) };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 파일을 처음 추가한 커밋만. `git log --diff-filter=A` 한 번.
|
|
233
|
+
* 최신→과거 순이므로 같은 경로가 다시 나오면 나중 값(최초 추가)이 이긴다.
|
|
234
|
+
*/
|
|
235
|
+
export function gitLogAdditions(repoRoot: string, goalsDir = 'docs/goals'): Map<string, AddedCommit> {
|
|
236
|
+
const r = spawnSync(
|
|
237
|
+
'git',
|
|
238
|
+
[
|
|
239
|
+
'-C', repoRoot,
|
|
240
|
+
'log',
|
|
241
|
+
'--diff-filter=A',
|
|
242
|
+
'--name-only',
|
|
243
|
+
'--format=@@%H%x09%s%x09%aI',
|
|
244
|
+
'--',
|
|
245
|
+
goalsDir,
|
|
246
|
+
],
|
|
247
|
+
{ encoding: 'utf8', maxBuffer: 512 * 1024 * 1024 },
|
|
248
|
+
);
|
|
249
|
+
if (r.status !== 0) {
|
|
250
|
+
throw new Error(`git log failed: ${(r.stderr || '').trim()}`);
|
|
251
|
+
}
|
|
252
|
+
const out = new Map<string, AddedCommit>();
|
|
253
|
+
let current: AddedCommit | undefined;
|
|
254
|
+
for (const line of r.stdout.split('\n')) {
|
|
255
|
+
if (!line) continue;
|
|
256
|
+
if (line.startsWith('@@')) {
|
|
257
|
+
const body = line.slice(2);
|
|
258
|
+
const tab = body.indexOf('\t');
|
|
259
|
+
const tab2 = body.lastIndexOf('\t');
|
|
260
|
+
if (tab > 0 && tab2 > tab) {
|
|
261
|
+
current = {
|
|
262
|
+
hash: body.slice(0, tab),
|
|
263
|
+
subject: body.slice(tab + 1, tab2),
|
|
264
|
+
authoredAt: body.slice(tab2 + 1),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (current) out.set(line, current);
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** 작업 트리의 미추적 파일. `git ls-files --others --exclude-standard`. */
|
|
275
|
+
export function gitListUntracked(repoRoot: string, goalsDir = 'docs/goals'): string[] {
|
|
276
|
+
const r = spawnSync(
|
|
277
|
+
'git',
|
|
278
|
+
['-C', repoRoot, 'ls-files', '--others', '--exclude-standard', '--', goalsDir],
|
|
279
|
+
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
|
|
280
|
+
);
|
|
281
|
+
if (r.status !== 0) {
|
|
282
|
+
throw new Error(`git ls-files failed: ${(r.stderr || '').trim()}`);
|
|
283
|
+
}
|
|
284
|
+
return r.stdout.split('\n').filter((p) => p.length > 0);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function formatSummary(census: GoalCensus): string {
|
|
288
|
+
const { summary } = census;
|
|
289
|
+
const lines: string[] = [];
|
|
290
|
+
lines.push(`total ${summary.total} bytes ${summary.bytes}`);
|
|
291
|
+
lines.push('class × kind');
|
|
292
|
+
const header = ['class', ...KINDS, 'bytes'].join('\t');
|
|
293
|
+
lines.push(header);
|
|
294
|
+
for (const c of CLASSES) {
|
|
295
|
+
const row = [
|
|
296
|
+
c,
|
|
297
|
+
...KINDS.map((k) => String(summary.matrix[c][k])),
|
|
298
|
+
String(summary.bytesByClass[c]),
|
|
299
|
+
];
|
|
300
|
+
lines.push(row.join('\t'));
|
|
301
|
+
}
|
|
302
|
+
lines.push('added by month (KST)');
|
|
303
|
+
const months = Object.keys(summary.addedByMonth).sort();
|
|
304
|
+
for (const month of months) lines.push(`${month}\t${summary.addedByMonth[month]}`);
|
|
305
|
+
return lines.join('\n') + '\n';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function parseArgs(argv: readonly string[]): { json: boolean; out?: string } {
|
|
309
|
+
let json = false;
|
|
310
|
+
let out: string | undefined;
|
|
311
|
+
for (let i = 0; i < argv.length; i++) {
|
|
312
|
+
const a = argv[i];
|
|
313
|
+
if (a === '--json') json = true;
|
|
314
|
+
else if (a === '--out') {
|
|
315
|
+
out = argv[i + 1];
|
|
316
|
+
i += 1;
|
|
317
|
+
} else if (a.startsWith('--out=')) out = a.slice('--out='.length);
|
|
318
|
+
}
|
|
319
|
+
return { json, out };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function main(argv: readonly string[]): void {
|
|
323
|
+
const repoRoot = resolve(import.meta.dir, '../..');
|
|
324
|
+
const args = parseArgs(argv);
|
|
325
|
+
const census = censusGoals({
|
|
326
|
+
repoRoot,
|
|
327
|
+
gitLog: (root) => gitLogAdditions(root),
|
|
328
|
+
listUntracked: (root) => gitListUntracked(root),
|
|
329
|
+
});
|
|
330
|
+
const { summary } = census;
|
|
331
|
+
debug.log('goals.census', 'scanned', {
|
|
332
|
+
total: summary.total,
|
|
333
|
+
landed: summary.byClass.landed,
|
|
334
|
+
untracked: summary.byClass.untracked,
|
|
335
|
+
trackedNoPr: summary.byClass['tracked-no-pr'],
|
|
336
|
+
bytes: summary.bytes,
|
|
337
|
+
});
|
|
338
|
+
const body = args.json ? JSON.stringify(census, null, 2) + '\n' : formatSummary(census);
|
|
339
|
+
if (args.out) writeFileSync(args.out, body);
|
|
340
|
+
process.stdout.write(body);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (import.meta.main) main(process.argv.slice(2));
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export interface AuthorSummary {
|
|
5
|
+
authorRunId: string;
|
|
6
|
+
sectionChars: Record<string, number>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AssignedGoalEvent {
|
|
10
|
+
category: string;
|
|
11
|
+
event: string;
|
|
12
|
+
ts: string;
|
|
13
|
+
data: { authorRunId?: string; goalId?: string };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ExecutionRun {
|
|
17
|
+
runId: string;
|
|
18
|
+
goalId: string;
|
|
19
|
+
startedAt: string;
|
|
20
|
+
outcome: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SectionContribution {
|
|
24
|
+
authorRunId: string;
|
|
25
|
+
goalId: string;
|
|
26
|
+
runId: string;
|
|
27
|
+
section: string;
|
|
28
|
+
chars: number;
|
|
29
|
+
outcome: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Join an authored summary to the first execution starting after that author's assignment. */
|
|
33
|
+
export function measureAuthorSectionContribution(
|
|
34
|
+
summaries: readonly AuthorSummary[],
|
|
35
|
+
events: readonly AssignedGoalEvent[],
|
|
36
|
+
runs: readonly ExecutionRun[],
|
|
37
|
+
): SectionContribution[] {
|
|
38
|
+
const rows: SectionContribution[] = [];
|
|
39
|
+
for (const summary of summaries) {
|
|
40
|
+
const assignment = events.filter((event) => event.category === 'goal-author'
|
|
41
|
+
&& event.event === 'goal-id-assigned'
|
|
42
|
+
&& event.data?.authorRunId === summary.authorRunId
|
|
43
|
+
&& typeof event.data.goalId === 'string'
|
|
44
|
+
&& Number.isFinite(Date.parse(event.ts)))
|
|
45
|
+
.sort((left, right) => Date.parse(left.ts) - Date.parse(right.ts)
|
|
46
|
+
|| left.data.goalId!.localeCompare(right.data.goalId!))[0];
|
|
47
|
+
if (!assignment) continue;
|
|
48
|
+
const assignedAt = Date.parse(assignment.ts);
|
|
49
|
+
const run = runs.filter((candidate) => candidate.goalId === assignment.data.goalId
|
|
50
|
+
&& Number.isFinite(Date.parse(candidate.startedAt))
|
|
51
|
+
&& Date.parse(candidate.startedAt) >= assignedAt)
|
|
52
|
+
.sort((left, right) => Date.parse(left.startedAt) - Date.parse(right.startedAt)
|
|
53
|
+
|| left.runId.localeCompare(right.runId))[0];
|
|
54
|
+
if (!run) continue;
|
|
55
|
+
for (const [section, chars] of Object.entries(summary.sectionChars)) {
|
|
56
|
+
rows.push({ authorRunId: summary.authorRunId, goalId: run.goalId, runId: run.runId, section, chars, outcome: run.outcome });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return rows;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readRunLedger(path: string): ExecutionRun[] {
|
|
63
|
+
const db = new Database(path, { readonly: true });
|
|
64
|
+
try {
|
|
65
|
+
const records = db.query('SELECT run_id, goal_id, doc FROM goal_run').all() as Array<{ run_id: string; goal_id: string; doc: string }>;
|
|
66
|
+
return records.map(({ run_id, goal_id, doc }) => {
|
|
67
|
+
const record = JSON.parse(doc) as { startedAt: string; outcome: string };
|
|
68
|
+
return { runId: run_id, goalId: goal_id, startedAt: record.startedAt, outcome: record.outcome };
|
|
69
|
+
});
|
|
70
|
+
} finally {
|
|
71
|
+
db.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (import.meta.main) {
|
|
76
|
+
const [summaryFile, eventsFile, ledgerFile] = process.argv.slice(2);
|
|
77
|
+
if (!summaryFile || !eventsFile || !ledgerFile) throw new Error('usage: bun scripts/measure/author-section-contribution.ts <summaries.json> <events.jsonl> <goal-runs.db>');
|
|
78
|
+
const summaries = JSON.parse(readFileSync(summaryFile, 'utf8')) as AuthorSummary[];
|
|
79
|
+
const events = readFileSync(eventsFile, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line) as AssignedGoalEvent);
|
|
80
|
+
const rows = measureAuthorSectionContribution(summaries, events, readRunLedger(ledgerFile));
|
|
81
|
+
console.log('| authorRunId | goalId | runId | section | chars | outcome |');
|
|
82
|
+
console.log('| --- | --- | --- | --- | ---: | --- |');
|
|
83
|
+
for (const row of rows) console.log(`| ${row.authorRunId} | ${row.goalId} | ${row.runId} | ${row.section} | ${row.chars} | ${row.outcome} |`);
|
|
84
|
+
}
|
|
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import { debug } from '../../src/debug/log.js';
|
|
6
6
|
|
|
7
|
-
export interface CutBranchOptions { version: string; base: string; pick: string[]; dryRun?: boolean; repoRoot?: string; log?: (line: string) => void }
|
|
7
|
+
export interface CutBranchOptions { version: string; base: string; pick: string[]; append?: boolean; dryRun?: boolean; repoRoot?: string; log?: (line: string) => void }
|
|
8
8
|
|
|
9
9
|
// git-spawn-allow: release branch operations happen only in an isolated temporary worktree; main is never checked out or changed.
|
|
10
10
|
function git(repo: string, args: string[]): string {
|
|
@@ -23,8 +23,8 @@ export function cutReleaseBranch(opts: CutBranchOptions): { branch: string; comm
|
|
|
23
23
|
const repo = resolve(opts.repoRoot ?? process.cwd());
|
|
24
24
|
const branch = `release/${opts.version}`;
|
|
25
25
|
const log = opts.log ?? console.log;
|
|
26
|
-
const report = (event: 'created' | 'refused' | 'conflict', reason?: string, commit?: string) =>
|
|
27
|
-
debug.log('release-loop.cut-branch', event, { version: opts.version, branch, ...(commit ? { commit } : {}), ...(reason ? { reason } : {}) });
|
|
26
|
+
const report = (event: 'created' | 'refused' | 'conflict' | 'appended', reason?: string, commit?: string, extra?: Record<string, string | string[]>) =>
|
|
27
|
+
debug.log('release-loop.cut-branch', event, { version: opts.version, branch, ...(commit ? { commit } : {}), ...(reason ? { reason } : {}), ...extra });
|
|
28
28
|
try {
|
|
29
29
|
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(opts.version)) throw new Error(`invalid release version: ${opts.version}`);
|
|
30
30
|
if (!opts.pick.length) throw new Error('at least one --pick commit is required');
|
|
@@ -37,18 +37,31 @@ export function cutReleaseBranch(opts: CutBranchOptions): { branch: string; comm
|
|
|
37
37
|
for (const sha of picks) if (ancestor(repo, sha, base)) throw new Error(`pick ${sha} is already in base ${base}`);
|
|
38
38
|
if (new Set(picks).size !== picks.length) throw new Error('duplicate --pick commit');
|
|
39
39
|
for (const sha of picks) if (git(repo, ['rev-list', '--parents', '-n', '1', sha]).split(' ').length !== 2) throw new Error(`pick ${sha} must have exactly one parent`);
|
|
40
|
-
|
|
40
|
+
const remoteLine = git(repo, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`]);
|
|
41
|
+
if (remoteLine && !opts.append) throw new Error(`${branch} already exists on origin — use --append to fast-forward more picks onto it`);
|
|
42
|
+
if (!remoteLine && opts.append) throw new Error(`${branch} does not exist on origin — omit --append to create it`);
|
|
41
43
|
if (spawnSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd: repo }).status === 0) throw new Error(`${branch} already exists locally`);
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
44
|
+
let remoteTip: string | undefined;
|
|
45
|
+
if (opts.append) {
|
|
46
|
+
const advertised = remoteLine.split(/\s+/)[0] ?? '';
|
|
47
|
+
if (!/^[0-9a-f]{40}$/i.test(advertised)) throw new Error(`${branch} remote tip is not a commit SHA`);
|
|
48
|
+
git(repo, ['fetch', 'origin', `refs/heads/${branch}:refs/remotes/origin/${branch}`]);
|
|
49
|
+
remoteTip = git(repo, ['rev-parse', '--verify', `refs/remotes/origin/${branch}^{commit}`]);
|
|
50
|
+
if (advertised !== remoteTip) throw new Error(`${branch} remote tip moved — fast-forward refused (no force push)`);
|
|
51
|
+
for (const sha of picks) if (ancestor(repo, sha, remoteTip)) throw new Error(`pick ${sha} is already in ${branch}`);
|
|
52
|
+
}
|
|
53
|
+
const versionAt = remoteTip ?? base;
|
|
54
|
+
const pkg = JSON.parse(git(repo, ['show', `${versionAt}:package.json`])) as { version?: unknown };
|
|
55
|
+
if (pkg.version !== opts.version) throw new Error(`${opts.append ? 'release branch' : 'base'} package.json version ${pkg.version} is not ${opts.version}`);
|
|
56
|
+
const start = remoteTip ?? base;
|
|
57
|
+
log(`${branch}: ${start} + ${picks.join(' + ')}${opts.append ? ' (append)' : ''}${opts.dryRun ? ' (dry-run)' : ''}`);
|
|
45
58
|
if (opts.dryRun) return { branch, commit: null, dryRun: true };
|
|
46
59
|
const temp = mkdtempSync(join(tmpdir(), 'release-cut-branch-'));
|
|
47
60
|
const tree = join(temp, 'tree');
|
|
48
61
|
let added = false;
|
|
49
62
|
let commit: string | undefined;
|
|
50
63
|
try {
|
|
51
|
-
git(repo, ['worktree', 'add', '--detach', tree,
|
|
64
|
+
git(repo, ['worktree', 'add', '--detach', tree, start]);
|
|
52
65
|
added = true;
|
|
53
66
|
for (const sha of picks) {
|
|
54
67
|
try { git(tree, ['cherry-pick', sha]); }
|
|
@@ -68,13 +81,30 @@ export function cutReleaseBranch(opts: CutBranchOptions): { branch: string; comm
|
|
|
68
81
|
} finally { rmSync(temp, { recursive: true, force: true }); }
|
|
69
82
|
}
|
|
70
83
|
if (!commit) throw new Error('cherry-pick produced no commit');
|
|
71
|
-
const
|
|
72
|
-
|
|
84
|
+
const lease = opts.append ? remoteTip! : '';
|
|
85
|
+
let push: string;
|
|
86
|
+
try {
|
|
87
|
+
push = git(repo, ['push', '--porcelain', `--force-with-lease=refs/heads/${branch}:${lease}`, 'origin', `${commit}:refs/heads/${branch}`]);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
90
|
+
if (opts.append && /stale info|rejected|non-fast-forward|failed to push/i.test(message)) {
|
|
91
|
+
throw new Error(`${branch} remote tip moved — fast-forward refused (no force push)`);
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
const landed = push.split('\n').some((line) => {
|
|
73
96
|
const [flag, ref, summary] = line.split('\t');
|
|
74
|
-
|
|
97
|
+
if (flag !== ' ' && flag !== '*') return false;
|
|
98
|
+
if (!ref?.endsWith(`:refs/heads/${branch}`)) return false;
|
|
99
|
+
if (!opts.append) return flag === '*' && summary === '[new branch]';
|
|
100
|
+
const range = summary?.match(/^([0-9a-f]+)\.\.([0-9a-f]+)$/i);
|
|
101
|
+
return flag === ' ' && !!range && remoteTip!.startsWith(range[1]!) && commit.startsWith(range[2]!);
|
|
75
102
|
});
|
|
76
|
-
if (!
|
|
77
|
-
|
|
103
|
+
if (!landed) throw new Error(opts.append
|
|
104
|
+
? `${branch} remote tip moved — fast-forward refused (no force push)`
|
|
105
|
+
: `${branch} already exists on origin (push did not create a new branch)`);
|
|
106
|
+
if (opts.append) report('appended', undefined, commit, { from: remoteTip!, to: commit, picks });
|
|
107
|
+
else report('created', undefined, commit);
|
|
78
108
|
log(`${branch} → ${commit}`);
|
|
79
109
|
return { branch, commit, dryRun: false };
|
|
80
110
|
} catch (error) {
|
|
@@ -242,8 +242,10 @@ export function createGateRunner(repo: string, remote?: string, commandOverride?
|
|
|
242
242
|
// GNU timeout signals its own process group: -s KILL would kill timeout too (ssh 255), so TERM first then KILL after 10s (124 / 137).
|
|
243
243
|
// No coreutils timeout on the remote → run unlimited there and rely on the local ssh deadline.
|
|
244
244
|
const limitPrefix = limitMs ? `$(command -v timeout >/dev/null 2>&1 && echo 'timeout -k 10 ${Math.ceil(limitMs / 1000)}') ` : '';
|
|
245
|
+
// Start in /tmp, not the caller checkout. macOS resolves that symlink to /private/tmp before the child sees it;
|
|
246
|
+
// the remote command still `cd`s to the quoted path, so either spelling is the same directory.
|
|
245
247
|
const run = spawnSync('ssh', [remote, `PATH=$HOME/.bun/bin:/opt/homebrew/bin:$PATH; export PATH; cd ${quote(cwd)} && ${limitPrefix}${[cmd, ...args].map(quote).join(' ')}`],
|
|
246
|
-
{ encoding: 'utf8', maxBuffer: 128 * 1024 * 1024, ...(limitMs ? { timeout: limitMs + 60_000, killSignal: 'SIGKILL' as const } : {}) });
|
|
248
|
+
{ cwd: '/tmp', encoding: 'utf8', maxBuffer: 128 * 1024 * 1024, ...(limitMs ? { timeout: limitMs + 60_000, killSignal: 'SIGKILL' as const } : {}) });
|
|
247
249
|
const timedOut = !!limitMs && (run.status === 124 || run.status === 137 || (run.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT');
|
|
248
250
|
return { rc: run.status ?? 2, output: `${run.stdout ?? ''}\n${run.stderr ?? ''}${run.error ? `\n${run.error}` : ''}`, ...(timedOut ? { timedOut } : {}) };
|
|
249
251
|
}
|
|
@@ -215,11 +215,28 @@ function landingClause(text: string): string {
|
|
|
215
215
|
// A candidate with broken punctuation is not repaired by deleting its unmatched character.
|
|
216
216
|
if (!balanced(text)) return '';
|
|
217
217
|
// release/next.md lines lead with their kind («feat — …»); the headline is the change, not the kind.
|
|
218
|
-
const
|
|
219
|
-
|
|
218
|
+
const trimmed = text.replace(/^-\s+/, '');
|
|
219
|
+
// Release bookkeeping (version bumps, «release 0.2.14» notes) is never this release's headline (0.2.15 real note).
|
|
220
|
+
if (/^(?:version|release)\s*:/i.test(trimmed) || /^(?:docs|chore)(?:\([^)]*\))?\s*:\s*release\s+v?\d/i.test(trimmed)) return '';
|
|
221
|
+
// Commit subjects lead with a conventional prefix («docs(marketing): …»); the headline is the change itself.
|
|
222
|
+
const body = trimmed.replace(/^(feat|fix|internal|docs|test|perf|refactor|chore|ops|security|breaking)\s+[—–-]\s+/i, '')
|
|
223
|
+
.replace(/^[a-z]+(?:\([^)]*\))?!?:\s+/i, '');
|
|
224
|
+
const clause = shortClause(firstClause(body));
|
|
220
225
|
return balanced(clause) ? clause : '';
|
|
221
226
|
}
|
|
222
227
|
|
|
228
|
+
/** A headline clause longer than 60 characters is cut at its first comma/semicolon, else at a word boundary with «…». */
|
|
229
|
+
function shortClause(clause: string): string {
|
|
230
|
+
const chars = [...clause];
|
|
231
|
+
if (chars.length <= 60) return clause;
|
|
232
|
+
const head = chars.slice(0, 60).join('');
|
|
233
|
+
// The first comma/semicolon after a minimal 10-character lead ends the clause.
|
|
234
|
+
const stop = head.slice(10).search(/[,;,;]/);
|
|
235
|
+
if (stop >= 0) return head.slice(0, 10 + stop);
|
|
236
|
+
const space = head.lastIndexOf(' ');
|
|
237
|
+
return `${(space >= 20 ? head.slice(0, space) : head).trimEnd()}…`;
|
|
238
|
+
}
|
|
239
|
+
|
|
223
240
|
function versionKey(value: string): number[] | null {
|
|
224
241
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
|
|
225
242
|
return match ? match.slice(1).map(Number) : null;
|