From 6d180412ba93b2c37c984e384ee290ffb0c4c2d4 Mon Sep 17 00:00:00 2001 From: akihidem Date: Mon, 11 May 2026 23:54:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(deepwork):=20permissionMode=20=E5=88=A5?= =?UTF-8?q?=E3=83=90=E3=82=B1=E3=83=83=E3=83=88=E9=9B=86=E8=A8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ディープワーク累積を Claude Code の permissionMode(manual / auto / bypass) 別に分配する。「自分が判断していた時間」と「automode/bypass で AI に丸投げ していた時間」を分けて見たい、というユーザー要望が出発点。 主な変更: - DeepWorkAccumulator: バケット別 ms を date 毎に保持、feed(state, at, bucket) - observers/claude_code: JSONL から現行 permissionMode を正規化して取得 - watch: tick 毎にモード判定して accum に渡す。status 行に内訳とモードを表示 - advise: cap 判定は manual バケットのみで行い、auto/bypass は除外 - MCP deepwork resource: payload に manual/auto/bypass 内訳を同梱 永続化は schema=1 を維持し、byDate(合計 ms)に並走させる形で byDateBuckets(バケット別 ms)を追加。旧バージョンの cogsync は byDate のみ 読めば従来通り動くので、npm 公開済みクライアントを壊さない。 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/coach/advise.ts | 17 ++++- src/infer/work_state.ts | 114 +++++++++++++++++++++++++---- src/mcp/resources.ts | 44 +++++++++-- src/mcp/server.ts | 2 +- src/mcp/tools.ts | 20 ++++- src/observers/claude_code.ts | 34 ++++++++- src/state/store.ts | 14 +++- src/watch.ts | 22 ++++-- tests/deepwork-accumulator.test.ts | 64 ++++++++++++++++ tests/mcp-resources.test.ts | 43 ++++++++++- 10 files changed, 329 insertions(+), 45 deletions(-) create mode 100644 tests/deepwork-accumulator.test.ts diff --git a/src/coach/advise.ts b/src/coach/advise.ts index d9fd1bd..48ba653 100644 --- a/src/coach/advise.ts +++ b/src/coach/advise.ts @@ -37,7 +37,14 @@ export type AdviseInput = { workState: WorkState; /** ai_busy が継続している分数(active/idle 時は 0) */ aiBusyDurationMin: number; + /** 当日のディープワーク総分(manual + auto + bypass)。表示用。 */ deepWorkAccumMin: number; + /** + * 当日の manual バケット分。permissionMode=default 下での累積。 + * 「人間が判断していた時間」の近似で、cap 判定はこちらを使う。 + * 未指定なら deepWorkAccumMin にフォールバック(後方互換)。 + */ + deepWorkManualMin?: number; parallelCapacity: number; /** 設定: limit 警告閾値 */ limitWarnMin: number; @@ -88,14 +95,18 @@ export function advise(input: AdviseInput): Advice { } // 優先順位 3: ディープワーク日次上限到達 - if (input.deepWorkAccumMin >= input.dailyDeepWorkCapMin) { + // cap 判定は manual バケットのみで行う(auto/bypass は AI に丸投げの時間なので + // 認知負荷が低い前提)。manual が未指定なら従来通り total を使う。 + const capMin = input.deepWorkManualMin ?? input.deepWorkAccumMin; + if (capMin >= input.dailyDeepWorkCapMin) { return { action: "stop_for_today", - rationale: `今日のディープワーク累積 ${input.deepWorkAccumMin} 分が上限 ${input.dailyDeepWorkCapMin} 分に到達。これ以上は精度が落ちやすい。`, + rationale: `今日の判断系ディープワーク ${capMin} 分が上限 ${input.dailyDeepWorkCapMin} 分に到達。これ以上は精度が落ちやすい。`, confidence: 0.7, templateId: "deepwork_cap_reached", vars: { - accumulated_min: input.deepWorkAccumMin, + accumulated_min: capMin, + total_min: input.deepWorkAccumMin, daily_cap_min: input.dailyDeepWorkCapMin, }, }; diff --git a/src/infer/work_state.ts b/src/infer/work_state.ts index 5db25a3..bc84371 100644 --- a/src/infer/work_state.ts +++ b/src/infer/work_state.ts @@ -11,12 +11,17 @@ * ディープワーク累積: * - active と ai_busy の合計時間を分単位で集計(人間がエンゲージしている時間) * - 当日 (ローカルタイムゾーン) のみカウント + * - permissionMode 別に "manual" / "auto" / "bypass" の 3 バケットへ分配する。 + * manual = 通常 (default) モード、auto = acceptEdits / plan 系、bypass = bypassPermissions。 * * 注: ai_busy 中の長時間放置は「人間は別作業に出ている = ブレイク取得済み」と扱える * → CO-5 ブレイク提案の根拠になる。 */ export type WorkState = "ai_busy" | "active" | "idle"; +/** ディープワーク集計バケット。permissionMode のクラス分け。 */ +export type PermissionBucket = "manual" | "auto" | "bypass"; +export const ALL_BUCKETS: readonly PermissionBucket[] = ["manual", "auto", "bypass"]; export type WorkSnapshot = { state: WorkState; @@ -102,51 +107,130 @@ export function classifyWorkState( }; } +/** + * バケット別 ms。 + */ +export type DeepWorkBuckets = { manual: number; auto: number; bypass: number }; + +/** + * 永続化フォーマット。 + * - byDate : ms 合計(旧フィールド、互換のため常に書き出す) + * - byDateBuckets : バケット別 ms(新フィールド、書き出し時は常に同梱) + * 旧バージョンの cogsync は byDate のみを読み取る。新バージョンは byDateBuckets を + * 優先し、無ければ byDate を manual に寄せて取り込む。schema は 1 のまま据え置く。 + */ +export type DeepWorkPersisted = { + byDate: Record; + byDateBuckets?: Record; +}; + /** * ディープワーク累積追跡。 - * watch ループからの「snapshot 列」を順に受け、active/ai_busy 状態の時間を集計する。 + * watch ループからの「snapshot 列」を順に受け、active/ai_busy 状態の時間を + * permissionMode バケット別に集計する。 */ export class DeepWorkAccumulator { - private accumMsByDate = new Map(); // YYYY-MM-DD → ms + private accumByDate = new Map(); // YYYY-MM-DD → buckets ms private lastCheckAt: Date | null = null; private lastState: WorkState = "idle"; + private lastBucket: PermissionBucket = "manual"; /** * 新しい状態と時刻を受け取り、前回からの差分を「人間がエンゲージしていた時間」として累積する。 - * 前回が active or ai_busy のときだけ加算。 + * 前回が active or ai_busy のときだけ加算。差分は前回観測時点の bucket に分配する。 */ - feed(state: WorkState, at: Date = new Date()): void { + feed(state: WorkState, at: Date = new Date(), bucket: PermissionBucket = "manual"): void { if (this.lastCheckAt) { const deltaMs = at.getTime() - this.lastCheckAt.getTime(); if (deltaMs > 0 && (this.lastState === "active" || this.lastState === "ai_busy")) { const dateKey = ymd(this.lastCheckAt); - this.accumMsByDate.set(dateKey, (this.accumMsByDate.get(dateKey) ?? 0) + deltaMs); + const cur = this.accumByDate.get(dateKey) ?? emptyBuckets(); + cur[this.lastBucket] += deltaMs; + this.accumByDate.set(dateKey, cur); } } this.lastCheckAt = at; this.lastState = state; + this.lastBucket = bucket; } + /** 当日の総分(manual+auto+bypass)。 */ todayMin(now: Date = new Date()): number { - return Math.round((this.accumMsByDate.get(ymd(now)) ?? 0) / 60000); + const b = this.accumByDate.get(ymd(now)); + if (!b) return 0; + return Math.round((b.manual + b.auto + b.bypass) / 60000); + } + + /** 当日のバケット別分。 */ + todayBreakdown(now: Date = new Date()): { manual: number; auto: number; bypass: number; total: number } { + const b = this.accumByDate.get(ymd(now)) ?? emptyBuckets(); + const manual = Math.round(b.manual / 60000); + const auto = Math.round(b.auto / 60000); + const bypass = Math.round(b.bypass / 60000); + return { manual, auto, bypass, total: manual + auto + bypass }; } - snapshot(): { date: string; min: number }[] { - return [...this.accumMsByDate.entries()] - .map(([date, ms]) => ({ date, min: Math.round(ms / 60000) })) + snapshot(): { date: string; min: number; manual: number; auto: number; bypass: number }[] { + return [...this.accumByDate.entries()] + .map(([date, b]) => ({ + date, + min: Math.round((b.manual + b.auto + b.bypass) / 60000), + manual: Math.round(b.manual / 60000), + auto: Math.round(b.auto / 60000), + bypass: Math.round(b.bypass / 60000), + })) .sort((a, b) => a.date.localeCompare(b.date)); } - /** 永続化用のシリアライズ/復元 */ - toJSON(): { byDate: Record } { - return { byDate: Object.fromEntries(this.accumMsByDate) }; + /** + * 永続化シリアライズ。 + * byDate(旧互換:ms 合計)と byDateBuckets(新:バケット別 ms)の両方を書き出す。 + */ + toJSON(): DeepWorkPersisted { + const byDate: Record = {}; + const byDateBuckets: Record = {}; + for (const [date, b] of this.accumByDate.entries()) { + byDate[date] = b.manual + b.auto + b.bypass; + byDateBuckets[date] = { ...b }; + } + return { byDate, byDateBuckets }; } - loadFromJSON(data: { byDate?: Record } | null): void { - if (!data?.byDate) return; - this.accumMsByDate = new Map(Object.entries(data.byDate)); + + /** + * 両フォーマット対応で取り込む。 + * byDateBuckets があれば優先。なければ byDate の number を manual に寄せる。 + */ + loadFromJSON(data: DeepWorkPersisted | null): void { + if (!data) return; + const next = new Map(); + if (data.byDateBuckets) { + for (const [date, val] of Object.entries(data.byDateBuckets)) { + if (val && typeof val === "object") { + next.set(date, { + manual: typeof val.manual === "number" ? val.manual : 0, + auto: typeof val.auto === "number" ? val.auto : 0, + bypass: typeof val.bypass === "number" ? val.bypass : 0, + }); + } + } + } + // byDate は補完用: byDateBuckets に同じ日付があればスキップ + if (data.byDate) { + for (const [date, val] of Object.entries(data.byDate)) { + if (next.has(date)) continue; + if (typeof val === "number") { + next.set(date, { manual: val, auto: 0, bypass: 0 }); + } + } + } + this.accumByDate = next; } } +function emptyBuckets(): DeepWorkBuckets { + return { manual: 0, auto: 0, bypass: 0 }; +} + function ymd(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); diff --git a/src/mcp/resources.ts b/src/mcp/resources.ts index a40af97..4442865 100644 --- a/src/mcp/resources.ts +++ b/src/mcp/resources.ts @@ -20,6 +20,7 @@ import { type PhaseState, } from "../coach/phase.ts"; import { JsonStore } from "../state/store.ts"; +import type { DeepWorkBuckets, DeepWorkPersisted } from "../infer/work_state.ts"; import { fetchActiveBlockCached, CcusageError } from "../observers/ccusage.ts"; import { computeWindowStatus } from "../infer/window5h.ts"; import { @@ -110,22 +111,51 @@ export async function buildLimitsState( // state/deepwork // ────────────────────────────────────────────────────────────────────────────── +/** + * minutes は manual+auto+bypass の総分。バケット別内訳も同梱する。 + * 旧クライアント互換のため minutes フィールドは残す。 + */ +export type DeepWorkDayBreakdown = { + date: string; + minutes: number; + manual: number; + auto: number; + bypass: number; +}; export type DeepWorkPayload = { - today: { date: string; minutes: number }; - history: Array<{ date: string; minutes: number }>; + today: DeepWorkDayBreakdown; + history: Array; }; export function buildDeepWorkState( - raw: { byDate?: Record } | null, + raw: DeepWorkPersisted | null, now: Date = new Date(), ): DeepWorkPayload { const byDate = raw?.byDate ?? {}; + const byDateBuckets = raw?.byDateBuckets ?? {}; const todayKey = ymd(now); - const todayMin = Math.round((byDate[todayKey] ?? 0) / 60000); - const history = Object.entries(byDate) - .map(([date, ms]) => ({ date, minutes: Math.round(ms / 60000) })) + + const allDates = new Set([...Object.keys(byDate), ...Object.keys(byDateBuckets)]); + const toBreakdown = (date: string): DeepWorkDayBreakdown => { + const buckets = byDateBuckets[date]; + if (buckets) { + const manual = Math.round((buckets.manual ?? 0) / 60000); + const auto = Math.round((buckets.auto ?? 0) / 60000); + const bypass = Math.round((buckets.bypass ?? 0) / 60000); + return { date, minutes: manual + auto + bypass, manual, auto, bypass }; + } + // 旧データ: byDate のみ。manual に寄せる。 + const m = Math.round((byDate[date] ?? 0) / 60000); + return { date, minutes: m, manual: m, auto: 0, bypass: 0 }; + }; + + const today = allDates.has(todayKey) + ? toBreakdown(todayKey) + : { date: todayKey, minutes: 0, manual: 0, auto: 0, bypass: 0 }; + const history = [...allDates] + .map((date) => toBreakdown(date)) .sort((a, b) => a.date.localeCompare(b.date)); - return { today: { date: todayKey, minutes: todayMin }, history }; + return { today, history }; } function ymd(d: Date): string { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 37b1033..2ced4e2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -72,7 +72,7 @@ export async function runMcpServer(): Promise { { title: "今日のディープワーク累積", description: - "watch コマンド経由で永続化された deepWork.byDate から今日の合計分と履歴を返す。watch を起動していない期間はカウントされない。", + "watch コマンド経由で永続化された deepWork.byDate から今日の合計分と履歴を返す。permissionMode 別に manual/auto/bypass バケットの内訳も同梱。watch を起動していない期間はカウントされない。", mimeType: "application/json", }, (uri) => jsonResource(uri, readDeepWorkResource(ctx)), diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 82ee060..e2696d9 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -148,12 +148,23 @@ async function buildAdviseInput(ctx: ResourceContext): Promise { phaseState != null && isPhaseStale(phaseState, config.thresholds.phaseStaleHours, now); const phase: Phase = phaseState && !phaseExpired ? phaseState.phase : "implement"; - // 5. ディープワーク累積 + // 5. ディープワーク累積(バケット別) const dwRaw = store.loadDeepWork(); const todayKey = ymd(now); - const deepWorkAccumMin = dwRaw?.byDate?.[todayKey] - ? Math.round(dwRaw.byDate[todayKey] / 60000) - : 0; + const buckets = dwRaw?.byDateBuckets?.[todayKey]; + let manualMs = 0; + let totalMs = 0; + if (buckets) { + manualMs = buckets.manual ?? 0; + totalMs = (buckets.manual ?? 0) + (buckets.auto ?? 0) + (buckets.bypass ?? 0); + } else { + // 旧データのみ: byDate (number) を manual に寄せる + const legacy = dwRaw?.byDate?.[todayKey] ?? 0; + manualMs = legacy; + totalMs = legacy; + } + const deepWorkAccumMin = Math.round(totalMs / 60000); + const deepWorkManualMin = Math.round(manualMs / 60000); return { phase, @@ -162,6 +173,7 @@ async function buildAdviseInput(ctx: ResourceContext): Promise { workState: ws.state, aiBusyDurationMin: Math.round(aiBusyDurationMin * 10) / 10, deepWorkAccumMin, + deepWorkManualMin, parallelCapacity: config.profile.parallelCapacity, limitWarnMin: config.thresholds.limitWarnMin, dailyDeepWorkCapMin: config.profile.dailyDeepWorkCapMin, diff --git a/src/observers/claude_code.ts b/src/observers/claude_code.ts index ff64abf..f6d54b3 100644 --- a/src/observers/claude_code.ts +++ b/src/observers/claude_code.ts @@ -53,8 +53,17 @@ type AssistantUsage = { type AnyRecord = { type?: string; timestamp?: string; + permissionMode?: string; }; +/** Claude Code が JSONL に書く permissionMode を 3 バケットへ正規化する。 */ +export type PermissionMode = "manual" | "auto" | "bypass"; +export function normalizePermissionMode(raw: string | undefined | null): PermissionMode { + if (raw === "bypassPermissions") return "bypass"; + if (raw === "auto" || raw === "acceptEdits" || raw === "plan") return "auto"; + return "manual"; +} + /** * ログディレクトリ配下の全 JSONL ファイルを列挙する。 * mtime 降順で返す。 @@ -167,6 +176,7 @@ export function findActiveSession( file: SessionFile; lastUserAt: Date | null; lastAssistantAt: Date | null; + currentPermissionMode: PermissionMode; } | null { const cutoffMs = now.getTime() - recentMin * 60_000; const files = listSessionFiles(logDir).slice(0, candidateLimit); @@ -177,7 +187,12 @@ export function findActiveSession( ts.lastAssistantAt?.getTime() ?? 0, ); if (newestMs >= cutoffMs) { - return { file: f, lastUserAt: ts.lastUserAt, lastAssistantAt: ts.lastAssistantAt }; + return { + file: f, + lastUserAt: ts.lastUserAt, + lastAssistantAt: ts.lastAssistantAt, + currentPermissionMode: ts.currentPermissionMode, + }; } } return null; @@ -188,14 +203,18 @@ export function findActiveSession( * AI 処理状態判定 (work_state) に使う。 * * 末尾から走査して各タイプ最初の 1 件で early break。 + * 併せて、末尾走査中に最初に観測した permissionMode を「現行モード」として返す + * (permission-mode タイプの transition record、または user/assistant record の + * permissionMode フィールドのどちらでも採用)。観測できなければ "manual"(default)。 */ export function readLastEventTimestamps( file: SessionFile, -): { lastUserAt: Date | null; lastAssistantAt: Date | null } { +): { lastUserAt: Date | null; lastAssistantAt: Date | null; currentPermissionMode: PermissionMode } { const text = readFileSync(file.path, "utf8"); const lines = text.split("\n"); let lastUser: Date | null = null; let lastAssistant: Date | null = null; + let mode: PermissionMode | null = null; for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]!; if (line.length === 0) continue; @@ -205,13 +224,20 @@ export function readLastEventTimestamps( } catch { continue; } + if (mode === null && rec.permissionMode) { + mode = normalizePermissionMode(rec.permissionMode); + } if (!rec.timestamp) continue; if (rec.type === "user" && !lastUser) { lastUser = new Date(rec.timestamp); } else if (rec.type === "assistant" && !lastAssistant) { lastAssistant = new Date(rec.timestamp); } - if (lastUser && lastAssistant) break; + if (lastUser && lastAssistant && mode !== null) break; } - return { lastUserAt: lastUser, lastAssistantAt: lastAssistant }; + return { + lastUserAt: lastUser, + lastAssistantAt: lastAssistant, + currentPermissionMode: mode ?? "manual", + }; } diff --git a/src/state/store.ts b/src/state/store.ts index d450b3b..fb10924 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -12,13 +12,19 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from " import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { Phase, PhaseState } from "../coach/phase.ts"; +import type { DeepWorkPersisted } from "../infer/work_state.ts"; export type PersistedState = { - /** スキーマバージョン。今後増やす可能性あり */ + /** + * スキーマバージョン。 + * 1: 現行。deepWork.byDate は ms 合計 (number) を保持。 + * 新仕様の permissionMode バケット内訳は deepWork.byDateBuckets に並走させる + * (旧バージョンが byDate のみ読んでも壊れないように設計)。 + */ schema: 1; phase?: PhaseState | null; /** ディープワーク累積(DeepWorkAccumulator.toJSON() の戻り値) */ - deepWork?: { byDate: Record } | null; + deepWork?: DeepWorkPersisted | null; }; const EMPTY: PersistedState = { schema: 1, phase: null, deepWork: null }; @@ -76,13 +82,13 @@ export class JsonStore { return this.read().phase ?? null; } - saveDeepWork(data: { byDate: Record }): void { + saveDeepWork(data: DeepWorkPersisted): void { const state = this.read(); state.deepWork = data; this.write(state); } - loadDeepWork(): { byDate: Record } | null { + loadDeepWork(): DeepWorkPersisted | null { return this.read().deepWork ?? null; } diff --git a/src/watch.ts b/src/watch.ts index fcf63bb..7f4d6c8 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -23,7 +23,12 @@ import { type SessionFile, } from "./observers/claude_code.ts"; import { detectSnowball, type SnowballState } from "./infer/snowball.ts"; -import { classifyWorkState, DeepWorkAccumulator, type WorkState } from "./infer/work_state.ts"; +import { + classifyWorkState, + DeepWorkAccumulator, + type PermissionBucket, + type WorkState, +} from "./infer/work_state.ts"; import { advise, type Advice } from "./coach/advise.ts"; import { createDesktopNotifier, type NotifyRequest } from "./notify/desktop.ts"; import { JsonStore } from "./state/store.ts"; @@ -121,6 +126,7 @@ async function tick(ctx: Ctx, aiBusySince: Date | null): Promise<{ aiBusySince: const ws = sessionInfo ? classifyWorkState(sessionInfo.lastUserAt, sessionInfo.lastAssistantAt, now) : { state: "idle" as WorkState, lastUserAt: null, lastAssistantAt: null, reason: "no session" }; + const bucket: PermissionBucket = sessionInfo?.currentPermissionMode ?? "manual"; // ai_busy 起算時刻の更新 let nextAiBusySince: Date | null; @@ -133,9 +139,10 @@ async function tick(ctx: Ctx, aiBusySince: Date | null): Promise<{ aiBusySince: ? Math.max(0, (now.getTime() - nextAiBusySince.getTime()) / 60000) : 0; - // ディープワーク累積 - accum.feed(ws.state, now); - const deepWorkMin = accum.todayMin(now); + // ディープワーク累積(permissionMode バケット別に分配) + accum.feed(ws.state, now, bucket); + const dwBreakdown = accum.todayBreakdown(now); + const deepWorkMin = dwBreakdown.total; const phaseState = store.getPhase(); const phaseExpired = @@ -153,7 +160,10 @@ async function tick(ctx: Ctx, aiBusySince: Date | null): Promise<{ aiBusySince: ); } statusBits.push(`| ws=${ws.state}` + (aiBusyDurationMin > 0 ? `(${aiBusyDurationMin.toFixed(1)}m)` : "")); - statusBits.push(`| dw=${deepWorkMin}m`); + statusBits.push( + `| dw=${deepWorkMin}m(M:${dwBreakdown.manual}/A:${dwBreakdown.auto}/B:${dwBreakdown.bypass})`, + ); + statusBits.push(`| mode=${bucket}`); statusBits.push(`| phase=${phase}${phaseExpired ? "(stale→default)" : ""}`); console.log(statusBits.join(" ")); @@ -164,6 +174,7 @@ async function tick(ctx: Ctx, aiBusySince: Date | null): Promise<{ aiBusySince: workState: ws.state, aiBusyDurationMin: Math.round(aiBusyDurationMin * 10) / 10, deepWorkAccumMin: deepWorkMin, + deepWorkManualMin: dwBreakdown.manual, parallelCapacity: config.profile.parallelCapacity, limitWarnMin: config.thresholds.limitWarnMin, dailyDeepWorkCapMin: config.profile.dailyDeepWorkCapMin, @@ -233,6 +244,7 @@ function safeReadLatestSession(config: CogsyncConfig): { file: SessionFile; lastUserAt: Date | null; lastAssistantAt: Date | null; + currentPermissionMode: PermissionBucket; } | null { if (!config.observers.claudeCode.enabled) return null; try { diff --git a/tests/deepwork-accumulator.test.ts b/tests/deepwork-accumulator.test.ts new file mode 100644 index 0000000..706b5f4 --- /dev/null +++ b/tests/deepwork-accumulator.test.ts @@ -0,0 +1,64 @@ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { DeepWorkAccumulator } from "../src/infer/work_state.ts"; + +test("DeepWorkAccumulator: バケット別に分配する", () => { + const acc = new DeepWorkAccumulator(); + const t0 = new Date("2026-05-09T01:00:00Z"); + const t1 = new Date("2026-05-09T01:05:00Z"); // +5 min manual + const t2 = new Date("2026-05-09T01:15:00Z"); // +10 min auto + const t3 = new Date("2026-05-09T01:18:00Z"); // +3 min bypass + const t4 = new Date("2026-05-09T01:25:00Z"); // +7 min idle (no contribution) + + acc.feed("active", t0, "manual"); + acc.feed("active", t1, "auto"); // 5 min を manual に積む + acc.feed("ai_busy", t2, "bypass"); // 10 min を auto に積む + acc.feed("idle", t3, "manual"); // 3 min を bypass に積む + acc.feed("idle", t4, "manual"); // idle なので何もしない + + const bd = acc.todayBreakdown(t4); + assert.equal(bd.manual, 5); + assert.equal(bd.auto, 10); + assert.equal(bd.bypass, 3); + assert.equal(bd.total, 18); +}); + +test("DeepWorkAccumulator: 旧 byDate のみ → manual に寄せる", () => { + const acc = new DeepWorkAccumulator(); + acc.loadFromJSON({ + byDate: { + "2026-05-08": 60 * 60_000, + }, + }); + const ref = new Date("2026-05-08T12:00:00Z"); + const bd = acc.todayBreakdown(ref); + assert.equal(bd.manual, 60); + assert.equal(bd.auto, 0); + assert.equal(bd.bypass, 0); +}); + +test("DeepWorkAccumulator: byDateBuckets を優先し round-trip 一致", () => { + const acc = new DeepWorkAccumulator(); + acc.loadFromJSON({ + byDate: { "2026-05-09": 16 * 60_000 }, + byDateBuckets: { + "2026-05-09": { manual: 12 * 60_000, auto: 3 * 60_000, bypass: 1 * 60_000 }, + }, + }); + const out = acc.toJSON(); + // toJSON は両フィールド書き出し + assert.equal(out.byDate["2026-05-09"], 16 * 60_000); + assert.ok(out.byDateBuckets); + const day = out.byDateBuckets!["2026-05-09"]; + assert.equal(day!.manual, 12 * 60_000); + assert.equal(day!.auto, 3 * 60_000); + assert.equal(day!.bypass, 1 * 60_000); +}); + +test("DeepWorkAccumulator: 初回 feed では加算しない(lastCheckAt が無い)", () => { + const acc = new DeepWorkAccumulator(); + const t0 = new Date("2026-05-09T01:00:00Z"); + acc.feed("active", t0, "manual"); + const bd = acc.todayBreakdown(t0); + assert.equal(bd.total, 0); +}); diff --git a/tests/mcp-resources.test.ts b/tests/mcp-resources.test.ts index 31c5da5..3376aab 100644 --- a/tests/mcp-resources.test.ts +++ b/tests/mcp-resources.test.ts @@ -38,10 +38,13 @@ test("buildDeepWorkState: 空入力で today=0 / history=[]", () => { const out = buildDeepWorkState(null, new Date("2026-05-09T10:00:00Z")); assert.equal(out.today.date, "2026-05-09"); assert.equal(out.today.minutes, 0); + assert.equal(out.today.manual, 0); + assert.equal(out.today.auto, 0); + assert.equal(out.today.bypass, 0); assert.deepEqual(out.history, []); }); -test("buildDeepWorkState: 当日と過去日が混在しても today を正しく拾う", () => { +test("buildDeepWorkState: v1 互換(number は manual に寄せる)", () => { const now = new Date("2026-05-09T10:00:00Z"); const raw = { byDate: { @@ -52,7 +55,9 @@ test("buildDeepWorkState: 当日と過去日が混在しても today を正し }; const out = buildDeepWorkState(raw, now); assert.equal(out.today.minutes, 30); - // history は日付昇順 + assert.equal(out.today.manual, 30); + assert.equal(out.today.auto, 0); + assert.equal(out.today.bypass, 0); assert.deepEqual( out.history.map((h) => h.date), ["2026-05-07", "2026-05-08", "2026-05-09"], @@ -60,6 +65,40 @@ test("buildDeepWorkState: 当日と過去日が混在しても today を正し assert.equal(out.history[2]!.minutes, 30); }); +test("buildDeepWorkState: byDateBuckets を優先しサマリする", () => { + const now = new Date("2026-05-09T10:00:00Z"); + const raw = { + byDate: { + "2026-05-09": 40 * 60_000, + "2026-05-08": 40 * 60_000, + }, + byDateBuckets: { + "2026-05-09": { manual: 20 * 60_000, auto: 15 * 60_000, bypass: 5 * 60_000 }, + "2026-05-08": { manual: 30 * 60_000, auto: 0, bypass: 10 * 60_000 }, + }, + }; + const out = buildDeepWorkState(raw, now); + assert.equal(out.today.minutes, 40); + assert.equal(out.today.manual, 20); + assert.equal(out.today.auto, 15); + assert.equal(out.today.bypass, 5); + assert.equal(out.history[0]!.date, "2026-05-08"); + assert.equal(out.history[0]!.minutes, 40); + assert.equal(out.history[0]!.bypass, 10); +}); + +test("buildDeepWorkState: byDate のみ (旧データ) は manual に寄せる", () => { + const now = new Date("2026-05-09T10:00:00Z"); + const raw = { + byDate: { "2026-05-09": 25 * 60_000 }, + }; + const out = buildDeepWorkState(raw, now); + assert.equal(out.today.minutes, 25); + assert.equal(out.today.manual, 25); + assert.equal(out.today.auto, 0); + assert.equal(out.today.bypass, 0); +}); + test("buildDeepWorkState: today の日付フォーマットがゼロパディング", () => { // JST(UTC+9) を想定: 2026-01-03T05:00:00Z = JST 14:00 → 2026-01-03 const out = buildDeepWorkState(null, new Date("2026-01-03T05:00:00Z"));