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
17 changes: 14 additions & 3 deletions src/coach/advise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
},
};
Expand Down
114 changes: 99 additions & 15 deletions src/infer/work_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, number>;
byDateBuckets?: Record<string, DeepWorkBuckets>;
};

/**
* ディープワーク累積追跡。
* watch ループからの「snapshot 列」を順に受け、active/ai_busy 状態の時間を集計する。
* watch ループからの「snapshot 列」を順に受け、active/ai_busy 状態の時間を
* permissionMode バケット別に集計する。
*/
export class DeepWorkAccumulator {
private accumMsByDate = new Map<string, number>(); // YYYY-MM-DD → ms
private accumByDate = new Map<string, DeepWorkBuckets>(); // 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<string, number> } {
return { byDate: Object.fromEntries(this.accumMsByDate) };
/**
* 永続化シリアライズ。
* byDate(旧互換:ms 合計)と byDateBuckets(新:バケット別 ms)の両方を書き出す。
*/
toJSON(): DeepWorkPersisted {
const byDate: Record<string, number> = {};
const byDateBuckets: Record<string, DeepWorkBuckets> = {};
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<string, number> } | 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<string, DeepWorkBuckets>();
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");
Expand Down
44 changes: 37 additions & 7 deletions src/mcp/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<DeepWorkDayBreakdown>;
};

export function buildDeepWorkState(
raw: { byDate?: Record<string, number> } | 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<string>([...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 {
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export async function runMcpServer(): Promise<void> {
{
title: "今日のディープワーク累積",
description:
"watch コマンド経由で永続化された deepWork.byDate から今日の合計分と履歴を返す。watch を起動していない期間はカウントされない。",
"watch コマンド経由で永続化された deepWork.byDate から今日の合計分と履歴を返す。permissionMode 別に manual/auto/bypass バケットの内訳も同梱。watch を起動していない期間はカウントされない。",
mimeType: "application/json",
},
(uri) => jsonResource(uri, readDeepWorkResource(ctx)),
Expand Down
20 changes: 16 additions & 4 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,23 @@ async function buildAdviseInput(ctx: ResourceContext): Promise<AdviseInput> {
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,
Expand All @@ -162,6 +173,7 @@ async function buildAdviseInput(ctx: ResourceContext): Promise<AdviseInput> {
workState: ws.state,
aiBusyDurationMin: Math.round(aiBusyDurationMin * 10) / 10,
deepWorkAccumMin,
deepWorkManualMin,
parallelCapacity: config.profile.parallelCapacity,
limitWarnMin: config.thresholds.limitWarnMin,
dailyDeepWorkCapMin: config.profile.dailyDeepWorkCapMin,
Expand Down
34 changes: 30 additions & 4 deletions src/observers/claude_code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 降順で返す。
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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",
};
}
Loading
Loading