triflux 10.41.1 → 10.42.0

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/CLAUDE.md ADDED
@@ -0,0 +1,233 @@
1
+ # triflux — Claude Code 운영 가이드
2
+
3
+ <core-systems>
4
+ ## 핵심 스킬 시스템 (항상 인지)
5
+
6
+ 이 프로젝트는 3개의 스킬 시스템을 동시에 사용한다. 어떤 작업이든 해당 시스템의 스킬이 있는지 먼저 확인한다.
7
+
8
+ | 시스템 | 접두사 | 용도 | 스킬 수 |
9
+ |--------|--------|------|---------|
10
+ | **triflux** | `/tfx-*` | CLI 라우팅, 멀티모델 오케스트레이션, 스웜, 원격 실행 | ~40개 |
11
+ | **gstack** | `/` (접두사 없음) | QA, ship, investigate, design, review, checkpoint | ~35개 |
12
+ | **omc** | `/oh-my-claudecode:*` | autopilot, ralph, team, ultrawork, ccg | ~25개 |
13
+
14
+ 스킬을 모르면 자연어 라우팅(`.claude/rules/tfx-routing.md`)으로 자동 매핑된다.
15
+ 세션 종료 전 메모리 파일이 3개+ 변경됐으면 `/memory-hygiene` 제안을 검토한다.
16
+ </core-systems>
17
+
18
+ <psmux-wt>
19
+ ## psmux/WT 규칙
20
+
21
+ > **이 섹션은 Windows 환경 한정.** macOS/Linux는 platform guard로 코드 레벨 no-op 처리되며 이 섹션 전체를 skip해도 됨. mac 인프라는 아래 `<macos-terminal>` 섹션 참조.
22
+
23
+ psmux 세션·WT 패인을 생성/조작/정리할 때 `tfx-psmux-rules` 스킬을 참조한다.
24
+ WT 프리징 방지: exit → sleep 2 → kill 순서. 바로 kill하지 않는다.
25
+
26
+ ### wt.exe → wt-manager 경유
27
+
28
+ safety-guard가 `wt.exe`, `wt new-tab`, `wt split-pane`, `Start-Process wt`를 차단한다.
29
+ `hub/team/wt-manager.mjs`의 API를 사용한다.
30
+
31
+ | 용도 | API |
32
+ |------|-----|
33
+ | 새 탭 | `createTab({ title, command, profile, cwd })` |
34
+ | 패인 분할 | `splitPane({ direction: 'H'\|'V', title, command })` |
35
+ | 다중 배치 | `applySplitLayout([{ title, command, direction }])` |
36
+ | 탭 정리 | `closeTab(title)` / `closeStale({ olderThanMs, titlePattern })` |
37
+
38
+ 차단과 대안은 항상 쌍으로 존재해야 한다. 차단만 추가하고 대안을 안 만들면 데드락.
39
+
40
+ ### raw `psmux kill-session` → psmux wrapper 경유
41
+
42
+ safety-guard가 raw `psmux kill-session`을 차단한다.
43
+ 세션 정리는 `hub/team/psmux.mjs` 공개 API 또는 internal wrapper로 우회한다.
44
+
45
+ | 용도 | API / 래퍼 |
46
+ |------|------------|
47
+ | 세션 조회 | `listSessions({ filterTitle?, olderThanMs? })` |
48
+ | title prefix / regex kill | `killSessionByTitle(titlePattern)` |
49
+ | stale idle 세션 정리 | `pruneStale({ olderThanMs, dryRun })` |
50
+ | Bash 훅 우회용 래퍼 | `node hub/team/psmux.mjs --internal kill-by-title <prefix\|/regex/>` |
51
+
52
+ ### psmux에서 Codex 실행
53
+
54
+ | 방식 | 동작 | 이유 |
55
+ |------|------|------|
56
+ | `codex` (interactive) | 불가 | psmux에서 TTY를 못 잡음 |
57
+ | `codex < prompt.md` | 불가 | "stdin is not a terminal" |
58
+ | `codex exec "$(cat prompt.md)" -s danger-full-access --dangerously-bypass-approvals-and-sandbox` | 사용 | 유일한 안전 경로 |
59
+
60
+ `codex exec`는 config.toml `approval_mode`를 무시하므로 `--dangerously-bypass-approvals-and-sandbox` 필수.
61
+ `-s` 유효값: read-only, workspace-write, danger-full-access.
62
+ </psmux-wt>
63
+
64
+ <macos-terminal>
65
+ ## macOS / Linux 터미널 처리
66
+
67
+ 위 `<psmux-wt>` 룰셋은 Windows 전용이다. macOS/Linux 환경에서 triflux가 터미널/세션을 다루는 방식.
68
+
69
+ ### terminal-opener.mjs 3단계 fallback (`hub/team/terminal-opener.mjs:124~149`)
70
+
71
+ `openCommand()` 가 순차 평가하는 분기:
72
+
73
+ | 우선순위 | 조건 | 동작 | API |
74
+ |---------|------|------|-----|
75
+ | 1 | `platform === "win32"` | wt-manager.createTab | `hub/team/wt-manager.mjs` |
76
+ | 2 | `isTmuxLikeMux(mux, platform)` (`detectMultiplexer()` → `getMultiplexerType()`/`hasMultiplexer()`/`hasTmux()` + literal psmux 검사) | `tmux new-window -n <title> <command>` | shell 직접 호출 |
77
+ | 3 | `platform === "darwin"` (fallback) | `open -a Terminal` | macOS `open` 명령 |
78
+ | — | Linux (mux 없음) | unsupported (return false) | — |
79
+
80
+ ### 별도 mac 매니저 불필요
81
+
82
+ | 후보 | 필요성 | 이유 |
83
+ |------|--------|------|
84
+ | iTerm2 manager | **불필요** | `hub/lib/env-detect.mjs:96`이 `TERM_PROGRAM === "iTerm.app"` 감지하지만 별도 GUI 패인 조작은 tmux/psmux로 cover됨. 새 창 띄우기는 `open -a Terminal` fallback으로 충분 |
85
+ | tmux manager | **불필요** | psmux 자체가 tmux fork. `terminal-opener.mjs`가 `tmux new-window`로 직접 호출 |
86
+ | psmux manager | **이미 있음** | `hub/team/psmux.mjs` (`IS_WINDOWS`/`IS_MAC` 분기, cross-platform) |
87
+
88
+ 참고 사례: OMC(`oh-my-claudecode`)도 OS-specific 매니저를 만들지 않고 Tmux Manager + Worktree Manager + Claude Launcher 3개 컴포넌트로 정리한다. 본 repo 결정의 외부 근거가 아니라 비교 참고로만 본다.
89
+
90
+ ### platform guard 위치 (참고)
91
+
92
+ | 파일 | 라인 | guard |
93
+ |------|------|-------|
94
+ | `hub/team/wt-manager.mjs` | 199 | `if (platform() !== "win32") return createNonWindowsStubManager();` |
95
+ | `hub/team/headless.mjs` | 1725-1726 | `if (process.platform !== "win32") return false;` + `WT_SESSION` 체크 |
96
+ | `tfx-route.sh` | 86, 1662, 1681 | `case "$(uname -s)"` 분기 |
97
+
98
+ mac에서 위 코드가 호출돼도 early-return이라 dead code 실행 없음. **dead text(문서)만 inject되는 게 잔여 drift.**
99
+
100
+ ### macOS notification (참고)
101
+
102
+ `hub/team/notify.mjs:221~234`이 `osascript`로 macOS 네이티브 알림 발송 (별도 의존성 없음).
103
+ </macos-terminal>
104
+
105
+ <codex-config>
106
+ ## Codex config.toml
107
+
108
+ config.toml에 이미 설정된 값은 CLI 플래그로 중복 지정하지 않는다.
109
+
110
+ | config.toml에 있으면 | CLI에서 생략 |
111
+ |---------------------|-------------|
112
+ | `approval_mode = "auto"` | `-a`, `--full-auto` |
113
+ | `sandbox = "workspace-write"` | `-s`, `--full-auto` |
114
+
115
+ 안전 패턴: config.toml에 기본값을 두고, CLI에서는 `--profile` 선택만 한다.
116
+ </codex-config>
117
+
118
+ <account-broker>
119
+ ## AccountBroker (계정 브로커)
120
+
121
+ conductor, headless, swarm-hypervisor가 하나의 AccountBroker 싱글턴을 공유한다.
122
+
123
+ | 항목 | 설명 |
124
+ |------|------|
125
+ | 계정별 CircuitBreaker | 장애 격리 — 한 계정 오류가 다른 계정에 전파되지 않음 |
126
+ | busy 플래그 | 동일 계정 이중 임대(double-lease) 방지 |
127
+ | `/broker/reload` | 장시간 세션 중 accounts.json 핫리로드. active lease ownership은 reload 후에도 보존 |
128
+ | Adapter no-lease 정책 | headless adapter는 broker가 비활성/empty이면 기본 CLI auth path로 실행, broker가 enabled인데 lease가 없으면 `circuit_open` 실패 |
129
+ | Conductor no-lease 정책 | 로컬 Conductor 세션은 `broker_no_lease`를 event log에 남기고 spawn은 계속 진행. accountId가 없으므로 release는 호출하지 않음 |
130
+ | Public snapshot 정책 | `/broker/snapshot`과 dashboard는 `publicSnapshot()`만 사용. `env`, `authFile`, `profile`, `host`, 파일 경로, raw failure timestamp는 공개하지 않음 |
131
+ | Diagnostic 이벤트 | `securityViolation`, `authSyncError`는 hub가 redacted warn 로그(`broker.security_violation`, `broker.auth_sync_error`)로 처리 |
132
+ | EventEmitter 이벤트 | `lease`, `release`, `cooldown`, `tierFallback`, `circuitOpen`, `circuitClose`, `noAvailableAccounts` — HUD 연동용 |
133
+ </account-broker>
134
+
135
+ <remote>
136
+ ## 원격 실행
137
+
138
+ ### 스킬 구분
139
+
140
+ | 스킬 | 대상 | 방식 |
141
+ |------|------|------|
142
+ | tfx-codex-swarm | 로컬 전용 | 로컬 worktree + psmux |
143
+ | tfx-remote | Claude Code 원격 | SSH → Claude Code 세션 → 내부 tfx 라우팅 |
144
+ | tfx-remote-spawn | deprecated alias | tfx-remote로 통합됨; 직접 호출 금지 |
145
+
146
+ codex를 SSH 너머로 직접 실행하지 않는다. config.toml 충돌 + TTY 문제.
147
+ 원격에서 codex가 필요하면: tfx-remote → Claude Code → Claude가 내부에서 codex 호출.
148
+
149
+ ### SSH 패턴
150
+
151
+ hosts.json `os` 필드로 대상 셸을 판단한다. safety-guard도 이 필드를 참조.
152
+
153
+ | 대상 OS | 셸 | 패턴 |
154
+ |---------|-----|------|
155
+ | windows | PowerShell | scp + `pwsh -File` 필수. `$var` → `$env:VAR`, `2>/dev/null` → `2>$null` |
156
+ | darwin | zsh | 인라인 가능. brew PATH 주의 (`/opt/homebrew/bin`) |
157
+ | linux | bash | 인라인 가능. 표준 POSIX |
158
+
159
+ - `~` → `$HOME` 변환은 모든 OS 공통
160
+ </remote>
161
+
162
+ <headless-retrieval>
163
+ ## Headless 결과 회수
164
+
165
+ background로 실행한 headless 결과는 **반드시 task-notification 완료 후** 읽는다.
166
+
167
+ | 패턴 | 올바름 | 이유 |
168
+ |------|--------|------|
169
+ | task-notification 후 output 파일 읽기 | YES | 프로세스 종료 = 워커 전부 완료 |
170
+ | task-notification 전 output 파일 tail | NO | 시작 메시지만 보이고 "실패"로 오진 |
171
+ | psmux capture-pane으로 중간 체크 | NO | 워커 진행 중이면 빈 화면일 수 있음 |
172
+
173
+ 완료 마커: `=== HEADLESS_COMPLETE succeeded=N failed=N total=N ===`
174
+ 워커 상세: `$TMPDIR/tfx-headless/{sessionName}-worker-N.txt`
175
+ </headless-retrieval>
176
+
177
+ <native-bridge>
178
+ ## native-bridge UI (claude agents 노출)
179
+
180
+ headless 워커는 default 로 `claude agents` 패널에 row 로 등장한다. opt-out 은 `--no-native-bridge-ui`.
181
+
182
+ | 모드 | default | row 가 보이는 위치 |
183
+ |------|---------|--------------------|
184
+ | `tfx-auto` / `tfx multi` (headless) | on | 로컬 `claude agents` |
185
+ | `tfx swarm` 로컬 shard | on | 로컬 `claude agents` (`Triflux swarm <shard-name>`) |
186
+ | `tfx swarm` 원격 shard | **skip + warn** | n/a — `registerSwarmShard()` 가 host!=local 일 때 `{ ok: true, skipped: true }` 반환하며 `"remote launcher must register on that host"` 만 경고. 원격 daemon 실제 등록은 후속 PRD. |
187
+ | interactive (tmux/wt) | off | n/a |
188
+
189
+ sentinel exit 즉시 daemon `sendKillBySessionId` 발사 → stale row 잔존 없음. PRD: `.triflux/plans/native-bridge-ui-default-expansion.md` (PR #323 으로 머지).
190
+ </native-bridge>
191
+
192
+ <cross-review>
193
+ ## 교차 검증
194
+
195
+ - Claude 작성 코드 → Codex 리뷰
196
+ - Codex 작성 코드 → Claude 리뷰
197
+ - 동일 모델 self-approve 하지 않는다
198
+ - git commit 전 미검증 파일 감지 시 nudge
199
+ </cross-review>
200
+
201
+ <session-context>
202
+ ## 맥락 이탈 판단
203
+
204
+ 현재 세션 맥락과 무관한 요청이 감지되면 psmux 격리를 제안한다.
205
+
206
+ | 확신도 | 신호 | 행동 |
207
+ |--------|------|------|
208
+ | 확실 | "새 탭", "별도로", "새 세션" | 바로 psmux spawn |
209
+ | 높음 | 다른 프로젝트/스택 언급 | 분리 제안 |
210
+ | 중간 | 작업 유형 전환 | 분리 제안 + 현재 세션 옵션 |
211
+ | 낮음 | 현재 작업 연장 | 세션 유지 |
212
+ </session-context>
213
+
214
+ ## 세부 규칙은 `.claude/rules/` 참조
215
+
216
+ | 파일 | 내용 |
217
+ |------|------|
218
+ | `.claude/rules/tfx-routing.md` | 자연어 → 스킬 라우팅, CLI 라우팅 Layer 1~3, 충돌 해소 |
219
+ | `.claude/rules/tfx-execution-skill-map.md` | tfx-auto / multi / swarm 실행 엔진 매핑, 격리 기준, 안티패턴 |
220
+ | `.claude/rules/tfx-autoplan-principles.md` | gstack autoplan의 6 decision principles, phase 우선순위, 충돌 해소 규칙 추출본 |
221
+ | `.claude/rules/tfx-update-logic.md` | triflux / OMC / gstack / Codex / Antigravity 업데이트 로직 |
222
+ | `.claude/rules/tfx-stack-coexistence.md` | gstack / superpowers / triflux 공존 원칙, 레이어 분리, 의존 방향, 충돌 해소 |
223
+ | `.claude/rules/tfx-mirror-policy.md` | packages/ 3-layer mirror 정책 (core 단순 cp / remote import 변환 / triflux byte-identical), tests 제외 룰, drift 차단 |
224
+
225
+ Claude Code는 `.claude/rules/*.md` 를 자동 로드한다. Codex CLI는 `@import` 미지원이므로 필요 시 `AGENTS.md` 를 독립 유지한다.
226
+
227
+ ## GBrain Configuration (configured by /setup-gbrain)
228
+ - Engine: pglite
229
+ - Config file: ~/.gbrain/config.json (mode 0600)
230
+ - Setup date: 2026-04-25
231
+ - MCP registered: yes (user scope, absolute path)
232
+ - Memory sync: artifacts-only (repo: github.com/tellang/gstack-brain-tellang)
233
+ - Current repo policy: read-write (github.com/tellang/triflux)
package/bin/triflux.mjs CHANGED
@@ -499,13 +499,60 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
499
499
  },
500
500
  },
501
501
  cto: {
502
- usage: "tfx cto <collect|status|dashboard|hygiene|event> [options]",
502
+ usage: "tfx cto <collect|status|dashboard|hygiene|steward|event> [options]",
503
503
  description: "repo-local authority layer console",
504
504
  subcommands: {
505
505
  collect: "refresh .triflux/lake/current.json from authority sources",
506
506
  status: "print the current authority summary",
507
507
  dashboard: "render the CTO console dashboard, optionally with --watch",
508
508
  hygiene: "project CTO hygiene counts and actionable dry-run rows",
509
+ steward: {
510
+ usage:
511
+ "tfx cto steward [--watch] [--max-runs N] [--interval-ms N] [--apply|--dry-run] [--json] [--no-collect]",
512
+ description:
513
+ "periodically collect and invoke one-shot CTO hygiene without destructive cleanup",
514
+ options: [
515
+ {
516
+ name: "--watch",
517
+ type: "boolean",
518
+ description:
519
+ "run repeatedly until interrupted or --max-runs is reached",
520
+ },
521
+ {
522
+ name: "--max-runs",
523
+ type: "number",
524
+ description:
525
+ "maximum steward iterations; required for --watch --json",
526
+ },
527
+ {
528
+ name: "--interval-ms",
529
+ type: "number",
530
+ description: "delay between watch iterations in milliseconds",
531
+ },
532
+ {
533
+ name: "--apply",
534
+ type: "boolean",
535
+ description:
536
+ "invoke hygiene apply mode (ledger acknowledgement only)",
537
+ },
538
+ {
539
+ name: "--dry-run",
540
+ type: "boolean",
541
+ description: "invoke hygiene dry-run mode (default)",
542
+ },
543
+ {
544
+ name: "--json",
545
+ type: "boolean",
546
+ description:
547
+ "print one finite steward JSON summary object; with --watch requires --max-runs",
548
+ },
549
+ {
550
+ name: "--no-collect",
551
+ type: "boolean",
552
+ description: "skip the collect pass before hygiene",
553
+ },
554
+ ],
555
+ },
509
556
  event:
510
557
  "append wrapper lineage events: context-save, context-restore, pr-created, pr-merged-or-closed",
511
558
  },
@@ -0,0 +1,355 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ copyFileSync,
4
+ cpSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ renameSync,
9
+ rmSync,
10
+ statSync,
11
+ unlinkSync,
12
+ } from "node:fs";
13
+ import {
14
+ basename,
15
+ dirname,
16
+ isAbsolute,
17
+ join,
18
+ relative,
19
+ resolve,
20
+ } from "node:path";
21
+
22
+ const ARCHIVABLE_ACTIONS = new Set([
23
+ "archive_or_resume_session",
24
+ "prune_superseded_checkpoint",
25
+ ]);
26
+
27
+ function shortHash(value) {
28
+ const str = String(value ?? "");
29
+ let h = 5381;
30
+ for (let i = 0; i < str.length; i += 1) {
31
+ h = (h * 33) ^ str.charCodeAt(i);
32
+ }
33
+ return (h >>> 0).toString(36);
34
+ }
35
+
36
+ function stewardRootHash(rootDir) {
37
+ return createHash("sha256")
38
+ .update(String(rootDir || ""))
39
+ .digest("hex")
40
+ .slice(0, 12);
41
+ }
42
+
43
+ function hygieneKeyForRow(row) {
44
+ return `${row.kind}:${row.id}`;
45
+ }
46
+
47
+ function yyyymmdd(value) {
48
+ const date = value ? new Date(value) : new Date();
49
+ if (Number.isNaN(date.getTime())) return yyyymmdd(new Date().toISOString());
50
+ return date.toISOString().slice(0, 10).replace(/-/gu, "");
51
+ }
52
+
53
+ function safeLabel(value) {
54
+ return (
55
+ String(value || "item")
56
+ .toLowerCase()
57
+ .replace(/[^a-z0-9._-]+/gu, "-")
58
+ .replace(/^-+|-+$/gu, "")
59
+ .slice(0, 80) || "item"
60
+ );
61
+ }
62
+
63
+ function isInside(parent, child) {
64
+ const rel = relative(resolve(parent), resolve(child));
65
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
66
+ }
67
+
68
+ function normalizeActiveSessions(active) {
69
+ const sessions = Array.isArray(active)
70
+ ? active
71
+ : Array.isArray(active?.sessions)
72
+ ? active.sessions
73
+ : Array.isArray(active?.live_sessions)
74
+ ? active.live_sessions
75
+ : [];
76
+ return sessions
77
+ .map((session) => ({
78
+ sessionId: String(session?.sessionId || session?.session_id || "").trim(),
79
+ phase: String(
80
+ session?.phase || session?.status || "active",
81
+ ).toLowerCase(),
82
+ }))
83
+ .filter((session) => session.sessionId);
84
+ }
85
+
86
+ function isLiveOwned(row, activeSessions) {
87
+ const rowSession = String(row?.session_id || row?.id || "").trim();
88
+ if (!rowSession) return false;
89
+ return activeSessions.some(
90
+ (session) => session.sessionId === rowSession && session.phase !== "stale",
91
+ );
92
+ }
93
+
94
+ function assertProjectHash(row, rootDir) {
95
+ if (!row?.project_root_hash) return;
96
+ const allowed = new Set([shortHash(rootDir), stewardRootHash(rootDir)]);
97
+ if (!allowed.has(String(row.project_root_hash))) {
98
+ throw new Error(
99
+ `project_root_hash mismatch for ${hygieneKeyForRow(row)}: ${row.project_root_hash}`,
100
+ );
101
+ }
102
+ }
103
+
104
+ function assertLakePath(lakeRoot, path, label) {
105
+ if (!isInside(lakeRoot, path)) {
106
+ throw new Error(`${label} must be under lakeRoot: ${path}`);
107
+ }
108
+ }
109
+
110
+ function sourceBytes(path) {
111
+ const info = statSync(path);
112
+ if (info.isFile()) return info.size;
113
+ if (!info.isDirectory()) return info.size || 0;
114
+ return 0;
115
+ }
116
+
117
+ function targetPathFor({ lakeRoot, row, sourcePath, now }) {
118
+ const archiveDir = join(lakeRoot, "archive", yyyymmdd(now));
119
+ const label = safeLabel(hygieneKeyForRow(row));
120
+ return join(archiveDir, `${label}-${basename(sourcePath)}`);
121
+ }
122
+
123
+ function hasSecondActorApproval(opts = {}) {
124
+ if (opts.humanAck === true || opts.humanGate === true) return true;
125
+ const actorSessionId = String(opts.actorSessionId || "").trim();
126
+ if (!actorSessionId) return false;
127
+ const hygieneKey = opts.hygieneKey;
128
+ const approvals = Array.isArray(opts.approvals) ? opts.approvals : [];
129
+ return approvals.some((entry) => {
130
+ const ref = entry?.ref && typeof entry.ref === "object" ? entry.ref : {};
131
+ if (ref.hygiene_key !== hygieneKey) return false;
132
+ const approverSession = String(ref?.actor?.session_id || "").trim();
133
+ return Boolean(approverSession && approverSession !== actorSessionId);
134
+ });
135
+ }
136
+
137
+ function movePath(sourcePath, targetPath, fsOps = {}) {
138
+ const ops = {
139
+ copyFileSync,
140
+ cpSync,
141
+ existsSync,
142
+ lstatSync,
143
+ mkdirSync,
144
+ renameSync,
145
+ rmSync,
146
+ unlinkSync,
147
+ ...fsOps,
148
+ };
149
+ ops.mkdirSync(dirname(targetPath), { recursive: true });
150
+ try {
151
+ ops.renameSync(sourcePath, targetPath);
152
+ return "rename";
153
+ } catch (error) {
154
+ if (error?.code !== "EXDEV") throw error;
155
+ const info = ops.lstatSync(sourcePath);
156
+ if (info.isDirectory()) {
157
+ ops.cpSync(sourcePath, targetPath, { recursive: true, force: false });
158
+ ops.rmSync(sourcePath, { recursive: true, force: false });
159
+ } else {
160
+ ops.copyFileSync(sourcePath, targetPath);
161
+ ops.unlinkSync(sourcePath);
162
+ }
163
+ return "copy_unlink";
164
+ }
165
+ }
166
+
167
+ function digestOperations(operations) {
168
+ const payload = operations.map((operation) => ({
169
+ hygiene_key: operation.hygiene_key,
170
+ status: operation.status,
171
+ source_path: operation.source_path,
172
+ target_path: operation.target_path,
173
+ bytes: operation.bytes,
174
+ reason: operation.reason,
175
+ }));
176
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
177
+ }
178
+
179
+ function rowsFromProjection(projection) {
180
+ return Array.isArray(projection?.rows) ? projection.rows : [];
181
+ }
182
+
183
+ export async function applyHygieneArchiveActions({
184
+ rootDir,
185
+ lakeRoot,
186
+ projection,
187
+ dryRun = true,
188
+ now = new Date().toISOString(),
189
+ applyMode = "off",
190
+ active = {},
191
+ humanAck = false,
192
+ humanGate = false,
193
+ actorSessionId = null,
194
+ approvals = [],
195
+ fsOps = {},
196
+ } = {}) {
197
+ const archiveRoot = join(lakeRoot, "archive", yyyymmdd(now));
198
+ assertLakePath(lakeRoot, archiveRoot, "archive target");
199
+
200
+ const activeSessions = normalizeActiveSessions(active);
201
+ const operations = [];
202
+
203
+ for (const row of rowsFromProjection(projection)) {
204
+ const hygieneKey = hygieneKeyForRow(row);
205
+ if (row?.kind === "worktree" && row?.status === "orphaned") {
206
+ operations.push({
207
+ hygiene_key: hygieneKey,
208
+ kind: row.kind,
209
+ id: row.id,
210
+ action: row.action,
211
+ status: "blocked",
212
+ reason: "orphan_worktree_requires_human",
213
+ bytes: 0,
214
+ });
215
+ continue;
216
+ }
217
+
218
+ if (!ARCHIVABLE_ACTIONS.has(row?.action)) continue;
219
+
220
+ assertProjectHash(row, rootDir);
221
+
222
+ if (isLiveOwned(row, activeSessions)) {
223
+ operations.push({
224
+ hygiene_key: hygieneKey,
225
+ kind: row.kind,
226
+ id: row.id,
227
+ action: row.action,
228
+ status: "skipped",
229
+ reason: "live_session_owned",
230
+ bytes: 0,
231
+ });
232
+ continue;
233
+ }
234
+
235
+ const sourcePath = row.artifact_path ? resolve(row.artifact_path) : null;
236
+ if (!sourcePath) {
237
+ operations.push({
238
+ hygiene_key: hygieneKey,
239
+ kind: row.kind,
240
+ id: row.id,
241
+ action: row.action,
242
+ status: "skipped",
243
+ reason: "missing_artifact_path",
244
+ bytes: 0,
245
+ });
246
+ continue;
247
+ }
248
+ // lake 밖 산출물은 자동 이동 대상이 아니다 — 크래시 대신 skip으로 보고한다.
249
+ // (checkpoint 등 실제 산출물은 대부분 lake 밖에 있으므로 throw 하면 apply 전체가 죽는다.)
250
+ if (!isInside(lakeRoot, sourcePath)) {
251
+ operations.push({
252
+ hygiene_key: hygieneKey,
253
+ kind: row.kind,
254
+ id: row.id,
255
+ action: row.action,
256
+ source_path: sourcePath,
257
+ status: "skipped",
258
+ reason: "source_outside_lake",
259
+ bytes: 0,
260
+ });
261
+ continue;
262
+ }
263
+
264
+ const targetPath = targetPathFor({ lakeRoot, row, sourcePath, now });
265
+ assertLakePath(lakeRoot, targetPath, "archive target");
266
+
267
+ if (!existsSync(sourcePath)) {
268
+ operations.push({
269
+ hygiene_key: hygieneKey,
270
+ kind: row.kind,
271
+ id: row.id,
272
+ action: row.action,
273
+ source_path: sourcePath,
274
+ target_path: targetPath,
275
+ status: existsSync(targetPath) ? "already_archived" : "skipped",
276
+ reason: existsSync(targetPath) ? undefined : "source_missing",
277
+ bytes: 0,
278
+ });
279
+ continue;
280
+ }
281
+
282
+ operations.push({
283
+ hygiene_key: hygieneKey,
284
+ kind: row.kind,
285
+ id: row.id,
286
+ action: row.action,
287
+ source_path: sourcePath,
288
+ target_path: targetPath,
289
+ status: "planned",
290
+ bytes: sourceBytes(sourcePath),
291
+ });
292
+ }
293
+
294
+ if (!dryRun) {
295
+ for (const operation of operations) {
296
+ if (operation.status !== "planned") continue;
297
+ if (applyMode !== "archive") {
298
+ operation.status = "blocked";
299
+ operation.reason = "apply_mode_off";
300
+ continue;
301
+ }
302
+ if (
303
+ !hasSecondActorApproval({
304
+ humanAck,
305
+ humanGate,
306
+ actorSessionId,
307
+ approvals,
308
+ hygieneKey: operation.hygiene_key,
309
+ })
310
+ ) {
311
+ operation.status = "blocked";
312
+ operation.reason = "second_actor_ack_required";
313
+ continue;
314
+ }
315
+ operation.move_method = movePath(
316
+ operation.source_path,
317
+ operation.target_path,
318
+ fsOps,
319
+ );
320
+ operation.status = "archived";
321
+ }
322
+ }
323
+
324
+ const plannedCount = operations.filter((operation) =>
325
+ ["planned", "archived", "already_archived"].includes(operation.status),
326
+ ).length;
327
+ const appliedCount = operations.filter(
328
+ (operation) => operation.status === "archived",
329
+ ).length;
330
+ const blockedCount = operations.filter(
331
+ (operation) => operation.status === "blocked",
332
+ ).length;
333
+
334
+ return {
335
+ schema_version: "cto-hygiene-actions.v1",
336
+ dry_run: Boolean(dryRun),
337
+ apply_mode: applyMode,
338
+ archive_root: archiveRoot,
339
+ planned_count: plannedCount,
340
+ applied_count: appliedCount,
341
+ blocked_count: blockedCount,
342
+ total_bytes: operations.reduce(
343
+ (sum, operation) => sum + Number(operation.bytes || 0),
344
+ 0,
345
+ ),
346
+ digest: digestOperations(operations),
347
+ live_safety: {
348
+ active_session_count: activeSessions.length,
349
+ skipped_live_count: operations.filter(
350
+ (operation) => operation.reason === "live_session_owned",
351
+ ).length,
352
+ },
353
+ operations,
354
+ };
355
+ }