lean-spec 0.2.1 → 0.2.2

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 (36) hide show
  1. package/dist/{chunk-ER23B6KS.js → chunk-7MCDTSVE.js} +1797 -193
  2. package/dist/chunk-7MCDTSVE.js.map +1 -0
  3. package/dist/cli.js +36 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands-GRG5UUOF.js +4 -0
  6. package/dist/{commands-ZNL7ZCHU.js.map → commands-GRG5UUOF.js.map} +1 -1
  7. package/dist/mcp-server.js +1 -1
  8. package/package.json +5 -3
  9. package/templates/_shared/agents-components/core-rules-base.md +5 -0
  10. package/templates/_shared/agents-components/core-rules-enterprise.md +5 -0
  11. package/templates/_shared/agents-components/discovery-commands-enterprise.md +10 -0
  12. package/templates/_shared/agents-components/discovery-commands-minimal.md +8 -0
  13. package/templates/_shared/agents-components/discovery-commands-standard.md +9 -0
  14. package/templates/_shared/agents-components/enterprise-approval.md +10 -0
  15. package/templates/_shared/agents-components/enterprise-compliance.md +12 -0
  16. package/templates/_shared/agents-components/enterprise-when-required.md +13 -0
  17. package/templates/_shared/agents-components/frontmatter-enterprise.md +33 -0
  18. package/templates/_shared/agents-components/frontmatter-minimal.md +18 -0
  19. package/templates/_shared/agents-components/frontmatter-standard.md +23 -0
  20. package/templates/_shared/agents-components/quality-standards-base.md +5 -0
  21. package/templates/_shared/agents-components/quality-standards-enterprise.md +6 -0
  22. package/templates/_shared/agents-components/when-to-use-enterprise.md +11 -0
  23. package/templates/_shared/agents-components/when-to-use-minimal.md +9 -0
  24. package/templates/_shared/agents-components/when-to-use-standard.md +9 -0
  25. package/templates/_shared/agents-components/workflow-enterprise.md +8 -0
  26. package/templates/_shared/agents-components/workflow-standard-detailed.md +7 -0
  27. package/templates/_shared/agents-components/workflow-standard.md +5 -0
  28. package/templates/_shared/agents-template.hbs +39 -0
  29. package/templates/enterprise/agents-config.json +15 -0
  30. package/templates/enterprise/files/AGENTS.md +1 -0
  31. package/templates/minimal/agents-config.json +12 -0
  32. package/templates/minimal/files/AGENTS.md +1 -0
  33. package/templates/standard/agents-config.json +12 -0
  34. package/templates/standard/files/AGENTS.md +1 -0
  35. package/dist/chunk-ER23B6KS.js.map +0 -1
  36. package/dist/commands-ZNL7ZCHU.js +0 -4
@@ -3,18 +3,19 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
5
  import * as fs9 from 'fs/promises';
6
+ import { readFile, writeFile } from 'fs/promises';
6
7
  import * as path2 from 'path';
7
8
  import { dirname, join } from 'path';
8
9
  import chalk16 from 'chalk';
9
- import matter from 'gray-matter';
10
- import yaml from 'js-yaml';
10
+ import matter4 from 'gray-matter';
11
+ import yaml3 from 'js-yaml';
11
12
  import { spawn, execSync } from 'child_process';
12
13
  import ora from 'ora';
13
14
  import stripAnsi from 'strip-ansi';
14
15
  import { fileURLToPath } from 'url';
15
16
  import { select } from '@inquirer/prompts';
16
- import { ComplexityValidator, TokenCounter, countTokens } from '@leanspec/core';
17
- import dayjs2 from 'dayjs';
17
+ import { encoding_for_model } from 'tiktoken';
18
+ import dayjs3 from 'dayjs';
18
19
  import { marked } from 'marked';
19
20
  import { markedTerminal } from 'marked-terminal';
20
21
  import { readFileSync } from 'fs';
@@ -142,13 +143,13 @@ async function loadSubFiles(specDir, options = {}) {
142
143
  if (entry.name === "README.md") continue;
143
144
  if (entry.isDirectory()) continue;
144
145
  const filePath = path2.join(specDir, entry.name);
145
- const stat5 = await fs9.stat(filePath);
146
+ const stat6 = await fs9.stat(filePath);
146
147
  const ext = path2.extname(entry.name).toLowerCase();
147
148
  const isDocument = ext === ".md";
148
149
  const subFile = {
149
150
  name: entry.name,
150
151
  path: filePath,
151
- size: stat5.size,
152
+ size: stat6.size,
152
153
  type: isDocument ? "document" : "asset"
153
154
  };
154
155
  if (isDocument && options.includeContent) {
@@ -669,9 +670,9 @@ async function createSpec(name, options = {}) {
669
670
  const title = options.title || name;
670
671
  const varContext = await buildVariableContext(config, { name: title, date });
671
672
  content = resolveVariables(template, varContext);
672
- const parsed = matter(content, {
673
+ const parsed = matter4(content, {
673
674
  engines: {
674
- yaml: (str) => yaml.load(str, { schema: yaml.FAILSAFE_SCHEMA })
675
+ yaml: (str) => yaml3.load(str, { schema: yaml3.FAILSAFE_SCHEMA })
675
676
  }
676
677
  });
677
678
  normalizeDateFields(parsed.data);
@@ -694,9 +695,9 @@ async function createSpec(name, options = {}) {
694
695
  frontmatter: parsed.data
695
696
  };
696
697
  parsed.content = resolveVariables(parsed.content, contextWithFrontmatter);
697
- const { enrichWithTimestamps } = await import('./frontmatter-R2DANL5X.js');
698
- enrichWithTimestamps(parsed.data);
699
- content = matter.stringify(parsed.content, parsed.data);
698
+ const { enrichWithTimestamps: enrichWithTimestamps2 } = await import('./frontmatter-R2DANL5X.js');
699
+ enrichWithTimestamps2(parsed.data);
700
+ content = matter4.stringify(parsed.content, parsed.data);
700
701
  if (options.description) {
701
702
  content = content.replace(
702
703
  /## Overview\s+<!-- What are we solving\? Why now\? -->/,
@@ -1330,8 +1331,8 @@ async function listTemplates(cwd = process.cwd()) {
1330
1331
  console.log(chalk16.cyan("Available files:"));
1331
1332
  for (const file of templateFiles) {
1332
1333
  const filePath = path2.join(templatesDir, file);
1333
- const stat5 = await fs9.stat(filePath);
1334
- const sizeKB = (stat5.size / 1024).toFixed(1);
1334
+ const stat6 = await fs9.stat(filePath);
1335
+ const sizeKB = (stat6.size / 1024).toFixed(1);
1335
1336
  console.log(` ${file} (${sizeKB} KB)`);
1336
1337
  }
1337
1338
  console.log("");
@@ -1840,9 +1841,9 @@ var FrontmatterValidator = class {
1840
1841
  const warnings = [];
1841
1842
  let parsed;
1842
1843
  try {
1843
- parsed = matter(content, {
1844
+ parsed = matter4(content, {
1844
1845
  engines: {
1845
- yaml: (str) => yaml.load(str, { schema: yaml.FAILSAFE_SCHEMA })
1846
+ yaml: (str) => yaml3.load(str, { schema: yaml3.FAILSAFE_SCHEMA })
1846
1847
  }
1847
1848
  });
1848
1849
  } catch (error) {
@@ -1970,7 +1971,7 @@ var StructureValidator = class {
1970
1971
  const warnings = [];
1971
1972
  let parsed;
1972
1973
  try {
1973
- parsed = matter(content);
1974
+ parsed = matter4(content);
1974
1975
  } catch (error) {
1975
1976
  errors.push({
1976
1977
  message: "Failed to parse frontmatter",
@@ -1987,33 +1988,6 @@ var StructureValidator = class {
1987
1988
  });
1988
1989
  }
1989
1990
  const headings = this.extractHeadings(body);
1990
- for (const requiredSection of this.requiredSections) {
1991
- const found = headings.some(
1992
- (h) => h.level === 2 && h.text.toLowerCase() === requiredSection.toLowerCase()
1993
- );
1994
- if (!found) {
1995
- if (this.strict) {
1996
- errors.push({
1997
- message: `Missing required section: ## ${requiredSection}`,
1998
- suggestion: `Add ## ${requiredSection} section to the spec`
1999
- });
2000
- } else {
2001
- warnings.push({
2002
- message: `Recommended section missing: ## ${requiredSection}`,
2003
- suggestion: `Consider adding ## ${requiredSection} section`
2004
- });
2005
- }
2006
- }
2007
- }
2008
- const emptySections = this.findEmptySections(body, headings);
2009
- for (const section of emptySections) {
2010
- if (this.requiredSections.some((req) => req.toLowerCase() === section.toLowerCase())) {
2011
- warnings.push({
2012
- message: `Empty required section: ## ${section}`,
2013
- suggestion: "Add content to this section or remove it"
2014
- });
2015
- }
2016
- }
2017
1991
  const duplicates = this.findDuplicateHeaders(headings);
2018
1992
  for (const dup of duplicates) {
2019
1993
  errors.push({
@@ -2296,9 +2270,1099 @@ var CorruptionValidator = class {
2296
2270
  suggestion: "Check for missing closing * in markdown content (not code blocks)"
2297
2271
  });
2298
2272
  }
2299
- return errors;
2273
+ return errors;
2274
+ }
2275
+ };
2276
+ function normalizeDateFields2(data) {
2277
+ const dateFields = ["created", "completed", "updated", "due"];
2278
+ for (const field of dateFields) {
2279
+ if (data[field] instanceof Date) {
2280
+ data[field] = data[field].toISOString().split("T")[0];
2281
+ }
2282
+ }
2283
+ }
2284
+ function enrichWithTimestamps(data, previousData) {
2285
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2286
+ if (!data.created_at) {
2287
+ data.created_at = now;
2288
+ }
2289
+ if (previousData) {
2290
+ data.updated_at = now;
2291
+ }
2292
+ if (data.status === "complete" && previousData?.status !== "complete" && !data.completed_at) {
2293
+ data.completed_at = now;
2294
+ if (!data.completed) {
2295
+ data.completed = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2296
+ }
2297
+ }
2298
+ if (previousData && data.status !== previousData.status) {
2299
+ if (!Array.isArray(data.transitions)) {
2300
+ data.transitions = [];
2301
+ }
2302
+ data.transitions.push({
2303
+ status: data.status,
2304
+ at: now
2305
+ });
2306
+ }
2307
+ }
2308
+ function normalizeTagsField(data) {
2309
+ if (data.tags && typeof data.tags === "string") {
2310
+ try {
2311
+ const parsed = JSON.parse(data.tags);
2312
+ if (Array.isArray(parsed)) {
2313
+ data.tags = parsed;
2314
+ }
2315
+ } catch {
2316
+ data.tags = data.tags.split(",").map((t) => t.trim());
2317
+ }
2318
+ }
2319
+ }
2320
+ function validateCustomFields(frontmatter, config) {
2321
+ {
2322
+ return frontmatter;
2323
+ }
2324
+ }
2325
+ function parseFrontmatterFromString(content, filePath, config) {
2326
+ try {
2327
+ const parsed = matter4(content, {
2328
+ engines: {
2329
+ yaml: (str) => yaml3.load(str, { schema: yaml3.FAILSAFE_SCHEMA })
2330
+ }
2331
+ });
2332
+ if (!parsed.data || Object.keys(parsed.data).length === 0) {
2333
+ return parseFallbackFields(content);
2334
+ }
2335
+ if (!parsed.data.status) {
2336
+ if (filePath) ;
2337
+ return null;
2338
+ }
2339
+ if (!parsed.data.created) {
2340
+ if (filePath) ;
2341
+ return null;
2342
+ }
2343
+ const validStatuses = ["planned", "in-progress", "complete", "archived"];
2344
+ if (!validStatuses.includes(parsed.data.status)) {
2345
+ if (filePath) ;
2346
+ }
2347
+ if (parsed.data.priority) {
2348
+ const validPriorities = ["low", "medium", "high", "critical"];
2349
+ if (!validPriorities.includes(parsed.data.priority)) {
2350
+ if (filePath) ;
2351
+ }
2352
+ }
2353
+ normalizeTagsField(parsed.data);
2354
+ const knownFields = [
2355
+ "status",
2356
+ "created",
2357
+ "tags",
2358
+ "priority",
2359
+ "related",
2360
+ "depends_on",
2361
+ "updated",
2362
+ "completed",
2363
+ "assignee",
2364
+ "reviewer",
2365
+ "issue",
2366
+ "pr",
2367
+ "epic",
2368
+ "breaking",
2369
+ "due",
2370
+ "created_at",
2371
+ "updated_at",
2372
+ "completed_at",
2373
+ "transitions"
2374
+ ];
2375
+ const customFields = config?.frontmatter?.custom ? Object.keys(config.frontmatter.custom) : [];
2376
+ const allKnownFields = [...knownFields, ...customFields];
2377
+ const unknownFields = Object.keys(parsed.data).filter((k) => !allKnownFields.includes(k));
2378
+ if (unknownFields.length > 0 && filePath) ;
2379
+ const validatedData = validateCustomFields(parsed.data, config);
2380
+ return validatedData;
2381
+ } catch (error) {
2382
+ console.error(`Error parsing frontmatter${""}:`, error);
2383
+ return null;
2384
+ }
2385
+ }
2386
+ function parseFallbackFields(content) {
2387
+ const statusMatch = content.match(/\*\*Status\*\*:\s*(?:📅\s*)?(\w+(?:-\w+)?)/i);
2388
+ const createdMatch = content.match(/\*\*Created\*\*:\s*(\d{4}-\d{2}-\d{2})/);
2389
+ if (statusMatch && createdMatch) {
2390
+ const status = statusMatch[1].toLowerCase().replace(/\s+/g, "-");
2391
+ const created = createdMatch[1];
2392
+ return {
2393
+ status,
2394
+ created
2395
+ };
2396
+ }
2397
+ return null;
2398
+ }
2399
+ function createUpdatedFrontmatter(existingContent, updates) {
2400
+ const parsed = matter4(existingContent, {
2401
+ engines: {
2402
+ yaml: (str) => yaml3.load(str, { schema: yaml3.FAILSAFE_SCHEMA })
2403
+ }
2404
+ });
2405
+ const previousData = { ...parsed.data };
2406
+ const newData = { ...parsed.data, ...updates };
2407
+ normalizeDateFields2(newData);
2408
+ enrichWithTimestamps(newData, previousData);
2409
+ if (updates.status === "complete" && !newData.completed) {
2410
+ newData.completed = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2411
+ }
2412
+ if ("updated" in parsed.data) {
2413
+ newData.updated = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2414
+ }
2415
+ let updatedContent = parsed.content;
2416
+ updatedContent = updateVisualMetadata(updatedContent, newData);
2417
+ const newContent = matter4.stringify(updatedContent, newData);
2418
+ return {
2419
+ content: newContent,
2420
+ frontmatter: newData
2421
+ };
2422
+ }
2423
+ function updateVisualMetadata(content, frontmatter) {
2424
+ const statusEmoji = getStatusEmojiPlain(frontmatter.status);
2425
+ const statusLabel = frontmatter.status.charAt(0).toUpperCase() + frontmatter.status.slice(1).replace("-", " ");
2426
+ const created = frontmatter.created;
2427
+ let metadataLine = `> **Status**: ${statusEmoji} ${statusLabel}`;
2428
+ if (frontmatter.priority) {
2429
+ const priorityLabel = frontmatter.priority.charAt(0).toUpperCase() + frontmatter.priority.slice(1);
2430
+ metadataLine += ` \xB7 **Priority**: ${priorityLabel}`;
2431
+ }
2432
+ metadataLine += ` \xB7 **Created**: ${created}`;
2433
+ if (frontmatter.tags && frontmatter.tags.length > 0) {
2434
+ metadataLine += ` \xB7 **Tags**: ${frontmatter.tags.join(", ")}`;
2435
+ }
2436
+ let secondLine = "";
2437
+ if (frontmatter.assignee || frontmatter.reviewer) {
2438
+ const assignee = frontmatter.assignee || "TBD";
2439
+ const reviewer = frontmatter.reviewer || "TBD";
2440
+ secondLine = `
2441
+ > **Assignee**: ${assignee} \xB7 **Reviewer**: ${reviewer}`;
2442
+ }
2443
+ const metadataPattern = /^>\s+\*\*Status\*\*:.*(?:\n>\s+\*\*Assignee\*\*:.*)?/m;
2444
+ if (metadataPattern.test(content)) {
2445
+ return content.replace(metadataPattern, metadataLine + secondLine);
2446
+ } else {
2447
+ const titleMatch = content.match(/^#\s+.+$/m);
2448
+ if (titleMatch) {
2449
+ const insertPos = titleMatch.index + titleMatch[0].length;
2450
+ return content.slice(0, insertPos) + "\n\n" + metadataLine + secondLine + "\n" + content.slice(insertPos);
2451
+ }
2452
+ }
2453
+ return content;
2454
+ }
2455
+ function getStatusEmojiPlain(status) {
2456
+ switch (status) {
2457
+ case "planned":
2458
+ return "\u{1F4C5}";
2459
+ case "in-progress":
2460
+ return "\u23F3";
2461
+ case "complete":
2462
+ return "\u2705";
2463
+ case "archived":
2464
+ return "\u{1F4E6}";
2465
+ default:
2466
+ return "\u{1F4C4}";
2467
+ }
2468
+ }
2469
+ function parseMarkdownSections(content) {
2470
+ const lines = content.split("\n");
2471
+ const sections = [];
2472
+ const sectionStack = [];
2473
+ let inCodeBlock = false;
2474
+ let currentLineNum = 1;
2475
+ for (let i = 0; i < lines.length; i++) {
2476
+ const line = lines[i];
2477
+ currentLineNum = i + 1;
2478
+ if (line.trimStart().startsWith("```")) {
2479
+ inCodeBlock = !inCodeBlock;
2480
+ continue;
2481
+ }
2482
+ if (inCodeBlock) {
2483
+ continue;
2484
+ }
2485
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
2486
+ if (headingMatch) {
2487
+ const level = headingMatch[1].length;
2488
+ const title = headingMatch[2].trim();
2489
+ while (sectionStack.length > 0 && sectionStack[sectionStack.length - 1].level >= level) {
2490
+ const closedSection = sectionStack.pop();
2491
+ closedSection.endLine = currentLineNum - 1;
2492
+ closedSection.lineCount = closedSection.endLine - closedSection.startLine + 1;
2493
+ }
2494
+ const newSection = {
2495
+ title,
2496
+ level,
2497
+ startLine: currentLineNum,
2498
+ endLine: lines.length,
2499
+ // Will be updated when section closes
2500
+ lineCount: 0,
2501
+ // Will be calculated when section closes
2502
+ subsections: []
2503
+ };
2504
+ if (sectionStack.length > 0) {
2505
+ sectionStack[sectionStack.length - 1].subsections.push(newSection);
2506
+ } else {
2507
+ sections.push(newSection);
2508
+ }
2509
+ sectionStack.push(newSection);
2510
+ }
2511
+ }
2512
+ while (sectionStack.length > 0) {
2513
+ const closedSection = sectionStack.pop();
2514
+ closedSection.endLine = lines.length;
2515
+ closedSection.lineCount = closedSection.endLine - closedSection.startLine + 1;
2516
+ }
2517
+ return sections;
2518
+ }
2519
+ function flattenSections(sections) {
2520
+ const result = [];
2521
+ for (const section of sections) {
2522
+ result.push(section);
2523
+ result.push(...flattenSections(section.subsections));
2524
+ }
2525
+ return result;
2526
+ }
2527
+ function extractLines(content, startLine, endLine) {
2528
+ const lines = content.split("\n");
2529
+ if (startLine < 1 || endLine < startLine || startLine > lines.length || endLine > lines.length) {
2530
+ throw new Error(`Invalid line range: ${startLine}-${endLine}`);
2531
+ }
2532
+ const extracted = lines.slice(startLine - 1, endLine);
2533
+ return extracted.join("\n");
2534
+ }
2535
+ function removeLines(content, startLine, endLine) {
2536
+ const lines = content.split("\n");
2537
+ if (startLine < 1 || endLine < startLine || startLine > lines.length) {
2538
+ throw new Error(`Invalid line range: ${startLine}-${endLine}`);
2539
+ }
2540
+ lines.splice(startLine - 1, endLine - startLine + 1);
2541
+ return lines.join("\n");
2542
+ }
2543
+ function countLines(content) {
2544
+ return content.split("\n").length;
2545
+ }
2546
+ function analyzeMarkdownStructure(content) {
2547
+ const lines = content.split("\n");
2548
+ const sections = parseMarkdownSections(content);
2549
+ const allSections = flattenSections(sections);
2550
+ const levelCounts = { h1: 0, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0, total: 0 };
2551
+ for (const section of allSections) {
2552
+ levelCounts[`h${section.level}`]++;
2553
+ levelCounts.total++;
2554
+ }
2555
+ let codeBlocks = 0;
2556
+ let inCodeBlock = false;
2557
+ for (const line of lines) {
2558
+ if (line.trimStart().startsWith("```")) {
2559
+ if (!inCodeBlock) {
2560
+ codeBlocks++;
2561
+ }
2562
+ inCodeBlock = !inCodeBlock;
2563
+ }
2564
+ }
2565
+ let maxNesting = 0;
2566
+ function calculateNesting(secs, depth) {
2567
+ for (const section of secs) {
2568
+ maxNesting = Math.max(maxNesting, depth);
2569
+ calculateNesting(section.subsections, depth + 1);
2570
+ }
2571
+ }
2572
+ calculateNesting(sections, 1);
2573
+ return {
2574
+ lines: lines.length,
2575
+ sections,
2576
+ allSections,
2577
+ sectionsByLevel: levelCounts,
2578
+ codeBlocks,
2579
+ maxNesting
2580
+ };
2581
+ }
2582
+ var TokenCounter = class {
2583
+ encoding;
2584
+ constructor() {
2585
+ this.encoding = encoding_for_model("gpt-4");
2586
+ }
2587
+ /**
2588
+ * Clean up resources (important to prevent memory leaks)
2589
+ */
2590
+ dispose() {
2591
+ this.encoding.free();
2592
+ }
2593
+ /**
2594
+ * Count tokens in a string
2595
+ */
2596
+ countString(text) {
2597
+ const tokens = this.encoding.encode(text);
2598
+ return tokens.length;
2599
+ }
2600
+ /**
2601
+ * Count tokens in content (convenience method for analyze command)
2602
+ * Alias for countString - provided for clarity in command usage
2603
+ */
2604
+ async countTokensInContent(content) {
2605
+ return this.countString(content);
2606
+ }
2607
+ /**
2608
+ * Count tokens in a single file
2609
+ */
2610
+ async countFile(filePath, options = {}) {
2611
+ const content = await fs9.readFile(filePath, "utf-8");
2612
+ const tokens = this.countString(content);
2613
+ const lines = content.split("\n").length;
2614
+ const result = {
2615
+ total: tokens,
2616
+ files: [{
2617
+ path: filePath,
2618
+ tokens,
2619
+ lines
2620
+ }]
2621
+ };
2622
+ if (options.detailed) {
2623
+ result.breakdown = await this.analyzeBreakdown(content);
2624
+ }
2625
+ return result;
2626
+ }
2627
+ /**
2628
+ * Count tokens in a spec (including sub-specs if requested)
2629
+ */
2630
+ async countSpec(specPath, options = {}) {
2631
+ const stats = await fs9.stat(specPath);
2632
+ if (stats.isFile()) {
2633
+ return this.countFile(specPath, options);
2634
+ }
2635
+ const files = await fs9.readdir(specPath);
2636
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
2637
+ const filesToCount = [];
2638
+ if (mdFiles.includes("README.md")) {
2639
+ filesToCount.push("README.md");
2640
+ }
2641
+ if (options.includeSubSpecs) {
2642
+ mdFiles.forEach((f) => {
2643
+ if (f !== "README.md") {
2644
+ filesToCount.push(f);
2645
+ }
2646
+ });
2647
+ }
2648
+ const fileCounts = [];
2649
+ let totalTokens = 0;
2650
+ let totalBreakdown;
2651
+ if (options.detailed) {
2652
+ totalBreakdown = {
2653
+ code: 0,
2654
+ prose: 0,
2655
+ tables: 0,
2656
+ frontmatter: 0
2657
+ };
2658
+ }
2659
+ for (const file of filesToCount) {
2660
+ const filePath = path2.join(specPath, file);
2661
+ const content = await fs9.readFile(filePath, "utf-8");
2662
+ const tokens = this.countString(content);
2663
+ const lines = content.split("\n").length;
2664
+ fileCounts.push({
2665
+ path: file,
2666
+ tokens,
2667
+ lines
2668
+ });
2669
+ totalTokens += tokens;
2670
+ if (options.detailed && totalBreakdown) {
2671
+ const breakdown = await this.analyzeBreakdown(content);
2672
+ totalBreakdown.code += breakdown.code;
2673
+ totalBreakdown.prose += breakdown.prose;
2674
+ totalBreakdown.tables += breakdown.tables;
2675
+ totalBreakdown.frontmatter += breakdown.frontmatter;
2676
+ }
2677
+ }
2678
+ return {
2679
+ total: totalTokens,
2680
+ files: fileCounts,
2681
+ breakdown: totalBreakdown
2682
+ };
2683
+ }
2684
+ /**
2685
+ * Analyze token breakdown by content type
2686
+ */
2687
+ async analyzeBreakdown(content) {
2688
+ const breakdown = {
2689
+ code: 0,
2690
+ prose: 0,
2691
+ tables: 0,
2692
+ frontmatter: 0
2693
+ };
2694
+ let body = content;
2695
+ let frontmatterContent = "";
2696
+ try {
2697
+ const parsed = matter4(content);
2698
+ body = parsed.content;
2699
+ frontmatterContent = parsed.matter;
2700
+ breakdown.frontmatter = this.countString(frontmatterContent);
2701
+ } catch {
2702
+ }
2703
+ let inCodeBlock = false;
2704
+ let inTable = false;
2705
+ const lines = body.split("\n");
2706
+ for (let i = 0; i < lines.length; i++) {
2707
+ const line = lines[i];
2708
+ const trimmed = line.trim();
2709
+ if (trimmed.startsWith("```")) {
2710
+ inCodeBlock = !inCodeBlock;
2711
+ breakdown.code += this.countString(line + "\n");
2712
+ continue;
2713
+ }
2714
+ if (inCodeBlock) {
2715
+ breakdown.code += this.countString(line + "\n");
2716
+ continue;
2717
+ }
2718
+ const isTableSeparator = trimmed.includes("|") && /[-:]{3,}/.test(trimmed);
2719
+ const isTableRow = trimmed.includes("|") && trimmed.startsWith("|");
2720
+ if (isTableSeparator || inTable && isTableRow) {
2721
+ inTable = true;
2722
+ breakdown.tables += this.countString(line + "\n");
2723
+ continue;
2724
+ } else if (inTable && !isTableRow) {
2725
+ inTable = false;
2726
+ }
2727
+ breakdown.prose += this.countString(line + "\n");
2728
+ }
2729
+ return breakdown;
2730
+ }
2731
+ /**
2732
+ * Check if content fits within token limit
2733
+ */
2734
+ isWithinLimit(count, limit) {
2735
+ return count.total <= limit;
2736
+ }
2737
+ /**
2738
+ * Format token count for display
2739
+ */
2740
+ formatCount(count, verbose = false) {
2741
+ if (!verbose) {
2742
+ return `${count.total.toLocaleString()} tokens`;
2743
+ }
2744
+ const lines = [
2745
+ `Total: ${count.total.toLocaleString()} tokens`,
2746
+ "",
2747
+ "Files:"
2748
+ ];
2749
+ for (const file of count.files) {
2750
+ const lineInfo = file.lines ? ` (${file.lines} lines)` : "";
2751
+ lines.push(` ${file.path}: ${file.tokens.toLocaleString()} tokens${lineInfo}`);
2752
+ }
2753
+ if (count.breakdown) {
2754
+ const b = count.breakdown;
2755
+ const total = b.code + b.prose + b.tables + b.frontmatter;
2756
+ lines.push("");
2757
+ lines.push("Content Breakdown:");
2758
+ lines.push(` Prose: ${b.prose.toLocaleString()} tokens (${Math.round(b.prose / total * 100)}%)`);
2759
+ lines.push(` Code: ${b.code.toLocaleString()} tokens (${Math.round(b.code / total * 100)}%)`);
2760
+ lines.push(` Tables: ${b.tables.toLocaleString()} tokens (${Math.round(b.tables / total * 100)}%)`);
2761
+ lines.push(` Frontmatter: ${b.frontmatter.toLocaleString()} tokens (${Math.round(b.frontmatter / total * 100)}%)`);
2762
+ }
2763
+ return lines.join("\n");
2764
+ }
2765
+ /**
2766
+ * Get performance indicators based on token count
2767
+ * Based on research from spec 066
2768
+ */
2769
+ getPerformanceIndicators(tokenCount) {
2770
+ const baselineTokens = 1200;
2771
+ const costMultiplier = Math.round(tokenCount / baselineTokens * 10) / 10;
2772
+ if (tokenCount < 2e3) {
2773
+ return {
2774
+ level: "excellent",
2775
+ costMultiplier,
2776
+ effectiveness: 100,
2777
+ recommendation: "Optimal size for Context Economy"
2778
+ };
2779
+ } else if (tokenCount < 3500) {
2780
+ return {
2781
+ level: "good",
2782
+ costMultiplier,
2783
+ effectiveness: 95,
2784
+ recommendation: "Good size, no action needed"
2785
+ };
2786
+ } else if (tokenCount < 5e3) {
2787
+ return {
2788
+ level: "warning",
2789
+ costMultiplier,
2790
+ effectiveness: 85,
2791
+ recommendation: "Consider simplification or sub-specs"
2792
+ };
2793
+ } else {
2794
+ return {
2795
+ level: "problem",
2796
+ costMultiplier,
2797
+ effectiveness: 70,
2798
+ recommendation: "Should split - elevated token count"
2799
+ };
2800
+ }
2801
+ }
2802
+ };
2803
+ async function countTokens(input, options) {
2804
+ const counter = new TokenCounter();
2805
+ try {
2806
+ if (typeof input === "string") {
2807
+ return {
2808
+ total: counter.countString(input),
2809
+ files: []
2810
+ };
2811
+ } else if ("content" in input) {
2812
+ return {
2813
+ total: counter.countString(input.content),
2814
+ files: []
2815
+ };
2816
+ } else if ("filePath" in input) {
2817
+ return await counter.countFile(input.filePath, options);
2818
+ } else if ("specPath" in input) {
2819
+ return await counter.countSpec(input.specPath, options);
2820
+ }
2821
+ throw new Error("Invalid input type");
2822
+ } finally {
2823
+ counter.dispose();
2824
+ }
2825
+ }
2826
+ var ComplexityValidator = class {
2827
+ name = "complexity";
2828
+ description = "Direct token threshold validation with independent structure checks";
2829
+ excellentThreshold;
2830
+ goodThreshold;
2831
+ warningThreshold;
2832
+ maxLines;
2833
+ warningLines;
2834
+ constructor(options = {}) {
2835
+ this.excellentThreshold = options.excellentThreshold ?? 2e3;
2836
+ this.goodThreshold = options.goodThreshold ?? 3500;
2837
+ this.warningThreshold = options.warningThreshold ?? 5e3;
2838
+ this.maxLines = options.maxLines ?? 500;
2839
+ this.warningLines = options.warningLines ?? 400;
2840
+ }
2841
+ async validate(spec, content) {
2842
+ const errors = [];
2843
+ const warnings = [];
2844
+ const metrics = await this.analyzeComplexity(content, spec);
2845
+ const tokenValidation = this.validateTokens(metrics.tokenCount);
2846
+ if (tokenValidation.level === "error") {
2847
+ errors.push({
2848
+ message: tokenValidation.message,
2849
+ suggestion: "Consider splitting for Context Economy (attention and cognitive load)"
2850
+ });
2851
+ } else if (tokenValidation.level === "warning") {
2852
+ warnings.push({
2853
+ message: tokenValidation.message,
2854
+ suggestion: "Consider simplification or splitting into sub-specs"
2855
+ });
2856
+ }
2857
+ const structureChecks = this.checkStructure(metrics);
2858
+ for (const check of structureChecks) {
2859
+ if (!check.passed && check.message) {
2860
+ warnings.push({
2861
+ message: check.message,
2862
+ suggestion: check.suggestion
2863
+ });
2864
+ }
2865
+ }
2866
+ return {
2867
+ passed: errors.length === 0,
2868
+ errors,
2869
+ warnings
2870
+ };
2871
+ }
2872
+ /**
2873
+ * Validate token count with direct thresholds
2874
+ */
2875
+ validateTokens(tokens) {
2876
+ if (tokens > this.warningThreshold) {
2877
+ return {
2878
+ level: "error",
2879
+ message: `Spec has ${tokens.toLocaleString()} tokens (threshold: ${this.warningThreshold.toLocaleString()}) - should split`
2880
+ };
2881
+ }
2882
+ if (tokens > this.goodThreshold) {
2883
+ return {
2884
+ level: "warning",
2885
+ message: `Spec has ${tokens.toLocaleString()} tokens (threshold: ${this.goodThreshold.toLocaleString()})`
2886
+ };
2887
+ }
2888
+ if (tokens > this.excellentThreshold) {
2889
+ return {
2890
+ level: "info",
2891
+ message: `Spec has ${tokens.toLocaleString()} tokens - acceptable, watch for growth`
2892
+ };
2893
+ }
2894
+ return {
2895
+ level: "excellent",
2896
+ message: `Spec has ${tokens.toLocaleString()} tokens - excellent`
2897
+ };
2898
+ }
2899
+ /**
2900
+ * Check structure quality independently
2901
+ */
2902
+ checkStructure(metrics) {
2903
+ const checks = [];
2904
+ if (metrics.hasSubSpecs) {
2905
+ if (metrics.tokenCount > this.excellentThreshold) {
2906
+ checks.push({
2907
+ passed: true,
2908
+ message: `Uses ${metrics.subSpecCount} sub-spec file${metrics.subSpecCount > 1 ? "s" : ""} for progressive disclosure`
2909
+ });
2910
+ }
2911
+ } else if (metrics.tokenCount > this.goodThreshold) {
2912
+ checks.push({
2913
+ passed: false,
2914
+ message: "Consider using sub-spec files (DESIGN.md, IMPLEMENTATION.md, etc.)",
2915
+ suggestion: "Progressive disclosure reduces cognitive load for large specs"
2916
+ });
2917
+ }
2918
+ if (metrics.sectionCount >= 15 && metrics.sectionCount <= 35) {
2919
+ if (metrics.tokenCount > this.excellentThreshold) {
2920
+ checks.push({
2921
+ passed: true,
2922
+ message: `Good sectioning (${metrics.sectionCount} sections) enables cognitive chunking`
2923
+ });
2924
+ }
2925
+ } else if (metrics.sectionCount < 8 && metrics.lineCount > 200) {
2926
+ checks.push({
2927
+ passed: false,
2928
+ message: `Only ${metrics.sectionCount} sections - too monolithic`,
2929
+ suggestion: "Break into 15-35 sections for better readability (7\xB12 cognitive chunks)"
2930
+ });
2931
+ }
2932
+ if (metrics.codeBlockCount > 20) {
2933
+ checks.push({
2934
+ passed: false,
2935
+ message: `High code block density (${metrics.codeBlockCount} blocks)`,
2936
+ suggestion: "Consider moving examples to separate files or sub-specs"
2937
+ });
2938
+ }
2939
+ return checks;
2940
+ }
2941
+ /**
2942
+ * Analyze complexity metrics from spec content
2943
+ */
2944
+ async analyzeComplexity(content, spec) {
2945
+ let body;
2946
+ try {
2947
+ const parsed = matter4(content);
2948
+ body = parsed.content;
2949
+ } catch {
2950
+ body = content;
2951
+ }
2952
+ const lines = content.split("\n");
2953
+ const lineCount = lines.length;
2954
+ let sectionCount = 0;
2955
+ let inCodeBlock = false;
2956
+ for (const line of lines) {
2957
+ if (line.trim().startsWith("```")) {
2958
+ inCodeBlock = !inCodeBlock;
2959
+ continue;
2960
+ }
2961
+ if (!inCodeBlock && line.match(/^#{2,4}\s/)) {
2962
+ sectionCount++;
2963
+ }
2964
+ }
2965
+ const codeBlockCount = Math.floor((content.match(/```/g) || []).length / 2);
2966
+ const listItemCount = lines.filter((line) => line.match(/^[\s]*[-*]\s/) || line.match(/^[\s]*\d+\.\s/)).length;
2967
+ const tableCount = lines.filter((line) => line.includes("|") && line.match(/[-:]{3,}/)).length;
2968
+ const counter = new TokenCounter();
2969
+ const tokenCount = counter.countString(content);
2970
+ counter.dispose();
2971
+ let hasSubSpecs = false;
2972
+ let subSpecCount = 0;
2973
+ try {
2974
+ const specDir = path2.dirname(spec.filePath);
2975
+ const files = await fs9.readdir(specDir);
2976
+ const mdFiles = files.filter(
2977
+ (f) => f.endsWith(".md") && f !== "README.md"
2978
+ );
2979
+ hasSubSpecs = mdFiles.length > 0;
2980
+ subSpecCount = mdFiles.length;
2981
+ } catch (error) {
2982
+ hasSubSpecs = /\b(DESIGN|IMPLEMENTATION|TESTING|CONFIGURATION|API|MIGRATION)\.md\b/.test(content);
2983
+ const subSpecMatches = content.match(/\b[A-Z-]+\.md\b/g) || [];
2984
+ const uniqueSubSpecs = new Set(subSpecMatches.filter((m) => m !== "README.md"));
2985
+ subSpecCount = uniqueSubSpecs.size;
2986
+ }
2987
+ const averageSectionLength = sectionCount > 0 ? Math.round(lineCount / sectionCount) : 0;
2988
+ return {
2989
+ lineCount,
2990
+ tokenCount,
2991
+ sectionCount,
2992
+ codeBlockCount,
2993
+ listItemCount,
2994
+ tableCount,
2995
+ hasSubSpecs,
2996
+ subSpecCount,
2997
+ averageSectionLength
2998
+ };
2999
+ }
3000
+ };
3001
+ var FIELD_WEIGHTS = {
3002
+ title: 100,
3003
+ name: 70,
3004
+ tags: 70,
3005
+ description: 50,
3006
+ content: 10
3007
+ };
3008
+ function calculateMatchScore(match, queryTerms, totalMatches, matchPosition) {
3009
+ let score = FIELD_WEIGHTS[match.field];
3010
+ match.text.toLowerCase();
3011
+ const hasExactMatch = queryTerms.some((term) => {
3012
+ const regex = new RegExp(`\\b${escapeRegex(term)}\\b`, "i");
3013
+ return regex.test(match.text);
3014
+ });
3015
+ if (hasExactMatch) {
3016
+ score *= 2;
3017
+ }
3018
+ const positionBonus = Math.max(1, 1.5 - matchPosition * 0.1);
3019
+ score *= positionBonus;
3020
+ const frequencyFactor = Math.min(1, 3 / totalMatches);
3021
+ score *= frequencyFactor;
3022
+ return Math.min(100, score * 10);
3023
+ }
3024
+ function calculateSpecScore(matches) {
3025
+ if (matches.length === 0) return 0;
3026
+ const fieldScores = {};
3027
+ for (const match of matches) {
3028
+ const field = match.field;
3029
+ const currentScore = fieldScores[field] || 0;
3030
+ fieldScores[field] = Math.max(currentScore, match.score);
3031
+ }
3032
+ let totalScore = 0;
3033
+ let totalWeight = 0;
3034
+ for (const [field, score] of Object.entries(fieldScores)) {
3035
+ const weight = FIELD_WEIGHTS[field] || 1;
3036
+ totalScore += score * weight;
3037
+ totalWeight += weight;
3038
+ }
3039
+ return totalWeight > 0 ? Math.round(totalScore / totalWeight) : 0;
3040
+ }
3041
+ function containsAllTerms(text, queryTerms) {
3042
+ const textLower = text.toLowerCase();
3043
+ return queryTerms.every((term) => textLower.includes(term));
3044
+ }
3045
+ function countOccurrences(text, queryTerms) {
3046
+ const textLower = text.toLowerCase();
3047
+ let count = 0;
3048
+ for (const term of queryTerms) {
3049
+ const regex = new RegExp(escapeRegex(term), "gi");
3050
+ const matches = textLower.match(regex);
3051
+ count += matches ? matches.length : 0;
3052
+ }
3053
+ return count;
3054
+ }
3055
+ function escapeRegex(str) {
3056
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3057
+ }
3058
+ function findMatchPositions(text, queryTerms) {
3059
+ const positions = [];
3060
+ const textLower = text.toLowerCase();
3061
+ for (const term of queryTerms) {
3062
+ const termLower = term.toLowerCase();
3063
+ let index = 0;
3064
+ while ((index = textLower.indexOf(termLower, index)) !== -1) {
3065
+ positions.push([index, index + term.length]);
3066
+ index += term.length;
3067
+ }
3068
+ }
3069
+ positions.sort((a, b) => a[0] - b[0]);
3070
+ const merged = [];
3071
+ for (const pos of positions) {
3072
+ if (merged.length === 0) {
3073
+ merged.push(pos);
3074
+ } else {
3075
+ const last = merged[merged.length - 1];
3076
+ if (pos[0] <= last[1]) {
3077
+ last[1] = Math.max(last[1], pos[1]);
3078
+ } else {
3079
+ merged.push(pos);
3080
+ }
3081
+ }
3082
+ }
3083
+ return merged;
3084
+ }
3085
+ function extractContext(text, matchIndex, queryTerms, contextLength = 80) {
3086
+ const lines = text.split("\n");
3087
+ const matchLine = lines[matchIndex] || "";
3088
+ if (matchLine.length <= contextLength * 2) {
3089
+ const highlights2 = findMatchPositions(matchLine, queryTerms);
3090
+ return { text: matchLine, highlights: highlights2 };
3091
+ }
3092
+ const matchLineLower = matchLine.toLowerCase();
3093
+ let firstMatchPos = matchLine.length;
3094
+ for (const term of queryTerms) {
3095
+ const pos = matchLineLower.indexOf(term.toLowerCase());
3096
+ if (pos !== -1 && pos < firstMatchPos) {
3097
+ firstMatchPos = pos;
3098
+ }
3099
+ }
3100
+ const start = Math.max(0, firstMatchPos - contextLength);
3101
+ const end = Math.min(matchLine.length, firstMatchPos + contextLength);
3102
+ let contextText = matchLine.substring(start, end);
3103
+ if (start > 0) contextText = "..." + contextText;
3104
+ if (end < matchLine.length) contextText = contextText + "...";
3105
+ const highlights = findMatchPositions(contextText, queryTerms);
3106
+ return { text: contextText, highlights };
3107
+ }
3108
+ function extractSmartContext(text, matchIndex, queryTerms, contextLength = 80) {
3109
+ const lines = text.split("\n");
3110
+ const matchLine = lines[matchIndex] || "";
3111
+ if (matchLine.length <= contextLength * 2) {
3112
+ return extractContext(text, matchIndex, queryTerms, contextLength);
3113
+ }
3114
+ const matchLineLower = matchLine.toLowerCase();
3115
+ let firstMatchPos = matchLine.length;
3116
+ for (const term of queryTerms) {
3117
+ const pos = matchLineLower.indexOf(term.toLowerCase());
3118
+ if (pos !== -1 && pos < firstMatchPos) {
3119
+ firstMatchPos = pos;
3120
+ }
3121
+ }
3122
+ let start = Math.max(0, firstMatchPos - contextLength);
3123
+ let end = Math.min(matchLine.length, firstMatchPos + contextLength);
3124
+ const beforeText = matchLine.substring(0, start);
3125
+ const lastSentence = beforeText.lastIndexOf(". ");
3126
+ if (lastSentence !== -1 && start - lastSentence < 20) {
3127
+ start = lastSentence + 2;
3128
+ }
3129
+ const afterText = matchLine.substring(end);
3130
+ const nextSentence = afterText.indexOf(". ");
3131
+ if (nextSentence !== -1 && nextSentence < 20) {
3132
+ end = end + nextSentence + 1;
3133
+ }
3134
+ let contextText = matchLine.substring(start, end);
3135
+ if (start > 0) contextText = "..." + contextText;
3136
+ if (end < matchLine.length) contextText = contextText + "...";
3137
+ const highlights = findMatchPositions(contextText, queryTerms);
3138
+ return { text: contextText, highlights };
3139
+ }
3140
+ function deduplicateMatches(matches, minDistance = 3) {
3141
+ if (matches.length === 0) return matches;
3142
+ const sorted = [...matches].sort((a, b) => {
3143
+ if (b.score !== a.score) return b.score - a.score;
3144
+ return (a.lineNumber || 0) - (b.lineNumber || 0);
3145
+ });
3146
+ const deduplicated = [];
3147
+ const usedLines = /* @__PURE__ */ new Set();
3148
+ for (const match of sorted) {
3149
+ if (match.field !== "content") {
3150
+ deduplicated.push(match);
3151
+ continue;
3152
+ }
3153
+ const lineNum = match.lineNumber || 0;
3154
+ let tooClose = false;
3155
+ for (let i = lineNum - minDistance; i <= lineNum + minDistance; i++) {
3156
+ if (usedLines.has(i)) {
3157
+ tooClose = true;
3158
+ break;
3159
+ }
3160
+ }
3161
+ if (!tooClose) {
3162
+ deduplicated.push(match);
3163
+ usedLines.add(lineNum);
3164
+ }
3165
+ }
3166
+ return deduplicated.sort((a, b) => {
3167
+ const fieldOrder = { title: 0, name: 1, tags: 2, description: 3, content: 4 };
3168
+ const orderA = fieldOrder[a.field];
3169
+ const orderB = fieldOrder[b.field];
3170
+ if (orderA !== orderB) return orderA - orderB;
3171
+ return b.score - a.score;
3172
+ });
3173
+ }
3174
+ function limitMatches(matches, maxMatches = 5) {
3175
+ if (matches.length <= maxMatches) return matches;
3176
+ const fieldMatches = {
3177
+ title: [],
3178
+ name: [],
3179
+ tags: [],
3180
+ description: [],
3181
+ content: []
3182
+ };
3183
+ for (const match of matches) {
3184
+ fieldMatches[match.field].push(match);
3185
+ }
3186
+ const nonContent = [
3187
+ ...fieldMatches.title,
3188
+ ...fieldMatches.name,
3189
+ ...fieldMatches.tags,
3190
+ ...fieldMatches.description
3191
+ ];
3192
+ const contentMatches = fieldMatches.content.sort((a, b) => b.score - a.score).slice(0, Math.max(0, maxMatches - nonContent.length));
3193
+ return [...nonContent, ...contentMatches];
3194
+ }
3195
+ function searchSpecs(query, specs, options = {}) {
3196
+ const startTime = Date.now();
3197
+ const queryTerms = query.trim().toLowerCase().split(/\s+/).filter((term) => term.length > 0);
3198
+ if (queryTerms.length === 0) {
3199
+ return {
3200
+ results: [],
3201
+ metadata: {
3202
+ totalResults: 0,
3203
+ searchTime: Date.now() - startTime,
3204
+ query,
3205
+ specsSearched: specs.length
3206
+ }
3207
+ };
3208
+ }
3209
+ const maxMatchesPerSpec = options.maxMatchesPerSpec || 5;
3210
+ const contextLength = options.contextLength || 80;
3211
+ const results = [];
3212
+ for (const spec of specs) {
3213
+ const matches = searchSpec(spec, queryTerms, contextLength);
3214
+ if (matches.length > 0) {
3215
+ let processedMatches = deduplicateMatches(matches, 3);
3216
+ processedMatches = limitMatches(processedMatches, maxMatchesPerSpec);
3217
+ const score = calculateSpecScore(processedMatches);
3218
+ results.push({
3219
+ spec: specToSearchResult(spec),
3220
+ score,
3221
+ totalMatches: matches.length,
3222
+ matches: processedMatches
3223
+ });
3224
+ }
3225
+ }
3226
+ results.sort((a, b) => b.score - a.score);
3227
+ return {
3228
+ results,
3229
+ metadata: {
3230
+ totalResults: results.length,
3231
+ searchTime: Date.now() - startTime,
3232
+ query,
3233
+ specsSearched: specs.length
3234
+ }
3235
+ };
3236
+ }
3237
+ function searchSpec(spec, queryTerms, contextLength) {
3238
+ const matches = [];
3239
+ if (spec.title && containsAllTerms(spec.title, queryTerms)) {
3240
+ const occurrences = countOccurrences(spec.title, queryTerms);
3241
+ const highlights = findMatchPositions(spec.title, queryTerms);
3242
+ const score = calculateMatchScore(
3243
+ { field: "title", text: spec.title },
3244
+ queryTerms,
3245
+ 1,
3246
+ 0
3247
+ );
3248
+ matches.push({
3249
+ field: "title",
3250
+ text: spec.title,
3251
+ score,
3252
+ highlights,
3253
+ occurrences
3254
+ });
3255
+ }
3256
+ if (spec.name && containsAllTerms(spec.name, queryTerms)) {
3257
+ const occurrences = countOccurrences(spec.name, queryTerms);
3258
+ const highlights = findMatchPositions(spec.name, queryTerms);
3259
+ const score = calculateMatchScore(
3260
+ { field: "name", text: spec.name },
3261
+ queryTerms,
3262
+ 1,
3263
+ 0
3264
+ );
3265
+ matches.push({
3266
+ field: "name",
3267
+ text: spec.name,
3268
+ score,
3269
+ highlights,
3270
+ occurrences
3271
+ });
3272
+ }
3273
+ if (spec.tags && spec.tags.length > 0) {
3274
+ for (const tag of spec.tags) {
3275
+ if (containsAllTerms(tag, queryTerms)) {
3276
+ const occurrences = countOccurrences(tag, queryTerms);
3277
+ const highlights = findMatchPositions(tag, queryTerms);
3278
+ const score = calculateMatchScore(
3279
+ { field: "tags", text: tag },
3280
+ queryTerms,
3281
+ spec.tags.length,
3282
+ spec.tags.indexOf(tag)
3283
+ );
3284
+ matches.push({
3285
+ field: "tags",
3286
+ text: tag,
3287
+ score,
3288
+ highlights,
3289
+ occurrences
3290
+ });
3291
+ }
3292
+ }
3293
+ }
3294
+ if (spec.description && containsAllTerms(spec.description, queryTerms)) {
3295
+ const occurrences = countOccurrences(spec.description, queryTerms);
3296
+ const highlights = findMatchPositions(spec.description, queryTerms);
3297
+ const score = calculateMatchScore(
3298
+ { field: "description", text: spec.description },
3299
+ queryTerms,
3300
+ 1,
3301
+ 0
3302
+ );
3303
+ matches.push({
3304
+ field: "description",
3305
+ text: spec.description,
3306
+ score,
3307
+ highlights,
3308
+ occurrences
3309
+ });
3310
+ }
3311
+ if (spec.content) {
3312
+ const contentMatches = searchContent(
3313
+ spec.content,
3314
+ queryTerms,
3315
+ contextLength
3316
+ );
3317
+ matches.push(...contentMatches);
3318
+ }
3319
+ return matches;
3320
+ }
3321
+ function searchContent(content, queryTerms, contextLength) {
3322
+ const matches = [];
3323
+ const lines = content.split("\n");
3324
+ for (let i = 0; i < lines.length; i++) {
3325
+ const line = lines[i];
3326
+ if (containsAllTerms(line, queryTerms)) {
3327
+ const occurrences = countOccurrences(line, queryTerms);
3328
+ const { text, highlights } = extractSmartContext(
3329
+ content,
3330
+ i,
3331
+ queryTerms,
3332
+ contextLength
3333
+ );
3334
+ const score = calculateMatchScore(
3335
+ { field: "content", text: line },
3336
+ queryTerms,
3337
+ lines.length,
3338
+ i
3339
+ );
3340
+ matches.push({
3341
+ field: "content",
3342
+ text,
3343
+ lineNumber: i + 1,
3344
+ // 1-based line numbers
3345
+ score,
3346
+ highlights,
3347
+ occurrences
3348
+ });
3349
+ }
2300
3350
  }
2301
- };
3351
+ return matches;
3352
+ }
3353
+ function specToSearchResult(spec) {
3354
+ return {
3355
+ name: spec.name,
3356
+ path: spec.path,
3357
+ status: spec.status,
3358
+ priority: spec.priority,
3359
+ tags: spec.tags,
3360
+ title: spec.title,
3361
+ description: spec.description
3362
+ };
3363
+ }
3364
+
3365
+ // src/validators/sub-spec.ts
2302
3366
  var SubSpecValidator = class {
2303
3367
  name = "sub-specs";
2304
3368
  description = "Validate sub-spec files using direct token thresholds (spec 071)";
@@ -2306,13 +3370,11 @@ var SubSpecValidator = class {
2306
3370
  goodThreshold;
2307
3371
  warningThreshold;
2308
3372
  maxLines;
2309
- checkCrossReferences;
2310
3373
  constructor(options = {}) {
2311
3374
  this.excellentThreshold = options.excellentThreshold ?? 2e3;
2312
3375
  this.goodThreshold = options.goodThreshold ?? 3500;
2313
3376
  this.warningThreshold = options.warningThreshold ?? 5e3;
2314
3377
  this.maxLines = options.maxLines ?? 500;
2315
- this.checkCrossReferences = options.checkCrossReferences ?? true;
2316
3378
  }
2317
3379
  async validate(spec, content) {
2318
3380
  const errors = [];
@@ -2325,9 +3387,6 @@ var SubSpecValidator = class {
2325
3387
  this.validateNamingConventions(subSpecs, warnings);
2326
3388
  await this.validateComplexity(subSpecs, errors, warnings);
2327
3389
  this.checkOrphanedSubSpecs(subSpecs, content, warnings);
2328
- if (this.checkCrossReferences) {
2329
- await this.validateCrossReferences(subSpecs, spec, warnings);
2330
- }
2331
3390
  return {
2332
3391
  passed: errors.length === 0,
2333
3392
  errors,
@@ -2390,12 +3449,6 @@ var SubSpecValidator = class {
2390
3449
  suggestion: "Break into 15-35 sections for better readability (7\xB12 cognitive chunks)"
2391
3450
  });
2392
3451
  }
2393
- if (lineCount > this.maxLines) {
2394
- warnings.push({
2395
- message: `Sub-spec ${subSpec.name} is very long (${lineCount} lines)`,
2396
- suggestion: "Consider splitting even if token count is acceptable"
2397
- });
2398
- }
2399
3452
  }
2400
3453
  }
2401
3454
  /**
@@ -2415,29 +3468,6 @@ var SubSpecValidator = class {
2415
3468
  }
2416
3469
  }
2417
3470
  }
2418
- /**
2419
- * Validate cross-document references between sub-specs
2420
- */
2421
- async validateCrossReferences(subSpecs, spec, warnings) {
2422
- const validFileNames = new Set(subSpecs.map((s) => s.name));
2423
- validFileNames.add("README.md");
2424
- for (const subSpec of subSpecs) {
2425
- if (!subSpec.content) {
2426
- continue;
2427
- }
2428
- const linkRegex = /\[([^\]]+)\]\((?:\.\/)?(([^)]+)\.md)\)/g;
2429
- let match;
2430
- while ((match = linkRegex.exec(subSpec.content)) !== null) {
2431
- const referencedFile = match[2];
2432
- if (!validFileNames.has(referencedFile)) {
2433
- warnings.push({
2434
- message: `Broken reference in ${subSpec.name}: ${referencedFile} not found`,
2435
- suggestion: `Check if ${referencedFile} exists or update the link`
2436
- });
2437
- }
2438
- }
2439
- }
2440
- }
2441
3471
  };
2442
3472
  function groupIssuesByFile(results) {
2443
3473
  const fileMap = /* @__PURE__ */ new Map();
@@ -2911,7 +3941,7 @@ function isCriticalOverdue(spec) {
2911
3941
  if (!spec.frontmatter.due) {
2912
3942
  return false;
2913
3943
  }
2914
- const isOverdue = dayjs2(spec.frontmatter.due).isBefore(dayjs2(), "day");
3944
+ const isOverdue = dayjs3(spec.frontmatter.due).isBefore(dayjs3(), "day");
2915
3945
  const isCritical = spec.frontmatter.priority === "critical" || spec.frontmatter.priority === "high";
2916
3946
  return isOverdue && isCritical;
2917
3947
  }
@@ -2923,7 +3953,7 @@ function isLongRunning(spec) {
2923
3953
  if (!updatedAt) {
2924
3954
  return false;
2925
3955
  }
2926
- const daysSinceUpdate = dayjs2().diff(dayjs2(updatedAt), "day");
3956
+ const daysSinceUpdate = dayjs3().diff(dayjs3(updatedAt), "day");
2927
3957
  return daysSinceUpdate > 7;
2928
3958
  }
2929
3959
  function calculateCompletion(specs) {
@@ -2975,8 +4005,8 @@ function calculateCycleTime(spec) {
2975
4005
  if (!createdAt || !completedAt) {
2976
4006
  return null;
2977
4007
  }
2978
- const created = dayjs2(createdAt);
2979
- const completed = dayjs2(completedAt);
4008
+ const created = dayjs3(createdAt);
4009
+ const completed = dayjs3(completedAt);
2980
4010
  return completed.diff(created, "day", true);
2981
4011
  }
2982
4012
  function calculateLeadTime(spec, fromStatus, toStatus) {
@@ -2989,12 +4019,12 @@ function calculateLeadTime(spec, fromStatus, toStatus) {
2989
4019
  if (!fromTransition || !toTransition) {
2990
4020
  return null;
2991
4021
  }
2992
- const from = dayjs2(fromTransition.at);
2993
- const to = dayjs2(toTransition.at);
4022
+ const from = dayjs3(fromTransition.at);
4023
+ const to = dayjs3(toTransition.at);
2994
4024
  return to.diff(from, "day", true);
2995
4025
  }
2996
4026
  function calculateThroughput(specs, days) {
2997
- const cutoff = dayjs2().subtract(days, "day");
4027
+ const cutoff = dayjs3().subtract(days, "day");
2998
4028
  return specs.filter((s) => {
2999
4029
  if (s.frontmatter.status !== "complete" && s.frontmatter.status !== "archived") {
3000
4030
  return false;
@@ -3003,19 +4033,19 @@ function calculateThroughput(specs, days) {
3003
4033
  if (!completedAt) {
3004
4034
  return false;
3005
4035
  }
3006
- return dayjs2(completedAt).isAfter(cutoff);
4036
+ return dayjs3(completedAt).isAfter(cutoff);
3007
4037
  }).length;
3008
4038
  }
3009
- function calculateWIP(specs, date = dayjs2()) {
4039
+ function calculateWIP(specs, date = dayjs3()) {
3010
4040
  return specs.filter((s) => {
3011
4041
  const createdAt = s.frontmatter.created_at || s.frontmatter.created;
3012
- const created = dayjs2(createdAt);
4042
+ const created = dayjs3(createdAt);
3013
4043
  if (created.isAfter(date)) {
3014
4044
  return false;
3015
4045
  }
3016
4046
  const completedAt = s.frontmatter.completed_at || s.frontmatter.completed;
3017
4047
  if (completedAt) {
3018
- const completed = dayjs2(completedAt);
4048
+ const completed = dayjs3(completedAt);
3019
4049
  return completed.isAfter(date);
3020
4050
  }
3021
4051
  return s.frontmatter.status !== "complete" && s.frontmatter.status !== "archived";
@@ -3032,19 +4062,19 @@ function calculateVelocityMetrics(specs) {
3032
4062
  const avgInProgressToComplete = inProgressToCompleteTimes.length > 0 ? inProgressToCompleteTimes.reduce((sum, t) => sum + t, 0) / inProgressToCompleteTimes.length : 0;
3033
4063
  const throughputWeek = calculateThroughput(specs, 7);
3034
4064
  const throughputMonth = calculateThroughput(specs, 30);
3035
- const prevWeekStart = dayjs2().subtract(14, "day");
3036
- const prevWeekEnd = dayjs2().subtract(7, "day");
4065
+ const prevWeekStart = dayjs3().subtract(14, "day");
4066
+ const prevWeekEnd = dayjs3().subtract(7, "day");
3037
4067
  const throughputPrevWeek = specs.filter((s) => {
3038
4068
  const completedAt = s.frontmatter.completed_at || s.frontmatter.completed;
3039
4069
  if (!completedAt) return false;
3040
- const completed = dayjs2(completedAt);
4070
+ const completed = dayjs3(completedAt);
3041
4071
  return completed.isAfter(prevWeekStart) && !completed.isAfter(prevWeekEnd);
3042
4072
  }).length;
3043
4073
  const throughputTrend = throughputWeek > throughputPrevWeek ? "up" : throughputWeek < throughputPrevWeek ? "down" : "stable";
3044
4074
  const currentWIP = calculateWIP(specs);
3045
4075
  const wipSamples = [];
3046
4076
  for (let i = 0; i < 30; i++) {
3047
- const sampleDate = dayjs2().subtract(i, "day");
4077
+ const sampleDate = dayjs3().subtract(i, "day");
3048
4078
  wipSamples.push(calculateWIP(specs, sampleDate));
3049
4079
  }
3050
4080
  const avgWIP = wipSamples.length > 0 ? wipSamples.reduce((sum, w) => sum + w, 0) / wipSamples.length : 0;
@@ -3249,7 +4279,7 @@ function countSpecsByStatusAndPriority(specs) {
3249
4279
  function generateInsights(specs) {
3250
4280
  const insights = [];
3251
4281
  const criticalOverdue = specs.filter(
3252
- (s) => s.frontmatter.priority === "critical" && s.frontmatter.due && dayjs2(s.frontmatter.due).isBefore(dayjs2(), "day") && s.frontmatter.status !== "complete" && s.frontmatter.status !== "archived"
4282
+ (s) => s.frontmatter.priority === "critical" && s.frontmatter.due && dayjs3(s.frontmatter.due).isBefore(dayjs3(), "day") && s.frontmatter.status !== "complete" && s.frontmatter.status !== "archived"
3253
4283
  );
3254
4284
  if (criticalOverdue.length > 0) {
3255
4285
  insights.push({
@@ -3259,7 +4289,7 @@ function generateInsights(specs) {
3259
4289
  });
3260
4290
  }
3261
4291
  const highOverdue = specs.filter(
3262
- (s) => s.frontmatter.priority === "high" && s.frontmatter.due && dayjs2(s.frontmatter.due).isBefore(dayjs2(), "day") && s.frontmatter.status !== "complete" && s.frontmatter.status !== "archived"
4292
+ (s) => s.frontmatter.priority === "high" && s.frontmatter.due && dayjs3(s.frontmatter.due).isBefore(dayjs3(), "day") && s.frontmatter.status !== "complete" && s.frontmatter.status !== "archived"
3263
4293
  );
3264
4294
  if (highOverdue.length > 0) {
3265
4295
  insights.push({
@@ -3276,7 +4306,7 @@ function generateInsights(specs) {
3276
4306
  if (!updatedAt) {
3277
4307
  return false;
3278
4308
  }
3279
- const daysSinceUpdate = dayjs2().diff(dayjs2(updatedAt), "day");
4309
+ const daysSinceUpdate = dayjs3().diff(dayjs3(updatedAt), "day");
3280
4310
  return daysSinceUpdate > 7;
3281
4311
  });
3282
4312
  if (longRunning.length > 0) {
@@ -3309,14 +4339,14 @@ function generateInsights(specs) {
3309
4339
  return insights.slice(0, 5);
3310
4340
  }
3311
4341
  function getSpecInsightDetails(spec) {
3312
- if (spec.frontmatter.due && dayjs2(spec.frontmatter.due).isBefore(dayjs2(), "day") && spec.frontmatter.status !== "complete" && spec.frontmatter.status !== "archived") {
3313
- const daysOverdue = dayjs2().diff(dayjs2(spec.frontmatter.due), "day");
4342
+ if (spec.frontmatter.due && dayjs3(spec.frontmatter.due).isBefore(dayjs3(), "day") && spec.frontmatter.status !== "complete" && spec.frontmatter.status !== "archived") {
4343
+ const daysOverdue = dayjs3().diff(dayjs3(spec.frontmatter.due), "day");
3314
4344
  return `overdue by ${daysOverdue} day${daysOverdue > 1 ? "s" : ""}`;
3315
4345
  }
3316
4346
  if (spec.frontmatter.status === "in-progress") {
3317
4347
  const updatedAt = spec.frontmatter.updated || spec.frontmatter.updated_at || spec.frontmatter.created || spec.frontmatter.created_at;
3318
4348
  if (updatedAt) {
3319
- const daysSinceUpdate = dayjs2().diff(dayjs2(updatedAt), "day");
4349
+ const daysSinceUpdate = dayjs3().diff(dayjs3(updatedAt), "day");
3320
4350
  if (daysSinceUpdate > 7) {
3321
4351
  return `in-progress for ${daysSinceUpdate} days`;
3322
4352
  }
@@ -3416,7 +4446,7 @@ async function statsCommand(options) {
3416
4446
  const criticalInProgress = specs.filter((s) => s.frontmatter.priority === "critical" && s.frontmatter.status === "in-progress").length;
3417
4447
  const criticalComplete = specs.filter((s) => s.frontmatter.priority === "critical" && s.frontmatter.status === "complete").length;
3418
4448
  const criticalOverdue = specs.filter(
3419
- (s) => s.frontmatter.priority === "critical" && s.frontmatter.due && dayjs2(s.frontmatter.due).isBefore(dayjs2(), "day") && s.frontmatter.status !== "complete"
4449
+ (s) => s.frontmatter.priority === "critical" && s.frontmatter.due && dayjs3(s.frontmatter.due).isBefore(dayjs3(), "day") && s.frontmatter.status !== "complete"
3420
4450
  ).length;
3421
4451
  const parts = [];
3422
4452
  if (criticalPlanned > 0) parts.push(chalk16.dim(`${criticalPlanned} planned`));
@@ -3430,7 +4460,7 @@ async function statsCommand(options) {
3430
4460
  const highInProgress = specs.filter((s) => s.frontmatter.priority === "high" && s.frontmatter.status === "in-progress").length;
3431
4461
  const highComplete = specs.filter((s) => s.frontmatter.priority === "high" && s.frontmatter.status === "complete").length;
3432
4462
  const highOverdue = specs.filter(
3433
- (s) => s.frontmatter.priority === "high" && s.frontmatter.due && dayjs2(s.frontmatter.due).isBefore(dayjs2(), "day") && s.frontmatter.status !== "complete"
4463
+ (s) => s.frontmatter.priority === "high" && s.frontmatter.due && dayjs3(s.frontmatter.due).isBefore(dayjs3(), "day") && s.frontmatter.status !== "complete"
3434
4464
  ).length;
3435
4465
  const parts = [];
3436
4466
  if (highPlanned > 0) parts.push(chalk16.dim(`${highPlanned} planned`));
@@ -3592,18 +4622,18 @@ async function statsCommand(options) {
3592
4622
  }
3593
4623
  if (showTimeline) {
3594
4624
  const days = 30;
3595
- const today = dayjs2();
4625
+ const today = dayjs3();
3596
4626
  const startDate = today.subtract(days, "day");
3597
4627
  const createdByDate = {};
3598
4628
  const completedByDate = {};
3599
4629
  for (const spec of specs) {
3600
- const created = dayjs2(spec.frontmatter.created);
4630
+ const created = dayjs3(spec.frontmatter.created);
3601
4631
  if (created.isAfter(startDate)) {
3602
4632
  const dateKey = created.format("YYYY-MM-DD");
3603
4633
  createdByDate[dateKey] = (createdByDate[dateKey] || 0) + 1;
3604
4634
  }
3605
4635
  if (spec.frontmatter.completed) {
3606
- const completed = dayjs2(spec.frontmatter.completed);
4636
+ const completed = dayjs3(spec.frontmatter.completed);
3607
4637
  if (completed.isAfter(startDate)) {
3608
4638
  const dateKey = completed.format("YYYY-MM-DD");
3609
4639
  completedByDate[dateKey] = (completedByDate[dateKey] || 0) + 1;
@@ -3742,27 +4772,21 @@ async function searchCommand(query, options) {
3742
4772
  console.log("No specs found matching filters.");
3743
4773
  return;
3744
4774
  }
3745
- const results = [];
3746
- const queryLower = query.toLowerCase();
3747
- for (const spec of specs) {
3748
- if (!spec.content) continue;
3749
- const matches = [];
3750
- const lines = spec.content.split("\n");
3751
- for (let i = 0; i < lines.length; i++) {
3752
- const line = lines[i];
3753
- if (line.toLowerCase().includes(queryLower)) {
3754
- const contextStart = Math.max(0, i - 1);
3755
- const contextEnd = Math.min(lines.length - 1, i + 1);
3756
- const context = lines.slice(contextStart, contextEnd + 1);
3757
- const matchLine = context[i - contextStart];
3758
- const highlighted = highlightMatch(matchLine, query);
3759
- matches.push(highlighted);
3760
- }
3761
- }
3762
- if (matches.length > 0) {
3763
- results.push({ spec, matches });
3764
- }
3765
- }
4775
+ const searchableSpecs = specs.map((spec) => ({
4776
+ path: spec.path,
4777
+ name: spec.path,
4778
+ status: spec.frontmatter.status,
4779
+ priority: spec.frontmatter.priority,
4780
+ tags: spec.frontmatter.tags,
4781
+ title: spec.frontmatter.title,
4782
+ description: spec.frontmatter.description,
4783
+ content: spec.content
4784
+ }));
4785
+ const searchResult = searchSpecs(query, searchableSpecs, {
4786
+ maxMatchesPerSpec: 5,
4787
+ contextLength: 80
4788
+ });
4789
+ const { results, metadata } = searchResult;
3766
4790
  if (results.length === 0) {
3767
4791
  console.log("");
3768
4792
  console.log(chalk16.yellow(`\u{1F50D} No specs found matching "${sanitizeUserInput(query)}"`));
@@ -3779,45 +4803,68 @@ async function searchCommand(query, options) {
3779
4803
  }
3780
4804
  console.log("");
3781
4805
  console.log(chalk16.green(`\u{1F50D} Found ${results.length} spec${results.length === 1 ? "" : "s"} matching "${sanitizeUserInput(query)}"`));
4806
+ console.log(chalk16.gray(` Searched ${metadata.specsSearched} specs in ${metadata.searchTime}ms`));
3782
4807
  if (Object.keys(filter).length > 0) {
3783
4808
  const filters = [];
3784
4809
  if (options.status) filters.push(`status=${sanitizeUserInput(options.status)}`);
3785
4810
  if (options.tag) filters.push(`tag=${sanitizeUserInput(options.tag)}`);
3786
4811
  if (options.priority) filters.push(`priority=${sanitizeUserInput(options.priority)}`);
3787
4812
  if (options.assignee) filters.push(`assignee=${sanitizeUserInput(options.assignee)}`);
3788
- console.log(chalk16.gray(`With filters: ${filters.join(", ")}`));
4813
+ console.log(chalk16.gray(` With filters: ${filters.join(", ")}`));
3789
4814
  }
3790
4815
  console.log("");
3791
4816
  for (const result of results) {
3792
- const { spec, matches } = result;
3793
- console.log(chalk16.cyan(`${spec.frontmatter.status === "in-progress" ? "\u{1F528}" : spec.frontmatter.status === "complete" ? "\u2705" : "\u{1F4C5}"} ${sanitizeUserInput(spec.path)}`));
4817
+ const { spec, matches, score, totalMatches } = result;
4818
+ const statusEmoji = spec.status === "in-progress" ? "\u{1F528}" : spec.status === "complete" ? "\u2705" : "\u{1F4C5}";
4819
+ console.log(chalk16.cyan(`${statusEmoji} ${sanitizeUserInput(spec.path)} ${chalk16.gray(`(${score}% match)`)}`));
3794
4820
  const meta = [];
3795
- if (spec.frontmatter.priority) {
3796
- const priorityEmoji = spec.frontmatter.priority === "critical" ? "\u{1F534}" : spec.frontmatter.priority === "high" ? "\u{1F7E1}" : spec.frontmatter.priority === "medium" ? "\u{1F7E0}" : "\u{1F7E2}";
3797
- meta.push(`${priorityEmoji} ${sanitizeUserInput(spec.frontmatter.priority)}`);
4821
+ if (spec.priority) {
4822
+ const priorityEmoji = spec.priority === "critical" ? "\u{1F534}" : spec.priority === "high" ? "\u{1F7E1}" : spec.priority === "medium" ? "\u{1F7E0}" : "\u{1F7E2}";
4823
+ meta.push(`${priorityEmoji} ${sanitizeUserInput(spec.priority)}`);
3798
4824
  }
3799
- if (spec.frontmatter.tags && spec.frontmatter.tags.length > 0) {
3800
- meta.push(`[${spec.frontmatter.tags.map((tag) => sanitizeUserInput(tag)).join(", ")}]`);
4825
+ if (spec.tags && spec.tags.length > 0) {
4826
+ meta.push(`[${spec.tags.map((tag) => sanitizeUserInput(tag)).join(", ")}]`);
3801
4827
  }
3802
4828
  if (meta.length > 0) {
3803
- console.log(chalk16.gray(` ${meta.join(" \u2022 ")}`));
4829
+ console.log(chalk16.gray(` ${meta.join(" \u2022 ")}`));
3804
4830
  }
3805
- const maxMatches = 3;
3806
- for (let i = 0; i < Math.min(matches.length, maxMatches); i++) {
3807
- console.log(` ${chalk16.gray("Match:")} ${matches[i].trim()}`);
4831
+ const titleMatch = matches.find((m) => m.field === "title");
4832
+ if (titleMatch) {
4833
+ console.log(` ${chalk16.bold("Title:")} ${highlightMatches(titleMatch.text, titleMatch.highlights)}`);
4834
+ }
4835
+ const descMatch = matches.find((m) => m.field === "description");
4836
+ if (descMatch) {
4837
+ console.log(` ${chalk16.bold("Description:")} ${highlightMatches(descMatch.text, descMatch.highlights)}`);
4838
+ }
4839
+ const tagMatches = matches.filter((m) => m.field === "tags");
4840
+ if (tagMatches.length > 0) {
4841
+ console.log(` ${chalk16.bold("Tags:")} ${tagMatches.map((m) => highlightMatches(m.text, m.highlights)).join(", ")}`);
4842
+ }
4843
+ const contentMatches = matches.filter((m) => m.field === "content");
4844
+ if (contentMatches.length > 0) {
4845
+ console.log(` ${chalk16.bold("Content matches:")}`);
4846
+ for (const match of contentMatches) {
4847
+ const lineInfo = match.lineNumber ? chalk16.gray(`[L${match.lineNumber}]`) : "";
4848
+ console.log(` ${lineInfo} ${highlightMatches(match.text, match.highlights)}`);
4849
+ }
3808
4850
  }
3809
- if (matches.length > maxMatches) {
3810
- console.log(chalk16.gray(` ... and ${matches.length - maxMatches} more match${matches.length - maxMatches === 1 ? "" : "es"}`));
4851
+ if (totalMatches > matches.length) {
4852
+ console.log(chalk16.gray(` ... and ${totalMatches - matches.length} more match${totalMatches - matches.length === 1 ? "" : "es"}`));
3811
4853
  }
3812
4854
  console.log("");
3813
4855
  }
3814
4856
  }
3815
- function highlightMatch(text, query) {
3816
- const regex = new RegExp(`(${escapeRegex(query)})`, "gi");
3817
- return text.replace(regex, chalk16.yellow("$1"));
3818
- }
3819
- function escapeRegex(str) {
3820
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4857
+ function highlightMatches(text, highlights) {
4858
+ if (highlights.length === 0) return text;
4859
+ let result = "";
4860
+ let lastEnd = 0;
4861
+ for (const [start, end] of highlights) {
4862
+ result += text.substring(lastEnd, start);
4863
+ result += chalk16.yellow(text.substring(start, end));
4864
+ lastEnd = end;
4865
+ }
4866
+ result += text.substring(lastEnd);
4867
+ return result;
3821
4868
  }
3822
4869
  async function depsCommand(specPath, options) {
3823
4870
  await autoCheckIfEnabled();
@@ -3899,8 +4946,8 @@ function findDependencies(spec, specMap) {
3899
4946
  if (dep) {
3900
4947
  deps.push(dep);
3901
4948
  } else {
3902
- for (const [path21, s] of specMap.entries()) {
3903
- if (path21.includes(depPath)) {
4949
+ for (const [path26, s] of specMap.entries()) {
4950
+ if (path26.includes(depPath)) {
3904
4951
  deps.push(s);
3905
4952
  break;
3906
4953
  }
@@ -3932,8 +4979,8 @@ function findRelated(spec, specMap) {
3932
4979
  if (rel) {
3933
4980
  related.push(rel);
3934
4981
  } else {
3935
- for (const [path21, s] of specMap.entries()) {
3936
- if (path21.includes(relPath)) {
4982
+ for (const [path26, s] of specMap.entries()) {
4983
+ if (path26.includes(relPath)) {
3937
4984
  related.push(s);
3938
4985
  break;
3939
4986
  }
@@ -4016,13 +5063,13 @@ async function timelineCommand(options) {
4016
5063
  console.log("No specs found.");
4017
5064
  return;
4018
5065
  }
4019
- const today = dayjs2();
5066
+ const today = dayjs3();
4020
5067
  const startDate = today.subtract(days, "day");
4021
5068
  const createdByDate = {};
4022
5069
  const completedByDate = {};
4023
5070
  const createdByMonth = {};
4024
5071
  for (const spec of specs) {
4025
- const created = dayjs2(spec.frontmatter.created);
5072
+ const created = dayjs3(spec.frontmatter.created);
4026
5073
  if (created.isAfter(startDate)) {
4027
5074
  const dateKey = created.format("YYYY-MM-DD");
4028
5075
  createdByDate[dateKey] = (createdByDate[dateKey] || 0) + 1;
@@ -4030,7 +5077,7 @@ async function timelineCommand(options) {
4030
5077
  const monthKey = created.format("MMM YYYY");
4031
5078
  createdByMonth[monthKey] = (createdByMonth[monthKey] || 0) + 1;
4032
5079
  if (spec.frontmatter.completed) {
4033
- const completed = dayjs2(spec.frontmatter.completed);
5080
+ const completed = dayjs3(spec.frontmatter.completed);
4034
5081
  if (completed.isAfter(startDate)) {
4035
5082
  const dateKey = completed.format("YYYY-MM-DD");
4036
5083
  completedByDate[dateKey] = (completedByDate[dateKey] || 0) + 1;
@@ -4063,8 +5110,8 @@ async function timelineCommand(options) {
4063
5110
  console.log("");
4064
5111
  }
4065
5112
  const sortedMonths = Object.entries(createdByMonth).sort((a, b) => {
4066
- const dateA = dayjs2(a[0], "MMM YYYY");
4067
- const dateB = dayjs2(b[0], "MMM YYYY");
5113
+ const dateA = dayjs3(a[0], "MMM YYYY");
5114
+ const dateB = dayjs3(b[0], "MMM YYYY");
4068
5115
  return dateB.diff(dateA);
4069
5116
  }).slice(0, 6);
4070
5117
  if (sortedMonths.length > 0) {
@@ -4085,12 +5132,12 @@ async function timelineCommand(options) {
4085
5132
  }
4086
5133
  const last7Days = specs.filter((s) => {
4087
5134
  if (!s.frontmatter.completed) return false;
4088
- const completed = dayjs2(s.frontmatter.completed);
5135
+ const completed = dayjs3(s.frontmatter.completed);
4089
5136
  return completed.isAfter(today.subtract(7, "day"));
4090
5137
  }).length;
4091
5138
  const last30Days = specs.filter((s) => {
4092
5139
  if (!s.frontmatter.completed) return false;
4093
- const completed = dayjs2(s.frontmatter.completed);
5140
+ const completed = dayjs3(s.frontmatter.completed);
4094
5141
  return completed.isAfter(today.subtract(30, "day"));
4095
5142
  }).length;
4096
5143
  console.log(chalk16.bold("\u2705 Completion Rate"));
@@ -4105,14 +5152,14 @@ async function timelineCommand(options) {
4105
5152
  if (options.byTag) {
4106
5153
  const tagStats = {};
4107
5154
  for (const spec of specs) {
4108
- const created = dayjs2(spec.frontmatter.created);
5155
+ const created = dayjs3(spec.frontmatter.created);
4109
5156
  const isInRange = created.isAfter(startDate);
4110
5157
  if (isInRange && spec.frontmatter.tags) {
4111
5158
  for (const tag of spec.frontmatter.tags) {
4112
5159
  if (!tagStats[tag]) tagStats[tag] = { created: 0, completed: 0 };
4113
5160
  tagStats[tag].created++;
4114
5161
  if (spec.frontmatter.completed) {
4115
- const completed = dayjs2(spec.frontmatter.completed);
5162
+ const completed = dayjs3(spec.frontmatter.completed);
4116
5163
  if (completed.isAfter(startDate)) {
4117
5164
  tagStats[tag].completed++;
4118
5165
  }
@@ -4133,14 +5180,14 @@ async function timelineCommand(options) {
4133
5180
  const assigneeStats = {};
4134
5181
  for (const spec of specs) {
4135
5182
  if (!spec.frontmatter.assignee) continue;
4136
- const created = dayjs2(spec.frontmatter.created);
5183
+ const created = dayjs3(spec.frontmatter.created);
4137
5184
  const isInRange = created.isAfter(startDate);
4138
5185
  if (isInRange) {
4139
5186
  const assignee = spec.frontmatter.assignee;
4140
5187
  if (!assigneeStats[assignee]) assigneeStats[assignee] = { created: 0, completed: 0 };
4141
5188
  assigneeStats[assignee].created++;
4142
5189
  if (spec.frontmatter.completed) {
4143
- const completed = dayjs2(spec.frontmatter.completed);
5190
+ const completed = dayjs3(spec.frontmatter.completed);
4144
5191
  if (completed.isAfter(startDate)) {
4145
5192
  assigneeStats[assignee].completed++;
4146
5193
  }
@@ -4219,18 +5266,18 @@ async function ganttCommand(options) {
4219
5266
  if (a.frontmatter.due && !b.frontmatter.due) return -1;
4220
5267
  if (!a.frontmatter.due && b.frontmatter.due) return 1;
4221
5268
  if (a.frontmatter.due && b.frontmatter.due) {
4222
- return dayjs2(a.frontmatter.due).diff(dayjs2(b.frontmatter.due));
5269
+ return dayjs3(a.frontmatter.due).diff(dayjs3(b.frontmatter.due));
4223
5270
  }
4224
5271
  return 0;
4225
5272
  });
4226
5273
  };
4227
- const today = dayjs2();
5274
+ const today = dayjs3();
4228
5275
  const startDate = today.startOf("week");
4229
5276
  const endDate = startDate.add(weeks, "week");
4230
5277
  const inProgress = relevantSpecs.filter((s) => s.frontmatter.status === "in-progress").length;
4231
5278
  const planned = relevantSpecs.filter((s) => s.frontmatter.status === "planned").length;
4232
5279
  const overdue = relevantSpecs.filter(
4233
- (s) => s.frontmatter.due && dayjs2(s.frontmatter.due).isBefore(today) && s.frontmatter.status !== "complete"
5280
+ (s) => s.frontmatter.due && dayjs3(s.frontmatter.due).isBefore(today) && s.frontmatter.status !== "complete"
4234
5281
  ).length;
4235
5282
  console.log(chalk16.bold.cyan(`\u{1F4C5} Gantt Chart (${weeks} weeks from ${startDate.format("MMM D, YYYY")})`));
4236
5283
  console.log("");
@@ -4296,7 +5343,7 @@ function renderSpecRow(spec, startDate, endDate, weeks, today) {
4296
5343
  function renderTimelineBar(spec, startDate, endDate, weeks, today) {
4297
5344
  const charsPerWeek = 8;
4298
5345
  const totalChars = weeks * charsPerWeek;
4299
- const due = dayjs2(spec.frontmatter.due);
5346
+ const due = dayjs3(spec.frontmatter.due);
4300
5347
  const specStart = today;
4301
5348
  const startDaysFromStart = specStart.diff(startDate, "day");
4302
5349
  const dueDaysFromStart = due.diff(startDate, "day");
@@ -4471,6 +5518,450 @@ async function tokensAllCommand(options = {}) {
4471
5518
  console.log(chalk16.dim("Legend: \u2705 excellent (<2K) | \u{1F44D} good (<3.5K) | \u26A0\uFE0F warning (<5K) | \u{1F534} problem (>5K)"));
4472
5519
  console.log("");
4473
5520
  }
5521
+ async function analyzeCommand(specPath, options = {}) {
5522
+ await autoCheckIfEnabled();
5523
+ const counter = new TokenCounter();
5524
+ try {
5525
+ const config = await loadConfig();
5526
+ const cwd = process.cwd();
5527
+ const specsDir = path2.join(cwd, config.specsDir);
5528
+ const resolvedPath = await resolveSpecPath(specPath, cwd, specsDir);
5529
+ if (!resolvedPath) {
5530
+ throw new Error(`Spec not found: ${sanitizeUserInput(specPath)}`);
5531
+ }
5532
+ const specName = path2.basename(resolvedPath);
5533
+ const readmePath = path2.join(resolvedPath, "README.md");
5534
+ const content = await readFile(readmePath, "utf-8");
5535
+ const structure = analyzeMarkdownStructure(content);
5536
+ const tokenResult = await counter.countSpec(resolvedPath, {
5537
+ detailed: true,
5538
+ includeSubSpecs: false
5539
+ // Only analyze README.md for structure
5540
+ });
5541
+ const indicators = counter.getPerformanceIndicators(tokenResult.total);
5542
+ const sectionsWithTokens = await Promise.all(
5543
+ structure.allSections.map(async (section) => {
5544
+ const sectionContent = content.split("\n").slice(section.startLine - 1, section.endLine).join("\n");
5545
+ const sectionTokens = await counter.countTokensInContent(sectionContent);
5546
+ return {
5547
+ section: section.title,
5548
+ level: section.level,
5549
+ lineRange: [section.startLine, section.endLine],
5550
+ tokens: sectionTokens,
5551
+ subsections: section.subsections.map((s) => s.title)
5552
+ };
5553
+ })
5554
+ );
5555
+ const recommendation = generateRecommendation(tokenResult.total, structure, indicators.level);
5556
+ const result = {
5557
+ spec: specName,
5558
+ path: resolvedPath,
5559
+ metrics: {
5560
+ tokens: tokenResult.total,
5561
+ lines: structure.lines,
5562
+ characters: content.length,
5563
+ sections: structure.sectionsByLevel,
5564
+ codeBlocks: structure.codeBlocks,
5565
+ maxNesting: structure.maxNesting
5566
+ },
5567
+ threshold: {
5568
+ status: indicators.level,
5569
+ limit: getThresholdLimit(indicators.level),
5570
+ message: indicators.recommendation
5571
+ },
5572
+ structure: sectionsWithTokens,
5573
+ recommendation
5574
+ };
5575
+ if (options.json) {
5576
+ console.log(JSON.stringify(result, null, 2));
5577
+ return;
5578
+ }
5579
+ displayAnalysis(result, options.verbose);
5580
+ } finally {
5581
+ counter.dispose();
5582
+ }
5583
+ }
5584
+ function generateRecommendation(tokens, structure, level) {
5585
+ if (tokens < 2e3) {
5586
+ return {
5587
+ action: "none",
5588
+ reason: "Spec is under 2,000 tokens (optimal)",
5589
+ confidence: "high"
5590
+ };
5591
+ }
5592
+ if (tokens < 3500) {
5593
+ return {
5594
+ action: "compact",
5595
+ reason: "Spec could benefit from removing redundancy",
5596
+ confidence: "medium"
5597
+ };
5598
+ }
5599
+ if (tokens < 5e3) {
5600
+ const h2Count = structure.sectionsByLevel.h2;
5601
+ if (h2Count >= 3) {
5602
+ return {
5603
+ action: "split",
5604
+ reason: `Exceeds 3,500 token threshold with ${h2Count} concerns`,
5605
+ confidence: "high"
5606
+ };
5607
+ } else {
5608
+ return {
5609
+ action: "split",
5610
+ reason: "Exceeds 3,500 token threshold",
5611
+ confidence: "medium"
5612
+ };
5613
+ }
5614
+ }
5615
+ return {
5616
+ action: "split",
5617
+ reason: "Critically oversized - must split immediately",
5618
+ confidence: "high"
5619
+ };
5620
+ }
5621
+ function getThresholdLimit(level) {
5622
+ switch (level) {
5623
+ case "excellent":
5624
+ return 2e3;
5625
+ case "good":
5626
+ return 3500;
5627
+ case "warning":
5628
+ return 5e3;
5629
+ case "problem":
5630
+ return 5e3;
5631
+ default:
5632
+ return 2e3;
5633
+ }
5634
+ }
5635
+ function displayAnalysis(result, verbose) {
5636
+ console.log(chalk16.bold.cyan(`\u{1F4CA} Spec Analysis: ${result.spec}`));
5637
+ console.log("");
5638
+ const statusEmoji = result.threshold.status === "excellent" ? "\u2705" : result.threshold.status === "good" ? "\u{1F44D}" : result.threshold.status === "warning" ? "\u26A0\uFE0F" : "\u{1F534}";
5639
+ const tokenColor = result.threshold.status === "excellent" || result.threshold.status === "good" ? chalk16.cyan : result.threshold.status === "warning" ? chalk16.yellow : chalk16.red;
5640
+ console.log(chalk16.bold("Token Count:"), tokenColor(result.metrics.tokens.toLocaleString()), "tokens", statusEmoji);
5641
+ console.log(chalk16.dim(` Threshold: ${result.threshold.limit.toLocaleString()} tokens`));
5642
+ console.log(chalk16.dim(` Status: ${result.threshold.message}`));
5643
+ console.log("");
5644
+ console.log(chalk16.bold("Structure:"));
5645
+ console.log(` Lines: ${chalk16.cyan(result.metrics.lines.toLocaleString())}`);
5646
+ console.log(` Sections: ${chalk16.cyan(result.metrics.sections.total)} (H1:${result.metrics.sections.h1}, H2:${result.metrics.sections.h2}, H3:${result.metrics.sections.h3}, H4:${result.metrics.sections.h4})`);
5647
+ console.log(` Code blocks: ${chalk16.cyan(result.metrics.codeBlocks)}`);
5648
+ console.log(` Max nesting: ${chalk16.cyan(result.metrics.maxNesting)} levels`);
5649
+ console.log("");
5650
+ if (verbose && result.structure.length > 0) {
5651
+ const topSections = result.structure.filter((s) => s.level <= 2).sort((a, b) => b.tokens - a.tokens).slice(0, 5);
5652
+ console.log(chalk16.bold("Top Sections by Size:"));
5653
+ console.log("");
5654
+ for (let i = 0; i < topSections.length; i++) {
5655
+ const s = topSections[i];
5656
+ const percentage = Math.round(s.tokens / result.metrics.tokens * 100);
5657
+ const indent = " ".repeat(s.level - 1);
5658
+ console.log(` ${i + 1}. ${indent}${s.section}`);
5659
+ console.log(` ${chalk16.cyan(s.tokens.toLocaleString())} tokens / ${s.lineRange[1] - s.lineRange[0] + 1} lines ${chalk16.dim(`(${percentage}%)`)}`);
5660
+ console.log(chalk16.dim(` Lines ${s.lineRange[0]}-${s.lineRange[1]}`));
5661
+ }
5662
+ console.log("");
5663
+ }
5664
+ const actionColor = result.recommendation.action === "none" ? chalk16.green : result.recommendation.action === "compact" ? chalk16.yellow : result.recommendation.action === "split" ? chalk16.red : chalk16.blue;
5665
+ console.log(chalk16.bold("Recommendation:"), actionColor(result.recommendation.action.toUpperCase()));
5666
+ console.log(chalk16.dim(` ${result.recommendation.reason}`));
5667
+ console.log(chalk16.dim(` Confidence: ${result.recommendation.confidence}`));
5668
+ console.log("");
5669
+ if (result.recommendation.action === "split") {
5670
+ console.log(chalk16.dim("\u{1F4A1} Use `lean-spec split` to partition into sub-specs"));
5671
+ console.log(chalk16.dim("\u{1F4A1} Consider splitting by H2 sections (concerns)"));
5672
+ } else if (result.recommendation.action === "compact") {
5673
+ console.log(chalk16.dim("\u{1F4A1} Use `lean-spec compact` to remove redundancy"));
5674
+ }
5675
+ console.log("");
5676
+ }
5677
+ async function splitCommand(specPath, options) {
5678
+ await autoCheckIfEnabled();
5679
+ try {
5680
+ if (!options.outputs || options.outputs.length === 0) {
5681
+ throw new Error("At least one --output option is required");
5682
+ }
5683
+ const config = await loadConfig();
5684
+ const cwd = process.cwd();
5685
+ const specsDir = path2.join(cwd, config.specsDir);
5686
+ const resolvedPath = await resolveSpecPath(specPath, cwd, specsDir);
5687
+ if (!resolvedPath) {
5688
+ throw new Error(`Spec not found: ${sanitizeUserInput(specPath)}`);
5689
+ }
5690
+ const specName = path2.basename(resolvedPath);
5691
+ const readmePath = path2.join(resolvedPath, "README.md");
5692
+ const content = await readFile(readmePath, "utf-8");
5693
+ const parsedOutputs = parseOutputSpecs(options.outputs);
5694
+ validateNoOverlaps(parsedOutputs);
5695
+ const extractions = [];
5696
+ for (const output of parsedOutputs) {
5697
+ const extracted = extractLines(content, output.startLine, output.endLine);
5698
+ const lineCount = countLines(extracted);
5699
+ extractions.push({
5700
+ file: output.file,
5701
+ content: extracted,
5702
+ tokens: 0,
5703
+ // Will be calculated in dry-run or actual execution
5704
+ lines: lineCount
5705
+ });
5706
+ }
5707
+ if (options.dryRun) {
5708
+ await displayDryRun(specName, extractions);
5709
+ return;
5710
+ }
5711
+ await executeSplit(resolvedPath, specName, content, extractions, options);
5712
+ } catch (error) {
5713
+ if (error instanceof Error) {
5714
+ console.error(chalk16.red(`Error: ${error.message}`));
5715
+ }
5716
+ throw error;
5717
+ }
5718
+ }
5719
+ function parseOutputSpecs(outputs) {
5720
+ const parsed = [];
5721
+ for (const output of outputs) {
5722
+ const match = output.lines.match(/^(\d+)-(\d+)$/);
5723
+ if (!match) {
5724
+ throw new Error(`Invalid line range format: ${output.lines}. Expected format: "1-150"`);
5725
+ }
5726
+ const startLine = parseInt(match[1], 10);
5727
+ const endLine = parseInt(match[2], 10);
5728
+ if (startLine < 1 || endLine < startLine) {
5729
+ throw new Error(`Invalid line range: ${output.lines}`);
5730
+ }
5731
+ parsed.push({
5732
+ file: output.file,
5733
+ startLine,
5734
+ endLine
5735
+ });
5736
+ }
5737
+ return parsed;
5738
+ }
5739
+ function validateNoOverlaps(outputs) {
5740
+ const sorted = [...outputs].sort((a, b) => a.startLine - b.startLine);
5741
+ for (let i = 0; i < sorted.length - 1; i++) {
5742
+ const current = sorted[i];
5743
+ const next = sorted[i + 1];
5744
+ if (current.endLine >= next.startLine) {
5745
+ throw new Error(
5746
+ `Overlapping line ranges: ${current.file} (${current.startLine}-${current.endLine}) overlaps with ${next.file} (${next.startLine}-${next.endLine})`
5747
+ );
5748
+ }
5749
+ }
5750
+ }
5751
+ async function displayDryRun(specName, extractions) {
5752
+ console.log(chalk16.bold.cyan(`\u{1F4CB} Split Preview: ${specName}`));
5753
+ console.log("");
5754
+ console.log(chalk16.bold("Would create:"));
5755
+ console.log("");
5756
+ for (const ext of extractions) {
5757
+ console.log(` ${chalk16.cyan(ext.file)}`);
5758
+ console.log(` Lines: ${ext.lines}`);
5759
+ const previewLines = ext.content.split("\n").slice(0, 3);
5760
+ console.log(chalk16.dim(" Preview:"));
5761
+ for (const line of previewLines) {
5762
+ console.log(chalk16.dim(` ${line.substring(0, 60)}${line.length > 60 ? "..." : ""}`));
5763
+ }
5764
+ console.log("");
5765
+ }
5766
+ console.log(chalk16.dim("No files modified (dry run)"));
5767
+ console.log(chalk16.dim("Run without --dry-run to apply changes"));
5768
+ console.log("");
5769
+ }
5770
+ async function executeSplit(specPath, specName, originalContent, extractions, options) {
5771
+ console.log(chalk16.bold.cyan(`\u2702\uFE0F Splitting: ${specName}`));
5772
+ console.log("");
5773
+ const frontmatter = parseFrontmatterFromString(originalContent);
5774
+ for (const ext of extractions) {
5775
+ const outputPath = path2.join(specPath, ext.file);
5776
+ let finalContent = ext.content;
5777
+ if (ext.file === "README.md" && frontmatter) {
5778
+ const { content: contentWithFrontmatter } = createUpdatedFrontmatter(
5779
+ ext.content,
5780
+ frontmatter
5781
+ );
5782
+ finalContent = contentWithFrontmatter;
5783
+ }
5784
+ await writeFile(outputPath, finalContent, "utf-8");
5785
+ console.log(chalk16.green(`\u2713 Created ${ext.file} (${ext.lines} lines)`));
5786
+ }
5787
+ if (options.updateRefs) {
5788
+ const readmePath = path2.join(specPath, "README.md");
5789
+ const readmeContent = await readFile(readmePath, "utf-8");
5790
+ const updatedReadme = await addSubSpecLinks(
5791
+ readmeContent,
5792
+ extractions.map((e) => e.file).filter((f) => f !== "README.md")
5793
+ );
5794
+ await writeFile(readmePath, updatedReadme, "utf-8");
5795
+ console.log(chalk16.green(`\u2713 Updated README.md with sub-spec links`));
5796
+ }
5797
+ console.log("");
5798
+ console.log(chalk16.bold.green("Split complete!"));
5799
+ console.log(chalk16.dim(`Created ${extractions.length} files in ${specName}`));
5800
+ console.log("");
5801
+ }
5802
+ async function addSubSpecLinks(content, subSpecs) {
5803
+ if (subSpecs.length === 0) {
5804
+ return content;
5805
+ }
5806
+ if (content.includes("## Sub-Specs") || content.includes("## Sub-specs")) {
5807
+ return content;
5808
+ }
5809
+ const lines = content.split("\n");
5810
+ let insertIndex = -1;
5811
+ for (let i = 0; i < lines.length; i++) {
5812
+ const line = lines[i].toLowerCase();
5813
+ if (line.includes("## implementation") || line.includes("## plan") || line.includes("## test")) {
5814
+ insertIndex = i;
5815
+ break;
5816
+ }
5817
+ }
5818
+ if (insertIndex === -1) {
5819
+ insertIndex = lines.length;
5820
+ }
5821
+ const subSpecsSection = [
5822
+ "",
5823
+ "## Sub-Specs",
5824
+ "",
5825
+ "This spec is organized using sub-spec files:",
5826
+ "",
5827
+ ...subSpecs.map((file) => {
5828
+ const name = file.replace(".md", "");
5829
+ return `- **[${name}](./${file})** - ${getFileDescription(file)}`;
5830
+ }),
5831
+ ""
5832
+ ];
5833
+ lines.splice(insertIndex, 0, ...subSpecsSection);
5834
+ return lines.join("\n");
5835
+ }
5836
+ function getFileDescription(file) {
5837
+ const lower = file.toLowerCase();
5838
+ if (lower.includes("design")) return "Architecture and design details";
5839
+ if (lower.includes("implementation")) return "Implementation plan and phases";
5840
+ if (lower.includes("testing") || lower.includes("test")) return "Test strategy and cases";
5841
+ if (lower.includes("rationale")) return "Design rationale and decisions";
5842
+ if (lower.includes("api")) return "API specification";
5843
+ if (lower.includes("migration")) return "Migration plan and strategy";
5844
+ if (lower.includes("context")) return "Context and research";
5845
+ return "Additional documentation";
5846
+ }
5847
+ async function compactCommand(specPath, options) {
5848
+ await autoCheckIfEnabled();
5849
+ try {
5850
+ if (!options.removes || options.removes.length === 0) {
5851
+ throw new Error("At least one --remove option is required");
5852
+ }
5853
+ const config = await loadConfig();
5854
+ const cwd = process.cwd();
5855
+ const specsDir = path2.join(cwd, config.specsDir);
5856
+ const resolvedPath = await resolveSpecPath(specPath, cwd, specsDir);
5857
+ if (!resolvedPath) {
5858
+ throw new Error(`Spec not found: ${sanitizeUserInput(specPath)}`);
5859
+ }
5860
+ const specName = path2.basename(resolvedPath);
5861
+ const readmePath = path2.join(resolvedPath, "README.md");
5862
+ const content = await readFile(readmePath, "utf-8");
5863
+ const parsedRemoves = parseRemoveSpecs(options.removes);
5864
+ validateNoOverlaps2(parsedRemoves);
5865
+ if (options.dryRun) {
5866
+ await displayDryRun2(specName, content, parsedRemoves);
5867
+ return;
5868
+ }
5869
+ await executeCompact(readmePath, specName, content, parsedRemoves);
5870
+ } catch (error) {
5871
+ if (error instanceof Error) {
5872
+ console.error(chalk16.red(`Error: ${error.message}`));
5873
+ }
5874
+ throw error;
5875
+ }
5876
+ }
5877
+ function parseRemoveSpecs(removes) {
5878
+ const parsed = [];
5879
+ for (let i = 0; i < removes.length; i++) {
5880
+ const spec = removes[i];
5881
+ const match = spec.match(/^(\d+)-(\d+)$/);
5882
+ if (!match) {
5883
+ throw new Error(`Invalid line range format: ${spec}. Expected format: "145-153"`);
5884
+ }
5885
+ const startLine = parseInt(match[1], 10);
5886
+ const endLine = parseInt(match[2], 10);
5887
+ if (startLine < 1 || endLine < startLine) {
5888
+ throw new Error(`Invalid line range: ${spec}`);
5889
+ }
5890
+ parsed.push({
5891
+ startLine,
5892
+ endLine,
5893
+ originalIndex: i
5894
+ });
5895
+ }
5896
+ return parsed;
5897
+ }
5898
+ function validateNoOverlaps2(removes) {
5899
+ const sorted = [...removes].sort((a, b) => a.startLine - b.startLine);
5900
+ for (let i = 0; i < sorted.length - 1; i++) {
5901
+ const current = sorted[i];
5902
+ const next = sorted[i + 1];
5903
+ if (current.endLine >= next.startLine) {
5904
+ throw new Error(
5905
+ `Overlapping line ranges: ${current.startLine}-${current.endLine} overlaps with ${next.startLine}-${next.endLine}`
5906
+ );
5907
+ }
5908
+ }
5909
+ }
5910
+ async function displayDryRun2(specName, content, removes) {
5911
+ console.log(chalk16.bold.cyan(`\u{1F4CB} Compact Preview: ${specName}`));
5912
+ console.log("");
5913
+ console.log(chalk16.bold("Would remove:"));
5914
+ console.log("");
5915
+ let totalLines = 0;
5916
+ for (const remove of removes) {
5917
+ const lineCount = remove.endLine - remove.startLine + 1;
5918
+ totalLines += lineCount;
5919
+ const removedContent = extractLines(content, remove.startLine, remove.endLine);
5920
+ const previewLines = removedContent.split("\n").slice(0, 3);
5921
+ console.log(` Lines ${remove.startLine}-${remove.endLine} (${lineCount} lines)`);
5922
+ console.log(chalk16.dim(" Preview:"));
5923
+ for (const line of previewLines) {
5924
+ console.log(chalk16.dim(` ${line.substring(0, 60)}${line.length > 60 ? "..." : ""}`));
5925
+ }
5926
+ if (removedContent.split("\n").length > 3) {
5927
+ console.log(chalk16.dim(` ... (${removedContent.split("\n").length - 3} more lines)`));
5928
+ }
5929
+ console.log("");
5930
+ }
5931
+ const originalLines = countLines(content);
5932
+ const remainingLines = originalLines - totalLines;
5933
+ const percentage = Math.round(totalLines / originalLines * 100);
5934
+ console.log(chalk16.bold("Summary:"));
5935
+ console.log(` Original lines: ${chalk16.cyan(originalLines)}`);
5936
+ console.log(` Removing: ${chalk16.yellow(totalLines)} lines (${percentage}%)`);
5937
+ console.log(` Remaining lines: ${chalk16.cyan(remainingLines)}`);
5938
+ console.log("");
5939
+ console.log(chalk16.dim("No files modified (dry run)"));
5940
+ console.log(chalk16.dim("Run without --dry-run to apply changes"));
5941
+ console.log("");
5942
+ }
5943
+ async function executeCompact(readmePath, specName, content, removes) {
5944
+ console.log(chalk16.bold.cyan(`\u{1F5DC}\uFE0F Compacting: ${specName}`));
5945
+ console.log("");
5946
+ const sorted = [...removes].sort((a, b) => b.startLine - a.startLine);
5947
+ let updatedContent = content;
5948
+ let totalRemoved = 0;
5949
+ for (const remove of sorted) {
5950
+ const lineCount = remove.endLine - remove.startLine + 1;
5951
+ updatedContent = removeLines(updatedContent, remove.startLine, remove.endLine);
5952
+ totalRemoved += lineCount;
5953
+ console.log(chalk16.green(`\u2713 Removed lines ${remove.startLine}-${remove.endLine} (${lineCount} lines)`));
5954
+ }
5955
+ await writeFile(readmePath, updatedContent, "utf-8");
5956
+ const originalLines = countLines(content);
5957
+ const finalLines = countLines(updatedContent);
5958
+ const percentage = Math.round(totalRemoved / originalLines * 100);
5959
+ console.log("");
5960
+ console.log(chalk16.bold.green("Compaction complete!"));
5961
+ console.log(chalk16.dim(`Removed ${totalRemoved} lines (${percentage}%)`));
5962
+ console.log(chalk16.dim(`${originalLines} \u2192 ${finalLines} lines`));
5963
+ console.log("");
5964
+ }
4474
5965
  marked.use(markedTerminal());
4475
5966
  async function readSpecContent(specPath, cwd = process.cwd()) {
4476
5967
  const config = await loadConfig(cwd);
@@ -4756,24 +6247,46 @@ async function searchSpecsData(query, options) {
4756
6247
  includeContent: true,
4757
6248
  filter
4758
6249
  });
4759
- const results = [];
4760
- const queryLower = query.toLowerCase();
4761
- for (const spec of specs) {
4762
- if (!spec.content) continue;
4763
- const matches = [];
4764
- const lines = spec.content.split("\n");
4765
- for (let i = 0; i < lines.length; i++) {
4766
- const line = lines[i];
4767
- if (line.toLowerCase().includes(queryLower)) {
4768
- matches.push(line.trim());
4769
- if (matches.length >= 5) break;
4770
- }
4771
- }
4772
- if (matches.length > 0) {
4773
- results.push({ spec: specToData(spec), matches });
4774
- }
4775
- }
4776
- return results;
6250
+ const searchableSpecs = specs.map((spec) => ({
6251
+ path: spec.path,
6252
+ name: spec.path,
6253
+ status: spec.frontmatter.status,
6254
+ priority: spec.frontmatter.priority,
6255
+ tags: spec.frontmatter.tags,
6256
+ title: spec.frontmatter.title,
6257
+ description: spec.frontmatter.description,
6258
+ content: spec.content
6259
+ }));
6260
+ const searchResult = searchSpecs(query, searchableSpecs, {
6261
+ maxMatchesPerSpec: 5,
6262
+ contextLength: 80
6263
+ });
6264
+ return {
6265
+ results: searchResult.results.map((result) => ({
6266
+ spec: {
6267
+ name: result.spec.name,
6268
+ path: result.spec.path,
6269
+ status: result.spec.status,
6270
+ created: specs.find((s) => s.path === result.spec.path)?.frontmatter.created || "",
6271
+ title: result.spec.title,
6272
+ tags: result.spec.tags,
6273
+ priority: result.spec.priority,
6274
+ assignee: specs.find((s) => s.path === result.spec.path)?.frontmatter.assignee,
6275
+ description: result.spec.description,
6276
+ customFields: specs.find((s) => s.path === result.spec.path)?.frontmatter.custom
6277
+ },
6278
+ score: result.score,
6279
+ totalMatches: result.totalMatches,
6280
+ matches: result.matches.map((match) => ({
6281
+ field: match.field,
6282
+ text: match.text,
6283
+ lineNumber: match.lineNumber,
6284
+ score: match.score,
6285
+ highlights: match.highlights
6286
+ }))
6287
+ })),
6288
+ metadata: searchResult.metadata
6289
+ };
4777
6290
  }
4778
6291
  async function readSpecData(specPath) {
4779
6292
  const cwd = process.cwd();
@@ -4909,28 +6422,44 @@ async function createMcpServer() {
4909
6422
  "search",
4910
6423
  {
4911
6424
  title: "Search Specs",
4912
- description: "Full-text search across all specification content. Use this when you need to find specs by keyword, topic, or concept. Returns matching specs with relevant excerpts.",
6425
+ description: "Intelligent relevance-ranked search across all specification content. Uses field-weighted scoring (title > tags > description > content) to return the most relevant specs. Returns matching specs with relevance scores, highlighted excerpts, and metadata.",
4913
6426
  inputSchema: {
4914
- query: z.string().describe("Search term or phrase to find in spec content. Searches across titles, descriptions, and body text."),
6427
+ query: z.string().describe("Search term or phrase to find in spec content. Multiple terms are combined with AND logic. Searches across titles, tags, descriptions, and body text with intelligent relevance ranking."),
4915
6428
  status: z.enum(["planned", "in-progress", "complete", "archived"]).optional().describe("Limit search to specs with this status."),
4916
6429
  tags: z.array(z.string()).optional().describe("Limit search to specs with these tags."),
4917
6430
  priority: z.enum(["low", "medium", "high", "critical"]).optional().describe("Limit search to specs with this priority.")
4918
6431
  },
4919
6432
  outputSchema: {
4920
- results: z.array(z.any())
6433
+ results: z.array(z.object({
6434
+ spec: z.any(),
6435
+ score: z.number(),
6436
+ totalMatches: z.number(),
6437
+ matches: z.array(z.object({
6438
+ field: z.string(),
6439
+ text: z.string(),
6440
+ lineNumber: z.number().optional(),
6441
+ score: z.number(),
6442
+ highlights: z.array(z.tuple([z.number(), z.number()]))
6443
+ }))
6444
+ })),
6445
+ metadata: z.object({
6446
+ totalResults: z.number(),
6447
+ searchTime: z.number(),
6448
+ query: z.string(),
6449
+ specsSearched: z.number()
6450
+ })
4921
6451
  }
4922
6452
  },
4923
6453
  async (input) => {
4924
6454
  try {
4925
- const results = await searchSpecsData(input.query, {
6455
+ const searchResult = await searchSpecsData(input.query, {
4926
6456
  status: input.status,
4927
6457
  tags: input.tags,
4928
6458
  priority: input.priority
4929
6459
  });
4930
- const output = { results };
4931
6460
  return {
4932
- content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
4933
- structuredContent: output
6461
+ content: [{ type: "text", text: JSON.stringify(searchResult, null, 2) }],
6462
+ structuredContent: searchResult
4934
6463
  };
4935
6464
  } catch (error) {
4936
6465
  const errorMessage = formatErrorMessage("Error searching specs", error);
@@ -5442,6 +6971,81 @@ ${result.content}`;
5442
6971
  }
5443
6972
  }
5444
6973
  );
6974
+ server.registerTool(
6975
+ "tokens",
6976
+ {
6977
+ title: "Count Tokens",
6978
+ description: "Count tokens in spec or sub-spec for LLM context management. Use this before loading specs to check if they fit in context budget.",
6979
+ inputSchema: {
6980
+ specPath: z.string().describe('Spec name, number, or file path (e.g., "059", "unified-dashboard", "059/DESIGN.md")'),
6981
+ includeSubSpecs: z.boolean().optional().describe("Include all sub-spec files in count (default: false)"),
6982
+ detailed: z.boolean().optional().describe("Return breakdown by content type (code, prose, tables, frontmatter)")
6983
+ },
6984
+ outputSchema: {
6985
+ spec: z.string(),
6986
+ total: z.number(),
6987
+ files: z.array(z.any()),
6988
+ breakdown: z.any().optional(),
6989
+ performance: z.any().optional(),
6990
+ recommendation: z.string().optional()
6991
+ }
6992
+ },
6993
+ async (input) => {
6994
+ const counter = new TokenCounter();
6995
+ try {
6996
+ const config = await loadConfig();
6997
+ const cwd = process.cwd();
6998
+ const specsDir = path2.join(cwd, config.specsDir);
6999
+ const resolvedPath = await resolveSpecPath(input.specPath, cwd, specsDir);
7000
+ if (!resolvedPath) {
7001
+ return {
7002
+ content: [{ type: "text", text: JSON.stringify({
7003
+ error: `Spec not found: ${input.specPath}`,
7004
+ code: "SPEC_NOT_FOUND"
7005
+ }, null, 2) }],
7006
+ isError: true
7007
+ };
7008
+ }
7009
+ const specName = path2.basename(resolvedPath);
7010
+ const result = await counter.countSpec(resolvedPath, {
7011
+ detailed: input.detailed,
7012
+ includeSubSpecs: input.includeSubSpecs
7013
+ });
7014
+ const output = {
7015
+ spec: specName,
7016
+ total: result.total,
7017
+ files: result.files
7018
+ };
7019
+ if (input.detailed && result.breakdown) {
7020
+ output.breakdown = result.breakdown;
7021
+ const indicators = counter.getPerformanceIndicators(result.total);
7022
+ output.performance = {
7023
+ level: indicators.level,
7024
+ costMultiplier: indicators.costMultiplier,
7025
+ effectiveness: indicators.effectiveness,
7026
+ recommendation: indicators.recommendation
7027
+ };
7028
+ }
7029
+ if (result.total > 5e3) {
7030
+ output.recommendation = "\u26A0\uFE0F Total >5K tokens - consider loading README.md only";
7031
+ } else if (result.total > 3500) {
7032
+ output.recommendation = "\u26A0\uFE0F Total >3.5K tokens - consider loading in sections";
7033
+ }
7034
+ return {
7035
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
7036
+ structuredContent: output
7037
+ };
7038
+ } catch (error) {
7039
+ const errorMessage = formatErrorMessage("Error counting tokens", error);
7040
+ return {
7041
+ content: [{ type: "text", text: errorMessage }],
7042
+ isError: true
7043
+ };
7044
+ } finally {
7045
+ counter.dispose();
7046
+ }
7047
+ }
7048
+ );
5445
7049
  server.registerResource(
5446
7050
  "spec",
5447
7051
  new ResourceTemplate("spec://{specPath}", { list: void 0 }),
@@ -5621,6 +7225,6 @@ Please search for this topic and show me the dependencies between related specs.
5621
7225
  return server;
5622
7226
  }
5623
7227
 
5624
- export { addTemplate, archiveSpec, backfillTimestamps, boardCommand, checkSpecs, copyTemplate, createMcpServer, createSpec, depsCommand, filesCommand, ganttCommand, initProject, listSpecs, listTemplates, mcpCommand, migrateCommand, openCommand, removeTemplate, searchCommand, showTemplate, statsCommand, timelineCommand, tokensAllCommand, tokensCommand, updateSpec, validateCommand, viewCommand };
5625
- //# sourceMappingURL=chunk-ER23B6KS.js.map
5626
- //# sourceMappingURL=chunk-ER23B6KS.js.map
7228
+ export { addTemplate, analyzeCommand, archiveSpec, backfillTimestamps, boardCommand, checkSpecs, compactCommand, copyTemplate, createMcpServer, createSpec, depsCommand, filesCommand, ganttCommand, initProject, listSpecs, listTemplates, mcpCommand, migrateCommand, openCommand, removeTemplate, searchCommand, showTemplate, splitCommand, statsCommand, timelineCommand, tokensAllCommand, tokensCommand, updateSpec, validateCommand, viewCommand };
7229
+ //# sourceMappingURL=chunk-7MCDTSVE.js.map
7230
+ //# sourceMappingURL=chunk-7MCDTSVE.js.map