diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d3a6d2c..1b7775c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "mutils", "source": "./plugins/mutils", "description": "汎用ユーティリティ(フック・スキル)", - "version": "0.12.1" + "version": "0.13.0" }, { "name": "context", diff --git a/.claude/rules/ai-generated/project-context.md b/.claude/rules/ai-generated/project-context.md index c5ebff3..1c1af31 100644 --- a/.claude/rules/ai-generated/project-context.md +++ b/.claude/rules/ai-generated/project-context.md @@ -7,9 +7,9 @@ ## Rules - **全プラグインは mutils がインストールされていることを前提とする** — 各プラグイン・スキルに mutils の詳細説明を書かない -- **セッション管理の説明は `mutils:session-id スキルを使用` とだけ書く** — session-id フォーマットをインラインで重複定義しない -- **session-id の feature-name は kebab-case のみ許可** — 正規表現: `/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/` -- **セッションディレクトリは `.agents/sessions/[session-id]/` 形式** — `.agents/sessions/` だけでも `sessions/yyyymmdd.../` でもない +- **ワークスペース管理の説明は `mutils:workspace-id スキルを使用` とだけ書く** — workspace-id フォーマットをインラインで重複定義しない +- **workspace-id の feature-name は kebab-case のみ許可** — 正規表現: `/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/` +- **ワークスペースディレクトリは `.agents/workspaces/[workspace-id]/` 形式** — `.agents/workspaces/` だけでも `workspaces/yyyymmdd.../` でもない ## Pickup Topics @@ -23,7 +23,7 @@ ```markdown # My Plugin Skill -セッション管理には mutils:session-id スキルを使用。 +ワークスペース管理には mutils:workspace-id スキルを使用。 ``` ### Bad Example @@ -31,21 +31,21 @@ ```markdown # My Plugin Skill -セッションIDのフォーマット: yyyymmdd-HHmm-[feature-name] -ディレクトリ構造: .agents/sessions/[session-id]/ +ワークスペースIDのフォーマット: yyyymmdd-HHmm-[feature-name] +ディレクトリ構造: .agents/workspaces/[workspace-id]/ ファイル命名: [nnnnnn]-[subagent-name]-[content].md ``` -### Session ID フォーマット仕様 +### Workspace ID フォーマット仕様 -`mutils:session-id` スキルで一元定義されている canonical な仕様: +`mutils:workspace-id` スキルで一元定義されている canonical な仕様: | 要素 | 仕様 | |------|------| | フォーマット | `yyyymmdd-HHmm-[feature-name]` | | 日時 | ローカル時刻(例: `20260301-1430`) | | feature-name | kebab-case、正規表現 `/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/` | -| ディレクトリ | `.agents/sessions/[session-id]/` | +| ディレクトリ | `.agents/workspaces/[workspace-id]/` | | ファイル命名 | `[nnnnnn]-[subagent-name]-[content].md`(6桁ゼロ埋め連番) | -`rounds/` 構造(`rounds/[NNNN]/`)は plan プラグイン固有であり、session-id の一般仕様には含まない。 +`rounds/` 構造(`rounds/[NNNN]/`)は plan プラグイン固有であり、workspace-id の一般仕様には含まない。 diff --git a/plugins/mutils/hooks/hooks.json b/plugins/mutils/hooks/hooks.json index a35f591..efa7db3 100644 --- a/plugins/mutils/hooks/hooks.json +++ b/plugins/mutils/hooks/hooks.json @@ -11,6 +11,10 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/check-context-version.ts" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/workspace-id-persist.ts" } ] } diff --git a/plugins/mutils/hooks/workspace-id-persist.ts b/plugins/mutils/hooks/workspace-id-persist.ts new file mode 100644 index 0000000..26a403a --- /dev/null +++ b/plugins/mutils/hooks/workspace-id-persist.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env bun +import { existsSync } from "node:fs"; +import path from "node:path"; +import { + findGitRoot, + HookLogger, + MutilsDB, + wrapRun, +} from "@r_masseater/cc-plugin-lib"; +import { defineHook, runHook } from "cc-hooks-ts"; + +using logger = HookLogger.fromFile(import.meta.filename); + +type WorkspaceMapping = { + id?: number; + workspace_id: string; + feature_name: string; + created_at: string; +}; + +const WORKSPACE_MAPPINGS_SCHEMA = { + columns: ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL UNIQUE, + feature_name TEXT NOT NULL, + created_at TEXT NOT NULL + `, + indexes: [ + "CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_mappings_workspace_id ON workspace_mappings(workspace_id)", + ], +} as const; + +/** + * Get the latest workspace mapping from DB + */ +function getLatestWorkspaceMapping(cwd: string): WorkspaceMapping | null { + try { + using db = MutilsDB.open(cwd); + const table = db.table( + "workspace_mappings", + WORKSPACE_MAPPINGS_SCHEMA, + ); + // Get the most recent entry + const all = table.findAll("1=1 ORDER BY id DESC LIMIT 1", []); + return all[0] ?? null; + } catch (error) { + logger.error(`Failed to get workspace mapping: ${error}`); + return null; + } +} + +const hook = defineHook({ + trigger: { + SessionStart: true, + }, + run: wrapRun(logger, (context) => { + const cwd = process.cwd(); + const eventType = context.input.hook_event_name; + + // Handle SessionStart - restore workspace-id after compact or resume + if (eventType === "SessionStart") { + // Check git repository and .agents directory + const gitRoot = findGitRoot(cwd); + if (gitRoot === null) { + logger.debug("Skipping: not a git repository"); + return context.success({}); + } + + const agentsDir = path.join(gitRoot, ".agents"); + if (!existsSync(agentsDir)) { + logger.debug("Skipping: .agents directory does not exist"); + return context.success({}); + } + + const source = + "source" in context.input && typeof context.input.source === "string" + ? context.input.source + : "startup"; + + logger.debug(`SessionStart source: ${source}`); + + // Only restore on compact or resume + if (source !== "compact" && source !== "resume") { + logger.debug("Not a compact/resume session, skipping"); + return context.success({}); + } + + const mapping = getLatestWorkspaceMapping(gitRoot); + + if (!mapping) { + logger.debug("No workspace mapping found"); + return context.success({}); + } + + logger.info(`Restored workspace-id: ${mapping.workspace_id}`); + + const sourceDescription = + source === "compact" ? "Auto Compact" : "session resume"; + + // Provide restored workspace-id to Claude + return context.json({ + event: "SessionStart", + output: { + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: `Workspace ID Restored: ${mapping.workspace_id} + +The previous workspace-id has been restored after ${sourceDescription}. Continue using this workspace-id for your work. + +Workspace directory: .agents/workspaces/${mapping.workspace_id}/`, + }, + suppressOutput: true, + }, + }); + } + + return context.success({}); + }), +}); + +if (import.meta.main) { + await runHook(hook); +} diff --git a/plugins/mutils/plugin.json b/plugins/mutils/plugin.json index 5ebf2d4..24f990b 100644 --- a/plugins/mutils/plugin.json +++ b/plugins/mutils/plugin.json @@ -1,7 +1,7 @@ { "name": "mutils", "description": "汎用ユーティリティ(フック・スキル)", - "version": "0.12.1", + "version": "0.13.0", "author": { "name": "masseater" }, diff --git a/plugins/mutils/skills/ccs-handoff/ccs-handoff.ts b/plugins/mutils/skills/ccs-handoff/ccs-handoff.ts index af583d1..a3acab1 100755 --- a/plugins/mutils/skills/ccs-handoff/ccs-handoff.ts +++ b/plugins/mutils/skills/ccs-handoff/ccs-handoff.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { parseWorkspaceId } from "../workspace-id/patterns.js"; // --- Constants --- @@ -446,6 +447,51 @@ function detectSubagents( return fs.existsSync(subagentDir); } +// --- Session ID extraction from .agents/sessions/ --- + +interface UserSessionId { + fullId: string; + featureName: string; +} + +function extractUserSessionId(projectPath: string): UserSessionId | null { + const workspacesDir = path.join(projectPath, ".agents", "workspaces"); + if (!fs.existsSync(workspacesDir)) { + return null; + } + + // Get the most recently modified workspace directory + const entries = fs + .readdirSync(workspacesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => { + const fullPath = path.join(workspacesDir, d.name); + const stat = fs.statSync(fullPath); + return { name: d.name, mtime: stat.mtimeMs }; + }) + .sort((a, b) => b.mtime - a.mtime); + + if (entries.length === 0) { + return null; + } + + const workspaceId = entries[0]?.name; + if (!workspaceId) { + return null; + } + + // Validate workspace-id format using shared pattern + const parsed = parseWorkspaceId(workspaceId); + if (!parsed) { + return null; + } + + return { + fullId: workspaceId, + featureName: parsed.featureName, + }; +} + // --- Plan file extraction --- function isPlanFileToolUse( @@ -495,6 +541,7 @@ function formatMarkdown( todos: PendingTodo[], hasSubagents: boolean, planFiles: string[], + userSessionId: UserSessionId | null, ): string { const lines: string[] = []; @@ -529,6 +576,26 @@ function formatMarkdown( lines.push(summaryText); lines.push(""); + // User Session ID (for handoff) + lines.push("## User Session ID"); + lines.push(""); + if (userSessionId) { + lines.push(`**Session ID**: \`${userSessionId.fullId}\``); + lines.push(""); + lines.push(`**Feature Name**: ${userSessionId.featureName}`); + lines.push(""); + lines.push( + "To continue using this session-id, create the session directory:", + ); + lines.push(""); + lines.push("```bash"); + lines.push(`mkdir -p .agents/sessions/${userSessionId.fullId}`); + lines.push("```"); + } else { + lines.push("No user session-id found in .agents/sessions/"); + } + lines.push(""); + // First Prompt lines.push("## First Prompt"); lines.push(""); @@ -617,6 +684,11 @@ async function main(): Promise { ); const planFiles = extractPlanFilePaths(sessionInfo.jsonlPath); + // Extract user session-id for handoff + const userSessionId = sessionInfo.projectPath + ? extractUserSessionId(sessionInfo.projectPath) + : null; + const markdown = formatMarkdown( sessionInfo, messages, @@ -625,6 +697,7 @@ async function main(): Promise { todos, hasSubagents, planFiles, + userSessionId, ); console.log(markdown); diff --git a/plugins/mutils/skills/session-id/generate.ts b/plugins/mutils/skills/session-id/generate.ts deleted file mode 100755 index f83e493..0000000 --- a/plugins/mutils/skills/session-id/generate.ts +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bun - -const featureName = process.argv[2]; - -if (!featureName) { - process.stderr.write("Usage: generate.ts \n"); - process.exit(1); -} - -if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(featureName)) { - process.stderr.write( - `Error: feature-name must match /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, got: ${featureName}\n`, - ); - process.exit(1); -} - -const now = new Date(); -const pad = (n: number, len = 2) => String(n).padStart(len, "0"); -const yyyymmdd = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`; -const HHmm = `${pad(now.getHours())}${pad(now.getMinutes())}`; - -process.stdout.write(`${yyyymmdd}-${HHmm}-${featureName}`); diff --git a/plugins/mutils/skills/session-id/SKILL.md b/plugins/mutils/skills/workspace-id/SKILL.md similarity index 63% rename from plugins/mutils/skills/session-id/SKILL.md rename to plugins/mutils/skills/workspace-id/SKILL.md index 599c191..c595963 100644 --- a/plugins/mutils/skills/session-id/SKILL.md +++ b/plugins/mutils/skills/workspace-id/SKILL.md @@ -1,17 +1,17 @@ --- -name: session-id -description: "Use when creating session directories for agent work. Defines the canonical session-id format, directory structure, and file naming convention." +name: workspace-id +description: "Use when creating workspace directories for agent work. Defines the canonical workspace-id format, directory structure, and file naming convention." tools: - - Bash(${CLAUDE_PLUGIN_ROOT}/skills/session-id/generate.ts *) + - Bash(${CLAUDE_PLUGIN_ROOT}/skills/workspace-id/generate.ts *) --- -# Session ID Skill +# Workspace ID Skill -This skill provides the canonical format and conventions for managing agent work sessions. Use this skill when creating new session directories or documenting session-related workflows. +This skill provides the canonical format and conventions for managing agent workspaces. Use this skill when creating new workspace directories or documenting workspace-related workflows. -## Session ID Format +## Workspace ID Format -Session IDs follow a strict format to ensure consistency across all agent work: +Workspace IDs follow a strict format to ensure consistency across all agent work: ``` yyyymmdd-HHmm-[feature-name] @@ -35,21 +35,21 @@ yyyymmdd-HHmm-[feature-name] ## Directory Structure -All session files are organized under the `.agents/sessions/` directory: +All workspace files are organized under the `.agents/workspaces/` directory: ``` -.agents/sessions/ -└── [session-id]/ +.agents/workspaces/ +└── [workspace-id]/ ├── [nnnnnn]-[subagent-name]-[content].md ├── [nnnnnn]-[subagent-name]-[content].md └── ... ``` -The `.agents/sessions/[session-id]/` directory contains all reports and artifacts from a single agent work session. +The `.agents/workspaces/[workspace-id]/` directory contains all reports and artifacts from a single agent work session. ## File Naming Convention -Files within a session use a structured naming pattern: +Files within a workspace use a structured naming pattern: ``` [nnnnnn]-[subagent-name]-[content].md @@ -71,31 +71,31 @@ Files within a session use a structured naming pattern: ## Generation Tool -The `generate.ts` tool can be used to generate a properly formatted session ID: +The `generate.ts` tool generates a properly formatted workspace ID: ```bash -bash ${CLAUDE_PLUGIN_ROOT}/skills/session-id/generate.ts [feature-name] +${CLAUDE_PLUGIN_ROOT}/skills/workspace-id/generate.ts [feature-name] ``` **Parameters:** -- `[feature-name]` — The kebab-case feature name for your session +- `[feature-name]` — The kebab-case feature name for your workspace **Output:** -- A valid session ID in the format `yyyymmdd-HHmm-[feature-name]` +- A valid workspace ID in the format `yyyymmdd-HHmm-[feature-name]` **Example:** ```bash -bash ${CLAUDE_PLUGIN_ROOT}/skills/session-id/generate.ts doc-review +${CLAUDE_PLUGIN_ROOT}/skills/workspace-id/generate.ts doc-review # Output: 20260302-1430-doc-review ``` ## Good Examples -### Correct Session ID Usage +### Correct Workspace ID Usage ``` -Session ID: 20260302-1430-doc-engine -Directory: .agents/sessions/20260302-1430-doc-engine/ +Workspace ID: 20260302-1430-doc-engine +Directory: .agents/workspaces/20260302-1430-doc-engine/ Files: - 000001-documentation-engineer-report.md - 000002-technical-writer-review.md @@ -112,7 +112,7 @@ Files: ## Bad Examples -### Incorrect Session IDs +### Incorrect Workspace IDs ``` ❌ 20260302-14:30-doc-engine # Time format wrong (use HHmm, not HH:mm) @@ -138,17 +138,21 @@ Files: ❌ 000001-documentation-engineer # Missing .md extension ``` +## Persistence Across Auto Compact + +Workspace-ids generated with this skill are automatically persisted. After Auto Compact or when resuming a session, the workspace-id is restored automatically. + ## Special Notes -The `rounds/` directory structure (e.g., `rounds/[NNNN]/`) is specific to the plan plugin and is NOT part of the general session-id specification. Use session directories as defined above for standard agent work. +The `rounds/` directory structure (e.g., `rounds/[NNNN]/`) is specific to the plan plugin and is NOT part of the general workspace-id specification. Use workspace directories as defined above for standard agent work. ## Integration with Other Plugins -When creating sessions for plugin-specific work: +When creating workspaces for plugin-specific work: -- **plan plugin**: Creates `.agents/plans/` files and may organize rounds within sessions -- **progress-tracker plugin**: Creates progress-tracker session files under `.agents/sessions/[session-id]/` -- **code-review plugin**: Creates review reports under `.agents/sessions/[session-id]/` -- **research plugin**: Creates research findings under `.agents/sessions/[session-id]/` +- **plan plugin**: Creates `.agents/plans/` files and may organize rounds within workspaces +- **progress-tracker plugin**: Creates progress-tracker workspace files under `.agents/workspaces/[workspace-id]/` +- **code-review plugin**: Creates review reports under `.agents/workspaces/[workspace-id]/` +- **research plugin**: Creates research findings under `.agents/workspaces/[workspace-id]/` -All plugins use the same canonical session-id format defined by this skill. +All plugins use the same canonical workspace-id format defined by this skill. diff --git a/plugins/mutils/skills/workspace-id/generate.ts b/plugins/mutils/skills/workspace-id/generate.ts new file mode 100755 index 0000000..76686e5 --- /dev/null +++ b/plugins/mutils/skills/workspace-id/generate.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env bun +import { existsSync } from "node:fs"; +import path from "node:path"; +import { findGitRoot, MutilsDB } from "@r_masseater/cc-plugin-lib"; +import { isValidFeatureName } from "./patterns.js"; + +const featureName = process.argv[2]; + +if (!featureName) { + process.stderr.write("Usage: generate.ts \n"); + process.exit(1); +} + +if (!isValidFeatureName(featureName)) { + process.stderr.write( + `Error: feature-name must be kebab-case (lowercase letters, digits, hyphens), got: ${featureName}\n`, + ); + process.exit(1); +} + +const now = new Date(); +const pad = (n: number, len = 2) => String(n).padStart(len, "0"); +const yyyymmdd = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`; +const HHmm = `${pad(now.getHours())}${pad(now.getMinutes())}`; +const workspaceId = `${yyyymmdd}-${HHmm}-${featureName}`; + +// Save to DB for persistence across Auto Compact +const cwd = process.cwd(); +const gitRoot = findGitRoot(cwd); + +if (gitRoot) { + const agentsDir = path.join(gitRoot, ".agents"); + if (existsSync(agentsDir)) { + try { + using db = MutilsDB.open(gitRoot); + const table = db.table<{ + workspace_id: string; + feature_name: string; + created_at: string; + }>("workspace_mappings", { + columns: ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL UNIQUE, + feature_name TEXT NOT NULL, + created_at TEXT NOT NULL + `, + indexes: [ + "CREATE UNIQUE INDEX IF NOT EXISTS idx_workspace_mappings_workspace_id ON workspace_mappings(workspace_id)", + ], + }); + + const timestamp = now.toISOString(); + table.upsert({ + workspace_id: workspaceId, + feature_name: featureName, + created_at: timestamp, + }); + } catch { + // Fail silently - workspace-id generation should still work + } + } +} + +process.stdout.write(workspaceId); diff --git a/plugins/mutils/skills/workspace-id/patterns.ts b/plugins/mutils/skills/workspace-id/patterns.ts new file mode 100644 index 0000000..675edaf --- /dev/null +++ b/plugins/mutils/skills/workspace-id/patterns.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env bun + +/** + * Shared patterns for workspace-id skill and hooks + * + * This module provides the canonical regex patterns for workspace-id validation + * and parsing to ensure consistency across the codebase. + */ + +/** + * Feature name pattern: kebab-case, starts with lowercase letter + * Examples: doc-engine, api-v2-migration, fix-parser + */ +export const FEATURE_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; + +/** + * Full workspace-id pattern: yyyymmdd-HHmm-[feature-name] + * Captures: + * - Group 0: Full workspace-id + * - Group 1: Date-time prefix (yyyymmdd-HHmm) + * - Group 2: Feature name + */ +export const WORKSPACE_ID_PATTERN = + /^(\d{8}-\d{4})-([a-z][a-z0-9]*(-[a-z0-9]+)*)$/; + +/** + * Validate a feature name + */ +export function isValidFeatureName(name: string): boolean { + return FEATURE_NAME_PATTERN.test(name); +} + +/** + * Parse a workspace-id into its components + */ +export function parseWorkspaceId( + workspaceId: string, +): { dateTimePrefix: string; featureName: string } | null { + const match = workspaceId.match(WORKSPACE_ID_PATTERN); + if (match?.[1] && match[2]) { + return { + dateTimePrefix: match[1], + featureName: match[2], + }; + } + return null; +}