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:
catlog22
2026-02-26 22:03:13 +08:00
parent 430d817e43
commit 6155fcc7b8
115 changed files with 4883 additions and 21127 deletions

View File

@@ -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

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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));

View 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;
}

View File

@@ -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();

View File

@@ -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;

View File

@@ -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);

View File

@@ -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]';
}