mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
feat: add SpecDialog component for editing spec frontmatter
- Implement SpecDialog for managing spec details including title, read mode, priority, and keywords. - Add validation and keyword management functionality. - Integrate SpecDialog into SpecsSettingsPage for editing specs. feat: create index file for specs components - Export SpecCard, SpecDialog, and related types from a new index file for better organization. feat: implement SpecsSettingsPage for managing specs and hooks - Create main settings page with tabs for Project Specs, Personal Specs, Hooks, Injection, and Settings. - Integrate SpecDialog and HookDialog for editing specs and hooks. - Add search functionality and mock data for specs and hooks. feat: add spec management API routes - Implement API endpoints for listing specs, getting spec details, updating frontmatter, rebuilding indices, and initializing the spec system. - Handle errors and responses appropriately for each endpoint.
This commit is contained in:
@@ -716,7 +716,7 @@ async function notifyAction(options: HookOptions): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Project state action - reads project-tech.json and project-guidelines.json
|
||||
* Project state action - reads project-tech.json and specs
|
||||
* and outputs a concise summary for session context injection.
|
||||
*
|
||||
* Used as SessionStart hook: stdout → injected as system message.
|
||||
@@ -767,31 +767,19 @@ async function projectStateAction(options: HookOptions): Promise<void> {
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
// Read project-guidelines.json
|
||||
const guidelinesPath = join(projectPath, '.workflow', 'project-guidelines.json');
|
||||
if (existsSync(guidelinesPath)) {
|
||||
try {
|
||||
const gl = JSON.parse(readFileSync(guidelinesPath, 'utf8'));
|
||||
// constraints is Record<string, array> - flatten all categories
|
||||
const allConstraints: string[] = [];
|
||||
if (gl.constraints && typeof gl.constraints === 'object') {
|
||||
for (const entries of Object.values(gl.constraints)) {
|
||||
if (Array.isArray(entries)) {
|
||||
for (const c of entries) {
|
||||
allConstraints.push(typeof c === 'string' ? c : (c as { rule?: string }).rule || JSON.stringify(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Read specs from spec system (ccw spec load --dimension specs)
|
||||
try {
|
||||
const { getDimensionIndex } = await import('../tools/spec-index-builder.js');
|
||||
const specsIndex = await getDimensionIndex(projectPath, 'specs');
|
||||
const constraints: string[] = [];
|
||||
for (const entry of specsIndex.entries) {
|
||||
if (entry.readMode === 'required') {
|
||||
constraints.push(entry.title);
|
||||
}
|
||||
result.guidelines.constraints = allConstraints.slice(0, limit);
|
||||
|
||||
const learnings = Array.isArray(gl.learnings) ? gl.learnings : [];
|
||||
learnings.sort((a: { date?: string }, b: { date?: string }) => (b.date || '').localeCompare(a.date || ''));
|
||||
result.guidelines.recent_learnings = learnings.slice(0, limit).map(
|
||||
(l: { insight?: string; date?: string }) => ({ insight: l.insight || '', date: l.date || '' })
|
||||
);
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
}
|
||||
result.guidelines.constraints = constraints.slice(0, limit);
|
||||
result.guidelines.recent_learnings = [];
|
||||
} catch { /* ignore errors */ }
|
||||
|
||||
if (stdin) {
|
||||
// Format as <project-state> tag for system message injection
|
||||
|
||||
@@ -205,7 +205,7 @@ export async function aggregateData(sessions: ScanSessionsResult, workflowDir: s
|
||||
join(workflowDir, 'active'),
|
||||
join(workflowDir, 'archives'),
|
||||
join(workflowDir, 'project-tech.json'),
|
||||
join(workflowDir, 'project-guidelines.json'),
|
||||
join(workflowDir, 'specs'),
|
||||
...sessions.active.map(s => s.path),
|
||||
...sessions.archived.map(s => s.path)
|
||||
];
|
||||
@@ -564,14 +564,12 @@ function sortTaskIds(a: string, b: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load project overview from project-tech.json and project-guidelines.json
|
||||
* Load project overview from project-tech.json
|
||||
* @param workflowDir - Path to .workflow directory
|
||||
* @returns Project overview data or null if not found
|
||||
*/
|
||||
export function loadProjectOverview(workflowDir: string): ProjectOverview | null {
|
||||
const techFile = join(workflowDir, 'project-tech.json');
|
||||
const guidelinesFile = join(workflowDir, 'project-guidelines.json');
|
||||
|
||||
if (!existsSync(techFile)) {
|
||||
console.log(`Project file not found at: ${techFile}`);
|
||||
return null;
|
||||
@@ -607,44 +605,9 @@ export function loadProjectOverview(workflowDir: string): ProjectOverview | null
|
||||
});
|
||||
};
|
||||
|
||||
// Load guidelines from separate file if exists
|
||||
let guidelines: ProjectGuidelines | null = null;
|
||||
if (existsSync(guidelinesFile)) {
|
||||
try {
|
||||
const guidelinesContent = readFileSync(guidelinesFile, 'utf8');
|
||||
const guidelinesData = JSON.parse(guidelinesContent) as Record<string, unknown>;
|
||||
|
||||
const conventions = guidelinesData.conventions as Record<string, string[]> | undefined;
|
||||
const constraints = guidelinesData.constraints as Record<string, string[]> | undefined;
|
||||
|
||||
guidelines = {
|
||||
conventions: {
|
||||
coding_style: conventions?.coding_style || [],
|
||||
naming_patterns: conventions?.naming_patterns || [],
|
||||
file_structure: conventions?.file_structure || [],
|
||||
documentation: conventions?.documentation || []
|
||||
},
|
||||
constraints: {
|
||||
architecture: constraints?.architecture || [],
|
||||
tech_stack: constraints?.tech_stack || [],
|
||||
performance: constraints?.performance || [],
|
||||
security: constraints?.security || []
|
||||
},
|
||||
quality_rules: (guidelinesData.quality_rules as Array<{ rule: string; scope: string; enforced_by?: string }>) || [],
|
||||
learnings: (guidelinesData.learnings as Array<{
|
||||
date: string;
|
||||
session_id?: string;
|
||||
insight: string;
|
||||
context?: string;
|
||||
category?: string;
|
||||
}>) || [],
|
||||
_metadata: guidelinesData._metadata as ProjectGuidelines['_metadata'] | undefined
|
||||
};
|
||||
console.log(`Successfully loaded project guidelines`);
|
||||
} catch (guidelinesErr) {
|
||||
console.error(`Failed to parse project-guidelines.json:`, (guidelinesErr as Error).message);
|
||||
}
|
||||
}
|
||||
// Guidelines now managed by spec system (ccw spec load)
|
||||
// Return null - dashboard doesn't need guidelines data directly
|
||||
const guidelines: ProjectGuidelines | null = null;
|
||||
|
||||
return {
|
||||
projectName: (projectData.project_name as string) || 'Unknown',
|
||||
|
||||
@@ -7,7 +7,6 @@ import { listTools } from '../../tools/index.js';
|
||||
import { loadProjectOverview } from '../data-aggregator.js';
|
||||
import { resolvePath } from '../../utils/path-resolver.js';
|
||||
import { join } from 'path';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import type { RouteContext } from './types.js';
|
||||
|
||||
/**
|
||||
@@ -46,74 +45,23 @@ export async function handleCcwRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Get Project Guidelines
|
||||
// API: Get Project Guidelines (DEPRECATED - use spec system)
|
||||
if (pathname === '/api/ccw/guidelines' && req.method === 'GET') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
const guidelinesFile = join(resolvedPath, '.workflow', 'project-guidelines.json');
|
||||
|
||||
if (!existsSync(guidelinesFile)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ guidelines: null }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(guidelinesFile, 'utf-8');
|
||||
const guidelines = JSON.parse(content);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ guidelines }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to read guidelines file' }));
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
deprecated: true,
|
||||
message: 'Use /api/specs/list instead. Guidelines are now managed by the spec system (ccw spec).'
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Update Project Guidelines
|
||||
// API: Update Project Guidelines (DEPRECATED - use spec system)
|
||||
if (pathname === '/api/ccw/guidelines' && req.method === 'PUT') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
const guidelinesFile = join(resolvedPath, '.workflow', 'project-guidelines.json');
|
||||
|
||||
try {
|
||||
const data = body as Record<string, unknown>;
|
||||
|
||||
// Read existing file to preserve _metadata.created_at
|
||||
let existingMetadata: Record<string, unknown> = {};
|
||||
if (existsSync(guidelinesFile)) {
|
||||
try {
|
||||
const existing = JSON.parse(readFileSync(guidelinesFile, 'utf-8'));
|
||||
existingMetadata = existing._metadata || {};
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
// Build the guidelines object
|
||||
const guidelines = {
|
||||
conventions: data.conventions || { coding_style: [], naming_patterns: [], file_structure: [], documentation: [] },
|
||||
constraints: data.constraints || { architecture: [], tech_stack: [], performance: [], security: [] },
|
||||
quality_rules: data.quality_rules || [],
|
||||
learnings: data.learnings || [],
|
||||
_metadata: {
|
||||
created_at: (existingMetadata.created_at as string) || new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
version: (existingMetadata.version as string) || '1.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(guidelinesFile, JSON.stringify(guidelines, null, 2), 'utf-8');
|
||||
|
||||
broadcastToClients({
|
||||
type: 'PROJECT_GUIDELINES_UPDATED',
|
||||
payload: { timestamp: new Date().toISOString() },
|
||||
});
|
||||
|
||||
return { success: true, guidelines };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
deprecated: true,
|
||||
message: 'Use /api/specs/update-frontmatter instead. Guidelines are now managed by the spec system (ccw spec).'
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -548,29 +548,20 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
// Read project-guidelines.json
|
||||
const guidelinesPath = join(projectPath, '.workflow', 'project-guidelines.json');
|
||||
if (existsSync(guidelinesPath)) {
|
||||
try {
|
||||
const gl = JSON.parse(readFileSync(guidelinesPath, 'utf8'));
|
||||
const g = result.guidelines as Record<string, unknown>;
|
||||
// constraints is Record<string, array> - flatten all categories
|
||||
const allConstraints: string[] = [];
|
||||
if (gl.constraints && typeof gl.constraints === 'object') {
|
||||
for (const entries of Object.values(gl.constraints)) {
|
||||
if (Array.isArray(entries)) {
|
||||
for (const c of entries) {
|
||||
allConstraints.push(typeof c === 'string' ? c : (c as { rule?: string }).rule || JSON.stringify(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Read specs from spec system (ccw spec load --dimension specs)
|
||||
try {
|
||||
const { getDimensionIndex } = await import('../../tools/spec-index-builder.js');
|
||||
const specsIndex = await getDimensionIndex(projectPath, 'specs');
|
||||
const g = result.guidelines as Record<string, unknown>;
|
||||
const constraints: string[] = [];
|
||||
for (const entry of specsIndex.entries) {
|
||||
if (entry.readMode === 'required') {
|
||||
constraints.push(entry.title);
|
||||
}
|
||||
g.constraints = allConstraints.slice(0, limit);
|
||||
const learnings = Array.isArray(gl.learnings) ? gl.learnings : [];
|
||||
learnings.sort((a: { date?: string }, b: { date?: string }) => (b.date || '').localeCompare(a.date || ''));
|
||||
g.recent_learnings = learnings.slice(0, limit).map((l: { insight?: string; date?: string }) => ({ insight: l.insight || '', date: l.date || '' }));
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
}
|
||||
g.constraints = constraints.slice(0, limit);
|
||||
g.recent_learnings = [];
|
||||
} catch { /* ignore errors */ }
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
|
||||
232
ccw/src/core/routes/spec-routes.ts
Normal file
232
ccw/src/core/routes/spec-routes.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Spec Routes Module
|
||||
* Handles all spec management API endpoints
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { resolvePath } from '../../utils/path-resolver.js';
|
||||
import type { RouteContext } from './types.js';
|
||||
|
||||
/**
|
||||
* Handle Spec routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
*/
|
||||
export async function handleSpecRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
|
||||
|
||||
// API: List all specs from index
|
||||
if (pathname === '/api/specs/list' && req.method === 'GET') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
|
||||
try {
|
||||
const { getDimensionIndex, SPEC_DIMENSIONS } = await import(
|
||||
'../../tools/spec-index-builder.js'
|
||||
);
|
||||
|
||||
const result: Record<string, unknown[]> = {};
|
||||
|
||||
for (const dim of SPEC_DIMENSIONS) {
|
||||
const index = await getDimensionIndex(resolvedPath, dim);
|
||||
result[dim] = index.entries;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ specs: result }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Get spec detail (MD content)
|
||||
if (pathname === '/api/specs/detail' && req.method === 'GET') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
const file = url.searchParams.get('file');
|
||||
|
||||
if (!file) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing file parameter' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = join(resolvedPath, file);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'File not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ content }));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Update frontmatter (toggle readMode)
|
||||
if (pathname === '/api/specs/update-frontmatter' && req.method === 'PUT') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
const data = body as { file: string; readMode: string };
|
||||
|
||||
if (!data.file || !data.readMode) {
|
||||
return { error: 'Missing file or readMode', status: 400 };
|
||||
}
|
||||
|
||||
const filePath = join(resolvedPath, data.file);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return { error: 'File not found', status: 404 };
|
||||
}
|
||||
|
||||
try {
|
||||
const matter = (await import('gray-matter')).default;
|
||||
const raw = readFileSync(filePath, 'utf-8');
|
||||
const parsed = matter(raw);
|
||||
|
||||
parsed.data.readMode = data.readMode;
|
||||
|
||||
const updated = matter.stringify(parsed.content, parsed.data);
|
||||
writeFileSync(filePath, updated, 'utf-8');
|
||||
|
||||
return { success: true, readMode: data.readMode };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Rebuild index
|
||||
if (pathname === '/api/specs/rebuild' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async () => {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
|
||||
try {
|
||||
const { buildAllIndices, readCachedIndex, SPEC_DIMENSIONS } = await import(
|
||||
'../../tools/spec-index-builder.js'
|
||||
);
|
||||
|
||||
await buildAllIndices(resolvedPath);
|
||||
|
||||
const stats: Record<string, number> = {};
|
||||
for (const dim of SPEC_DIMENSIONS) {
|
||||
const cached = readCachedIndex(resolvedPath, dim);
|
||||
stats[dim] = cached?.entries.length ?? 0;
|
||||
}
|
||||
|
||||
return { success: true, stats };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Init spec system
|
||||
if (pathname === '/api/specs/init' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async () => {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
|
||||
try {
|
||||
const { initSpecSystem } = await import('../../tools/spec-init.js');
|
||||
const result = initSpecSystem(resolvedPath);
|
||||
return { success: true, ...result };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Get spec stats (dimensions count + injection length info)
|
||||
if (pathname === '/api/specs/stats' && req.method === 'GET') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
|
||||
try {
|
||||
const { getDimensionIndex, SPEC_DIMENSIONS } = await import(
|
||||
'../../tools/spec-index-builder.js'
|
||||
);
|
||||
|
||||
// Get maxLength from system settings
|
||||
let maxLength = 8000;
|
||||
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const rawSettings = readFileSync(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(rawSettings) as {
|
||||
system?: { injectionControl?: { maxLength?: number } };
|
||||
};
|
||||
maxLength = settings?.system?.injectionControl?.maxLength || 8000;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const dimensions: Record<string, { count: number; requiredCount: number }> = {};
|
||||
let totalRequiredLength = 0;
|
||||
let totalWithKeywords = 0;
|
||||
|
||||
for (const dim of SPEC_DIMENSIONS) {
|
||||
const index = await getDimensionIndex(resolvedPath, dim);
|
||||
let count = 0;
|
||||
let requiredCount = 0;
|
||||
|
||||
for (const entry of index.entries) {
|
||||
count++;
|
||||
// Calculate content length by reading the file
|
||||
const filePath = join(resolvedPath, entry.file);
|
||||
let contentLength = 0;
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
const rawContent = readFileSync(filePath, 'utf-8');
|
||||
// Strip frontmatter to get actual content length
|
||||
const matter = (await import('gray-matter')).default;
|
||||
const parsed = matter(rawContent);
|
||||
contentLength = parsed.content.length;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (entry.readMode === 'required') {
|
||||
requiredCount++;
|
||||
totalRequiredLength += contentLength;
|
||||
}
|
||||
totalWithKeywords += contentLength;
|
||||
}
|
||||
|
||||
dimensions[dim] = { count, requiredCount };
|
||||
}
|
||||
|
||||
const percentage = totalWithKeywords > 0 ? Math.round((totalWithKeywords / maxLength) * 100) : 0;
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
dimensions,
|
||||
injectionLength: {
|
||||
requiredOnly: totalRequiredLength,
|
||||
withKeywords: totalWithKeywords,
|
||||
maxLength,
|
||||
percentage
|
||||
}
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
* Handles all system-related API endpoints
|
||||
*/
|
||||
import type { Server } from 'http';
|
||||
import { readFileSync, existsSync, promises as fsPromises } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, promises as fsPromises } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { resolvePath, getRecentPaths, trackRecentPath, removeRecentPath, normalizePathForDisplay } from '../../utils/path-resolver.js';
|
||||
import { validatePath as validateAllowedPath } from '../../utils/path-validator.js';
|
||||
import { scanSessions } from '../session-scanner.js';
|
||||
@@ -24,6 +25,196 @@ interface SystemRouteContext extends RouteContext {
|
||||
server: Server;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// System Settings Helper Functions
|
||||
// ========================================
|
||||
|
||||
const GLOBAL_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
||||
|
||||
// Default system settings
|
||||
const DEFAULT_INJECTION_CONTROL = {
|
||||
maxLength: 8000,
|
||||
warnThreshold: 6000,
|
||||
truncateOnExceed: true
|
||||
};
|
||||
|
||||
const DEFAULT_PERSONAL_SPEC_DEFAULTS = {
|
||||
defaultReadMode: 'optional',
|
||||
autoEnable: true
|
||||
};
|
||||
|
||||
// Recommended hooks for spec injection
|
||||
const RECOMMENDED_HOOKS = [
|
||||
{
|
||||
id: 'spec-injection-session',
|
||||
event: 'SessionStart',
|
||||
name: 'Spec Context Injection (Session)',
|
||||
command: 'ccw spec load --stdin',
|
||||
description: 'Session开始时注入规范上下文',
|
||||
scope: 'global',
|
||||
autoInstall: true
|
||||
},
|
||||
{
|
||||
id: 'spec-injection-prompt',
|
||||
event: 'UserPromptSubmit',
|
||||
name: 'Spec Context Injection (Prompt)',
|
||||
command: 'ccw spec load --stdin',
|
||||
description: '提示词触发时注入规范上下文',
|
||||
scope: 'project',
|
||||
autoInstall: true
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Read settings file safely
|
||||
*/
|
||||
function readSettingsFile(filePath: string): Record<string, unknown> {
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
if (!content.trim()) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(content);
|
||||
} catch (error: unknown) {
|
||||
console.error(`Error reading settings file ${filePath}:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system settings from global settings file
|
||||
*/
|
||||
function getSystemSettings(): {
|
||||
injectionControl: typeof DEFAULT_INJECTION_CONTROL;
|
||||
personalSpecDefaults: typeof DEFAULT_PERSONAL_SPEC_DEFAULTS;
|
||||
recommendedHooks: typeof RECOMMENDED_HOOKS;
|
||||
} {
|
||||
const settings = readSettingsFile(GLOBAL_SETTINGS_PATH) as Record<string, unknown>;
|
||||
const system = (settings.system || {}) as Record<string, unknown>;
|
||||
const user = (settings.user || {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
injectionControl: {
|
||||
...DEFAULT_INJECTION_CONTROL,
|
||||
...((system.injectionControl || {}) as Record<string, unknown>)
|
||||
} as typeof DEFAULT_INJECTION_CONTROL,
|
||||
personalSpecDefaults: {
|
||||
...DEFAULT_PERSONAL_SPEC_DEFAULTS,
|
||||
...((user.personalSpecDefaults || {}) as Record<string, unknown>)
|
||||
} as typeof DEFAULT_PERSONAL_SPEC_DEFAULTS,
|
||||
recommendedHooks: RECOMMENDED_HOOKS
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save system settings to global settings file
|
||||
*/
|
||||
function saveSystemSettings(updates: {
|
||||
injectionControl?: Partial<typeof DEFAULT_INJECTION_CONTROL>;
|
||||
personalSpecDefaults?: Partial<typeof DEFAULT_PERSONAL_SPEC_DEFAULTS>;
|
||||
}): { success: boolean; settings?: Record<string, unknown>; error?: string } {
|
||||
try {
|
||||
const settings = readSettingsFile(GLOBAL_SETTINGS_PATH) as Record<string, unknown>;
|
||||
|
||||
// Initialize nested objects if needed
|
||||
if (!settings.system) settings.system = {};
|
||||
if (!settings.user) settings.user = {};
|
||||
|
||||
const system = settings.system as Record<string, unknown>;
|
||||
const user = settings.user as Record<string, unknown>;
|
||||
|
||||
// Apply updates
|
||||
if (updates.injectionControl) {
|
||||
system.injectionControl = {
|
||||
...DEFAULT_INJECTION_CONTROL,
|
||||
...((system.injectionControl || {}) as Record<string, unknown>),
|
||||
...updates.injectionControl
|
||||
};
|
||||
}
|
||||
|
||||
if (updates.personalSpecDefaults) {
|
||||
user.personalSpecDefaults = {
|
||||
...DEFAULT_PERSONAL_SPEC_DEFAULTS,
|
||||
...((user.personalSpecDefaults || {}) as Record<string, unknown>),
|
||||
...updates.personalSpecDefaults
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dirPath = dirname(GLOBAL_SETTINGS_PATH);
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(GLOBAL_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
||||
|
||||
return { success: true, settings };
|
||||
} catch (error: unknown) {
|
||||
console.error('Error saving system settings:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a recommended hook to settings
|
||||
*/
|
||||
function installRecommendedHook(
|
||||
hookId: string,
|
||||
scope: 'global' | 'project'
|
||||
): { success: boolean; installed?: Record<string, unknown>; error?: string; status?: string } {
|
||||
const hook = RECOMMENDED_HOOKS.find(h => h.id === hookId);
|
||||
if (!hook) {
|
||||
return { success: false, error: 'Hook not found', status: 'not-found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = scope === 'global' ? GLOBAL_SETTINGS_PATH : join(process.cwd(), '.claude', 'settings.json');
|
||||
const settings = readSettingsFile(filePath) as Record<string, unknown> & { hooks?: Record<string, unknown[]> };
|
||||
|
||||
// Initialize hooks object if needed
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
|
||||
const event = hook.event;
|
||||
if (!settings.hooks[event]) {
|
||||
settings.hooks[event] = [];
|
||||
}
|
||||
|
||||
// Check if hook already exists (by command)
|
||||
const existingHooks = (settings.hooks[event] || []) as Array<Record<string, unknown>>;
|
||||
const existingIndex = existingHooks.findIndex(
|
||||
(h) => (h as Record<string, unknown>).command === hook.command
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
return { success: true, installed: { id: hookId, event, status: 'already-exists' } };
|
||||
}
|
||||
|
||||
// Add new hook
|
||||
settings.hooks[event].push({
|
||||
name: hook.name,
|
||||
command: hook.command,
|
||||
timeout: 5000,
|
||||
failMode: 'silent'
|
||||
});
|
||||
|
||||
// Ensure directory exists
|
||||
const dirPath = dirname(filePath);
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
|
||||
return { success: true, installed: { id: hookId, event, status: 'installed' } };
|
||||
} catch (error: unknown) {
|
||||
console.error('Error installing hook:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper Functions
|
||||
// ========================================
|
||||
@@ -196,6 +387,67 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// System Settings API Endpoints
|
||||
// ========================================
|
||||
|
||||
// API: Get system settings (injection control + personal spec defaults + recommended hooks)
|
||||
if (pathname === '/api/system/settings' && req.method === 'GET') {
|
||||
try {
|
||||
const settings = getSystemSettings();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(settings));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Save system settings
|
||||
if (pathname === '/api/system/settings' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const updates = body as {
|
||||
injectionControl?: { maxLength?: number; warnThreshold?: number; truncateOnExceed?: boolean };
|
||||
personalSpecDefaults?: { defaultReadMode?: string; autoEnable?: boolean };
|
||||
};
|
||||
|
||||
const result = saveSystemSettings(updates);
|
||||
if (result.error) {
|
||||
return { error: result.error, status: 500 };
|
||||
}
|
||||
return { success: true, settings: result.settings };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Install recommended hooks
|
||||
if (pathname === '/api/system/hooks/install-recommended' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { hookIds, scope } = body as {
|
||||
hookIds?: string[];
|
||||
scope?: 'global' | 'project';
|
||||
};
|
||||
|
||||
if (!hookIds || !Array.isArray(hookIds)) {
|
||||
return { error: 'hookIds array is required', status: 400 };
|
||||
}
|
||||
|
||||
const targetScope = scope || 'global';
|
||||
const installed: Array<{ id: string; event: string; status: string }> = [];
|
||||
|
||||
for (const hookId of hookIds) {
|
||||
const result = installRecommendedHook(hookId, targetScope);
|
||||
if (result.success && result.installed) {
|
||||
installed.push(result.installed as { id: string; event: string; status: string });
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, installed };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Get recent paths
|
||||
if (pathname === '/api/recent-paths') {
|
||||
const paths = getRecentPaths();
|
||||
|
||||
@@ -41,6 +41,7 @@ import { handleConfigRoutes } from './routes/config-routes.js';
|
||||
import { handleTeamRoutes } from './routes/team-routes.js';
|
||||
import { handleNotificationRoutes } from './routes/notification-routes.js';
|
||||
import { handleAnalysisRoutes } from './routes/analysis-routes.js';
|
||||
import { handleSpecRoutes } from './routes/spec-routes.js';
|
||||
|
||||
// Import WebSocket handling
|
||||
import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js';
|
||||
@@ -523,6 +524,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleGraphRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Spec routes (/api/specs/*)
|
||||
if (pathname.startsWith('/api/specs/')) {
|
||||
if (await handleSpecRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// CCW routes (/api/ccw and /api/ccw/*)
|
||||
if (pathname.startsWith('/api/ccw')) {
|
||||
if (await handleCcwRoutes(routeContext)) return;
|
||||
|
||||
@@ -267,8 +267,8 @@ export function initSpecSystem(projectPath: string): InitResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Create index directory
|
||||
const indexPath = join(workflowDir, INDEX_DIR);
|
||||
// Create index directory at project root (matches spec-index-builder.ts location)
|
||||
const indexPath = join(projectPath, INDEX_DIR);
|
||||
if (!existsSync(indexPath)) {
|
||||
mkdirSync(indexPath, { recursive: true });
|
||||
result.directories.push(indexPath);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import matter from 'gray-matter';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
import {
|
||||
getDimensionIndex,
|
||||
@@ -49,6 +50,10 @@ export interface SpecLoadOptions {
|
||||
stdinData?: { user_prompt?: string; prompt?: string; [key: string]: unknown };
|
||||
/** Enable debug logging to stderr */
|
||||
debug?: boolean;
|
||||
/** Maximum content length in characters (default: 8000) */
|
||||
maxLength?: number;
|
||||
/** Whether to truncate content if it exceeds maxLength (default: true) */
|
||||
truncateOnExceed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,6 +68,19 @@ export interface SpecLoadResult {
|
||||
matchedSpecs: string[];
|
||||
/** Total number of spec files loaded */
|
||||
totalLoaded: number;
|
||||
/** Content length statistics */
|
||||
contentLength: {
|
||||
/** Original content length before truncation */
|
||||
original: number;
|
||||
/** Final content length (after truncation if applied) */
|
||||
final: number;
|
||||
/** Maximum allowed length */
|
||||
maxLength: number;
|
||||
/** Whether content was truncated */
|
||||
truncated: boolean;
|
||||
/** Percentage of max length used */
|
||||
percentage: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,7 +132,8 @@ const SPEC_PRIORITY_WEIGHT: Record<string, number> = {
|
||||
* 3. Filter: all required specs + optional specs with keyword match
|
||||
* 4. Load MD file content (strip frontmatter)
|
||||
* 5. Merge by dimension priority
|
||||
* 6. Format for CLI (markdown) or Hook (JSON)
|
||||
* 6. Check length and truncate if needed
|
||||
* 7. Format for CLI (markdown) or Hook (JSON)
|
||||
*
|
||||
* @param options - Loading configuration
|
||||
* @returns SpecLoadResult with formatted content
|
||||
@@ -122,6 +141,10 @@ const SPEC_PRIORITY_WEIGHT: Record<string, number> = {
|
||||
export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResult> {
|
||||
const { projectPath, outputFormat, debug } = options;
|
||||
|
||||
// Get injection control settings
|
||||
const maxLength = options.maxLength ?? 8000;
|
||||
const truncateOnExceed = options.truncateOnExceed ?? true;
|
||||
|
||||
// Step 1: Resolve keywords
|
||||
const keywords = resolveKeywords(options);
|
||||
|
||||
@@ -165,16 +188,40 @@ export async function loadSpecs(options: SpecLoadOptions): Promise<SpecLoadResul
|
||||
// Step 5: Merge by dimension priority
|
||||
const mergedContent = mergeByPriority(allLoadedSpecs);
|
||||
|
||||
// Step 6: Format output
|
||||
// Step 6: Check length and truncate if needed
|
||||
const originalLength = mergedContent.length;
|
||||
let finalContent = mergedContent;
|
||||
let truncated = false;
|
||||
|
||||
if (originalLength > maxLength && truncateOnExceed) {
|
||||
// Truncate content, preserving complete sections where possible
|
||||
finalContent = truncateContent(mergedContent, maxLength);
|
||||
truncated = true;
|
||||
|
||||
if (debug) {
|
||||
debugLog(`Content truncated: ${originalLength} -> ${finalContent.length} (max: ${maxLength})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Format output
|
||||
const matchedTitles = allLoadedSpecs.map(s => s.title);
|
||||
const content = formatOutput(mergedContent, matchedTitles, outputFormat);
|
||||
const content = formatOutput(finalContent, matchedTitles, outputFormat);
|
||||
const format = outputFormat === 'cli' ? 'markdown' : 'json';
|
||||
|
||||
const percentage = Math.round((originalLength / maxLength) * 100);
|
||||
|
||||
return {
|
||||
content,
|
||||
format,
|
||||
matchedSpecs: matchedTitles,
|
||||
totalLoaded: allLoadedSpecs.length,
|
||||
contentLength: {
|
||||
original: originalLength,
|
||||
final: finalContent.length,
|
||||
maxLength,
|
||||
truncated,
|
||||
percentage: Math.min(percentage, 100),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -376,3 +423,37 @@ function formatOutput(
|
||||
function debugLog(message: string): void {
|
||||
process.stderr.write(`[spec-loader] ${message}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate content to fit within maxLength while preserving complete sections.
|
||||
*
|
||||
* Strategy: Remove sections from the end (lowest priority) until within limit.
|
||||
* Each section is delimited by '\n\n---\n\n' from mergeByPriority.
|
||||
*
|
||||
* @param content - Full merged content
|
||||
* @param maxLength - Maximum allowed length
|
||||
* @returns Truncated content string
|
||||
*/
|
||||
function truncateContent(content: string, maxLength: number): string {
|
||||
if (content.length <= maxLength) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Split by section separator
|
||||
const sections = content.split('\n\n---\n\n');
|
||||
|
||||
// Remove sections from the end until we're within limit
|
||||
while (sections.length > 1) {
|
||||
sections.pop();
|
||||
|
||||
const newContent = sections.join('\n\n---\n\n');
|
||||
if (newContent.length <= maxLength) {
|
||||
// Add truncation notice
|
||||
return newContent + '\n\n---\n\n[Content truncated due to length limit]';
|
||||
}
|
||||
}
|
||||
|
||||
// If single section is still too long, hard truncate
|
||||
const truncated = sections[0]?.substring(0, maxLength - 50) ?? '';
|
||||
return truncated + '\n\n[Content truncated due to length limit]';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user