Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# 스캔 결과물 (사용자 PC 정보 포함 - 커밋 금지)
scan_result.json
/recovery-history.json
monitor_result.json
raw_facts.json
검사결과.html
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ All notable changes to this project are documented here. Format loosely follows
- **Project build-residue detection (Mac Edition).** The storage scan finds rebuildable build artifacts *inside* development projects — Flutter `build/`+`.dart_tool/`, `node_modules/`, Cargo `target/`, SwiftPM `.build/`, `Pods/`, Gradle `build/`+`.gradle/` — under common dev roots (override with `PCH_PROJECT_SCAN_ROOTS`). These directories are gitignored and lumped into "Documents" by macOS, so multi-GB residue is otherwise hard to attribute. Rows keep the official regeneration command (`flutter clean`, `cargo clean`, …); the path-bound approval recipe described above now provides the guarded execution path without turning an arbitrary path into a recipe ID. Sub-4MB residue is skipped as noise.

### Fixed
- **Codex task lookup no longer turns into a minute-long transcript sweep.** `modore sessions current` now resolves the calling task from Codex process context, validates matching owner-only JSONL headers, and returns its physical rollout fragments without reading conversation bodies. The session index groups restart fragments by the per-task `id` while keeping spawned subagents separate from their inherited root `session_id`; logical and physical totals are reported independently, search scans every fragment with duplicate events collapsed, and `search --first` provides an explicitly incomplete fast path when exhaustive absence proof is not wanted. The Mac Work surface keeps the logical identity stable and labels multi-fragment conversations.
- **The retention forecast urged rescuing conversations their owner had already thrown away.** Deleting a conversation in the Claude desktop app rewrites only that app's own index — it removes the `local_<id>.json` entry and drops a `deleted_<id>` tombstone — and never touches the transcript. scree reads transcripts, so a deleted conversation looked like any other live session and its D-day flag read as "act before this is gone", arguing with a decision the owner had already made. The tombstones are now read as the one record on disk of that decision (filenames only; the body is a delete timestamp and is never opened, so this stays inside the metadata-only contract, pinned by a test that makes the tombstone unreadable and asserts the verdict is unchanged). Expiring sessions carry `owner_deleted`, sort below the ones still wanted so the head of the list is only ever actionable, and the text report names the third count beside "alive workspaces" and "orphaned". The Mac app reads the same flag and labels the row `앱에서 삭제됨 · 트랜스크립트 잔존` rather than reporting whether the workspace still exists, which is not the salient fact for a conversation already discarded; a scree that does not emit the key can never make the app claim a deletion happened. Nothing is hidden and nothing is deleted — the preserve action stays available, because whether to keep a discarded transcript is the owner's call. First live run: 23 of 80 expiring sessions (29%, 15.3 MB) had already been deleted in the app, including one of the five the report put in front of the user.
- **A Claude project directory whose path holds a non-ASCII component resolved to its parent instead of being left unresolved.** Claude names a project directory by replacing every non-alphanumeric character of the cwd -- not only the separators -- with a hyphen, so `/Users/x/집필` becomes `-Users-x---` and differs from `/Users/x` by hyphens alone. `_decode_claude_project_dir` segmented that name and tested each candidate with `(current / segment).exists()`, but `Path(x) / ""` is `x`, so the empty segments left by the collapsed characters each reported that they existed and the walk stopped early — returning `/Users/x`, a live workspace, as the confident answer for a directory that is gone. Sessions with no `cwd` in their leading lines, and every nested transcript attributed through the project fallback, were filed under the wrong workspace with nothing marking the guess. Resolution now matches on the encoded form the way the app does: a component that encodes to itself is still tried by name (one `stat()`, and it covers every plain ASCII path), only a level where no name matches is enumerated, a bucket that resolves to more than one directory is left unresolved rather than picked from, and the result must re-encode to the bucket it came from. On the development machine all eight live buckets round-trip exactly, three vanished ones are now unresolved instead of claiming an ancestor, one bucket under a symlinked path that previously failed to resolve at all now resolves, and two Korean directories of equal length are correctly reported as ambiguous. Eleven buckets resolve in 22 ms.
- **friction counted turns the operator never typed, and counted the ones they did more than once.** Two defects in the same question — what is one operator turn. (1) A `/compact` continuation is stored as `type: "user"`, but its body is the assistant's summary of the conversation so far. `NOISE_PREFIXES` does not match it and the pasted-report heuristic keys on 합니다체, which the English summary opens without, so the whole summary was read as operator text — inventing pushback nobody expressed and, worse, emitting assistant prose inside a quote attributed to the operator, against the store's own content contract. On the development machine one such row was a 10,886-character summary quoted as `stalling-approval` at severity 2, matching `라니까` inside the assistant's own recap. Rows are now skipped on `isCompactSummary` / `isVisibleInTranscriptOnly` rather than on the summary's opening sentence, which is English and would miss any localised build. (2) Resuming or forking a Claude session writes a fresh `.jsonl` replaying the whole prior conversation — every message keeps its `uuid` and `timestamp`, only `sessionId` changes — so a turn typed once sat on disk once per resume and was counted once per copy; one podium conversation held eleven copies of the same turn. Findings are now deduplicated across the store on (source, timestamp, quote), the same rule the Gemini parser already applies to `$set` rewrites, widened from within one file to across it; the newest-active copy survives, a turn with no timestamp is kept rather than guessed at, and the collapsed count is reported rather than quietly applied. First live run after the fix: 54 findings → 42, with the `stalling-approval` false positive gone and 30 replayed copies collapsed.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,22 @@ not currently install this command. It owns AI-session continuity and storage
recovery only; it does not route Git, PR, or multi-repository work.

```bash
modore sessions current
modore sessions --limit 50
printf '%s' 'exact phrase' | modore search --first
modore storage status
modore cleanup list
modore storage recovery
```

`sessions current` resolves the calling Codex task from its process context and
validates the matching JSONL metadata. It does not search conversation bodies.
Codex rollout fragments with the same provider session ID are listed as one
logical session while their physical sources remain available for backup and
inspection.
`search --first` stops at the newest matching turn; ordinary `search` keeps its
full coverage contract for absence claims.

### Windows

Clone the repository or download its source archive, then run `scan.bat`. See [Installation](#installation) for requirements and troubleshooting.
Expand Down
23 changes: 18 additions & 5 deletions bin/modore
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ usage() {
/usr/bin/printf '%s\n' \
'Usage:' \
' modore sessions [--limit N]' \
' modore search [--limit N] [--budget-seconds N] # query on stdin' \
' modore sessions current' \
' modore search [--first] [--limit N] [--budget-seconds N] # query on stdin' \
' modore storage status' \
' modore storage recovery' \
' modore cleanup list' \
Expand Down Expand Up @@ -86,6 +87,12 @@ case "$COMMAND" in
usage
;;
sessions)
if [[ "${1:-}" == "current" ]]; then
[[ "$#" -eq 1 ]] \
|| fail_usage 'sessions current accepts no additional arguments.'
exec "$PYTHON" -I -B "$BOUNDED_EXEC" 5 -- \
"$PYTHON" -I -B "$SCREE" sessions current
fi
SESSION_LIMIT=50
while [[ "$#" -gt 0 ]]; do
case "$1" in
Expand All @@ -97,7 +104,7 @@ case "$COMMAND" in
SESSION_LIMIT="$2"
shift 2
;;
*) fail_usage 'sessions accepts only --limit.' ;;
*) fail_usage 'sessions accepts current or --limit.' ;;
esac
done
exec "$PYTHON" -I -B "$BOUNDED_EXEC" 30 -- \
Expand All @@ -122,12 +129,18 @@ case "$COMMAND" in
fi
shift 2
;;
*) fail_usage 'search accepts only --limit and --budget-seconds; send the query on stdin.' ;;
--first)
SEARCH_FIRST=1
shift
;;
*) fail_usage 'search accepts only --first, --limit and --budget-seconds; send the query on stdin.' ;;
esac
done
SEARCH_ARGS=(search --query-file /dev/stdin --limit "$SEARCH_LIMIT" \
--budget-seconds "$SEARCH_BUDGET_SECONDS")
[[ "${SEARCH_FIRST:-0}" == "0" ]] || SEARCH_ARGS+=(--first)
exec "$PYTHON" -I -B "$BOUNDED_EXEC" 60 -- \
"$PYTHON" -I -B "$SCREE" search --query-file /dev/stdin \
--limit "$SEARCH_LIMIT" --budget-seconds "$SEARCH_BUDGET_SECONDS"
"$PYTHON" -I -B "$SCREE" "${SEARCH_ARGS[@]}"
;;
storage)
SUBCOMMAND="${1:-}"
Expand Down
19 changes: 19 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,25 @@ store, including case variants and resolved symlink aliases.
- Child commands are spawned into a private process group and have bounded output/termination handling. Normal Cmd-Q termination is delayed while an approved destructive transaction is active so cleanup cannot become an unsupervised child.
- Directory traversal uses no-follow, descriptor-relative operations; symlinked or non-canonical targets are rejected at use time.
- A local receipt is written after execution.
- Project-residue process checks bound each cwd probe to about 2 seconds and stop starting probes after an 8-second pass budget (at most 64 candidates). An unknown cwd or exhausted budget blocks cleanup and is labeled as unverified, not as proven active use. Verdict and displayed evidence share one preview snapshot; approval-manifest and execution checks remain fresh.
- Preview diagnostics expose only allowlisted stage names, elapsed time and process termination metadata. Raw protocol output (including approval tokens) is not copied into logs. A failed, truncated, wrong-operation or wrong-recipe response cannot become an executable preview.

### Recovery accounting

- A recovery goal is the user's additional byte amount, not a hard-coded final free-space threshold. Approval takes a fresh volume reading and sets the final target to baseline plus that amount. The 20 GiB recommendation remains separate; retry after execution targets the remaining distance to the approved endpoint.
- Recovery arithmetic uses checked `Int64` bytes. Legacy scanner GiB estimates and `df`/`du` KiB readings convert at their boundaries; the recovery UI names binary units explicitly.
- Cleanup protocol version 1 gains additive `accountingVersion=2` and `estimatedBytes`, `reclaimedBytes`, and `physicalDeltaBytes` fields. Receipts also retain before/after available bytes. Legacy KB fields remain for older readers.
- Target occupancy reduction and whole-volume available-space net change are separate measurements. Volume changes remain signed, including decreases and measured zero; missing, malformed, or overflowing measurements are unknown. Unknown target occupancy never substitutes volume change.
- Byte fields are authoritative for accounting version 2. Older receipts convert KiB, but their clamped zero volume delta is unknown because the old writer conflated zero, decrease, and measurement failure. A missing final volume reading cannot prove goal attainment, even if an earlier reading succeeded.
- Plan-level history is stored separately from the old storage snapshots; it does not alter token expiry, target revalidation, or process-draining boundaries.

### Recovery plan history

- `RecoveryHistory` is a display-only record of one plan ID: byte goal and baseline, previewed paths and eligibility, approval time, per-item outcomes and receipt paths, final volume reading, and interruption state. Approval tokens are never serialized, and records cannot reconstruct executable plans.
- `recovery-history.json` lives in the private results root. `RecoveryHistoryStore` uses bounded owner-checked no-follow reads and atomic file/directory-synced writes. Unsupported or corrupt files are not overwritten. The first version caps storage at 200 plans / 8 MiB and refuses new writes at capacity instead of silently deleting history.
- Review, approval, before-item and after-item checkpoints are durable. Approval or pre-item write failure prevents execution; a failed result checkpoint prevents the next item. Cancellation/termination records whatever outcome is known, and the existing mutation marker still governs rescanning.
- On restart, approved/running records without a final checkpoint are shown as interrupted with unconfirmed results. Completed item records remain visible, but unrecorded receipts are not guessed or joined automatically. No historical plan is automatically resumed or re-approved.
- The Activity page presents the journal independently of scan snapshots. Existing receipts remain readable without inventing retroactive plan associations.

## Good contribution areas

Expand Down
59 changes: 39 additions & 20 deletions macos/Modore/Sources/Modore/Models/CleanupModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ struct CleanupPreview: Identifiable, Sendable {
let label: String
let estimatedKB: Int64
let estimateMeasured: Bool
let reclaimedKB: Int64
let physicalDeltaKB: Int64
let estimatedBytes: Int64?
let reclaimedBytes: Int64?
let physicalDeltaBytes: Int64?
let warning: String
let summary: String
let avoidWhen: String
Expand All @@ -35,8 +36,9 @@ struct CleanupPreview: Identifiable, Sendable {
label = payload.label
estimatedKB = payload.estimatedKB
estimateMeasured = payload.estimateMeasured
reclaimedKB = payload.reclaimedKB
physicalDeltaKB = payload.physicalDeltaKB
estimatedBytes = payload.estimatedBytes
reclaimedBytes = payload.reclaimedBytes
physicalDeltaBytes = payload.physicalDeltaBytes
warning = payload.warning
summary = payload.summary
avoidWhen = payload.avoidWhen
Expand Down Expand Up @@ -91,10 +93,10 @@ struct CleanupPreview: Identifiable, Sendable {
}

var estimatedText: String {
estimateMeasured ? Self.sizeText(estimatedKB) : "측정 보류"
estimateMeasured ? StorageBytes.text(estimatedBytes) : "측정 보류"
}
var reclaimedText: String { Self.sizeText(reclaimedKB) }
var physicalDeltaText: String { Self.sizeText(physicalDeltaKB) }
var reclaimedText: String { StorageBytes.text(reclaimedBytes) }
var physicalDeltaText: String { StorageBytes.changeText(physicalDeltaBytes) }

var statusText: String {
switch status {
Expand All @@ -107,15 +109,6 @@ struct CleanupPreview: Identifiable, Sendable {
}
}

private static func sizeText(_ value: Int64) -> String {
if value >= 1_048_576 {
return String(format: "%.1fGB", Double(value) / 1_048_576)
}
if value >= 1_024 {
return String(format: "%.1fMB", Double(value) / 1_024)
}
return "\(max(value, 0))KB"
}
}

private struct ParsedProtocolLines {
Expand All @@ -134,8 +127,9 @@ private struct CleanupProtocolPayload {
let label: String
let estimatedKB: Int64
let estimateMeasured: Bool
let reclaimedKB: Int64
let physicalDeltaKB: Int64
let estimatedBytes: Int64?
let reclaimedBytes: Int64?
let physicalDeltaBytes: Int64?
let warning: String
let summary: String
let avoidWhen: String
Expand Down Expand Up @@ -167,8 +161,9 @@ private struct CleanupProtocolPayload {
label: values["label"] ?? recipeID,
estimatedKB: integer(values["estimatedKB"]),
estimateMeasured: estimateMeasured(values, status: status),
reclaimedKB: integer(values["reclaimedKB"]),
physicalDeltaKB: integer(values["physicalDeltaKB"]),
estimatedBytes: byteValue(values, key: "estimated"),
reclaimedBytes: byteValue(values, key: "reclaimed"),
physicalDeltaBytes: byteValue(values, key: "physicalDelta", signed: true),
warning: values["warning"] ?? "",
// 구버전 런타임 미러에는 두 키가 없다. 설명이 비면 UI가 해당 줄을 숨긴다.
summary: values["description"] ?? "",
Expand Down Expand Up @@ -225,6 +220,30 @@ private struct CleanupProtocolPayload {
Int64(value ?? "0") ?? 0
}

private static func byteValue(_ values: [String: String], key: String, signed: Bool = false) -> Int64? {
let value: Int64?
if values["accountingVersion"] == "2" {
// An explicit empty/invalid measurement must not fall back to an
// old placeholder, nor may a missing value become a measured zero.
value = byteInteger(values[key + "Bytes"])
} else if values["accountingVersion"] == nil || values["accountingVersion"] == "1" {
let legacyValue = byteInteger(values[key + "KB"])
if signed && legacyValue == 0 { return nil }
value = StorageBytes.fromKiB(legacyValue)
} else {
value = nil
}
guard let value, signed || value >= 0 else { return nil }
return value
}

private static func byteInteger(_ text: String?) -> Int64? {
guard let text, !text.isEmpty, text.utf8.count <= 20 else { return nil }
let digits = text.hasPrefix("-") ? text.dropFirst() : text[...]
guard !digits.isEmpty, digits.utf8.allSatisfy({ (48...57).contains($0) }) else { return nil }
return Int64(text)
}

// 구버전 런타임 미러에는 estimateMeasured 키가 없다. 그 경우 차단 상태의
// estimatedKB 0은 측정값이 아니라 자리 표시 값이므로 미측정으로 해석한다.
private static func estimateMeasured(
Expand Down
Loading