@localsummer/incspec 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Workflow state management for incspec
3
+ * - Read/write WORKFLOW.md
4
+ * - Track current step
5
+ * - Manage workflow history
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { INCSPEC_DIR, FILES, getTemplatesDir } from './config.mjs';
11
+
12
+ /** Workflow steps definition */
13
+ export const STEPS = [
14
+ { id: 1, name: 'analyze-codeflow', label: '代码流程分析', command: 'analyze' },
15
+ { id: 2, name: 'collect-requirements', label: '结构化需求收集', command: 'collect-req' },
16
+ { id: 3, name: 'collect-dependencies', label: 'UI依赖采集', command: 'collect-dep' },
17
+ { id: 4, name: 'design-increment', label: '增量设计', command: 'design' },
18
+ { id: 5, name: 'apply-code', label: '应用代码变更', command: 'apply' },
19
+ { id: 6, name: 'merge-baseline', label: '合并到基线', command: 'merge' },
20
+ ];
21
+
22
+ /** Step status */
23
+ export const STATUS = {
24
+ PENDING: 'pending',
25
+ IN_PROGRESS: 'in_progress',
26
+ COMPLETED: 'completed',
27
+ };
28
+
29
+ function normalizeOutputName(outputFile) {
30
+ if (!outputFile || typeof outputFile !== 'string') {
31
+ return outputFile;
32
+ }
33
+
34
+ if (!/[\\/]/.test(outputFile)) {
35
+ return outputFile;
36
+ }
37
+
38
+ const parts = outputFile.split(/[/\\]+/);
39
+ return parts[parts.length - 1] || outputFile;
40
+ }
41
+
42
+ function ensureTableSeparator(content, sectionTitle, headerLine, separatorLine) {
43
+ const sectionIndex = content.indexOf(sectionTitle);
44
+ if (sectionIndex === -1) {
45
+ return { content, updated: false };
46
+ }
47
+
48
+ const headerIndex = content.indexOf(headerLine, sectionIndex);
49
+ if (headerIndex === -1) {
50
+ return { content, updated: false };
51
+ }
52
+
53
+ const headerLineEnd = content.indexOf('\n', headerIndex);
54
+ if (headerLineEnd === -1) {
55
+ return { content, updated: false };
56
+ }
57
+
58
+ const nextLineStart = headerLineEnd + 1;
59
+ const nextLineEnd = content.indexOf('\n', nextLineStart);
60
+ const nextLine = content
61
+ .slice(nextLineStart, nextLineEnd === -1 ? content.length : nextLineEnd)
62
+ .replace(/\r$/, '');
63
+
64
+ if (nextLine.trim() === separatorLine.trim()) {
65
+ return { content, updated: false };
66
+ }
67
+
68
+ const updatedContent =
69
+ content.slice(0, nextLineStart) +
70
+ `${separatorLine}\n` +
71
+ content.slice(nextLineStart);
72
+
73
+ return { content: updatedContent, updated: true };
74
+ }
75
+
76
+ function normalizeWorkflowContent(content) {
77
+ let updated = false;
78
+ let normalized = content;
79
+
80
+ const stepsResult = ensureTableSeparator(
81
+ normalized,
82
+ '## 步骤进度',
83
+ '| 步骤 | 状态 | 输出文件 | 完成时间 |',
84
+ '|------|------|---------|---------|'
85
+ );
86
+ normalized = stepsResult.content;
87
+ updated = updated || stepsResult.updated;
88
+
89
+ const historyResult = ensureTableSeparator(
90
+ normalized,
91
+ '## 工作流历史',
92
+ '| 工作流 | 状态 | 开始时间 | 完成时间 |',
93
+ '|--------|------|---------|---------|'
94
+ );
95
+ normalized = historyResult.content;
96
+ updated = updated || historyResult.updated;
97
+
98
+ return { content: normalized, updated };
99
+ }
100
+
101
+ /**
102
+ * Get workflow file path
103
+ * @param {string} projectRoot
104
+ * @returns {string}
105
+ */
106
+ export function getWorkflowPath(projectRoot) {
107
+ return path.join(projectRoot, INCSPEC_DIR, FILES.workflow);
108
+ }
109
+
110
+ /**
111
+ * Parse WORKFLOW.md content
112
+ * @param {string} content
113
+ * @returns {Object}
114
+ */
115
+ export function parseWorkflow(content) {
116
+ const workflow = {
117
+ currentWorkflow: null,
118
+ currentStep: null,
119
+ startTime: null,
120
+ lastUpdate: null,
121
+ steps: [],
122
+ history: [],
123
+ };
124
+
125
+ // Parse current workflow info
126
+ const workflowMatch = content.match(/\*\*当前工作流\*\*:\s*(.+)/);
127
+ if (workflowMatch) {
128
+ workflow.currentWorkflow = workflowMatch[1].trim();
129
+ if (workflow.currentWorkflow === '-' || workflow.currentWorkflow === 'none') {
130
+ workflow.currentWorkflow = null;
131
+ }
132
+ }
133
+
134
+ const stepMatch = content.match(/\*\*当前步骤\*\*:\s*(\d+)/);
135
+ if (stepMatch) {
136
+ workflow.currentStep = parseInt(stepMatch[1], 10);
137
+ }
138
+
139
+ const startMatch = content.match(/\*\*开始时间\*\*:\s*(.+)/);
140
+ if (startMatch) {
141
+ workflow.startTime = startMatch[1].trim();
142
+ }
143
+
144
+ const updateMatch = content.match(/\*\*最后更新\*\*:\s*(.+)/);
145
+ if (updateMatch) {
146
+ workflow.lastUpdate = updateMatch[1].trim();
147
+ }
148
+
149
+ // Parse steps table
150
+ const stepsTableMatch = content.match(/## 步骤进度\n\n\|[^\n]+\n\|[^\n]+\n([\s\S]*?)(?=\n##|\n*$)/);
151
+ if (stepsTableMatch) {
152
+ const rows = stepsTableMatch[1].trim().split('\n').filter(r => r.trim());
153
+ workflow.steps = rows.map(row => {
154
+ const cells = row.split('|').map(c => c.trim()).filter(c => c);
155
+ if (cells.length >= 4) {
156
+ return {
157
+ step: cells[0],
158
+ status: cells[1],
159
+ output: cells[2] === '-' ? null : cells[2],
160
+ completedAt: cells[3] === '-' ? null : cells[3],
161
+ };
162
+ }
163
+ return null;
164
+ }).filter(Boolean);
165
+ }
166
+
167
+ // Parse history table
168
+ const historyMatch = content.match(/## 工作流历史\n\n\|[^\n]+\n\|[^\n]+\n([\s\S]*?)(?=\n##|\n*$)/);
169
+ if (historyMatch) {
170
+ const rows = historyMatch[1].trim().split('\n').filter(r => r.trim());
171
+ workflow.history = rows.map(row => {
172
+ const cells = row.split('|').map(c => c.trim()).filter(c => c);
173
+ if (cells.length >= 4) {
174
+ return {
175
+ name: cells[0],
176
+ status: cells[1],
177
+ startTime: cells[2] === '-' ? null : cells[2],
178
+ endTime: cells[3] === '-' ? null : cells[3],
179
+ };
180
+ }
181
+ return null;
182
+ }).filter(Boolean);
183
+ }
184
+
185
+ return workflow;
186
+ }
187
+
188
+ /**
189
+ * Read workflow state
190
+ * @param {string} projectRoot
191
+ * @returns {Object|null}
192
+ */
193
+ export function readWorkflow(projectRoot) {
194
+ const workflowPath = getWorkflowPath(projectRoot);
195
+
196
+ if (!fs.existsSync(workflowPath)) {
197
+ return null;
198
+ }
199
+
200
+ const content = fs.readFileSync(workflowPath, 'utf-8');
201
+ const normalized = normalizeWorkflowContent(content);
202
+ if (normalized.updated) {
203
+ fs.writeFileSync(workflowPath, normalized.content, 'utf-8');
204
+ }
205
+ return parseWorkflow(normalized.content);
206
+ }
207
+
208
+ /**
209
+ * Generate WORKFLOW.md content
210
+ * @param {Object} workflow
211
+ * @returns {string}
212
+ */
213
+ export function generateWorkflowContent(workflow) {
214
+ const now = new Date().toISOString().replace('T', ' ').slice(0, 16);
215
+
216
+ const lines = [
217
+ '# Workflow Status',
218
+ '',
219
+ `**当前工作流**: ${workflow.currentWorkflow || '-'}`,
220
+ `**当前步骤**: ${workflow.currentStep || '-'}`,
221
+ `**开始时间**: ${workflow.startTime || '-'}`,
222
+ `**最后更新**: ${now}`,
223
+ '',
224
+ '## 步骤进度',
225
+ '',
226
+ '| 步骤 | 状态 | 输出文件 | 完成时间 |',
227
+ '|------|------|---------|---------|',
228
+ ];
229
+
230
+ // Generate steps
231
+ STEPS.forEach((step, index) => {
232
+ const stepData = workflow.steps[index] || {};
233
+ const status = stepData.status || STATUS.PENDING;
234
+ const output = stepData.output || '-';
235
+ const completedAt = stepData.completedAt || '-';
236
+ lines.push(`| ${step.id}. ${step.name} | ${status} | ${output} | ${completedAt} |`);
237
+ });
238
+
239
+ lines.push('');
240
+ lines.push('## 工作流历史');
241
+ lines.push('');
242
+ lines.push('| 工作流 | 状态 | 开始时间 | 完成时间 |');
243
+ lines.push('|--------|------|---------|---------|');
244
+
245
+ // Generate history
246
+ if (workflow.history && workflow.history.length > 0) {
247
+ workflow.history.forEach(item => {
248
+ lines.push(`| ${item.name} | ${item.status} | ${item.startTime || '-'} | ${item.endTime || '-'} |`);
249
+ });
250
+ }
251
+
252
+ return lines.join('\n');
253
+ }
254
+
255
+ /**
256
+ * Write workflow state
257
+ * @param {string} projectRoot
258
+ * @param {Object} workflow
259
+ */
260
+ export function writeWorkflow(projectRoot, workflow) {
261
+ const workflowPath = getWorkflowPath(projectRoot);
262
+ const content = generateWorkflowContent(workflow);
263
+ fs.writeFileSync(workflowPath, content, 'utf-8');
264
+ }
265
+
266
+ /**
267
+ * Initialize empty workflow
268
+ * @param {string} projectRoot
269
+ */
270
+ export function initWorkflow(projectRoot) {
271
+ const workflowPath = getWorkflowPath(projectRoot);
272
+ const content = generateInitialWorkflowContent();
273
+ fs.writeFileSync(workflowPath, content, 'utf-8');
274
+
275
+ return {
276
+ currentWorkflow: null,
277
+ currentStep: null,
278
+ startTime: null,
279
+ lastUpdate: null,
280
+ steps: STEPS.map(() => ({
281
+ status: STATUS.PENDING,
282
+ output: null,
283
+ completedAt: null,
284
+ })),
285
+ history: [],
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Generate initial WORKFLOW.md content from template
291
+ * @returns {string}
292
+ */
293
+ function generateInitialWorkflowContent() {
294
+ const templatePath = path.join(getTemplatesDir(), 'WORKFLOW.md');
295
+ const now = new Date().toISOString().replace('T', ' ').slice(0, 16);
296
+
297
+ if (fs.existsSync(templatePath)) {
298
+ let content = fs.readFileSync(templatePath, 'utf-8');
299
+ content = content.replace(/\{\{last_update\}\}/g, now);
300
+ return content;
301
+ }
302
+
303
+ // Fallback to generated content
304
+ const workflow = {
305
+ currentWorkflow: null,
306
+ currentStep: null,
307
+ startTime: null,
308
+ lastUpdate: null,
309
+ steps: STEPS.map(() => ({
310
+ status: STATUS.PENDING,
311
+ output: null,
312
+ completedAt: null,
313
+ })),
314
+ history: [],
315
+ };
316
+
317
+ return generateWorkflowContent(workflow);
318
+ }
319
+
320
+ /**
321
+ * Start a new workflow
322
+ * @param {string} projectRoot
323
+ * @param {string} workflowName
324
+ * @returns {Object}
325
+ */
326
+ export function startWorkflow(projectRoot, workflowName) {
327
+ const now = new Date().toISOString().replace('T', ' ').slice(0, 16);
328
+ let workflow = readWorkflow(projectRoot);
329
+
330
+ if (!workflow) {
331
+ workflow = initWorkflow(projectRoot);
332
+ }
333
+
334
+ // Archive current workflow if exists
335
+ if (workflow.currentWorkflow) {
336
+ const progress = getWorkflowProgress(workflow);
337
+ const isComplete = progress.completed === progress.total;
338
+
339
+ // Format: "workflowName (completed/total)" for incomplete, just name for complete
340
+ const historyName = isComplete
341
+ ? workflow.currentWorkflow
342
+ : `${workflow.currentWorkflow} (${progress.completed}/${progress.total})`;
343
+
344
+ workflow.history.unshift({
345
+ name: historyName,
346
+ status: isComplete ? 'completed' : 'incomplete',
347
+ startTime: workflow.startTime,
348
+ endTime: now,
349
+ });
350
+ }
351
+
352
+ // Start new workflow
353
+ workflow.currentWorkflow = workflowName;
354
+ workflow.currentStep = 1;
355
+ workflow.startTime = now;
356
+ workflow.steps = STEPS.map(() => ({
357
+ status: STATUS.PENDING,
358
+ output: null,
359
+ completedAt: null,
360
+ }));
361
+
362
+ writeWorkflow(projectRoot, workflow);
363
+ return workflow;
364
+ }
365
+
366
+ /**
367
+ * Update step status
368
+ * @param {string} projectRoot
369
+ * @param {number} stepNumber - 1-based step number
370
+ * @param {string} status
371
+ * @param {string} outputFile
372
+ */
373
+ export function updateStep(projectRoot, stepNumber, status, outputFile = null) {
374
+ const workflow = readWorkflow(projectRoot);
375
+ if (!workflow) {
376
+ throw new Error('工作流未初始化');
377
+ }
378
+
379
+ const index = stepNumber - 1;
380
+ if (index < 0 || index >= STEPS.length) {
381
+ throw new Error(`无效的步骤编号: ${stepNumber}`);
382
+ }
383
+
384
+ const now = new Date().toISOString().replace('T', ' ').slice(0, 16);
385
+ const normalizedOutput = normalizeOutputName(outputFile);
386
+
387
+ workflow.steps[index] = {
388
+ status,
389
+ output: normalizedOutput,
390
+ completedAt: status === STATUS.COMPLETED ? now : null,
391
+ };
392
+
393
+ // Update current step
394
+ if (status === STATUS.IN_PROGRESS) {
395
+ workflow.currentStep = stepNumber;
396
+ } else if (status === STATUS.COMPLETED && stepNumber < STEPS.length) {
397
+ workflow.currentStep = stepNumber + 1;
398
+ }
399
+
400
+ writeWorkflow(projectRoot, workflow);
401
+ return workflow;
402
+ }
403
+
404
+ /**
405
+ * Complete current workflow
406
+ * @param {string} projectRoot
407
+ */
408
+ export function completeWorkflow(projectRoot) {
409
+ const workflow = readWorkflow(projectRoot);
410
+ if (!workflow) {
411
+ throw new Error('工作流未初始化');
412
+ }
413
+
414
+ const now = new Date().toISOString().replace('T', ' ').slice(0, 16);
415
+
416
+ // Add to history
417
+ workflow.history.unshift({
418
+ name: workflow.currentWorkflow,
419
+ status: 'completed',
420
+ startTime: workflow.startTime,
421
+ endTime: now,
422
+ });
423
+
424
+ // Reset current workflow
425
+ workflow.currentWorkflow = null;
426
+ workflow.currentStep = null;
427
+ workflow.startTime = null;
428
+
429
+ writeWorkflow(projectRoot, workflow);
430
+ return workflow;
431
+ }
432
+
433
+ /**
434
+ * Archive current workflow
435
+ * @param {string} projectRoot
436
+ */
437
+ export function archiveWorkflow(projectRoot) {
438
+ const workflow = readWorkflow(projectRoot);
439
+ if (!workflow || !workflow.currentWorkflow) {
440
+ throw new Error('工作流未初始化');
441
+ }
442
+
443
+ const now = new Date().toISOString().replace('T', ' ').slice(0, 16);
444
+
445
+ workflow.history.unshift({
446
+ name: workflow.currentWorkflow,
447
+ status: 'archived',
448
+ startTime: workflow.startTime || '-',
449
+ endTime: now,
450
+ });
451
+
452
+ workflow.currentWorkflow = null;
453
+ workflow.currentStep = null;
454
+ workflow.startTime = null;
455
+ workflow.steps = STEPS.map(() => ({
456
+ status: STATUS.PENDING,
457
+ output: null,
458
+ completedAt: null,
459
+ }));
460
+
461
+ writeWorkflow(projectRoot, workflow);
462
+ return workflow;
463
+ }
464
+
465
+ /**
466
+ * Get step info by number
467
+ * @param {number} stepNumber
468
+ * @returns {Object|null}
469
+ */
470
+ export function getStepInfo(stepNumber) {
471
+ return STEPS.find(s => s.id === stepNumber) || null;
472
+ }
473
+
474
+ /**
475
+ * Get step info by command name
476
+ * @param {string} command
477
+ * @returns {Object|null}
478
+ */
479
+ export function getStepByCommand(command) {
480
+ return STEPS.find(s => s.command === command) || null;
481
+ }
482
+
483
+ /**
484
+ * Calculate workflow completion progress
485
+ * @param {Object} workflow
486
+ * @returns {{completed: number, total: number, lastCompletedStep: number|null}}
487
+ */
488
+ export function getWorkflowProgress(workflow) {
489
+ if (!workflow || !workflow.steps) {
490
+ return { completed: 0, total: STEPS.length, lastCompletedStep: null };
491
+ }
492
+
493
+ let completed = 0;
494
+ let lastCompletedStep = null;
495
+
496
+ workflow.steps.forEach((step, index) => {
497
+ if (step && step.status === STATUS.COMPLETED) {
498
+ completed++;
499
+ lastCompletedStep = index + 1;
500
+ }
501
+ });
502
+
503
+ return {
504
+ completed,
505
+ total: STEPS.length,
506
+ lastCompletedStep,
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Check if workflow is incomplete (has uncompleted steps)
512
+ * @param {Object} workflow
513
+ * @returns {boolean}
514
+ */
515
+ export function isWorkflowIncomplete(workflow) {
516
+ if (!workflow || !workflow.currentWorkflow) {
517
+ return false;
518
+ }
519
+
520
+ const progress = getWorkflowProgress(workflow);
521
+ return progress.completed < progress.total;
522
+ }
523
+
524
+ /**
525
+ * Add entry to workflow history
526
+ * @param {string} projectRoot
527
+ * @param {Object} entry - History entry
528
+ * @param {string} entry.name - Workflow/file name
529
+ * @param {string} entry.status - Status (e.g., 'archived', 'completed')
530
+ * @param {string} [entry.startTime] - Start time (optional)
531
+ * @param {string} [entry.endTime] - End time (optional, defaults to now)
532
+ * @returns {Object} Updated workflow
533
+ */
534
+ export function addToHistory(projectRoot, entry) {
535
+ let workflow = readWorkflow(projectRoot);
536
+
537
+ if (!workflow) {
538
+ workflow = {
539
+ currentWorkflow: null,
540
+ currentStep: null,
541
+ startTime: null,
542
+ lastUpdate: null,
543
+ steps: STEPS.map(() => ({
544
+ status: STATUS.PENDING,
545
+ output: null,
546
+ completedAt: null,
547
+ })),
548
+ history: [],
549
+ };
550
+ }
551
+
552
+ const now = new Date().toISOString().replace('T', ' ').slice(0, 16);
553
+
554
+ workflow.history.unshift({
555
+ name: entry.name,
556
+ status: entry.status,
557
+ startTime: entry.startTime || '-',
558
+ endTime: entry.endTime || now,
559
+ });
560
+
561
+ writeWorkflow(projectRoot, workflow);
562
+ return workflow;
563
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@localsummer/incspec",
3
+ "version": "0.0.1",
4
+ "description": "面向 AI 编程助手的增量规范驱动开发工具",
5
+ "bin": {
6
+ "incspec": "index.mjs"
7
+ },
8
+ "main": "index.mjs",
9
+ "type": "module",
10
+ "scripts": {
11
+ "start": "node index.mjs"
12
+ },
13
+ "keywords": [
14
+ "incspec",
15
+ "incremental",
16
+ "spec-driven",
17
+ "cli",
18
+ "workflow",
19
+ "ai"
20
+ ],
21
+ "author": "localSummer",
22
+ "license": "ISC",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/localSummer/IncSpec.git"
26
+ },
27
+ "homepage": "https://github.com/localSummer/IncSpec#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/localSummer/IncSpec/issues"
30
+ },
31
+ "engines": {
32
+ "node": ">=18.0.0"
33
+ },
34
+ "files": [
35
+ "index.mjs",
36
+ "commands/",
37
+ "lib/",
38
+ "templates/"
39
+ ]
40
+ }