From a418be0a49c042c6d83a55cdc29742ef81ae9380 Mon Sep 17 00:00:00 2001 From: Heznpc Date: Fri, 21 Aug 2026 17:33:11 +0900 Subject: [PATCH] State what Modore is for, name its public surface, and freeze the seam Three moves that had fallen a generation behind the code. The README led with "what your AI agents left behind" and gave the generic PC diagnostic equal billing. Neither sentence survived moraine: an orphaned trust root from a removed banking plugin and an orphaned worktree from a removed agent workspace are the same problem, and "AI" was never the boundary. The head now states the ontology -- durable local state that outlives the work, tool, or software that created it -- with "built for Macs shaped by AI-assisted development" as the audience line beneath it, and a Scope section fixes the rule every new capability is judged against. The diagnostic surface is declared maintained, not growing. The MCP vocabulary leaked implementation history: scree is a codename, hf_ is a vendor abbreviation, and a client calls these by string. Renamed in one pass while the consumer count is zero and no alias left behind -- an alias is a promise to support the old name forever, made to nobody: scree_report -> agent_state_report friction_scan -> operator_friction_report hf_orphans -> model_residue_report file_access -> agent_file_access mcp_hygiene and system_scan_summary already say what they do, and uninstall_residue_report landed under its final name. Internal filenames and handler names keep their codenames; the split between what a client calls and what a maintainer greps is the point. The bind output becomes the frozen seam: schema "modore.agent-state-snapshot", schemaVersion 1, generatedAt, with the contract documented and three semantic limits stated as part of it -- complete means conclusively classified rather than totally observed, the fingerprint is a same-or-changed check rather than provenance, and a snapshot is historical evidence that never authorizes destruction. The reference fixture is generated from a real run and pinned; the Swift consumer refuses a schemaVersion it does not know instead of best-effort parsing it, which is the contract's own versioning rule enforced where misreading would happen. --- CHANGELOG.md | 6 +- LANDING_PAGE_BRIEF_KO.md | 120 ++++++++++++++ README.md | 33 ++-- docs/agent-state-snapshot-v1.md | 85 ++++++++++ .../Sources/Modore/Services/ScanModel.swift | 1 + .../Services/TimeQuotaCardService.swift | 146 ++++++++++++++++++ .../Sources/Modore/Views/ScreeView.swift | 110 +++++++++++++ .../TimeQuotaCardServiceTests.swift | 95 ++++++++++++ scripts/mcp_server.py | 12 +- scripts/scree.py | 27 ++++ tests/fixtures/agent-state-snapshot-v1.json | 68 ++++++++ tests/test_mcp_server.py | 70 ++++----- tests/test_scree.py | 56 +++++++ .../MothballCore/Models/BindReport.swift | 14 ++ .../MothballCoreTests/BindReportTests.swift | 25 +++ 15 files changed, 815 insertions(+), 53 deletions(-) create mode 100644 LANDING_PAGE_BRIEF_KO.md create mode 100644 docs/agent-state-snapshot-v1.md create mode 100644 macos/Modore/Sources/Modore/Services/TimeQuotaCardService.swift create mode 100644 macos/Modore/Tests/ModoreTests/TimeQuotaCardServiceTests.swift create mode 100644 tests/fixtures/agent-state-snapshot-v1.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db2e79..c9174a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,17 +6,17 @@ All notable changes to this project are documented here. Format loosely follows ### Added - **Mothball's destructive API is now unreachable from Modore by mechanism, not by intention.** Absorbing Mothball absorbed two different deletion disciplines. Modore destroys nothing without a preview that issues a single-use 64-byte approval token, a 15-minute owner-only manifest, a remeasure at the destructive boundary, and a receipt. MothballCore's `ArchiveOrchestrator.archive()` is careful in its own right — it refuses `/` and `$HOME`, verifies the archive before touching the original, and moves to Trash rather than unlinking — but it is an *in-process* Swift call that takes no token and consumes no manifest, and the vendor dependency already compiles it into Modore's binary. Nothing but review stood between a future `try orchestrator.archive(…)` in a view action and a second deletion path the approval chain never sees. Two tests now pin the boundary: any Modore source file that names `ArchiveOrchestrator`, `Restorer`, `ArchiveRun`, or `trashItem` must also carry the approval-token symbol, so a diff cannot introduce one half without showing the other; and `MothballService` is pinned to the read-only scan-and-classify surface it actually uses. Wiring the archive path stays allowed — wiring it *around* the token does not. -- **File-access reverse index — `scripts/fileaccess.py`.** Absorbed from canary's `get_file_access` / `getFileAccessAggregates` (that repository is a frozen paper archive; this was the capability Modore had no equivalent for). Inverts the evidence scree already reads: for each path, how many reads, writes, and shell references it received, from how many sessions, and when last — with agent rule and config surfaces (CLAUDE.md, AGENTS.md, settings.json, anything under `~/.claude` or `~/.codex`) sorted first and returned by default, because a silently edited rule file is the case the view exists for. The rule-surface taxonomy is carried over verbatim. **The content contract is stricter than the original's**: canary attached a 200-character excerpt of the shell command to every row, and that excerpt is command content, so it is dropped here — a path extracted from a command is metadata about which file was touched, the command that touched it is not. Only tool names and paths are retained; assistant text, tool results, and non-path tool inputs are discarded in the same pass that reads them, and nested subagent transcripts are never opened, matching scree's collector. Paths are canonicalised before aggregation, so a file reached as `/Users/x/.claude/settings.json` from a tool input and as `~/.claude/settings.json` from a shell command is one row rather than several. Dedupe is keyed on the tool_use block id — Claude streams one assistant message as multiple lines sharing a message id, so a path-keyed dedupe would silently collapse a genuine second read. Exposed as the `file_access` MCP tool. +- **File-access reverse index — `scripts/fileaccess.py`.** Absorbed from canary's `get_file_access` / `getFileAccessAggregates` (that repository is a frozen paper archive; this was the capability Modore had no equivalent for). Inverts the evidence scree already reads: for each path, how many reads, writes, and shell references it received, from how many sessions, and when last — with agent rule and config surfaces (CLAUDE.md, AGENTS.md, settings.json, anything under `~/.claude` or `~/.codex`) sorted first and returned by default, because a silently edited rule file is the case the view exists for. The rule-surface taxonomy is carried over verbatim. **The content contract is stricter than the original's**: canary attached a 200-character excerpt of the shell command to every row, and that excerpt is command content, so it is dropped here — a path extracted from a command is metadata about which file was touched, the command that touched it is not. Only tool names and paths are retained; assistant text, tool results, and non-path tool inputs are discarded in the same pass that reads them, and nested subagent transcripts are never opened, matching scree's collector. Paths are canonicalised before aggregation, so a file reached as `/Users/x/.claude/settings.json` from a tool input and as `~/.claude/settings.json` from a shell command is one row rather than several. Dedupe is keyed on the tool_use block id — Claude streams one assistant message as multiple lines sharing a message id, so a path-keyed dedupe would silently collapse a genuine second read. Exposed as the `agent_file_access` MCP tool. - **Hugging Face cache audit — `scripts/hfscan.py`.** Absorbed from decant's `ContextProbe.swift` (that repository is being retired; this was one of two capabilities Modore did not already have). Derives each cached model's identifier from its hub directory name (`models--Qwen--Qwen2.5-Coder-1.5B-Instruct` → the `Qwen/…` slug and the bare leaf) and searches the given roots for any occurrence, case-insensitively, so that over-catching keeps a model rather than losing one. **The original's central defect is inverted here.** decant returned "unreferenced" whenever the search could not run — a missing search root, a failed grep, a mistyped `--projects` argument all produced the same answer as a genuinely exhaustive search that found nothing, so one typo could mark an entire hub cache safe to delete. Absence of evidence is only evidence of absence when the search actually happened: a root that does not exist, a file cap reached, or a subtree that could not be read now yields `unknown` for every model with the reason stated, and `--allow-missing-roots` / `--ignore-unreadable` widen the verdict only when the operator asks. Metadata-only: file contents are matched in memory and discarded, and the only paths emitted are the files that *do* reference a model, masked through `scree.mask_text`. - **MCP config hygiene — `scripts/mcpaudit.py`.** Absorbed from decant's `MCPHygiene.swift`. Reads `~/.claude.json` (including servers nested under per-project blocks), both Claude Desktop config locations, and `~/.mcp.json`, and reports entries that cannot start: `dead` (command does not resolve, or a script argument points at a path that is gone), `duplicate` (same command and args as another entry), `manual-review` (an `env` block is present), `unknown` (no command at all). Two deliberate deviations from the original: servers are named rather than anonymised to `server#N`, because a hygiene report the operator cannot act on is not one, and a server name is configuration metadata of the same kind scree already emits — while `env` is reported as a key *count*, never as keys or values. And a verdict that depends on PATH is withheld as `unknown` when PATH is unusable rather than reported as `dead`, the same fail-safe rule hfscan applies to its search. Read-only in the strong sense: the module has no code path that edits a config, disables a server, or starts one, pinned by a test. -- **Two more read-only MCP tools — `hf_orphans`, `mcp_hygiene`.** The audits above, exposed through the existing surface on the same terms as `scree_report` and `friction_scan`: allowlisted by name, annotated read-only and non-destructive, forwarding what the judgment script prints rather than reimplementing it. `hf_orphans` restates `search_complete` as a top-level `verdicts_withheld` flag so an incomplete search cannot be misread as "no orphans found", and its `roots` argument — the one place a caller supplies a path — is bounded to 8 entries and screened so a value can never be read as an option by the script it is passed to. +- **Two more read-only MCP tools — `model_residue_report`, `mcp_hygiene`.** The audits above, exposed through the existing surface on the same terms as `agent_state_report` and `operator_friction_report`: allowlisted by name, annotated read-only and non-destructive, forwarding what the judgment script prints rather than reimplementing it. `model_residue_report` restates `search_complete` as a top-level `verdicts_withheld` flag so an incomplete search cannot be misread as "no orphans found", and its `roots` argument — the one place a caller supplies a path — is bounded to 8 entries and screened so a value can never be read as an option by the script it is passed to. - **Leave-behind audit — `scripts/moraine.py`.** Reads the two records that outlive an uninstall — macOS installer receipts (`pkgutil`) and the admin/user trust domains (`security dump-trust-settings`) — and correlates them into a verdict neither produces alone: a trusted root certificate whose installing package has no payload left on disk. First live run on the development Mac found exactly that — `INNORIX.CA`, a self-signed `CA:TRUE` root valid to 2028-07-07, trusted unconditionally in the admin domain, installed 2026-05-01 by a Korean file-transfer plugin whose app, LaunchAgent, and processes are all gone. Receipts are judged `present`/`partial`/`vanished`/`no_payload` with any file sampling reported; roots with no owning receipt are `unattributed` (MDM profiles and enterprise Wi-Fi legitimately have none), never accused. Read-only: nothing is written and nothing deleted — removing a trust root is an admin act that stays a human decision. Exposed through the MCP surface as `uninstall_residue_report`. ### Changed - **The scree pitch no longer claims the unpushed-work verdict is unique.** Orca's workspace cleanup runs the same `rev-list --not --remotes` check against the worktrees it manages, verified in its source. README now states the actual difference — scree sweeps every agent worktree on the machine whatever created it, plus primary checkouts stranded off main and registry entries whose directory vanished — and names the prior art instead of implying none exists. - **Operator-friction scan — `scripts/friction.py`.** Deterministic port of canary's `lib/sessions/friction.ts` (that repository is now a frozen paper archive; this was its one capability Modore did not already have). Flags the user turns where the operator pushed back on agent behaviour, across nine categories — wrong-action, no-research-assertion, stalling-approval, rule-contamination, over-orchestration-token, stale-repetition, verbosity, tone-attitude, other-ai-friction — at severity 1-3. Keyword and tone matching only, no model in the judgment path. Claude Code and Codex sessions are discovered through scree's own collectors rather than a second traversal; Gemini CLI chats (`~/.gemini/tmp/*/chats`, joined to a workspace through the project registry) and Claude Desktop local-agent sessions are added because scree does not traverse either — its Gemini collector reads only the project registry, and it has no Claude Desktop collector. Content contract: user-authored turns only, quotes capped at 200 characters and masked by default (`--raw-quotes` opts out), nothing written. `--json` serves the structured report. First live run over a 30-day window: 200 sessions, 3,563 user turns, 430 findings (rage 247 / irritation 60 / correction 123). -- **Read-only MCP surface — `scripts/mcp_server.py`.** A zero-dependency stdio JSON-RPC server exposing three tools: `scree_report` (join, retention forecast, sole-copy and orphan verdicts, by section), `friction_scan` (the taxonomy above, filterable by store/category/minimum severity), and `system_scan_summary` (the storage and security scan result already on disk, with its age). Each tool runs `scree.py --json` or `friction.py --json` and forwards the output, so no judgment is reimplemented and the CLI, the Mac app, and the MCP surface cannot disagree. Cleanup, deletion, and scan execution are deliberately not exposed — Modore's destructive path is gated on an on-screen human approval, and an agent-reachable bypass would void it; pinned by a test that exercises every tool and asserts the only processes ever spawned are the two judgment scripts. The read-only contract is enforced where tools are registered, ported from AirMCP's own Swift MCP server (`IOSPreviewContract`): a tool is reachable only if it is on an explicit name allowlist and annotated read-only and non-destructive, so one added without a deliberate edit fails closed rather than being merely unlisted. All results are fenced as untrusted machine-derived data. +- **Read-only MCP surface — `scripts/mcp_server.py`.** A zero-dependency stdio JSON-RPC server exposing three tools: `agent_state_report` (join, retention forecast, sole-copy and orphan verdicts, by section), `operator_friction_report` (the taxonomy above, filterable by store/category/minimum severity), and `system_scan_summary` (the storage and security scan result already on disk, with its age). Each tool runs `scree.py --json` or `friction.py --json` and forwards the output, so no judgment is reimplemented and the CLI, the Mac app, and the MCP surface cannot disagree. Cleanup, deletion, and scan execution are deliberately not exposed — Modore's destructive path is gated on an on-screen human approval, and an agent-reachable bypass would void it; pinned by a test that exercises every tool and asserts the only processes ever spawned are the two judgment scripts. The read-only contract is enforced where tools are registered, ported from AirMCP's own Swift MCP server (`IOSPreviewContract`): a tool is reachable only if it is on an explicit name allowlist and annotated read-only and non-destructive, so one added without a deliberate edit fails closed rather than being merely unlisted. All results are fenced as untrusted machine-derived data. - **scree lineage: what the sessions remember versus what the disk still holds.** Every work path appearing in session records is classified alive+git / alive+plain / vanished, using existence and a `.git` presence check only; macOS case-variant ghosts (the same path recorded under different casings) are merged by casefold and reported once with their spellings. First live run: 422 paths — 118 alive git repos, 189 alive plain directories, 115 vanished (session records their only trace), 4 case ghosts. Consumers such as balance's lineup-gap diff read `--json .lineage`; scree itself reports universal facts only. - **Stray primary-checkout judgment (scree).** Agent sessions can leave a repo's *main checkout* parked on a non-default branch with unmerged work — the same unique-work risk as an orphaned worktree, but invisible to worktree listing. `collect_worktrees` now judges the primary checkout of every repo that hosts agent worktrees: a non-main/master branch is reported as `stray_checkout` with the same protected-versus-rebuildable rules, and the report lists each one with its branch and unpushed count. First live run found 11 stranded checkouts, three of them carrying unpushed unique commits. diff --git a/LANDING_PAGE_BRIEF_KO.md b/LANDING_PAGE_BRIEF_KO.md new file mode 100644 index 0000000..38e4586 --- /dev/null +++ b/LANDING_PAGE_BRIEF_KO.md @@ -0,0 +1,120 @@ +# Modore 랜딩페이지 콘텐츠 브리프 + +**작성일:** 2026-08-19 +**기준:** 저장소의 README, 현재 GitHub Pages 랜딩페이지 및 프로젝트 구조 검토 +**목적:** Modore의 제품 약속을 한 문장으로 이해시키고, 신뢰가 중요한 로컬 진단 도구의 가치·한계·다음 행동을 명확히 전달하는 랜딩페이지의 메시지와 정보 구조를 정의한다. + +> **권장 포지셔닝:** Modore는 AI 에이전트와 로컬 PC가 남긴 흔적을 **내용을 읽거나 외부로 보내지 않고**, 결정론적 로컬 증거로 설명해 주는 오픈소스 진단 도구다. + +## 1. 프로젝트 핵심 요약 + +Modore는 하나의 브랜드 아래 서로 다른 운영체제 에디션을 제공한다. **Mac Edition의 주력 기능**은 Claude Code, Codex, Gemini CLI, AI IDE 등의 세션·작업공간·Git worktree 흔적을 메타데이터로만 연결해, 곧 사라질 세션·고아 세션·유일한 미푸시 작업 사본을 식별하는 `scree` 감사 기능이다. **Windows Edition**은 팬 소음, 비정상 CPU/GPU 사용, 낯선 프로세스·네트워크·시작 프로그램·저장공간 문제를 증거 중심으로 설명하며, 특히 한국의 금융·공공 보안 플러그인을 맥락 없이 위험으로 오인하지 않도록 화이트리스트를 적용한다.[1] + +제품의 공통 가치는 단순한 “정리 도구”가 아니라 **삭제하기 전의 판단 도구**라는 점이다. 판정은 생성형 모델이 아닌 읽기 전용 메타데이터와 선언형 규칙에 기반하며, 정상·확인 필요·위험의 신호등 결과와 근거를 제공한다. 자동 삭제는 기본 동작이 아니고, macOS의 제한적인 정리 동작도 대상 미리보기와 명시적 재승인을 요구한다.[1] + +| 구분 | Mac Edition | Windows Edition | +|---|---|---| +| 가장 선명한 사용자 문제 | “AI 에이전트가 내 프로젝트에 무엇을 남겼나?” | “내 PC가 왜 계속 바쁜가?” | +| 주력 가치 | 세션 만료 예측, 고아 세션, sole-copy worktree, 작업 경로 계보 | 프로세스·네트워크·자동실행·보안·저장공간의 증거 결합 | +| 기본 인터페이스 | SwiftUI 유틸리티, HTML 내보내기, 읽을 수 있는 런타임 | PowerShell 실행기와 오프라인 HTML 리포트 | +| 신뢰 경계 | 메타데이터 전용, 세션 본문을 저장·출력하지 않음 | 로컬 검사, 선택적 VirusTotal 해시 조회 | +| 출시 상태 | 소스 검토·빌드 우선, 향후 서명·공증 DMG 예정 | 소스 검토 우선, 검증된 릴리스 ZIP은 향후 배포 | + +## 2. 랜딩페이지가 풀어야 할 문제 + +현재 프로젝트는 기술적 깊이와 신뢰 설계가 강점이지만, 첫 화면에서 **Mac의 AI 에이전트 잔여물 감사**와 **Windows 중심의 PC 상태 설명**이 동등하게 제시되면 신규 방문자가 “내게 어떤 도구인가?”를 빠르게 파악하기 어렵다. 랜딩페이지는 이 두 문제를 나열하기보다, 먼저 상위 약속인 **“내 컴퓨터에 남은 흔적을 증거로 설명한다”**를 제시하고, 운영체제별로 즉시 갈라지는 경로를 보여 주어야 한다. + +특히 Mac AI 코딩 사용자는 세션 저장소의 보존 기간, 삭제된 프로젝트를 가리키는 기록, 미푸시 작업만 든 worktree의 위험을 인식하지 못한 채 작업한다. 반면 일반 Windows 사용자는 낯선 프로그램을 보고 성급히 삭제하거나, 경고가 너무 많아 모두 무시하는 양극단에 놓이기 쉽다. Modore는 양쪽 모두에 “판단 전 증거”를 제공하지만, 각 사용자가 보는 첫 번째 메시지와 대표 장면은 달라야 한다.[1] + +| 우선순위 | 핵심 방문자 | 진입 상황 | 랜딩페이지가 즉시 답해야 할 질문 | 권장 CTA | +|---:|---|---|---|---| +| 1 | Mac에서 AI 코딩 도구를 사용하는 개발자 | 작업·세션이 쌓이고 만료 또는 소실이 걱정됨 | “어떤 프로젝트와 세션이 위험하며, 유일한 작업 사본은 어디에 있나?” | `Mac의 AI 흔적 확인하기` | +| 2 | 느려진 PC의 원인을 알고 싶은 사용자 | 팬 소음, 높은 사용량, 낯선 프로세스 또는 저장공간 부족 | “무엇이 실제로 확인이 필요하고, 무엇은 정상인가?” | `PC 상태 점검 방식 보기` | +| 3 | 보안·프라이버시를 검토하는 기술 사용자 | 외부 전송, 자동 정리, 불투명한 AI 판정이 우려됨 | “무슨 정보가 나가며, 판단과 삭제는 어떻게 통제되는가?” | `신뢰 경계 검토하기` | +| 4 | 오픈소스 기여자 및 얼리어답터 | 공개 설치본 전의 소스 프리뷰 단계 | “현재 무엇을 실제로 실행·검토할 수 있는가?” | `소스 검토 및 빌드하기` | + +## 3. 반드시 유지해야 하는 메시지 원칙 + +첫째, **“AI 기반 진단”으로 표현하지 않는다.** 제품은 AI 에이전트가 남긴 흔적을 다루지만, 판정 경로에는 LLM을 사용하지 않는다. 이 구분은 보안과 재현성의 핵심이며, “AI 에이전트 흔적을 감사하는 도구”라는 문장으로 유지하는 것이 정확하다.[1] + +둘째, **과도한 보안 약속을 피한다.** Modore는 백신·실시간 보호·악성코드 제거 도구를 대체하지 않는다. 의심 신호를 맥락과 함께 보여 주는 2차 의견이며, 위협 제거는 사용자의 기존 보안 도구와 판단에 남는다.[1] + +셋째, **출시 상태를 투명하게 쓴다.** 저장소는 현재 소스 프리뷰 상태이므로 “지금 다운로드”나 “원클릭 설치”를 전면 CTA로 사용하지 않는다. 공개 인스톨러가 아직 없다는 사실과, 향후 서명·공증·Gatekeeper 평가·SHA-256 메타데이터가 갖춰진 릴리스를 제공한다는 조건을 함께 명시한다.[1] + +넷째, **Mac과 Windows의 기능 동등성을 암시하지 않는다.** 브랜드의 공통 약속은 같지만 OS별 수집기와 대표 기능, 검증 조건은 분리되어 있다. 따라서 플랫폼 선택 카드와 각 카드의 범위를 초반에 분명히 표시한다.[1] + +## 4. 권장 정보 구조와 카피 방향 + +아래 구조는 기존 영문 정적 페이지의 장점인 코드 기반 신뢰 설명을 유지하면서, 제품의 중심축과 플랫폼 분기를 앞당기는 안이다. 구현은 기존처럼 `docs/`의 정적 HTML/CSS, 스크립트 없는 GitHub Pages 페이지로 충분하다.[2] + +| 섹션 | 방문자에게 전달할 한 가지 메시지 | 권장 구성 | 초안 카피 예시 | +|---|---|---|---| +| 1. Hero | AI 에이전트와 PC가 남긴 흔적을 로컬 증거로 읽는다 | 짧은 제목, 신뢰 배지, OS 선택 CTA, 실제 제품 화면 또는 사실 기반 모형 | **“AI가 남긴 작업 흔적, PC가 보내는 이상 신호. 지우기 전에 먼저 이해하세요.”** | +| 2. Trust strip | 내용·원격 전송·자동 삭제 없이 동작한다 | `Local-first`·`No LLM judgment`·`No telemetry`·`Approval-gated cleanup` 4개 항목 | “세션 본문을 보관하지 않고, 판정에 생성형 AI를 쓰지 않으며, 자동으로 삭제하지 않습니다.” | +| 3. Platform chooser | 사용자 문제는 OS에 따라 다르다 | Mac과 Windows를 동등한 크기의 카드로 제시하고 기능 범위를 명시 | “Mac: AI 세션과 유일한 작업 사본을 감사합니다. Windows: PC의 바쁨을 근거로 설명합니다.” | +| 4. Mac flagship | 가장 차별화된 제품 기능을 구체적으로 보여 준다 | `scree` 결과 이미지/CLI 예시, 세션-프로젝트 연결, D-day, worktree 보호 판정 | **“사라지기 전에, 에이전트 작업의 유일한 사본을 찾습니다.”** | +| 5. Evidence before deletion | 일반 PC 사용자의 불안을 행동 가능한 판단으로 바꾼다 | 일반 스캐너 대비 Modore의 맥락 설명, 한국 보안 플러그인 예시, 신호등 결과 | **“경고 수가 아니라, 확인해야 할 이유를 보여 줍니다.”** | +| 6. How it decides | 판정의 재현성과 검증 가능성을 보여 준다 | 수집 → 정규화/규칙 → 판단/리포트의 3단계와 읽기 전용 경계 | “같은 입력에는 같은 판정. 판단 근거는 소스에서 검토할 수 있습니다.” | +| 7. Privacy & control | 외부 전송과 정리 권한의 경계를 명확히 한다 | 해시 전용 VirusTotal은 선택 사항임을 분명히 표시, 자동 삭제 없음, 공개 코드 링크 | “파일은 업로드하지 않습니다. 선택한 해시 조회만 외부로 나갈 수 있습니다.” | +| 8. Source-preview CTA | 현재 가능한 다음 행동을 정직하게 제시한다 | Mac/Windows별 소스 실행 안내, 릴리스 상태, GitHub 링크 | “공개 인스톨러 전 단계입니다. 소스를 검토하고 직접 빌드하거나 검증된 릴리스를 기다리세요.” | +| 9. FAQ / Limits | 오해를 사전에 해소한다 | 백신 대체 여부, 자동 삭제, 지원 OS·현재 배포 상태, 데이터 전송 | “Modore는 백신도 자동 최적화 도구도 아닙니다.” | + +## 5. 첫 화면 권장안 + +첫 화면은 기존의 넓은 범용 PC 검사 메시지보다 **로컬 증거와 AI 작업 잔여물**을 중심으로 시작하는 편이 좋다. 이 프로젝트만의 가장 식별 가능한 차별점은 단순한 프로세스 스캔이 아니라, 여러 AI 코딩 도구의 세션 흔적과 Git 상태를 연결해 소실 위험을 읽는 기능이기 때문이다. Windows의 PC 상태 설명은 같은 히어로 안에서 보조 약속으로 두고, 바로 아래의 OS 선택 영역에서 명확하게 확장한다. + +> **Eyebrow** +> LOCAL-FIRST · DETERMINISTIC · OPEN SOURCE +> +> **Headline** +> **AI가 남긴 작업 흔적과 PC의 이상 신호를, 지우기 전에 이해하세요.** +> +> **Supporting copy** +> Modore는 Mac의 Claude Code·Codex·Gemini·AI IDE 세션과 Git 작업공간을 메타데이터로 감사해 사라질 기록과 유일한 미푸시 작업 사본을 찾습니다. Windows에서는 프로세스·네트워크·자동실행·저장공간의 신호를 결합해, 실제로 확인할 항목과 정상 항목을 구분해 설명합니다. +> +> **Primary CTA** +> `내 환경에 맞는 에디션 보기` +> +> **Secondary CTA** +> `소스와 신뢰 경계 검토하기` +> +> **Trust note** +> 생성형 AI 판정 없음 · 기본 로컬 실행 · 텔레메트리 없음 · 자동 삭제 없음 + +## 6. 시각·UX 가이드 + +페이지의 시각 언어는 “위협을 과장하는 보안 제품”보다는 **차분한 로컬 관측 도구**에 가까워야 한다. 현재 페이지의 어두운 콘솔·리포트 모형, 신호등 상태, 코드 근거라는 방향은 제품 성격과 잘 맞으므로 유지할 수 있다. 다만 히어로의 대표 화면은 Windows HTML 리포트 하나만 쓰기보다, Mac의 `scree` 감사 결과와 Mac/Windows 플랫폼 선택을 함께 보여 주어 프로젝트의 중심을 더 정확히 반영하는 것이 좋다.[2] + +| 요소 | 권장 | 피해야 할 표현 또는 연출 | +|---|---|---| +| 상태색 | 초록·노랑·빨강은 **판정 상태**에만 제한적으로 사용 | 모든 영역을 경고색으로 채워 공포를 유발하는 구성 | +| 제품 화면 | 실제 화면, 검증 가능한 CLI/리포트 형식, 명확히 “예시”라 표기한 모형 | 실제 결과처럼 보이는 임의의 탐지 수치 또는 공포성 가짜 위협 | +| 신뢰 설명 | 로컬 실행, 메타데이터, 해시 전용 조회, 승인 절차를 짧은 문장으로 반복 | “완전 보안”, “100% 안전”, “악성코드를 제거” 같은 절대 표현 | +| CTA | `소스 검토`, `에디션 보기`, `빌드 방법 보기` 중심 | 공개되지 않은 설치 파일을 암시하는 `지금 다운로드` | +| 언어 | 한국어 우선 페이지라면 제품명·파일명·기술 용어는 영문 병기 | AI 에이전트 감사와 PC 상태 점검을 한 문장에 과도하게 나열 | + +## 7. 구현 전에 확정할 항목 + +랜딩페이지를 실제로 개편하기 전에는 주 언어를 **한국어 우선**으로 할지, 현재처럼 **영어 우선**으로 유지할지 결정해야 한다. README는 영어를 기준 언어로 두고 한국어 설명 계층을 전문화된 데이터로 설명하지만, 현재 요청의 언어를 고려하면 한국어 메인 페이지 또는 한국어/영어 정적 페이지 분리가 유력하다.[1] + +또한 히어로에서 사용할 제품 이미지는 현재의 정적 모형을 유지할지, macOS SwiftUI 앱과 `scree` 결과의 검증 가능한 스크린샷으로 교체할지 정해야 한다. 외부 전송·릴리스 상태처럼 신뢰에 직접 연결되는 문구는 README와 출시 메타데이터를 단일 출처로 두고, 배포 상태가 바뀔 때마다 함께 갱신하는 방식이 안전하다. + +| 결정 항목 | 권장 기본안 | 이유 | +|---|---|---| +| 메인 언어 | 한국어 우선, 영어 페이지를 별도 정적 경로로 제공 | 한국 금융·공공 소프트웨어 맥락과 현재 요청자의 우선 언어에 맞음 | +| 첫 번째 제품 경로 | Mac AI 세션 감사 | 프로젝트의 가장 고유하고 차별적인 가치 제안 | +| 보조 제품 경로 | Windows의 증거 기반 PC 상태 설명 | 폭넓은 사용 문제를 포괄하되 기능 범위 혼동을 줄임 | +| 기본 CTA | 소스 검토 및 에디션 선택 | 현재 소스 프리뷰 상태를 정직하게 반영 | +| 시각 자료 | 실제 화면 우선, 불가하면 예시 모형에 명확한 라벨 | 신뢰 제품에서 과장된 데모를 방지 | +| 기술 방식 | 기존 `docs/` 정적 HTML/CSS 유지 | GitHub Pages 배포·무스크립트·관리 단순성에 부합 | + +## 8. 결론 + +Modore의 랜딩페이지는 “PC 건강검진” 일반론보다, **AI 에이전트가 남긴 세션·작업 사본의 소실 위험을 로컬 증거로 밝혀 주는 도구**라는 고유한 문장에 먼저 집중해야 한다. 그 다음에 Windows의 증거 기반 진단을 별도 운영체제 경로로 자연스럽게 연결하면, 프로젝트의 넓은 범위를 숨기지 않으면서도 첫인상을 선명하게 만들 수 있다. + +핵심은 기능의 개수를 늘어놓는 것이 아니라, **내용은 읽지 않고, 외부로 보내지 않으며, 자동 삭제하지 않고, 사용자가 결정을 내리기 전에 근거를 제공한다**는 신뢰 경계를 랜딩페이지 전반에서 일관되게 증명하는 것이다. 공개 인스톨러 전 단계라는 상태 역시 약점으로 감추지 말고, 소스를 읽고 검증할 수 있다는 오픈소스 제품의 가치와 연결해야 한다. + +## References + +[1] [Modore README — 제품 범위, 에디션, 프라이버시, 출시 상태](./README.md) +[2] [현재 GitHub Pages 랜딩페이지 — 정보 구조와 카피](./docs/index.html) diff --git a/README.md b/README.md index abd418b..5cca9fc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Modore -> **A local audit of what your AI agents left behind.** On a Mac where Claude Code, Codex, Gemini, or an AI IDE has been working, Modore answers the questions nothing else on that machine is asking: which sessions touched which projects, which transcripts silently expire in a few days, which agent worktrees hold the only copy of unpushed work — whichever tool you ran the agent from — and which paths survive nowhere except in a session record — deterministically, metadata-only, with no LLM anywhere in the judgment path. -> The same evidence-first approach also covers the PC that feels busy for no reason: process, network, autorun, security, and storage signals turned into plain-language evidence before you stop or delete anything. +> **Modore audits durable local state that outlives the work, tool, or software that created it** — so you can see what still matters, what no longer has an owner, and what is safe to reclaim. Agent sessions whose workspace is gone, worktrees holding the only copy of unpushed work, trust roots installed by software you removed months ago, model caches nothing references: state that survived its creator, judged by provenance and survival — deterministically, metadata-only, with no LLM anywhere in the judgment path. +> +> Built for Macs shaped by AI-assisted development. [🌐 **Website**](https://heznpc.github.io/modore/) · [📦 Releases (when published)](https://github.com/heznpc/modore/releases) · [Architecture](./docs/ARCHITECTURE.md) @@ -11,9 +12,11 @@ --- -## The two questions it answers +## What it answers -### 1. What did my AI tools leave behind? (Mac) +Everything below is one question asked of different stores: **what survived, what still matters, and what can be reclaimed.** A session outlives its workspace; a worktree outlives its branch; a trust root outlives its installer; a model cache outlives the experiment. Each audit connects a survivor to its origin and judges whether it is protected, orphaned, or rebuildable — and none of them deletes anything. + +### What your AI tools left behind (Mac) On a Mac where Claude Code, Codex, Gemini, or an AI IDE has been working, the machine fills with traces nothing audits end to end: session stores that silently expire on rolling windows, agent git worktrees holding the only copy of unpushed work, orphaned sessions pointing at deleted projects, gigabytes of rebuildable model and editor caches, and paths whose only surviving record is a session transcript. **scree**, Modore's session-and-residue audit, judges all of it — deterministically, metadata-only, with no LLM anywhere in the judgment path: @@ -70,20 +73,22 @@ python3 scripts/mcp_server.py --tools # inspect the surface without speaking {"mcpServers": {"modore": {"command": "python3", "args": ["/scripts/mcp_server.py"]}}} ``` -- `scree_report` — the join, retention forecast, orphan/sole-copy/lineage judgment, by section, with every truncation reported. -- `friction_scan` — the pushback taxonomy, filterable by store, category, and minimum severity. -- `hf_orphans` — which models in the Hugging Face hub cache no project file names, and how many gigabytes those account for. Reports `search_complete`; when the search could not be exhaustive every model is `unknown`, never `unreferenced`. +- `agent_state_report` — the join, retention forecast, orphan/sole-copy/lineage judgment, by section, with every truncation reported. +- `operator_friction_report` — the pushback taxonomy, filterable by store, category, and minimum severity. +- `model_residue_report` — which models in the Hugging Face hub cache no project file names, and how many gigabytes those account for. Reports `search_complete`; when the search could not be exhaustive every model is `unknown`, never `unreferenced`. - `mcp_hygiene` — registered MCP servers that cannot start: dead command, missing script path, duplicate entry, or an `env` block worth a human look (reported as a key count, never as keys or values). -- `file_access` — the reverse index: which sessions touched which paths, with reads/writes/shell counts. Agent rule surfaces first, because a silently edited `CLAUDE.md` is the case it exists for. +- `agent_file_access` — the reverse index: which sessions touched which paths, with reads/writes/shell counts. Agent rule surfaces first, because a silently edited `CLAUDE.md` is the case it exists for. - `system_scan_summary` — the storage and security scan result *already on disk*, with its age, because a stale result read as current is the failure mode here. - `uninstall_residue_report` — installer receipts, trusted roots, and the orphaned-root verdict above. - **A thin layer, not a second implementation** — each tool runs one judgment script with `--json` and forwards what it prints, so the CLI, the Mac app, and the MCP surface cannot disagree about what is true. - **Read-only by contract, enforced at registration** — a tool is reachable only if it is on an explicit allowlist and annotated read-only and non-destructive; one added without a deliberate edit fails closed. Cleanup, deletion, and scan execution are deliberately absent. Modore gates destruction on an approval a human grants on screen; an agent-reachable bypass would not be a feature, it would be the end of that guarantee. Every result is fenced as untrusted data. -### 2. Why is my PC this busy? +### Why is this machine busy? (maintained diagnostics) A fan that will not stop, CPU/GPU load while idle, an unknown process, a strange network connection, disk space vanishing overnight. Generic scanners detect but do not explain — and on a Korean banking/government PC they cry wolf over IPinside, nProtect, MagicLine and the rest of the mandated plugin set until users either panic-uninstall critical software or learn to ignore every warning. Modore is the second opinion: it joins process, network, autorun, security, and storage signals, checks miner-like runtime patterns, recognizes the Korean plugin set with a locale-aware whitelist, and explains every finding in plain Korean, English, or Japanese with a 🟢🟡🔴 verdict. Nothing is ever deleted automatically. +This diagnostic surface is **maintained, not growing**: bug and security fixes continue, but new Modore capability lands on the durable-state side above. Live monitoring that does not contribute to provenance, residue attribution, or a bounded cleanup decision is out of scope. + --- ## What ships today @@ -128,6 +133,16 @@ Modore is the brand. The OS editions are separate products under that brand, not Shared rules, whitelist data, i18n strings, and report vocabulary can be reused where they genuinely match. OS-specific collectors stay separate. +## Scope + +The boundary every new capability is judged against: + +**In scope** — durable local state whose creator, owner, workspace, package, or original purpose may no longer be present or obvious, when Modore can establish useful provenance, survival, protection, or reclaimability evidence. Sessions, worktrees, uninstall residue, model and build caches, trust roots, agent environment registrations. + +**Out of scope** — generic live system monitoring or threat detection that does not materially contribute to provenance, residue attribution, recovery, or a bounded cleanup decision. The existing diagnostic surface stays maintained; it does not grow. + +"AI-builder Mac" is the audience this is built for, not a filter on what qualifies: an orphaned trust root from a removed banking plugin and an orphaned worktree from a removed agent workspace are the same problem, and both are in. + ## Design intent **Windows stays scripts + HTML; Mac uses a native utility interface over the same readable runtime.** This is the load-bearing choice: diff --git a/docs/agent-state-snapshot-v1.md b/docs/agent-state-snapshot-v1.md new file mode 100644 index 0000000..cef3f78 --- /dev/null +++ b/docs/agent-state-snapshot-v1.md @@ -0,0 +1,85 @@ +# modore.agent-state-snapshot — v1 + +The wire contract for one workspace's session-binding snapshot, as +emitted by `scree.py bind ` and, per workspace, by +`scree.py bind-all`. This is the surface an external consumer (Zoint +first) may rely on. Everything not listed here is internal and may +change without notice. + +## Shape + +```json +{ + "schema": "modore.agent-state-snapshot", + "schemaVersion": 1, + "generatedAt": "2026-08-21T17:22:26+0900", + "workspace": "/absolute/path", + "repoUrl": "github.com/owner/name", + "deep": true, + "coverage": "complete", + "coverageDetail": { + "claude": "complete", + "codex": "complete", + "gemini": "complete", + "editors": "complete", + "unboundStores": [] + }, + "assessed": true, + "storeFingerprint": { "digest": "…64 hex…", "fileCount": 7171 }, + "bindings": [ + { + "provider": "claude", + "sessionId": "…", + "source": "/absolute/path/to/transcript", + "subtranscripts": ["…"], + "evidence": ["working-directory"], + "confidence": "medium", + "sizeBytes": 123456 + } + ], + "summary": { "total": 1, "byProvider": {}, "byConfidence": {}, "sizeBytes": 123456 } +} +``` + +`artifactRoot` (optional, editor entries only): the directory +`subtranscripts` are relative to, when the binder states it. + +`coverage` ∈ `shallow | truncated | complete` · `confidence` ∈ `high | +medium | low` · `evidence[]` ⊆ `remote-url | working-directory | +file-access` · `provider` ∈ `claude | codex | gemini | vscode | kiro | +cursor | windsurf | antigravity`. + +## Semantic limits — part of the contract + +1. **`coverage: "complete"` means every candidate binding was + conclusively classified** — decided by metadata authoritative enough + on its own (a recorded remote, a matching cwd, a workspace hash), or + read to EOF and found not to mention the workspace. It does **not** + mean the machine was completely observed, and a consumer that reads + it as total observation will over-trust an empty result. + +2. **`storeFingerprint` identifies the session-store state observed at + snapshot time**, for same-or-changed comparison before acting later. + It is a metadata digest (paths, sizes, mtimes — nothing opened), not + a provenance proof of anything inside the stores. + +3. **A snapshot is historical evidence, never destructive + authorization.** It records what was true when it was taken; sealing + or deleting anything on its strength requires Modore to revalidate at + the moment of action. The archive path's `revalidate` hook exists for + exactly this, and a consumer holding a snapshot inherits the same + duty. + +## Versioning + +Additive optional keys do not bump the version. Renaming, removing, or +changing the meaning of any key above does. A consumer receiving a +`schemaVersion` greater than it knows must treat the snapshot as +unreadable rather than best-effort parsing it — the fields it recognises +may no longer mean what they meant. + +## Reference fixture + +`tests/fixtures/agent-state-snapshot-v1.json` is generated from a real +run and pinned by `tests/test_scree.py`; consumer repositories copy that +file, never hand-write their own. diff --git a/macos/Modore/Sources/Modore/Services/ScanModel.swift b/macos/Modore/Sources/Modore/Services/ScanModel.swift index 619624c..7f92925 100644 --- a/macos/Modore/Sources/Modore/Services/ScanModel.swift +++ b/macos/Modore/Sources/Modore/Services/ScanModel.swift @@ -42,6 +42,7 @@ final class ScanModel: ObservableObject { @Published var observationResult: ObservationResult? @Published var observationInFlight = false @Published var observationErrorMessage: String? + @Published var timeQuotaSnapshot: TimeQuotaSnapshot? let logStore = ScanLogStore() let projectRoot: URL diff --git a/macos/Modore/Sources/Modore/Services/TimeQuotaCardService.swift b/macos/Modore/Sources/Modore/Services/TimeQuotaCardService.swift new file mode 100644 index 0000000..f201033 --- /dev/null +++ b/macos/Modore/Sources/Modore/Services/TimeQuotaCardService.swift @@ -0,0 +1,146 @@ +import Foundation + +/// The read side of the TimeQuota boundary contract. +/// +/// TimeQuota (a separate local app, github.com/heznpc/timequota) writes +/// `~/Library/Application Support/TimeQuota/quota.json` atomically on every +/// collection tick. Merging the two products was considered and rejected -- +/// their release postures differ -- so this file IS the integration: Modore +/// only ever reads it. Consumer rules agreed with the producer: +/// - file absent or unparseable -> no card at all (not an error) +/// - `generatedAt` stale -> no card (a dead producer must not keep +/// presenting last week's numbers as now) +/// - fresh but `healthy: false` -> say collection is broken, show no +/// numbers (the producer's own heartbeat +/// verdict outranks whatever it sampled) +struct TimeQuotaSnapshot { + struct Window { + let provider: String + let usedPercent: Double + let resetsAt: Date? + } + + struct BurnRow: Identifiable { + let id = UUID() + let remote: String + let percent: Double + let lastActiveAt: Date? + } + + struct ProviderState: Identifiable { + let id = UUID() + let name: String + let state: String + } + + let generatedAt: Date + let collectionHealthy: Bool + let providerStates: [ProviderState] + let window: Window? + let topBurn: [BurnRow] +} + +enum TimeQuotaCardService { + static let quotaFileURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/TimeQuota/quota.json") + /// The producer ticks every few minutes; an hour of silence means it is + /// not running, and a card built from its last write would present a + /// stopped collector's numbers as current -- the exact "checkmark that + /// only means installed" failure this codebase keeps relearning. + static let staleAfter: TimeInterval = 60 * 60 + private static let maximumBytes = 64 * 1024 + + /// Pure and independently testable: quota.json's agreed v1 shape. + /// An unknown `schemaVersion` returns nil -- fields could mean something + /// else under a future contract, and a hidden card is the agreed failure + /// mode, not a guessed one. + static func parse(_ data: Data) -> TimeQuotaSnapshot? { + guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + (root["schemaVersion"] as? NSNumber)?.intValue == 1, + let generatedAt = date(root["generatedAt"]), + let collection = root["collection"] as? [String: Any], + let healthy = collection["healthy"] as? Bool else { + return nil + } + + let providerStates = ((collection["providers"] as? [String: Any]) ?? [:]) + .compactMap { name, value -> TimeQuotaSnapshot.ProviderState? in + guard let state = value as? String, !state.isEmpty else { return nil } + return TimeQuotaSnapshot.ProviderState(name: name, state: state) + } + .sorted { $0.name < $1.name } + + var window: TimeQuotaSnapshot.Window? + if let raw = root["window"] as? [String: Any], + let provider = raw["provider"] as? String, !provider.isEmpty, + let usedPercent = (raw["usedPercent"] as? NSNumber)?.doubleValue, + usedPercent.isFinite { + window = TimeQuotaSnapshot.Window( + provider: provider, + usedPercent: usedPercent, + resetsAt: date(raw["resetsAt"]) + ) + } + + let topBurn = ((root["topBurn"] as? [[String: Any]]) ?? []) + .compactMap { raw -> TimeQuotaSnapshot.BurnRow? in + guard let remote = raw["remote"] as? String, !remote.isEmpty, + let percent = (raw["percent"] as? NSNumber)?.doubleValue, + percent.isFinite else { return nil } + return TimeQuotaSnapshot.BurnRow( + remote: remote, + percent: percent, + lastActiveAt: date(raw["lastActiveAt"]) + ) + } + + return TimeQuotaSnapshot( + generatedAt: generatedAt, + collectionHealthy: healthy, + providerStates: providerStates, + window: window, + topBurn: topBurn + ) + } + + /// A future `generatedAt` is as untrustworthy as an old one -- a clock + /// jump between producer and consumer must read as "cannot tell", not as + /// maximally fresh. Same rule ScanModel applies to its own snapshot age. + static func isFresh(_ snapshot: TimeQuotaSnapshot, now: Date = Date()) -> Bool { + let age = now.timeIntervalSince(snapshot.generatedAt) + return age >= -60 && age <= staleAfter + } + + static func loadDisplayable(now: Date = Date()) -> TimeQuotaSnapshot? { + guard let data = try? SecureLocalFileIO.boundedRead( + from: quotaFileURL, + maximumBytes: maximumBytes, + requireCurrentOwner: true + ), let snapshot = parse(data), isFresh(snapshot, now: now) else { + return nil + } + return snapshot + } + + // Formatters are built per call: ISO8601DateFormatter is not Sendable, + // so a cached static is rejected by the strict-concurrency (release) + // build, and one quota.json carries about ten dates -- caching buys + // nothing worth the shared mutable state. + private static func date(_ value: Any?) -> Date? { + guard let text = value as? String else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let parsed = fractional.date(from: text) { return parsed } + return ISO8601DateFormatter().date(from: text) + } +} + +extension ScanModel { + func refreshTimeQuotaCard() { + Task { + timeQuotaSnapshot = await Task.detached(priority: .utility) { + TimeQuotaCardService.loadDisplayable() + }.value + } + } +} diff --git a/macos/Modore/Sources/Modore/Views/ScreeView.swift b/macos/Modore/Sources/Modore/Views/ScreeView.swift index a5ba02e..a91bab5 100644 --- a/macos/Modore/Sources/Modore/Views/ScreeView.swift +++ b/macos/Modore/Sources/Modore/Views/ScreeView.swift @@ -43,6 +43,10 @@ struct ScreePage: View { ) } + if let quota = model.timeQuotaSnapshot { + TimeQuotaSection(snapshot: quota) + } + if let report = model.screeReport { ScreeStoresSection(stores: report.stores) ScreeExpiringSection( @@ -55,6 +59,112 @@ struct ScreePage: View { } } .macSettingsFormStyle() + .task { + model.refreshTimeQuotaCard() + } + } +} + +/// TimeQuota's quota.json, read-only -- the deliberate boundary between the +/// two products (see TimeQuotaCardService). The section only exists while +/// the file is present, fresh, and parseable, so machines without TimeQuota +/// never see a trace of it. +private struct TimeQuotaSection: View { + let snapshot: TimeQuotaSnapshot + + var body: some View { + Section { + if !snapshot.collectionHealthy { + ScreeNoticeRow( + symbol: "exclamationmark.triangle", + title: "TimeQuota 수집이 끊겼습니다", + detail: "마지막 기록이 신선하지 않아 수치를 표시하지 않습니다. TimeQuota 쪽 수집 상태를 확인하세요.", + tint: Color.secondary + ) + } else { + if let window = snapshot.window { + HStack(spacing: 12) { + Image(systemName: "gauge.with.needle") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text("\(window.provider) 주간 사용량") + .font(.body.weight(.medium)) + if let resetsAt = window.resetsAt { + Text("리셋 \(resetsAt.formatted(date: .abbreviated, time: .shortened))") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer() + Text(Self.percentText(window.usedPercent)) + .font(.callout.weight(.semibold)) + .monospacedDigit() + } + .padding(.vertical, 2) + } + ForEach(snapshot.topBurn.prefix(3)) { row in + HStack(spacing: 12) { + Image(systemName: "flame") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(row.remote) + .font(.body.weight(.medium)) + .lineLimit(1) + if let lastActiveAt = row.lastActiveAt { + Text("마지막 활동 \(lastActiveAt.formatted(date: .abbreviated, time: .shortened))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Text(Self.percentText(row.percent)) + .font(.callout.weight(.medium)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.vertical, 2) + } + ForEach(inactiveProviders) { provider in + ScreeNoticeRow( + symbol: "clock.badge.questionmark", + title: "\(provider.name): \(Self.stateText(provider.state))", + detail: "이 공급자의 사용량은 아직 수집되지 않고 있습니다.", + tint: Color.secondary + ) + } + } + } header: { + NativeSectionHeader( + title: "AI 사용량 (TimeQuota)", + subtitle: "TimeQuota가 로컬에 기록한 quota.json을 읽기만 합니다. 소진 상위는 세션 메타데이터 집계이며 대화 내용은 관여하지 않습니다.", + value: snapshot.window.map { "\($0.provider) \(Self.percentText($0.usedPercent))" } ?? "" + ) + } + } + + /// Providers whose collection is not currently succeeding -- the healthy + /// ones are already represented by the numbers above. + private var inactiveProviders: [TimeQuotaSnapshot.ProviderState] { + snapshot.providerStates.filter { $0.state != "recent-success" } + } + + private static func percentText(_ value: Double) -> String { + value == value.rounded() + ? String(format: "%.0f%%", value) + : String(format: "%.1f%%", value) + } + + private static func stateText(_ state: String) -> String { + switch state { + case "never-attempted": return "수집 시작 전" + case "attempted-then-failed": return "수집 실패" + case "stale-success": return "마지막 성공이 오래됨" + default: return state + } } } diff --git a/macos/Modore/Tests/ModoreTests/TimeQuotaCardServiceTests.swift b/macos/Modore/Tests/ModoreTests/TimeQuotaCardServiceTests.swift new file mode 100644 index 0000000..8eb5345 --- /dev/null +++ b/macos/Modore/Tests/ModoreTests/TimeQuotaCardServiceTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import Modore + +final class TimeQuotaCardServiceTests: XCTestCase { + /// Byte-shape of the real quota.json observed on this machine (values + /// included), not an idealized fixture -- fractional-second ISO8601, + /// integer and fractional percents, a bare non-git "remote" name. + private let realShape = """ + { + "schemaVersion": 1, + "generatedAt": "2026-08-16T16:48:11.980Z", + "collection": { + "lastSampleAt": "2026-08-16T16:48:13.130Z", + "healthy": true, + "providers": { "codex": "recent-success", "claude": "never-attempted" } + }, + "window": { "provider": "codex", "usedPercent": 66, "resetsAt": "2026-08-20T05:33:04.000Z" }, + "topBurn": [ + { "remote": "github.com/heznpc/modore", "percent": 29, "lastActiveAt": "2026-08-16T16:08:48.446Z" }, + { "remote": "ren", "percent": 28.8, "lastActiveAt": "2026-08-16T16:01:48.213Z" } + ] + } + """ + + func testParsesTheRealFileShape() throws { + let snapshot = try XCTUnwrap(TimeQuotaCardService.parse(Data(realShape.utf8))) + + XCTAssertTrue(snapshot.collectionHealthy) + XCTAssertEqual(snapshot.window?.provider, "codex") + XCTAssertEqual(snapshot.window?.usedPercent, 66) + XCTAssertNotNil(snapshot.window?.resetsAt) + XCTAssertEqual(snapshot.topBurn.map(\.remote), ["github.com/heznpc/modore", "ren"]) + XCTAssertEqual(snapshot.topBurn[1].percent, 28.8, accuracy: 0.001) + XCTAssertEqual( + snapshot.providerStates.map { "\($0.name)=\($0.state)" }, + ["claude=never-attempted", "codex=recent-success"] + ) + } + + // Fields could mean something else under a future contract; the agreed + // failure mode is a hidden card, not a guessed rendering. + func testRejectsAnUnknownSchemaVersion() { + let bumped = realShape.replacingOccurrences(of: "\"schemaVersion\": 1", with: "\"schemaVersion\": 2") + + XCTAssertNil(TimeQuotaCardService.parse(Data(bumped.utf8))) + } + + func testFreshnessWindowHidesADeadProducer() throws { + let snapshot = try XCTUnwrap(TimeQuotaCardService.parse(Data(realShape.utf8))) + let generatedAt = snapshot.generatedAt + + XCTAssertTrue(TimeQuotaCardService.isFresh(snapshot, now: generatedAt.addingTimeInterval(59 * 60))) + XCTAssertFalse(TimeQuotaCardService.isFresh(snapshot, now: generatedAt.addingTimeInterval(61 * 60))) + } + + // A producer timestamp meaningfully in the future is a clock jump, and + // "cannot tell" must not read as maximally fresh. + func testFutureGeneratedAtIsNotFresh() throws { + let snapshot = try XCTUnwrap(TimeQuotaCardService.parse(Data(realShape.utf8))) + + XCTAssertFalse(TimeQuotaCardService.isFresh(snapshot, now: snapshot.generatedAt.addingTimeInterval(-120))) + XCTAssertTrue(TimeQuotaCardService.isFresh(snapshot, now: snapshot.generatedAt.addingTimeInterval(-30))) + } + + func testUnhealthyCollectionParsesWithTheFlagDown() throws { + let unhealthy = realShape.replacingOccurrences(of: "\"healthy\": true", with: "\"healthy\": false") + + let snapshot = try XCTUnwrap(TimeQuotaCardService.parse(Data(unhealthy.utf8))) + + XCTAssertFalse(snapshot.collectionHealthy) + } + + func testMalformedBurnRowsAreDroppedNotZeroFilled() throws { + let broken = realShape.replacingOccurrences( + of: "{ \"remote\": \"ren\", \"percent\": 28.8, \"lastActiveAt\": \"2026-08-16T16:01:48.213Z\" }", + with: "{ \"percent\": 12.0 }" + ) + + let snapshot = try XCTUnwrap(TimeQuotaCardService.parse(Data(broken.utf8))) + + XCTAssertEqual(snapshot.topBurn.map(\.remote), ["github.com/heznpc/modore"]) + } + + func testMissingWindowStillParses() throws { + let windowless = realShape.replacingOccurrences( + of: "\"window\": { \"provider\": \"codex\", \"usedPercent\": 66, \"resetsAt\": \"2026-08-20T05:33:04.000Z\" },", + with: "" + ) + + let snapshot = try XCTUnwrap(TimeQuotaCardService.parse(Data(windowless.utf8))) + + XCTAssertNil(snapshot.window) + XCTAssertFalse(snapshot.topBurn.isEmpty) + } +} diff --git a/scripts/mcp_server.py b/scripts/mcp_server.py index 080be93..ac296d7 100644 --- a/scripts/mcp_server.py +++ b/scripts/mcp_server.py @@ -530,7 +530,7 @@ def _enum_arg(args: dict, name: str, allowed: tuple, default): TOOLS: list[dict] = [ { - "name": "scree_report", + "name": "agent_state_report", "title": "Scree — session & residue judgment", "description": ( "What the AI agents on this Mac left behind, judged deterministically and " @@ -564,7 +564,7 @@ def _enum_arg(args: dict, name: str, allowed: tuple, default): "handler": tool_scree_report, }, { - "name": "friction_scan", + "name": "operator_friction_report", "title": "Friction — where the operator pushed back", "description": ( "Turns in this machine's local AI session transcripts where the operator " @@ -599,7 +599,7 @@ def _enum_arg(args: dict, name: str, allowed: tuple, default): "handler": tool_friction_scan, }, { - "name": "hf_orphans", + "name": "model_residue_report", "title": "Hugging Face cache — models nothing here names", "description": ( "Which models in this machine's Hugging Face hub cache are referenced by no " @@ -665,7 +665,7 @@ def _enum_arg(args: dict, name: str, allowed: tuple, default): "handler": tool_mcp_hygiene, }, { - "name": "file_access", + "name": "agent_file_access", "title": "File access — which sessions touched which paths", "description": ( "Reverse index over local Claude Code and Codex transcripts: for each path, " @@ -765,8 +765,8 @@ def _enum_arg(args: dict, name: str, allowed: tuple, default): # refactor -- is unreachable rather than merely unlisted. Failing closed is the # point: "we simply never wrote a destructive tool" is an intention, and this # turns it into a mechanism. -EXPOSED_TOOL_NAMES = frozenset({"scree_report", "friction_scan", "hf_orphans", - "mcp_hygiene", "file_access", "system_scan_summary", +EXPOSED_TOOL_NAMES = frozenset({"agent_state_report", "operator_friction_report", "model_residue_report", + "mcp_hygiene", "agent_file_access", "system_scan_summary", "uninstall_residue_report"}) diff --git a/scripts/scree.py b/scripts/scree.py index 68cc266..a8db598 100644 --- a/scripts/scree.py +++ b/scripts/scree.py @@ -1283,6 +1283,30 @@ def build_bindings(home: Path, workspace: str, *, repo_url: Optional[str] = None ) +# The frozen wire contract for one workspace's snapshot. Version 1. +# +# Everything a consumer outside this repository (Zoint first) may rely +# on. Three semantic limits are part of the contract, not commentary: +# +# 1. `coverage: "complete"` means every candidate binding was +# conclusively classified -- decided by authoritative metadata or +# read to EOF and found not to mention the workspace. It does NOT +# mean the machine was completely observed. +# 2. `storeFingerprint` identifies the session-store state that was +# observed at snapshot time, for same-or-changed comparison. It is +# not a provenance proof of anything inside the stores. +# 3. A snapshot is historical evidence, never destructive authorization. +# Anything acting on it must have Modore revalidate at the moment of +# action -- the `revalidate` hook on the archive path exists for +# exactly this. +# +# Additive changes (new optional keys) do not bump the version; +# renaming, removing, or changing the meaning of any key listed here +# does. +SNAPSHOT_SCHEMA = "modore.agent-state-snapshot" +SNAPSHOT_SCHEMA_VERSION = 1 + + def _binding_result(workspace, repo_url, deep, bindings, fingerprint, claude_complete, codex_complete, gemini_complete, forks_complete, unbound, scanned_fully) -> dict: @@ -1290,6 +1314,9 @@ def _binding_result(workspace, repo_url, deep, bindings, fingerprint, produced it -- single or batch. Kept in one place so the two cannot drift into reporting coverage differently.""" return { + "schema": SNAPSHOT_SCHEMA, + "schemaVersion": SNAPSHOT_SCHEMA_VERSION, + "generatedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "contract": ("session ids, evidence types, and sizes only; transcript " "content is read for file-access evidence but never retained"), "workspace": workspace, diff --git a/tests/fixtures/agent-state-snapshot-v1.json b/tests/fixtures/agent-state-snapshot-v1.json new file mode 100644 index 0000000..9b1fc9f --- /dev/null +++ b/tests/fixtures/agent-state-snapshot-v1.json @@ -0,0 +1,68 @@ +{ + "schema": "modore.agent-state-snapshot", + "schemaVersion": 1, + "generatedAt": "2026-08-21T00:00:00+0000", + "contract": "session ids, evidence types, and sizes only; transcript content is read for file-access evidence but never retained", + "workspace": "/Users/example/projects/flowship", + "repoUrl": "github.com/example/flowship", + "deep": true, + "coverage": "complete", + "coverageDetail": { + "claude": "complete", + "codex": "complete", + "gemini": "complete", + "editors": "complete", + "unboundStores": [] + }, + "assessed": true, + "storeFingerprint": { + "digest": "c2196e4950bbc3aff014a67d825d901342016637393124ae1ea231167b9fa660", + "fileCount": 2 + }, + "bindings": [ + { + "provider": "claude", + "sessionId": "sess-main", + "source": "/Users/example/.claude/projects/-slug-flowship/sess-main.jsonl", + "subtranscripts": [ + "/Users/example/.claude/projects/-slug-flowship/sess-main/subagents/agent-0.jsonl" + ], + "evidence": [ + "working-directory" + ], + "confidence": "medium", + "sizeBytes": 110 + }, + { + "provider": "codex", + "sessionId": "codex-a", + "source": "/Users/example/.codex/sessions/rollout-a.jsonl", + "subtranscripts": [], + "evidence": [ + "remote-url", + "working-directory" + ], + "confidence": "high", + "sizeBytes": 224 + } + ], + "summary": { + "total": 2, + "byProvider": { + "claude": 1, + "codex": 1, + "gemini": 0, + "antigravity": 0, + "cursor": 0, + "kiro": 0, + "vscode": 0, + "windsurf": 0 + }, + "byConfidence": { + "high": 1, + "medium": 1, + "low": 0 + }, + "sizeBytes": 334 + } +} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index e69fb20..aa1fd22 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -24,9 +24,9 @@ def _payload(result: dict) -> dict: # --------------------------------------------------------------------------- def test_only_the_read_only_judgment_tools_are_exposed(): - assert sorted(mcp_server.HANDLERS) == ["file_access", "friction_scan", - "hf_orphans", "mcp_hygiene", - "scree_report", "system_scan_summary", + assert sorted(mcp_server.HANDLERS) == ["agent_file_access", "agent_state_report", + "mcp_hygiene", "model_residue_report", + "operator_friction_report", "system_scan_summary", "uninstall_residue_report"] @@ -92,11 +92,11 @@ def test_the_read_only_contract_is_enforced_where_tools_are_registered(): assert mcp_server.REJECTED_TOOLS == [] assert {t["name"] for t in mcp_server.REGISTERED_TOOLS} == mcp_server.EXPOSED_TOOL_NAMES - forgot_annotation = {"name": "scree_report", + forgot_annotation = {"name": "agent_state_report", "annotations": {"destructiveHint": False}} assert not mcp_server.contract_allows(forgot_annotation) - destructive = {"name": "scree_report", + destructive = {"name": "agent_state_report", "annotations": {"readOnlyHint": True, "destructiveHint": True}} assert not mcp_server.contract_allows(destructive) @@ -208,7 +208,7 @@ def fake(script, arguments, timeout): def test_scree_report_forwards_the_scripts_own_verdicts(stub_scripts): - payload = _payload(_call("scree_report", {"section": "all", "limit": 10})) + payload = _payload(_call("agent_state_report", {"section": "all", "limit": 10})) assert payload["summary"]["contract"] == _SCREE_FIXTURE["contract"] assert payload["summary"]["groups_orphan"] == 1 assert payload["summary"]["worktrees_protected"] == 2 @@ -217,20 +217,20 @@ def test_scree_report_forwards_the_scripts_own_verdicts(stub_scripts): def test_scree_report_orders_sole_copy_worktrees_first(stub_scripts): - payload = _payload(_call("scree_report", {"section": "worktrees", "limit": 1})) + payload = _payload(_call("agent_state_report", {"section": "worktrees", "limit": 1})) assert payload["worktrees"]["items"][0]["verdict"] == "protected" assert payload["worktrees"]["truncated"] is True assert payload["worktrees"]["omitted"] == 2 def test_scree_summary_section_is_counts_only(stub_scripts): - payload = _payload(_call("scree_report", {"section": "summary"})) + payload = _payload(_call("agent_state_report", {"section": "summary"})) assert "groups" not in payload and "worktrees" not in payload assert payload["groups_total"] == 2 def test_friction_scan_passes_filters_through_and_applies_the_rest_locally(stub_scripts): - payload = _payload(_call("friction_scan", { + payload = _payload(_call("operator_friction_report", { "since_days": 14, "source": "codex", "max_sessions": 50, "min_severity": 3, "limit": 10})) assert stub_scripts == [("friction.py", [ @@ -242,18 +242,18 @@ def test_friction_scan_passes_filters_through_and_applies_the_rest_locally(stub_ def test_friction_findings_come_back_newest_first(stub_scripts): - payload = _payload(_call("friction_scan", {"min_severity": 1})) + payload = _payload(_call("operator_friction_report", {"min_severity": 1})) assert [f["quote"] for f in payload["findings"]] == ["new nudge", "old rage"] def test_category_filter_is_applied(stub_scripts): - payload = _payload(_call("friction_scan", {"category": "verbosity"})) + payload = _payload(_call("operator_friction_report", {"category": "verbosity"})) assert payload["matched_findings"] == 1 assert payload["findings"][0]["category"] == "verbosity" def test_truncation_is_always_accounted_for(stub_scripts): - payload = _payload(_call("friction_scan", {"limit": 1, "min_severity": 1})) + payload = _payload(_call("operator_friction_report", {"limit": 1, "min_severity": 1})) assert payload["returned"] == 1 assert payload["total"] == 2 assert payload["truncated"] is True @@ -284,7 +284,7 @@ def test_every_tool_result_is_fenced_as_untrusted(stub_scripts, tmp_path, monkey monkeypatch.setenv("PCH_SCAN", str(tmp_path / "absent.json")) monkeypatch.setattr(mcp_server, "PROJECT_ROOT", tmp_path) monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) - for name, arguments in (("scree_report", {}), ("friction_scan", {}), + for name, arguments in (("agent_state_report", {}), ("operator_friction_report", {}), ("system_scan_summary", {}), ("uninstall_residue_report", {})): text = _call(name, arguments)["content"][0]["text"] assert text.startswith(mcp_server.UNTRUSTED_OPEN), name @@ -302,14 +302,14 @@ def test_server_instructions_state_the_read_only_contract(): # --------------------------------------------------------------------------- @pytest.mark.parametrize("tool,arguments", [ - ("friction_scan", {"since_days": 0}), - ("friction_scan", {"since_days": 9000}), - ("friction_scan", {"min_severity": 4}), - ("friction_scan", {"source": "notascan"}), - ("friction_scan", {"limit": 1.5}), - ("friction_scan", {"limit": True}), - ("scree_report", {"section": "everything"}), - ("scree_report", {"limit": 0}), + ("operator_friction_report", {"since_days": 0}), + ("operator_friction_report", {"since_days": 9000}), + ("operator_friction_report", {"min_severity": 4}), + ("operator_friction_report", {"source": "notascan"}), + ("operator_friction_report", {"limit": 1.5}), + ("operator_friction_report", {"limit": True}), + ("agent_state_report", {"section": "everything"}), + ("agent_state_report", {"limit": 0}), ("system_scan_summary", {"limit": 101}), ("uninstall_residue_report", {"section": "everything"}), ("uninstall_residue_report", {"limit": 201}), @@ -334,9 +334,9 @@ def test_initialize_echoes_a_supported_version_and_falls_back_otherwise(): def test_tools_list_declares_closed_input_schemas(): tools = mcp_server.handle_request("tools/list", {})["tools"] - assert [t["name"] for t in tools] == ["scree_report", "friction_scan", - "hf_orphans", "mcp_hygiene", - "file_access", "system_scan_summary", + assert [t["name"] for t in tools] == ["agent_state_report", "operator_friction_report", + "model_residue_report", "mcp_hygiene", + "agent_file_access", "system_scan_summary", "uninstall_residue_report"] for tool in tools: assert tool["inputSchema"]["additionalProperties"] is False @@ -357,10 +357,10 @@ def test_unknown_method_and_unknown_tool_are_protocol_errors(): def test_a_handler_crash_becomes_an_internal_error_not_a_dead_server(monkeypatch): - monkeypatch.setitem(mcp_server.HANDLERS, "scree_report", + monkeypatch.setitem(mcp_server.HANDLERS, "agent_state_report", lambda args: (_ for _ in ()).throw(RuntimeError("boom"))) response = mcp_server.dispatch({"jsonrpc": "2.0", "id": 3, "method": "tools/call", - "params": {"name": "scree_report", "arguments": {}}}) + "params": {"name": "agent_state_report", "arguments": {}}}) assert response["error"]["code"] == mcp_server.INTERNAL_ERROR assert "boom" in response["error"]["message"] @@ -385,9 +385,9 @@ def test_serve_handles_a_full_session_including_malformed_input(): def test_cli_tools_dump_is_the_registered_surface(capsys): assert mcp_server.main(["--tools"]) == 0 dumped = json.loads(capsys.readouterr().out) - assert [t["name"] for t in dumped["exposed"]] == ["scree_report", "friction_scan", - "hf_orphans", "mcp_hygiene", - "file_access", "system_scan_summary", + assert [t["name"] for t in dumped["exposed"]] == ["agent_state_report", "operator_friction_report", + "model_residue_report", "mcp_hygiene", + "agent_file_access", "system_scan_summary", "uninstall_residue_report"] assert dumped["rejected"] == [] @@ -403,11 +403,11 @@ def test_cli_rejects_unknown_arguments(capsys): def test_a_search_root_cannot_be_smuggled_in_as_an_option(): """`roots`는 이 표면에서 호출자가 경로를 넘기는 유일한 자리다.""" for bad in (["--home"], ["-x"], [""], "not-a-list", [1]): - result = _call("hf_orphans", {"roots": bad}) + result = _call("model_residue_report", {"roots": bad}) assert result.get("isError"), bad too_many = [f"/tmp/r{i}" for i in range(mcp_server.MAX_HF_ROOTS + 1)] - assert _call("hf_orphans", {"roots": too_many}).get("isError") + assert _call("model_residue_report", {"roots": too_many}).get("isError") def test_hf_orphans_surfaces_a_withheld_verdict_at_the_top_level(monkeypatch): @@ -423,7 +423,7 @@ def test_hf_orphans_surfaces_a_withheld_verdict_at_the_top_level(monkeypatch): } monkeypatch.setattr(mcp_server, "_run_json", lambda script, arguments, timeout: incomplete) - payload = _payload(_call("hf_orphans", {})) + payload = _payload(_call("model_residue_report", {})) assert payload["search_complete"] is False assert payload["verdicts_withheld"] is True assert payload["models"][0]["verdict"] == "unknown" @@ -474,17 +474,17 @@ def fake_run(script, arguments, timeout): monkeypatch.setattr(mcp_server, "_run_json", fake_run) - payload = _payload(_call("file_access", {})) + payload = _payload(_call("agent_file_access", {})) assert payload["filters"]["rule_surfaces_only"] is True assert "--all" not in captured["argv"] assert payload["paths"][0]["path"] == "~/.claude/settings.json" for key in ("detail", "command", "cmd"): assert key not in payload["paths"][0] - _call("file_access", {"include_all": True, "query": "settings"}) + _call("agent_file_access", {"include_all": True, "query": "settings"}) assert "--all" in captured["argv"] assert "--query" in captured["argv"] and "settings" in captured["argv"] def test_file_access_rejects_a_non_string_query(): - assert _call("file_access", {"query": 5}).get("isError") + assert _call("agent_file_access", {"query": 5}).get("isError") diff --git a/tests/test_scree.py b/tests/test_scree.py index 17ab3fc..b52e1d5 100644 --- a/tests/test_scree.py +++ b/tests/test_scree.py @@ -1354,3 +1354,59 @@ def test_bind_all_writes_to_a_file_when_asked(bind_home, tmp_path, capsys): payload = json.loads(out.read_text(encoding="utf-8")) assert str(bind_home["repo"]) in payload["results"] + + +# --- v1 스냅샷 계약 (외부 소비자 표면) --------------------------------------- + + +def _fixture_snapshot(): + fixture = Path(__file__).resolve().parent / "fixtures" / "agent-state-snapshot-v1.json" + return json.loads(fixture.read_text(encoding="utf-8")) + + +def test_snapshot_carries_its_schema_identity(bind_home): + """소비자는 이 두 필드로 무엇을 받았는지 판별한다. 없으면 '어떤 버전인지 + 모른 채 best-effort 파싱'이 되고, 그게 계약을 두는 이유의 정반대다.""" + out = scree.build_bindings(bind_home["home"], str(bind_home["repo"])) + assert out["schema"] == "modore.agent-state-snapshot" + assert out["schemaVersion"] == 1 + assert out["generatedAt"] + + +def test_snapshot_v1_frozen_keys_are_all_present(bind_home): + """v1이 약속한 키의 부재는 소비자 쪽에서 조용한 오독으로 나타난다. + 여기서 깨지는 편이 낫다.""" + out = scree.build_bindings(bind_home["home"], str(bind_home["repo"]), deep=True) + frozen = {"schema", "schemaVersion", "generatedAt", "workspace", "repoUrl", + "deep", "coverage", "coverageDetail", "assessed", + "storeFingerprint", "bindings", "summary"} + assert frozen <= set(out) + assert out["coverage"] in ("shallow", "truncated", "complete") + assert set(out["storeFingerprint"]) == {"digest", "fileCount"} + + +def test_snapshot_fixture_matches_what_the_code_emits(bind_home): + """픽스처는 실행 결과에서 생성했고 소비자 레포가 복사해 간다. 코드가 내는 + 모양과 픽스처가 갈라지면 소비자 테스트는 존재하지 않는 형식을 통과한다.""" + fixture = _fixture_snapshot() + live = scree.build_bindings(bind_home["home"], str(bind_home["repo"]), deep=True) + assert set(fixture) == set(live), "top-level keys must not drift from the fixture" + assert fixture["schema"] == live["schema"] + assert fixture["schemaVersion"] == live["schemaVersion"] + required = {"provider", "sessionId", "source", "subtranscripts", + "evidence", "confidence", "sizeBytes"} + for binding in fixture["bindings"]: + # `artifactRoot` is optional by contract: only a binder that knows + # its store's layout states one (editors do; the agent stores rely + # on the sealer's beside-the-transcript fallback). + assert required <= set(binding) + assert set(binding) - required <= {"artifactRoot"} + + +def test_fixture_contains_no_real_local_paths(): + """release_smoke의 local-user-path 감사와 같은 이유: 픽스처는 배포 소스다.""" + raw = (Path(__file__).resolve().parent / "fixtures" + / "agent-state-snapshot-v1.json").read_text(encoding="utf-8") + assert "/Users/example/" in raw + assert "/Users/ren" not in raw + assert "/var/folders" not in raw and "/tmp/" not in raw diff --git a/vendor/mothball/Sources/MothballCore/Models/BindReport.swift b/vendor/mothball/Sources/MothballCore/Models/BindReport.swift index dec57ca..ecd68ff 100644 --- a/vendor/mothball/Sources/MothballCore/Models/BindReport.swift +++ b/vendor/mothball/Sources/MothballCore/Models/BindReport.swift @@ -12,6 +12,12 @@ import Foundation /// cannot read as a completed assessment becomes `.notAssessed`, which /// blocks. public struct BindReport: Codable, Sendable, Equatable { + /// Contract identity, from `modore.agent-state-snapshot` v1. Absent + /// on output from a binder older than the contract; those decode as + /// version 1, which is what they are. + public let schema: String? + public let schemaVersion: Int? + public let workspace: String public let repoUrl: String? public let assessed: Bool @@ -91,6 +97,14 @@ extension ContinuityAssessment { report.assessed else { return .notAssessed } + // The contract's own versioning rule: a consumer receiving a + // schemaVersion greater than it knows must treat the snapshot as + // unreadable, not best-effort parse it -- the fields it + // recognises may no longer mean what they meant. Absent means + // pre-contract output, which is version 1 in fact. + if let version = report.schemaVersion, version > 1 { + return .notAssessed + } let bindings = report.bindings.compactMap(SessionBinding.init(entry:)) // A binder that returned entries none of which survive decoding // has not established "no sessions" — it has produced something diff --git a/vendor/mothball/Tests/MothballCoreTests/BindReportTests.swift b/vendor/mothball/Tests/MothballCoreTests/BindReportTests.swift index 6784c00..04be47f 100644 --- a/vendor/mothball/Tests/MothballCoreTests/BindReportTests.swift +++ b/vendor/mothball/Tests/MothballCoreTests/BindReportTests.swift @@ -155,4 +155,29 @@ final class BindReportTests: XCTestCase { .block(.unsealedSessions(count: 1)) ) } + /// The contract's versioning rule, enforced on the consumer side: a + /// snapshot from a future schema is unreadable, not best-effort + /// parseable -- the fields this build recognises may no longer mean + /// what they meant when it was written. + func test_aFutureSchemaVersionIsUnreadableNotBestEffort() { + let json = """ + {"schema":"modore.agent-state-snapshot","schemaVersion":2, + "workspace":"/w","repoUrl":null,"assessed":true,"deep":true, + "coverage":"complete","bindings":[]} + """ + guard case .notAssessed = ContinuityAssessment.fromBindReport(data(json)) else { + return XCTFail("a v2 snapshot must not be parsed by v1 rules") + } + } + + /// Pre-contract output has no schema fields and is version 1 in fact. + func test_absentSchemaFieldsDecodeAsVersionOne() { + let json = """ + {"workspace":"/w","repoUrl":null,"assessed":true,"deep":true, + "coverage":"complete","bindings":[]} + """ + guard case .assessedNoSessions = ContinuityAssessment.fromBindReport(data(json)) else { + return XCTFail("pre-contract output must keep decoding") + } + } }