diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1b7775c..43f587e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "mutils", "source": "./plugins/mutils", "description": "汎用ユーティリティ(フック・スキル)", - "version": "0.13.0" + "version": "0.14.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 27b18d6..efa7db3 100644 --- a/plugins/mutils/hooks/hooks.json +++ b/plugins/mutils/hooks/hooks.json @@ -14,7 +14,7 @@ }, { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-id-persist.ts" + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/workspace-id-persist.ts" } ] } @@ -39,17 +39,6 @@ ] } ], - "PostToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-id-persist.ts" - } - ] - } - ], "Stop": [ { "hooks": [ diff --git a/plugins/mutils/hooks/session-id-persist.ts b/plugins/mutils/hooks/session-id-persist.ts deleted file mode 100644 index a480b25..0000000 --- a/plugins/mutils/hooks/session-id-persist.ts +++ /dev/null @@ -1,238 +0,0 @@ -#!/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"; -import { parseSessionId } from "../skills/session-id/patterns.js"; - -using logger = HookLogger.fromFile(import.meta.filename); - -// generate.ts path pattern (matches both forward and backslashes) -const GENERATE_PATTERN = /skills[/\\]session-id[/\\]generate\.ts/; - -type SessionMapping = { - id?: number; - claude_session_id: string; - user_session_id: string; - feature_name: string; - created_at: string; - updated_at: string; -}; - -const SESSION_MAPPINGS_SCHEMA = { - columns: ` - id INTEGER PRIMARY KEY AUTOINCREMENT, - claude_session_id TEXT NOT NULL UNIQUE, - user_session_id TEXT NOT NULL, - feature_name TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - `, - indexes: [ - "CREATE UNIQUE INDEX IF NOT EXISTS idx_session_mappings_claude_session_id ON session_mappings(claude_session_id)", - ], -} as const; - -/** - * Format current datetime for DB storage - */ -function formatDateTime(date: Date): string { - const pad = (n: number) => n.toString().padStart(2, "0"); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; -} - -/** - * Save session mapping to DB - * Preserves created_at for existing records - */ -function saveSessionMapping( - cwd: string, - claudeSessionId: string, - userSessionId: string, - featureName: string, -): void { - try { - using db = MutilsDB.open(cwd); - const table = db.table( - "session_mappings", - SESSION_MAPPINGS_SCHEMA, - ); - const now = formatDateTime(new Date()); - - // Check if record exists to preserve created_at - const existing = table.findOne("claude_session_id = ?", [claudeSessionId]); - const createdAt = existing?.created_at ?? now; - - table.upsert({ - claude_session_id: claudeSessionId, - user_session_id: userSessionId, - feature_name: featureName, - created_at: createdAt, - updated_at: now, - }); - - logger.info( - `Saved session mapping: ${claudeSessionId} -> ${userSessionId}`, - ); - } catch (error) { - logger.error(`Failed to save session mapping: ${error}`); - // Don't throw - hook should fail gracefully - } -} - -/** - * Get session mapping from DB - */ -function getSessionMapping( - cwd: string, - claudeSessionId: string, -): SessionMapping | null { - try { - using db = MutilsDB.open(cwd); - const table = db.table( - "session_mappings", - SESSION_MAPPINGS_SCHEMA, - ); - return table.findOne("claude_session_id = ?", [claudeSessionId]); - } catch (error) { - logger.error(`Failed to get session mapping: ${error}`); - return null; - } -} - -/** - * Check if the Bash command was running generate.ts - */ -function isGenerateTsCommand(command: string): boolean { - return GENERATE_PATTERN.test(command); -} - -const hook = defineHook({ - trigger: { - SessionStart: true, - PostToolUse: { - Bash: true, - }, - }, - run: wrapRun(logger, (context) => { - const cwd = process.cwd(); - const eventType = context.input.hook_event_name; - - // Check if we're in a git repository - const gitRoot = findGitRoot(cwd); - if (gitRoot === null) { - logger.debug("Skipping: not a git repository"); - return context.success({}); - } - - // Check if .agents directory exists - const agentsDir = path.join(gitRoot, ".agents"); - if (!existsSync(agentsDir)) { - logger.debug("Skipping: .agents directory does not exist"); - return context.success({}); - } - - // Handle PostToolUse[Bash] - capture session-id generation - if (eventType === "PostToolUse" && context.input.tool_name === "Bash") { - // Type assertion for Bash tool input/output - const toolInput = context.input.tool_input as { command?: string }; - const toolResponse = context.input.tool_response as string; - const command = toolInput.command ?? ""; - - logger.debug(`PostToolUse[Bash] command: ${command}`); - logger.debug(`PostToolUse[Bash] output: ${toolResponse}`); - - // Check if this was a generate.ts execution - if (!isGenerateTsCommand(command)) { - logger.debug("Not a generate.ts command, skipping"); - return context.success({}); - } - - // Extract session-id from output using shared parser - const parsed = parseSessionId(toolResponse); - if (!parsed) { - logger.warn( - `Could not extract session-id from output: ${toolResponse}`, - ); - return context.success({}); - } - - // Save mapping to DB - const claudeSessionId = context.input.session_id; - saveSessionMapping( - gitRoot, - claudeSessionId, - toolResponse, // The full session-id - parsed.featureName, - ); - - // Provide feedback to Claude - return context.json({ - event: "PostToolUse", - output: { - hookSpecificOutput: { - hookEventName: "PostToolUse", - additionalContext: `Session ID "${toolResponse}" has been persisted and will be restored after Auto Compact.`, - }, - suppressOutput: true, - }, - }); - } - - // Handle SessionStart - restore session-id after compact or resume - if (eventType === "SessionStart") { - 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 claudeSessionId = context.input.session_id; - const mapping = getSessionMapping(gitRoot, claudeSessionId); - - if (!mapping) { - logger.debug(`No session mapping found for: ${claudeSessionId}`); - return context.success({}); - } - - logger.info(`Restored session-id: ${mapping.user_session_id}`); - - const sourceDescription = - source === "compact" ? "Auto Compact" : "session resume"; - - // Provide restored session-id to Claude - return context.json({ - event: "SessionStart", - output: { - hookSpecificOutput: { - hookEventName: "SessionStart", - additionalContext: `Session ID Restored: ${mapping.user_session_id} - -The previous session-id has been restored after ${sourceDescription}. Continue using this session-id for your work. - -Session directory: .agents/sessions/${mapping.user_session_id}/`, - }, - suppressOutput: true, - }, - }); - } - - return context.success({}); - }), -}); - -if (import.meta.main) { - await runHook(hook); -} 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 24f990b..4a9c1a5 100644 --- a/plugins/mutils/plugin.json +++ b/plugins/mutils/plugin.json @@ -1,7 +1,7 @@ { "name": "mutils", "description": "汎用ユーティリティ(フック・スキル)", - "version": "0.13.0", + "version": "0.14.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 bbb8c77..a3acab1 100755 --- a/plugins/mutils/skills/ccs-handoff/ccs-handoff.ts +++ b/plugins/mutils/skills/ccs-handoff/ccs-handoff.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { parseSessionId } from "../session-id/patterns.js"; +import { parseWorkspaceId } from "../workspace-id/patterns.js"; // --- Constants --- @@ -455,17 +455,17 @@ interface UserSessionId { } function extractUserSessionId(projectPath: string): UserSessionId | null { - const sessionsDir = path.join(projectPath, ".agents", "sessions"); - if (!fs.existsSync(sessionsDir)) { + const workspacesDir = path.join(projectPath, ".agents", "workspaces"); + if (!fs.existsSync(workspacesDir)) { return null; } - // Get the most recently modified session directory + // Get the most recently modified workspace directory const entries = fs - .readdirSync(sessionsDir, { withFileTypes: true }) + .readdirSync(workspacesDir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => { - const fullPath = path.join(sessionsDir, d.name); + const fullPath = path.join(workspacesDir, d.name); const stat = fs.statSync(fullPath); return { name: d.name, mtime: stat.mtimeMs }; }) @@ -475,19 +475,19 @@ function extractUserSessionId(projectPath: string): UserSessionId | null { return null; } - const sessionId = entries[0]?.name; - if (!sessionId) { + const workspaceId = entries[0]?.name; + if (!workspaceId) { return null; } - // Validate session-id format using shared pattern - const parsed = parseSessionId(sessionId); + // Validate workspace-id format using shared pattern + const parsed = parseWorkspaceId(workspaceId); if (!parsed) { return null; } return { - fullId: sessionId, + fullId: workspaceId, featureName: parsed.featureName, }; } diff --git a/plugins/mutils/skills/workspace-id/SKILL.md b/plugins/mutils/skills/workspace-id/SKILL.md new file mode 100644 index 0000000..c595963 --- /dev/null +++ b/plugins/mutils/skills/workspace-id/SKILL.md @@ -0,0 +1,158 @@ +--- +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/workspace-id/generate.ts *) +--- + +# Workspace ID Skill + +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. + +## Workspace ID Format + +Workspace IDs follow a strict format to ensure consistency across all agent work: + +``` +yyyymmdd-HHmm-[feature-name] +``` + +### Format Components + +| Component | Format | Example | Notes | +|-----------|--------|---------|-------| +| Date | `yyyymmdd` | `20260302` | Local date, 4-digit year, 2-digit month, 2-digit day | +| Time | `HHmm` | `1430` | Local time in 24-hour format (hours and minutes only) | +| Feature Name | kebab-case | `doc-engine` | Must match regex: `/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/` | + +### Feature Name Rules + +- Must start with a lowercase letter +- Can contain lowercase letters and digits +- Hyphens separate words (kebab-case only) +- No underscores, spaces, or special characters +- Examples: `fix-parser`, `add-auth`, `refactor-utils`, `v2-migration` + +## Directory Structure + +All workspace files are organized under the `.agents/workspaces/` directory: + +``` +.agents/workspaces/ +└── [workspace-id]/ + ├── [nnnnnn]-[subagent-name]-[content].md + ├── [nnnnnn]-[subagent-name]-[content].md + └── ... +``` + +The `.agents/workspaces/[workspace-id]/` directory contains all reports and artifacts from a single agent work session. + +## File Naming Convention + +Files within a workspace use a structured naming pattern: + +``` +[nnnnnn]-[subagent-name]-[content].md +``` + +### File Name Components + +| Component | Format | Example | Notes | +|-----------|--------|---------|-------| +| Sequence | `[nnnnnn]` | `000001` | 6-digit zero-padded integer, incremented per file | +| Subagent | `[subagent-name]` | `documentation-engineer` | Identifies which subagent created the file | +| Content Type | `[content]` | `report` | Describes the file's purpose (e.g., `report`, `review`, `plan`) | + +### File Name Examples + +- `000001-documentation-engineer-report.md` — Initial report from documentation engineer +- `000002-code-review-assistant-review.md` — Code review from code review assistant +- `000003-api-designer-feedback.md` — Feedback from API designer + +## Generation Tool + +The `generate.ts` tool generates a properly formatted workspace ID: + +```bash +${CLAUDE_PLUGIN_ROOT}/skills/workspace-id/generate.ts [feature-name] +``` + +**Parameters:** +- `[feature-name]` — The kebab-case feature name for your workspace + +**Output:** +- A valid workspace ID in the format `yyyymmdd-HHmm-[feature-name]` + +**Example:** +```bash +${CLAUDE_PLUGIN_ROOT}/skills/workspace-id/generate.ts doc-review +# Output: 20260302-1430-doc-review +``` + +## Good Examples + +### Correct Workspace ID Usage + +``` +Workspace ID: 20260302-1430-doc-engine +Directory: .agents/workspaces/20260302-1430-doc-engine/ +Files: + - 000001-documentation-engineer-report.md + - 000002-technical-writer-review.md + - 000003-qa-expert-feedback.md +``` + +### Correct Feature Names + +- `doc-engine` — Documentation system building +- `api-v2-migration` — API v2 migration work +- `fix-type-checking` — Type checking fixes +- `add-e2e-tests` — Adding end-to-end tests +- `refactor-auth` — Authentication refactoring + +## Bad Examples + +### Incorrect Workspace IDs + +``` +❌ 20260302-14:30-doc-engine # Time format wrong (use HHmm, not HH:mm) +❌ 20260302-1430-docEngine # Feature name not kebab-case (docEngine) +❌ 20260302-1430-doc_engine # Feature name uses underscore (use hyphens) +❌ 2026-03-02-1430-doc-engine # Date format wrong (use yyyymmdd) +``` + +### Incorrect Feature Names + +- `docEngine` — Camel-case (must be kebab-case) +- `doc_engine` — Underscores (use hyphens only) +- `DOC-ENGINE` — Uppercase (must be lowercase) +- `2-doc-engine` — Starts with digit (must start with letter) +- `doc--engine` — Double hyphens (use single hyphens) + +### Incorrect File Names + +``` +❌ 1-doc-engineer-report.md # Only 1 digit (use 6) +❌ 000001-doc engineer-report.md # Space in subagent name +❌ 000001_documentation_engineer_report.md # Underscores (use hyphens) +❌ 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 workspace-id specification. Use workspace directories as defined above for standard agent work. + +## Integration with Other Plugins + +When creating workspaces for plugin-specific work: + +- **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 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; +}