teamspec 3.2.0

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +252 -0
  3. package/bin/teamspec-init.js +10 -0
  4. package/extensions/teamspec-0.1.0.vsix +0 -0
  5. package/lib/cli.js +1174 -0
  6. package/lib/extension-installer.js +236 -0
  7. package/lib/linter.js +1184 -0
  8. package/lib/prompt-generator.js +409 -0
  9. package/package.json +51 -0
  10. package/teamspec-core/agents/AGENT_BA.md +486 -0
  11. package/teamspec-core/agents/AGENT_BOOTSTRAP.md +447 -0
  12. package/teamspec-core/agents/AGENT_DES.md +623 -0
  13. package/teamspec-core/agents/AGENT_DEV.md +611 -0
  14. package/teamspec-core/agents/AGENT_FA.md +736 -0
  15. package/teamspec-core/agents/AGENT_FEEDBACK.md +202 -0
  16. package/teamspec-core/agents/AGENT_FIX.md +380 -0
  17. package/teamspec-core/agents/AGENT_QA.md +756 -0
  18. package/teamspec-core/agents/AGENT_SA.md +581 -0
  19. package/teamspec-core/agents/AGENT_SM.md +771 -0
  20. package/teamspec-core/agents/README.md +383 -0
  21. package/teamspec-core/context/_schema.yml +222 -0
  22. package/teamspec-core/copilot-instructions.md +356 -0
  23. package/teamspec-core/definitions/definition-of-done.md +129 -0
  24. package/teamspec-core/definitions/definition-of-ready.md +104 -0
  25. package/teamspec-core/profiles/enterprise.yml +127 -0
  26. package/teamspec-core/profiles/platform-team.yml +104 -0
  27. package/teamspec-core/profiles/regulated.yml +97 -0
  28. package/teamspec-core/profiles/startup.yml +85 -0
  29. package/teamspec-core/teamspec.yml +69 -0
  30. package/teamspec-core/templates/README.md +211 -0
  31. package/teamspec-core/templates/active-sprint-template.md +98 -0
  32. package/teamspec-core/templates/adr-template.md +194 -0
  33. package/teamspec-core/templates/bug-report-template.md +188 -0
  34. package/teamspec-core/templates/business-analysis-template.md +164 -0
  35. package/teamspec-core/templates/decision-log-template.md +216 -0
  36. package/teamspec-core/templates/feature-template.md +269 -0
  37. package/teamspec-core/templates/functional-spec-template.md +161 -0
  38. package/teamspec-core/templates/refinement-notes-template.md +133 -0
  39. package/teamspec-core/templates/sprint-goal-template.md +129 -0
  40. package/teamspec-core/templates/sprint-template.md +175 -0
  41. package/teamspec-core/templates/sprints-index-template.md +67 -0
  42. package/teamspec-core/templates/story-template.md +244 -0
  43. package/teamspec-core/templates/storymap-template.md +204 -0
  44. package/teamspec-core/templates/testcases-template.md +147 -0
  45. package/teamspec-core/templates/uat-pack-template.md +161 -0
package/lib/linter.js ADDED
@@ -0,0 +1,1184 @@
1
+ /**
2
+ * TeamSpec Linter
3
+ * Enforces TeamSpec Feature Canon operating model rules
4
+ *
5
+ * Rule Categories:
6
+ * - TS-PROJ: Project structure and registration
7
+ * - TS-FEAT: Feature Canon integrity
8
+ * - TS-STORY: Story format and delta compliance
9
+ * - TS-ADR: Architecture decisions
10
+ * - TS-DEVPLAN: Development planning
11
+ * - TS-DOD: Definition of Done gates
12
+ * - TS-NAMING: Naming conventions (from PROJECT_STRUCTURE.yml)
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ // =============================================================================
19
+ // Severity Levels
20
+ // =============================================================================
21
+
22
+ const SEVERITY = {
23
+ ERROR: 'error',
24
+ BLOCKER: 'blocker',
25
+ WARNING: 'warning',
26
+ INFO: 'info',
27
+ };
28
+
29
+ // =============================================================================
30
+ // Naming Patterns (from PROJECT_STRUCTURE.yml)
31
+ // =============================================================================
32
+
33
+ const NAMING_PATTERNS = {
34
+ feature: /^F-\d{3,}-[a-z][a-z0-9-]*\.md$/,
35
+ story: /^S-\d{3,}-[a-z][a-z0-9-]*\.md$/,
36
+ adr: /^ADR-\d{3,}-[a-z][a-z0-9-]*\.md$/,
37
+ decision: /^DECISION-\d{3,}-[a-z][a-z0-9-]*\.md$/,
38
+ epic: /^EPIC-\d{3,}-[a-z][a-z0-9-]*\.md$/,
39
+ devPlan: /^story-\d{3,}-tasks\.md$/,
40
+ sprint: /^sprint-\d+$/,
41
+ };
42
+
43
+ // =============================================================================
44
+ // Required Sections
45
+ // =============================================================================
46
+
47
+ const FEATURE_REQUIRED_SECTIONS = [
48
+ 'Purpose',
49
+ 'Scope|In Scope',
50
+ 'Actors|Personas|Users',
51
+ 'Main Flow|Current Behavior|Behavior',
52
+ 'Business Rules|Rules',
53
+ 'Edge Cases|Exceptions|Error Handling',
54
+ 'Non-Goals|Out of Scope',
55
+ 'Change Log|Story Ledger|Changelog',
56
+ ];
57
+
58
+ const STORY_FORBIDDEN_HEADINGS = [
59
+ 'Full Specification',
60
+ 'Complete Requirements',
61
+ 'End-to-End Behavior',
62
+ 'Full Flow',
63
+ ];
64
+
65
+ const PLACEHOLDER_PATTERNS = [
66
+ /\{TBD\}/i,
67
+ /\bTBD\b/,
68
+ /\?\?\?/,
69
+ /lorem ipsum/i,
70
+ /to be defined/i,
71
+ /\bplaceholder\b/i,
72
+ ];
73
+
74
+ // =============================================================================
75
+ // Helper Functions
76
+ // =============================================================================
77
+
78
+ /**
79
+ * Parse YAML-like frontmatter from markdown
80
+ */
81
+ function parseYamlFrontmatter(content) {
82
+ const yamlMatch = content.match(/^---\n([\s\S]*?)\n---/);
83
+ if (!yamlMatch) return {};
84
+
85
+ const yaml = {};
86
+ const lines = yamlMatch[1].split('\n');
87
+ for (const line of lines) {
88
+ const match = line.match(/^([^:]+):\s*(.*)$/);
89
+ if (match) {
90
+ yaml[match[1].trim()] = match[2].trim();
91
+ }
92
+ }
93
+ return yaml;
94
+ }
95
+
96
+ /**
97
+ * Parse simple YAML file
98
+ */
99
+ function parseSimpleYaml(content) {
100
+ const result = {};
101
+ const lines = content.split('\n');
102
+ let currentKey = null;
103
+ let currentArray = null;
104
+
105
+ for (const line of lines) {
106
+ const trimmed = line.trim();
107
+ if (!trimmed || trimmed.startsWith('#')) continue;
108
+
109
+ // Array item
110
+ if (trimmed.startsWith('- ')) {
111
+ if (currentArray) {
112
+ currentArray.push(trimmed.slice(2).trim());
113
+ }
114
+ continue;
115
+ }
116
+
117
+ // Key-value pair
118
+ const match = trimmed.match(/^([^:]+):\s*(.*)$/);
119
+ if (match) {
120
+ const key = match[1].trim();
121
+ const value = match[2].trim();
122
+
123
+ if (value === '' || value === '[]') {
124
+ result[key] = [];
125
+ currentArray = result[key];
126
+ currentKey = key;
127
+ } else {
128
+ result[key] = value;
129
+ currentArray = null;
130
+ currentKey = key;
131
+ }
132
+ }
133
+ }
134
+
135
+ return result;
136
+ }
137
+
138
+ /**
139
+ * Extract headings from markdown
140
+ */
141
+ function extractHeadings(content) {
142
+ const headings = [];
143
+ const lines = content.split(/\r?\n/);
144
+ for (const line of lines) {
145
+ const match = line.match(/^(#{1,6})\s+(.+?)[\r\s]*$/);
146
+ if (match) {
147
+ headings.push({
148
+ level: match[1].length,
149
+ text: match[2].trim(),
150
+ });
151
+ }
152
+ }
153
+ return headings;
154
+ }
155
+
156
+ /**
157
+ * Check if content contains a pattern
158
+ */
159
+ function containsPattern(content, pattern) {
160
+ if (typeof pattern === 'string') {
161
+ return content.includes(pattern);
162
+ }
163
+ return pattern.test(content);
164
+ }
165
+
166
+ /**
167
+ * Extract checkboxes from markdown
168
+ */
169
+ function extractCheckboxes(content, sectionHeading = null) {
170
+ let searchContent = content;
171
+
172
+ if (sectionHeading) {
173
+ const sectionPattern = new RegExp(`##\\s+(${sectionHeading})\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i');
174
+ const match = content.match(sectionPattern);
175
+ if (match) {
176
+ searchContent = match[2];
177
+ } else {
178
+ return [];
179
+ }
180
+ }
181
+
182
+ const checkboxes = [];
183
+ const regex = /- \[([ xX])\]\s*(.+)/g;
184
+ let match;
185
+
186
+ while ((match = regex.exec(searchContent)) !== null) {
187
+ checkboxes.push({
188
+ checked: match[1].toLowerCase() === 'x',
189
+ text: match[2].trim(),
190
+ });
191
+ }
192
+
193
+ return checkboxes;
194
+ }
195
+
196
+ /**
197
+ * Extract feature ID from story content
198
+ */
199
+ function extractFeatureLinks(content) {
200
+ const links = [];
201
+ const patterns = [
202
+ /\[F-(\d{3,})/g,
203
+ /F-(\d{3,})/g,
204
+ ];
205
+
206
+ for (const pattern of patterns) {
207
+ let match;
208
+ while ((match = pattern.exec(content)) !== null) {
209
+ links.push(`F-${match[1]}`);
210
+ }
211
+ }
212
+
213
+ return [...new Set(links)];
214
+ }
215
+
216
+ /**
217
+ * Extract story ID from filename or content
218
+ */
219
+ function extractStoryId(filename, content) {
220
+ // Try filename first
221
+ const filenameMatch = filename.match(/S-(\d{3,})/);
222
+ if (filenameMatch) return filenameMatch[1];
223
+
224
+ // Try content
225
+ const contentMatch = content.match(/# Story: S-(\d{3,})/);
226
+ if (contentMatch) return contentMatch[1];
227
+
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * Get metadata from markdown (bold fields like **Status:** value)
233
+ */
234
+ function extractMetadata(content) {
235
+ const metadata = {};
236
+ const patterns = [
237
+ // Pattern: **Key:** Value (colon inside bold)
238
+ /\*\*([^*:]+):\*\*\s*(.+)/g,
239
+ // Pattern: **Key**: Value (colon outside bold)
240
+ /\*\*([^*]+)\*\*:\s*(.+)/g,
241
+ // Pattern: Key: Value at line start
242
+ /^([A-Za-z ]+):\s*(.+)/gm,
243
+ ];
244
+
245
+ for (const pattern of patterns) {
246
+ let match;
247
+ while ((match = pattern.exec(content)) !== null) {
248
+ const key = match[1].trim().replace(/:$/, ''); // Remove trailing colon if any
249
+ const value = match[2].trim();
250
+ if (!metadata[key]) { // Don't overwrite existing keys
251
+ metadata[key] = value;
252
+ }
253
+ }
254
+ }
255
+
256
+ return metadata;
257
+ }
258
+
259
+ /**
260
+ * Recursively find files matching a pattern
261
+ */
262
+ function findFiles(dir, pattern, results = []) {
263
+ if (!fs.existsSync(dir)) return results;
264
+
265
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
266
+
267
+ for (const entry of entries) {
268
+ const fullPath = path.join(dir, entry.name);
269
+
270
+ if (entry.isDirectory()) {
271
+ findFiles(fullPath, pattern, results);
272
+ } else if (pattern.test(entry.name)) {
273
+ results.push(fullPath);
274
+ }
275
+ }
276
+
277
+ return results;
278
+ }
279
+
280
+ /**
281
+ * Find all projects in workspace
282
+ */
283
+ function findProjects(workspaceDir) {
284
+ const projectsDir = path.join(workspaceDir, 'projects');
285
+ if (!fs.existsSync(projectsDir)) return [];
286
+
287
+ const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
288
+ return entries
289
+ .filter(e => e.isDirectory() && e.name !== '.git')
290
+ .map(e => e.name);
291
+ }
292
+
293
+ // =============================================================================
294
+ // Rule Definitions
295
+ // =============================================================================
296
+
297
+ const rules = {
298
+ // -------------------------------------------------------------------------
299
+ // Project Rules (TS-PROJ)
300
+ // -------------------------------------------------------------------------
301
+
302
+ 'TS-PROJ-001': {
303
+ id: 'TS-PROJ-001',
304
+ name: 'Project folder must be registered',
305
+ severity: SEVERITY.ERROR,
306
+ owner: 'BA',
307
+ async check(ctx) {
308
+ const results = [];
309
+ const indexPath = path.join(ctx.workspaceDir, 'projects', 'projects-index.md');
310
+
311
+ if (!fs.existsSync(indexPath)) {
312
+ // If no index exists, skip (will be caught by other rules)
313
+ return results;
314
+ }
315
+
316
+ const indexContent = fs.readFileSync(indexPath, 'utf-8');
317
+
318
+ for (const projectId of ctx.projects) {
319
+ if (!indexContent.includes(projectId)) {
320
+ results.push({
321
+ ruleId: 'TS-PROJ-001',
322
+ severity: SEVERITY.ERROR,
323
+ file: path.join(ctx.workspaceDir, 'projects', projectId),
324
+ message: `Project '${projectId}' is not registered in projects-index.md`,
325
+ owner: 'BA',
326
+ });
327
+ }
328
+ }
329
+
330
+ return results;
331
+ },
332
+ },
333
+
334
+ 'TS-PROJ-002': {
335
+ id: 'TS-PROJ-002',
336
+ name: 'project.yml required with minimum metadata',
337
+ severity: SEVERITY.ERROR,
338
+ owner: 'BA',
339
+ requiredFields: ['project_id', 'name', 'status', 'stakeholders', 'roles'],
340
+ async check(ctx) {
341
+ const results = [];
342
+
343
+ for (const projectId of ctx.projects) {
344
+ const ymlPath = path.join(ctx.workspaceDir, 'projects', projectId, 'project.yml');
345
+
346
+ if (!fs.existsSync(ymlPath)) {
347
+ results.push({
348
+ ruleId: 'TS-PROJ-002',
349
+ severity: SEVERITY.ERROR,
350
+ file: ymlPath,
351
+ message: `project.yml is missing for project '${projectId}'`,
352
+ owner: 'BA',
353
+ });
354
+ continue;
355
+ }
356
+
357
+ const content = fs.readFileSync(ymlPath, 'utf-8');
358
+ const yaml = parseSimpleYaml(content);
359
+
360
+ for (const field of this.requiredFields) {
361
+ if (!(field in yaml)) {
362
+ results.push({
363
+ ruleId: 'TS-PROJ-002',
364
+ severity: SEVERITY.ERROR,
365
+ file: ymlPath,
366
+ message: `project.yml is missing required field: '${field}'`,
367
+ owner: 'BA',
368
+ });
369
+ }
370
+ }
371
+ }
372
+
373
+ return results;
374
+ },
375
+ },
376
+
377
+ // -------------------------------------------------------------------------
378
+ // Feature Rules (TS-FEAT)
379
+ // -------------------------------------------------------------------------
380
+
381
+ 'TS-FEAT-001': {
382
+ id: 'TS-FEAT-001',
383
+ name: 'Feature file required for any story link',
384
+ severity: SEVERITY.ERROR,
385
+ owner: 'BA/FA',
386
+ async check(ctx) {
387
+ const results = [];
388
+
389
+ for (const projectId of ctx.projects) {
390
+ const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
391
+ const featuresDir = path.join(ctx.workspaceDir, 'projects', projectId, 'features');
392
+ const storyFiles = findFiles(storiesDir, /\.md$/);
393
+
394
+ for (const storyFile of storyFiles) {
395
+ if (path.basename(storyFile) === 'README.md') continue;
396
+
397
+ const content = fs.readFileSync(storyFile, 'utf-8');
398
+ const featureLinks = extractFeatureLinks(content);
399
+
400
+ for (const featureId of featureLinks) {
401
+ const featurePattern = new RegExp(`^${featureId}-.*\\.md$`);
402
+ const featureFiles = fs.existsSync(featuresDir)
403
+ ? fs.readdirSync(featuresDir).filter(f => featurePattern.test(f))
404
+ : [];
405
+
406
+ if (featureFiles.length === 0) {
407
+ results.push({
408
+ ruleId: 'TS-FEAT-001',
409
+ severity: SEVERITY.ERROR,
410
+ file: storyFile,
411
+ message: `Referenced feature '${featureId}' not found in features/`,
412
+ owner: 'BA/FA',
413
+ });
414
+ }
415
+ }
416
+ }
417
+ }
418
+
419
+ return results;
420
+ },
421
+ },
422
+
423
+ 'TS-FEAT-002': {
424
+ id: 'TS-FEAT-002',
425
+ name: 'Feature must include canon sections',
426
+ severity: SEVERITY.ERROR,
427
+ owner: 'BA/FA',
428
+ async check(ctx) {
429
+ const results = [];
430
+
431
+ for (const projectId of ctx.projects) {
432
+ const featuresDir = path.join(ctx.workspaceDir, 'projects', projectId, 'features');
433
+ if (!fs.existsSync(featuresDir)) continue;
434
+
435
+ const featureFiles = findFiles(featuresDir, /^F-\d{3,}-.*\.md$/);
436
+
437
+ for (const featureFile of featureFiles) {
438
+ const content = fs.readFileSync(featureFile, 'utf-8');
439
+ const headings = extractHeadings(content);
440
+ const headingTexts = headings.map(h => h.text);
441
+
442
+ for (const required of FEATURE_REQUIRED_SECTIONS) {
443
+ const patterns = required.split('|');
444
+ const found = patterns.some(p =>
445
+ headingTexts.some(h => h.toLowerCase().includes(p.toLowerCase()))
446
+ );
447
+
448
+ if (!found) {
449
+ results.push({
450
+ ruleId: 'TS-FEAT-002',
451
+ severity: SEVERITY.ERROR,
452
+ file: featureFile,
453
+ message: `Feature is missing required section: '${required}'`,
454
+ owner: 'BA/FA',
455
+ });
456
+ }
457
+ }
458
+ }
459
+ }
460
+
461
+ return results;
462
+ },
463
+ },
464
+
465
+ 'TS-FEAT-003': {
466
+ id: 'TS-FEAT-003',
467
+ name: 'Feature IDs must be unique within project',
468
+ severity: SEVERITY.ERROR,
469
+ owner: 'BA/FA',
470
+ async check(ctx) {
471
+ const results = [];
472
+
473
+ for (const projectId of ctx.projects) {
474
+ const featuresDir = path.join(ctx.workspaceDir, 'projects', projectId, 'features');
475
+ if (!fs.existsSync(featuresDir)) continue;
476
+
477
+ const featureFiles = findFiles(featuresDir, /^F-\d{3,}-.*\.md$/);
478
+ const idToFiles = new Map();
479
+
480
+ for (const featureFile of featureFiles) {
481
+ const match = path.basename(featureFile).match(/^(F-\d{3,})/);
482
+ if (match) {
483
+ const id = match[1];
484
+ if (!idToFiles.has(id)) {
485
+ idToFiles.set(id, []);
486
+ }
487
+ idToFiles.get(id).push(featureFile);
488
+ }
489
+ }
490
+
491
+ for (const [id, files] of idToFiles) {
492
+ if (files.length > 1) {
493
+ results.push({
494
+ ruleId: 'TS-FEAT-003',
495
+ severity: SEVERITY.ERROR,
496
+ file: files[1],
497
+ message: `Duplicate feature ID '${id}' found in: ${files.map(f => path.basename(f)).join(', ')}`,
498
+ owner: 'BA/FA',
499
+ });
500
+ }
501
+ }
502
+ }
503
+
504
+ return results;
505
+ },
506
+ },
507
+
508
+ // -------------------------------------------------------------------------
509
+ // Story Rules (TS-STORY)
510
+ // -------------------------------------------------------------------------
511
+
512
+ 'TS-STORY-001': {
513
+ id: 'TS-STORY-001',
514
+ name: 'Story must link to feature',
515
+ severity: SEVERITY.ERROR,
516
+ owner: 'FA',
517
+ async check(ctx) {
518
+ const results = [];
519
+
520
+ for (const projectId of ctx.projects) {
521
+ const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
522
+ const storyFiles = findFiles(storiesDir, /^S-\d{3,}-.*\.md$/);
523
+
524
+ for (const storyFile of storyFiles) {
525
+ const content = fs.readFileSync(storyFile, 'utf-8');
526
+ const featureLinks = extractFeatureLinks(content);
527
+
528
+ // Check for Linked Features section
529
+ const hasLinkedSection = /##\s*(Linked Features?|Features?)/i.test(content);
530
+
531
+ if (featureLinks.length === 0 && !hasLinkedSection) {
532
+ results.push({
533
+ ruleId: 'TS-STORY-001',
534
+ severity: SEVERITY.ERROR,
535
+ file: storyFile,
536
+ message: 'Story has no feature link. Stories must link to at least one feature.',
537
+ owner: 'FA',
538
+ });
539
+ }
540
+ }
541
+ }
542
+
543
+ return results;
544
+ },
545
+ },
546
+
547
+ 'TS-STORY-002': {
548
+ id: 'TS-STORY-002',
549
+ name: 'Story must describe delta-only behavior',
550
+ severity: SEVERITY.ERROR,
551
+ owner: 'FA',
552
+ async check(ctx) {
553
+ const results = [];
554
+
555
+ for (const projectId of ctx.projects) {
556
+ const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
557
+ const storyFiles = findFiles(storiesDir, /^S-\d{3,}-.*\.md$/);
558
+
559
+ for (const storyFile of storyFiles) {
560
+ const content = fs.readFileSync(storyFile, 'utf-8');
561
+
562
+ // Check for Before/After pattern
563
+ const hasBefore = /\b(Before|Current behavior).*:/i.test(content);
564
+ const hasAfter = /\b(After|New behavior).*:/i.test(content);
565
+
566
+ if (!hasBefore || !hasAfter) {
567
+ results.push({
568
+ ruleId: 'TS-STORY-002',
569
+ severity: SEVERITY.ERROR,
570
+ file: storyFile,
571
+ message: 'Story must have Before/After sections describing delta behavior.',
572
+ owner: 'FA',
573
+ });
574
+ }
575
+
576
+ // Check for forbidden full-spec headings
577
+ const headings = extractHeadings(content);
578
+ for (const heading of headings) {
579
+ for (const forbidden of STORY_FORBIDDEN_HEADINGS) {
580
+ if (heading.text.toLowerCase().includes(forbidden.toLowerCase())) {
581
+ results.push({
582
+ ruleId: 'TS-STORY-002',
583
+ severity: SEVERITY.ERROR,
584
+ file: storyFile,
585
+ message: `Story contains forbidden heading '${heading.text}'. Stories describe deltas, not full specifications.`,
586
+ owner: 'FA',
587
+ });
588
+ }
589
+ }
590
+ }
591
+ }
592
+ }
593
+
594
+ return results;
595
+ },
596
+ },
597
+
598
+ 'TS-STORY-003': {
599
+ id: 'TS-STORY-003',
600
+ name: 'Acceptance Criteria must be present and testable',
601
+ severity: SEVERITY.ERROR,
602
+ owner: 'FA',
603
+ async check(ctx) {
604
+ const results = [];
605
+
606
+ for (const projectId of ctx.projects) {
607
+ const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
608
+ const storyFiles = findFiles(storiesDir, /^S-\d{3,}-.*\.md$/);
609
+
610
+ for (const storyFile of storyFiles) {
611
+ const content = fs.readFileSync(storyFile, 'utf-8');
612
+
613
+ // Check for AC section
614
+ const hasAC = /##\s*Acceptance Criteria/i.test(content);
615
+
616
+ if (!hasAC) {
617
+ results.push({
618
+ ruleId: 'TS-STORY-003',
619
+ severity: SEVERITY.ERROR,
620
+ file: storyFile,
621
+ message: 'Acceptance Criteria section is missing.',
622
+ owner: 'FA',
623
+ });
624
+ continue;
625
+ }
626
+
627
+ // Check for placeholders
628
+ for (const pattern of PLACEHOLDER_PATTERNS) {
629
+ if (pattern.test(content)) {
630
+ results.push({
631
+ ruleId: 'TS-STORY-003',
632
+ severity: SEVERITY.ERROR,
633
+ file: storyFile,
634
+ message: `Story contains placeholder text (${pattern.source}). All content must be complete.`,
635
+ owner: 'FA',
636
+ });
637
+ break;
638
+ }
639
+ }
640
+ }
641
+ }
642
+
643
+ return results;
644
+ },
645
+ },
646
+
647
+ 'TS-STORY-004': {
648
+ id: 'TS-STORY-004',
649
+ name: 'Only SM can assign sprint',
650
+ severity: SEVERITY.ERROR,
651
+ owner: 'SM',
652
+ async check(ctx) {
653
+ const results = [];
654
+
655
+ for (const projectId of ctx.projects) {
656
+ const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
657
+ const storyFiles = findFiles(storiesDir, /\.md$/);
658
+
659
+ for (const storyFile of storyFiles) {
660
+ if (path.basename(storyFile) === 'README.md') continue;
661
+
662
+ const content = fs.readFileSync(storyFile, 'utf-8');
663
+ const metadata = extractMetadata(content);
664
+
665
+ // Check if sprint is assigned
666
+ if (metadata.Sprint && metadata.Sprint !== '-' && metadata.Sprint !== 'None') {
667
+ // Check for SM role in assignment - various patterns
668
+ const hasSMAssignment = /Assigned By:.*Role:\s*SM/i.test(content) ||
669
+ /Role:\s*SM.*Assigned/i.test(content) ||
670
+ /\*\*Assigned By:\*\*.*SM/i.test(content) ||
671
+ /Assigned By:.*SM\s*$/im.test(content);
672
+
673
+ // Also fail if explicitly NOT SM
674
+ const hasNonSMAssignment = /\*\*Assigned By:\*\*\s*(DEV|BA|FA|ARCH|QA)\s*(\(|$)/i.test(content);
675
+
676
+ if (!hasSMAssignment || hasNonSMAssignment) {
677
+ results.push({
678
+ ruleId: 'TS-STORY-004',
679
+ severity: SEVERITY.ERROR,
680
+ file: storyFile,
681
+ message: 'Sprint assignment must be done by SM role. Add "Assigned By: Role: SM".',
682
+ owner: 'SM',
683
+ });
684
+ }
685
+ }
686
+ }
687
+ }
688
+
689
+ return results;
690
+ },
691
+ },
692
+
693
+ 'TS-STORY-005': {
694
+ id: 'TS-STORY-005',
695
+ name: 'Ready for Development requires DoR checklist complete',
696
+ severity: SEVERITY.ERROR,
697
+ owner: 'FA',
698
+ async check(ctx) {
699
+ const results = [];
700
+
701
+ for (const projectId of ctx.projects) {
702
+ const readyDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories', 'ready-for-development');
703
+ if (!fs.existsSync(readyDir)) continue;
704
+
705
+ const storyFiles = findFiles(readyDir, /\.md$/);
706
+
707
+ for (const storyFile of storyFiles) {
708
+ if (path.basename(storyFile) === 'README.md') continue;
709
+
710
+ const content = fs.readFileSync(storyFile, 'utf-8');
711
+
712
+ // Stories in ready-for-development folder must have complete DoR
713
+ // Check for DoR section
714
+ const dorCheckboxes = extractCheckboxes(content, 'DoR Checklist|Definition of Ready');
715
+
716
+ if (dorCheckboxes.length > 0) {
717
+ const unchecked = dorCheckboxes.filter(c => !c.checked);
718
+ if (unchecked.length > 0) {
719
+ results.push({
720
+ ruleId: 'TS-STORY-005',
721
+ severity: SEVERITY.ERROR,
722
+ file: storyFile,
723
+ message: `DoR Checklist incomplete. Unchecked items: ${unchecked.map(c => c.text).join(', ')}`,
724
+ owner: 'FA',
725
+ });
726
+ }
727
+ }
728
+ }
729
+ }
730
+
731
+ return results;
732
+ },
733
+ },
734
+
735
+ // -------------------------------------------------------------------------
736
+ // ADR Rules (TS-ADR)
737
+ // -------------------------------------------------------------------------
738
+
739
+ 'TS-ADR-001': {
740
+ id: 'TS-ADR-001',
741
+ name: 'Feature marked "Architecture Required" must have ADR',
742
+ severity: SEVERITY.ERROR,
743
+ owner: 'SA',
744
+ async check(ctx) {
745
+ const results = [];
746
+
747
+ for (const projectId of ctx.projects) {
748
+ const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
749
+ const storyFiles = findFiles(storiesDir, /\.md$/);
750
+
751
+ for (const storyFile of storyFiles) {
752
+ if (path.basename(storyFile) === 'README.md') continue;
753
+
754
+ const content = fs.readFileSync(storyFile, 'utf-8');
755
+
756
+ // Check if ADR Required is checked
757
+ const checkboxes = extractCheckboxes(content);
758
+ const adrRequired = checkboxes.some(c => c.checked && /ADR Required/i.test(c.text));
759
+
760
+ if (adrRequired) {
761
+ // Check for ADR reference
762
+ const hasAdrRef = /ADR-\d{3,}/i.test(content);
763
+
764
+ if (!hasAdrRef) {
765
+ results.push({
766
+ ruleId: 'TS-ADR-001',
767
+ severity: SEVERITY.ERROR,
768
+ file: storyFile,
769
+ message: 'Story has "ADR Required" checked but no ADR reference found.',
770
+ owner: 'SA',
771
+ });
772
+ }
773
+ }
774
+ }
775
+ }
776
+
777
+ return results;
778
+ },
779
+ },
780
+
781
+ 'TS-ADR-002': {
782
+ id: 'TS-ADR-002',
783
+ name: 'ADR must link to feature(s)',
784
+ severity: SEVERITY.ERROR,
785
+ owner: 'SA',
786
+ async check(ctx) {
787
+ const results = [];
788
+
789
+ for (const projectId of ctx.projects) {
790
+ const adrDir = path.join(ctx.workspaceDir, 'projects', projectId, 'adr');
791
+ if (!fs.existsSync(adrDir)) continue;
792
+
793
+ const adrFiles = findFiles(adrDir, /^ADR-\d{3,}-.*\.md$/);
794
+
795
+ for (const adrFile of adrFiles) {
796
+ const content = fs.readFileSync(adrFile, 'utf-8');
797
+
798
+ // Check for feature reference
799
+ const hasFeatureRef = /F-\d{3,}|Linked Feature|Related Feature/i.test(content);
800
+
801
+ if (!hasFeatureRef) {
802
+ results.push({
803
+ ruleId: 'TS-ADR-002',
804
+ severity: SEVERITY.ERROR,
805
+ file: adrFile,
806
+ message: 'ADR must link to at least one feature.',
807
+ owner: 'SA',
808
+ });
809
+ }
810
+ }
811
+ }
812
+
813
+ return results;
814
+ },
815
+ },
816
+
817
+ // -------------------------------------------------------------------------
818
+ // Dev Plan Rules (TS-DEVPLAN)
819
+ // -------------------------------------------------------------------------
820
+
821
+ 'TS-DEVPLAN-001': {
822
+ id: 'TS-DEVPLAN-001',
823
+ name: 'Story in sprint must have dev plan',
824
+ severity: SEVERITY.ERROR,
825
+ owner: 'DEV',
826
+ async check(ctx) {
827
+ const results = [];
828
+
829
+ for (const projectId of ctx.projects) {
830
+ const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
831
+ const devPlansDir = path.join(ctx.workspaceDir, 'projects', projectId, 'dev-plans');
832
+ const storyFiles = findFiles(storiesDir, /^S-\d{3,}-.*\.md$/);
833
+
834
+ for (const storyFile of storyFiles) {
835
+ const content = fs.readFileSync(storyFile, 'utf-8');
836
+ const metadata = extractMetadata(content);
837
+
838
+ // Check if story is in sprint
839
+ const isInSprint = metadata.Status && /in sprint|in progress|ready for testing/i.test(metadata.Status);
840
+
841
+ if (isInSprint) {
842
+ const storyId = extractStoryId(path.basename(storyFile), content);
843
+
844
+ if (storyId) {
845
+ const devPlanPath = path.join(devPlansDir, `story-${storyId}-tasks.md`);
846
+
847
+ if (!fs.existsSync(devPlanPath)) {
848
+ results.push({
849
+ ruleId: 'TS-DEVPLAN-001',
850
+ severity: SEVERITY.ERROR,
851
+ file: storyFile,
852
+ message: `Story is in sprint but dev plan is missing. Expected: dev-plans/story-${storyId}-tasks.md`,
853
+ owner: 'DEV',
854
+ });
855
+ }
856
+ }
857
+ }
858
+ }
859
+ }
860
+
861
+ return results;
862
+ },
863
+ },
864
+
865
+ // -------------------------------------------------------------------------
866
+ // DoD Rules (TS-DOD)
867
+ // -------------------------------------------------------------------------
868
+
869
+ 'TS-DOD-001': {
870
+ id: 'TS-DOD-001',
871
+ name: 'Story cannot be Done if behavior changed and Canon not updated',
872
+ severity: SEVERITY.BLOCKER,
873
+ owner: 'FA',
874
+ async check(ctx) {
875
+ const results = [];
876
+
877
+ for (const projectId of ctx.projects) {
878
+ const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
879
+ const storyFiles = findFiles(storiesDir, /\.md$/);
880
+
881
+ for (const storyFile of storyFiles) {
882
+ if (path.basename(storyFile) === 'README.md') continue;
883
+
884
+ const content = fs.readFileSync(storyFile, 'utf-8');
885
+ const metadata = extractMetadata(content);
886
+
887
+ // Check if status is Done
888
+ const isDone = metadata.Status && /done/i.test(metadata.Status);
889
+
890
+ if (isDone) {
891
+ // Check if behavior is being added/changed (anywhere in file)
892
+ const allCheckboxes = extractCheckboxes(content);
893
+ const addsBehavior = allCheckboxes.some(c => c.checked && /adds behavior/i.test(c.text));
894
+ const changesBehavior = allCheckboxes.some(c => c.checked && /changes behavior/i.test(c.text));
895
+
896
+ if (addsBehavior || changesBehavior) {
897
+ // Check DoD for Canon update - look for unchecked "Feature Canon updated" item
898
+ const dodCheckboxes = extractCheckboxes(content, 'DoD Checklist|Definition of Done');
899
+ const canonChecked = dodCheckboxes.some(c => c.checked && /feature canon updated|canon updated/i.test(c.text));
900
+ const canonUnchecked = dodCheckboxes.some(c => !c.checked && /feature canon updated|canon updated/i.test(c.text));
901
+
902
+ if (canonUnchecked || (!canonChecked && dodCheckboxes.length > 0)) {
903
+ results.push({
904
+ ruleId: 'TS-DOD-001',
905
+ severity: SEVERITY.BLOCKER,
906
+ file: storyFile,
907
+ message: 'Story is marked Done with behavior changes but Feature Canon not updated. This blocks release.',
908
+ owner: 'FA',
909
+ });
910
+ }
911
+ }
912
+ }
913
+ }
914
+ }
915
+
916
+ return results;
917
+ },
918
+ },
919
+
920
+ // -------------------------------------------------------------------------
921
+ // Naming Convention Rules (TS-NAMING)
922
+ // -------------------------------------------------------------------------
923
+
924
+ 'TS-NAMING-FEATURE': {
925
+ id: 'TS-NAMING-FEATURE',
926
+ name: 'Feature file naming convention',
927
+ severity: SEVERITY.WARNING,
928
+ owner: 'FA',
929
+ async check(ctx) {
930
+ const results = [];
931
+
932
+ for (const projectId of ctx.projects) {
933
+ const featuresDir = path.join(ctx.workspaceDir, 'projects', projectId, 'features');
934
+ if (!fs.existsSync(featuresDir)) continue;
935
+
936
+ const files = fs.readdirSync(featuresDir).filter(f => f.endsWith('.md'));
937
+
938
+ for (const file of files) {
939
+ if (['features-index.md', 'story-ledger.md', 'README.md'].includes(file)) continue;
940
+
941
+ if (!NAMING_PATTERNS.feature.test(file)) {
942
+ results.push({
943
+ ruleId: 'TS-NAMING-FEATURE',
944
+ severity: SEVERITY.WARNING,
945
+ file: path.join(featuresDir, file),
946
+ message: `Feature file '${file}' does not match naming convention: F-NNN-description.md`,
947
+ owner: 'FA',
948
+ });
949
+ }
950
+ }
951
+ }
952
+
953
+ return results;
954
+ },
955
+ },
956
+
957
+ 'TS-NAMING-STORY': {
958
+ id: 'TS-NAMING-STORY',
959
+ name: 'Story file naming convention',
960
+ severity: SEVERITY.WARNING,
961
+ owner: 'FA',
962
+ async check(ctx) {
963
+ const results = [];
964
+
965
+ for (const projectId of ctx.projects) {
966
+ const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
967
+ if (!fs.existsSync(storiesDir)) continue;
968
+
969
+ const storyFiles = findFiles(storiesDir, /\.md$/);
970
+
971
+ for (const storyFile of storyFiles) {
972
+ const filename = path.basename(storyFile);
973
+ if (filename === 'README.md') continue;
974
+
975
+ if (!NAMING_PATTERNS.story.test(filename)) {
976
+ results.push({
977
+ ruleId: 'TS-NAMING-STORY',
978
+ severity: SEVERITY.WARNING,
979
+ file: storyFile,
980
+ message: `Story file '${filename}' does not match naming convention: S-NNN-description.md`,
981
+ owner: 'FA',
982
+ });
983
+ }
984
+ }
985
+ }
986
+
987
+ return results;
988
+ },
989
+ },
990
+
991
+ 'TS-NAMING-DEVPLAN': {
992
+ id: 'TS-NAMING-DEVPLAN',
993
+ name: 'Dev plan file naming convention',
994
+ severity: SEVERITY.WARNING,
995
+ owner: 'DEV',
996
+ async check(ctx) {
997
+ const results = [];
998
+
999
+ for (const projectId of ctx.projects) {
1000
+ const devPlansDir = path.join(ctx.workspaceDir, 'projects', projectId, 'dev-plans');
1001
+ if (!fs.existsSync(devPlansDir)) continue;
1002
+
1003
+ const files = fs.readdirSync(devPlansDir).filter(f => f.endsWith('.md'));
1004
+
1005
+ for (const file of files) {
1006
+ if (file === 'README.md') continue;
1007
+
1008
+ if (!NAMING_PATTERNS.devPlan.test(file)) {
1009
+ results.push({
1010
+ ruleId: 'TS-NAMING-DEVPLAN',
1011
+ severity: SEVERITY.WARNING,
1012
+ file: path.join(devPlansDir, file),
1013
+ message: `Dev plan file '${file}' does not match naming convention: story-NNN-tasks.md`,
1014
+ owner: 'DEV',
1015
+ });
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ return results;
1021
+ },
1022
+ },
1023
+
1024
+ 'TS-NAMING-ADR': {
1025
+ id: 'TS-NAMING-ADR',
1026
+ name: 'ADR file naming convention',
1027
+ severity: SEVERITY.WARNING,
1028
+ owner: 'SA',
1029
+ async check(ctx) {
1030
+ const results = [];
1031
+
1032
+ for (const projectId of ctx.projects) {
1033
+ const adrDir = path.join(ctx.workspaceDir, 'projects', projectId, 'adr');
1034
+ if (!fs.existsSync(adrDir)) continue;
1035
+
1036
+ const files = fs.readdirSync(adrDir).filter(f => f.endsWith('.md'));
1037
+
1038
+ for (const file of files) {
1039
+ if (file === 'README.md') continue;
1040
+
1041
+ if (!NAMING_PATTERNS.adr.test(file)) {
1042
+ results.push({
1043
+ ruleId: 'TS-NAMING-ADR',
1044
+ severity: SEVERITY.WARNING,
1045
+ file: path.join(adrDir, file),
1046
+ message: `ADR file '${file}' does not match naming convention: ADR-NNN-description.md`,
1047
+ owner: 'SA',
1048
+ });
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ return results;
1054
+ },
1055
+ },
1056
+ };
1057
+
1058
+ // =============================================================================
1059
+ // Linter Class
1060
+ // =============================================================================
1061
+
1062
+ class Linter {
1063
+ constructor(workspaceDir) {
1064
+ this.workspaceDir = workspaceDir;
1065
+ }
1066
+
1067
+ /**
1068
+ * Run all linter rules
1069
+ */
1070
+ async run(options = {}) {
1071
+ const projects = options.project
1072
+ ? [options.project]
1073
+ : findProjects(this.workspaceDir);
1074
+
1075
+ const ctx = {
1076
+ workspaceDir: this.workspaceDir,
1077
+ projects,
1078
+ };
1079
+
1080
+ const results = [];
1081
+
1082
+ for (const rule of Object.values(rules)) {
1083
+ try {
1084
+ const ruleResults = await rule.check(ctx);
1085
+ results.push(...ruleResults);
1086
+ } catch (err) {
1087
+ results.push({
1088
+ ruleId: rule.id,
1089
+ severity: SEVERITY.ERROR,
1090
+ file: this.workspaceDir,
1091
+ message: `Rule execution failed: ${err.message}`,
1092
+ owner: 'System',
1093
+ });
1094
+ }
1095
+ }
1096
+
1097
+ return results;
1098
+ }
1099
+
1100
+ /**
1101
+ * Run a specific rule
1102
+ */
1103
+ async runRule(ruleId, options = {}) {
1104
+ const rule = rules[ruleId];
1105
+ if (!rule) {
1106
+ throw new Error(`Unknown rule: ${ruleId}`);
1107
+ }
1108
+
1109
+ const projects = options.project
1110
+ ? [options.project]
1111
+ : findProjects(this.workspaceDir);
1112
+
1113
+ const ctx = {
1114
+ workspaceDir: this.workspaceDir,
1115
+ projects,
1116
+ };
1117
+
1118
+ return rule.check(ctx);
1119
+ }
1120
+
1121
+ /**
1122
+ * Group results by file
1123
+ */
1124
+ groupByFile(results) {
1125
+ const grouped = {};
1126
+
1127
+ for (const result of results) {
1128
+ if (!grouped[result.file]) {
1129
+ grouped[result.file] = [];
1130
+ }
1131
+ grouped[result.file].push(result);
1132
+ }
1133
+
1134
+ return grouped;
1135
+ }
1136
+
1137
+ /**
1138
+ * Format results for console output
1139
+ */
1140
+ formatResults(results) {
1141
+ if (results.length === 0) {
1142
+ return 'āœ… No issues found.';
1143
+ }
1144
+
1145
+ const lines = [];
1146
+ const grouped = this.groupByFile(results);
1147
+
1148
+ for (const [file, fileResults] of Object.entries(grouped)) {
1149
+ lines.push(`\nšŸ“„ ${path.relative(this.workspaceDir, file)}`);
1150
+
1151
+ for (const result of fileResults) {
1152
+ const icon = result.severity === SEVERITY.ERROR || result.severity === SEVERITY.BLOCKER
1153
+ ? 'āŒ'
1154
+ : result.severity === SEVERITY.WARNING
1155
+ ? 'āš ļø'
1156
+ : 'ā„¹ļø';
1157
+
1158
+ lines.push(` ${icon} [${result.ruleId}] ${result.message}`);
1159
+ lines.push(` Owner: ${result.owner}`);
1160
+ }
1161
+ }
1162
+
1163
+ // Summary
1164
+ const errors = results.filter(r => r.severity === SEVERITY.ERROR || r.severity === SEVERITY.BLOCKER).length;
1165
+ const warnings = results.filter(r => r.severity === SEVERITY.WARNING).length;
1166
+ const info = results.filter(r => r.severity === SEVERITY.INFO).length;
1167
+
1168
+ lines.push('\n' + '─'.repeat(60));
1169
+ lines.push(`Summary: ${errors} errors, ${warnings} warnings, ${info} info`);
1170
+
1171
+ return lines.join('\n');
1172
+ }
1173
+ }
1174
+
1175
+ // =============================================================================
1176
+ // Exports
1177
+ // =============================================================================
1178
+
1179
+ module.exports = {
1180
+ Linter,
1181
+ rules,
1182
+ SEVERITY,
1183
+ NAMING_PATTERNS,
1184
+ };