roadmapsmith 0.9.6 → 0.9.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roadmapsmith",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Evidence-backed ROADMAP.md generator and sync tool for AI coding agents.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -2,12 +2,89 @@
2
2
 
3
3
  const { slugify } = require('../utils');
4
4
 
5
- const TASK_LINE_RE = /^(\s*)- \[( |x|X)\] (.*?)(?:\s*<!--\s*rs:task=([a-z0-9-]+)([^>]*)-->)?\s*$/;
6
- const WARNING_RE = /^\s*-\s+⚠️ attempted but validation failed:\s*(.+?)\s*$/;
7
- const HEADING_RE = /^#{2,3}\s+(.*)$/;
8
-
9
5
  const MANAGED_START = '<!-- rs:managed:start -->';
10
6
  const MANAGED_END = '<!-- rs:managed:end -->';
7
+ const WARNING_PREFIX = '⚠️ attempted but validation failed:';
8
+
9
+ function getIndentWidth(text) {
10
+ return String(text || '').replace(/\t/g, ' ').length;
11
+ }
12
+
13
+ function splitIndent(line) {
14
+ const value = String(line || '');
15
+ const trimmed = value.trimStart();
16
+ return {
17
+ indent: value.slice(0, value.length - trimmed.length),
18
+ content: trimmed
19
+ };
20
+ }
21
+
22
+ function parseHeadingLine(line) {
23
+ const { content } = splitIndent(line);
24
+ if (content.startsWith('## ')) {
25
+ return content.slice(3).trim();
26
+ }
27
+ if (content.startsWith('### ')) {
28
+ return content.slice(4).trim();
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function parseTaskLine(line) {
34
+ const { indent, content } = splitIndent(line);
35
+ if (content.length < 6) return null;
36
+ if (content[0] !== '-' || content[1] !== ' ' || content[2] !== '[') return null;
37
+
38
+ const checkedToken = content[3];
39
+ if (checkedToken !== ' ' && checkedToken !== 'x' && checkedToken !== 'X') return null;
40
+ if (content[4] !== ']' || content[5] !== ' ') return null;
41
+
42
+ let text = content.slice(6).trimEnd();
43
+ let markerId = null;
44
+ let markerFlags = '';
45
+
46
+ const markerStart = text.lastIndexOf('<!--');
47
+ if (markerStart >= 0 && text.endsWith('-->')) {
48
+ const markerBody = text.slice(markerStart + 4, -3).trim();
49
+ if (markerBody.startsWith('rs:task=')) {
50
+ const markerPayload = markerBody.slice('rs:task='.length).trim();
51
+ const markerParts = markerPayload.split(/\s+/).filter(Boolean);
52
+ markerId = markerParts[0] || null;
53
+ markerFlags = markerParts.slice(1).join(' ');
54
+ text = text.slice(0, markerStart).trimEnd();
55
+ }
56
+ }
57
+
58
+ return {
59
+ indent,
60
+ checked: checkedToken.toLowerCase() === 'x',
61
+ text: text.trim(),
62
+ markerId,
63
+ markerFlags
64
+ };
65
+ }
66
+
67
+ function parseChildBulletLine(line) {
68
+ const { indent, content } = splitIndent(line);
69
+ if (!content.startsWith('- ')) {
70
+ return null;
71
+ }
72
+ return {
73
+ indent,
74
+ content: content.slice(2).trim()
75
+ };
76
+ }
77
+
78
+ function parseEvidenceLine(content) {
79
+ if (content.length < 9) return null;
80
+ if (content.slice(0, 9).toLowerCase() !== 'evidence:') return null;
81
+ return content.slice(9).trim();
82
+ }
83
+
84
+ function parseWarningLine(content) {
85
+ if (!content.startsWith(WARNING_PREFIX)) return null;
86
+ return content.slice(WARNING_PREFIX.length).trim();
87
+ }
11
88
 
12
89
  function parseRoadmap(content) {
13
90
  const lines = String(content || '').split(/\r?\n/);
@@ -17,31 +94,56 @@ function parseRoadmap(content) {
17
94
 
18
95
  for (let index = 0; index < lines.length; index += 1) {
19
96
  const line = lines[index];
20
- const headingMatch = line.match(HEADING_RE);
21
- if (headingMatch) {
22
- section = headingMatch[1].trim();
97
+ const headingText = parseHeadingLine(line);
98
+ if (headingText) {
99
+ section = headingText;
23
100
  }
24
101
 
25
- const taskMatch = line.match(TASK_LINE_RE);
26
- if (!taskMatch) {
102
+ const taskLine = parseTaskLine(line);
103
+ if (!taskLine) {
27
104
  continue;
28
105
  }
29
106
 
30
- const indent = taskMatch[1] || '';
31
- const checked = taskMatch[2].toLowerCase() === 'x';
32
- const text = taskMatch[3].trim();
33
- const markerId = taskMatch[4] || null;
34
- const markerFlags = taskMatch[5] || '';
107
+ const { indent, checked, text, markerId, markerFlags } = taskLine;
35
108
  const noTest = /\brs:no-test\b/i.test(markerFlags);
109
+ const taskIndentWidth = getIndentWidth(indent);
36
110
 
37
111
  let warningLineIndex = null;
38
112
  let warningText = null;
39
- if (index + 1 < lines.length) {
40
- const nextLine = lines[index + 1];
41
- const warningMatch = nextLine.match(WARNING_RE);
42
- if (warningMatch) {
43
- warningLineIndex = index + 1;
44
- warningText = warningMatch[1].trim();
113
+ const evidenceLines = [];
114
+ let lastChildLineIndex = index;
115
+ for (let childIndex = index + 1; childIndex < lines.length; childIndex += 1) {
116
+ const childLine = lines[childIndex];
117
+ if (!childLine.trim()) {
118
+ break;
119
+ }
120
+ if (parseHeadingLine(childLine) || parseTaskLine(childLine)) {
121
+ break;
122
+ }
123
+
124
+ const childBullet = parseChildBulletLine(childLine);
125
+ if (!childBullet) {
126
+ break;
127
+ }
128
+ if (getIndentWidth(childBullet.indent || '') <= taskIndentWidth) {
129
+ break;
130
+ }
131
+
132
+ lastChildLineIndex = childIndex;
133
+
134
+ const evidenceText = parseEvidenceLine(childBullet.content);
135
+ if (evidenceText != null) {
136
+ evidenceLines.push({
137
+ lineIndex: childIndex,
138
+ text: evidenceText,
139
+ raw: childLine
140
+ });
141
+ }
142
+
143
+ const warningTextValue = parseWarningLine(childBullet.content);
144
+ if (warningTextValue != null) {
145
+ warningLineIndex = childIndex;
146
+ warningText = warningTextValue;
45
147
  }
46
148
  }
47
149
 
@@ -51,8 +153,10 @@ function parseRoadmap(content) {
51
153
  text,
52
154
  checked,
53
155
  lineIndex: index,
156
+ lastChildLineIndex,
54
157
  warningLineIndex,
55
158
  warningText,
159
+ evidenceLines,
56
160
  markerId,
57
161
  noTest,
58
162
  indent,
package/src/sync/index.js CHANGED
@@ -34,6 +34,7 @@ function applySync(content, parsedTasks, results) {
34
34
  const warningText = formatWarning(task.indent || '', reason || 'validation failed');
35
35
  const hasWarning = task.warningLineIndex != null;
36
36
  const warningIndex = hasWarning ? task.warningLineIndex + offset : null;
37
+ const lastChildLineIndex = (task.lastChildLineIndex != null ? task.lastChildLineIndex : task.lineIndex) + offset;
37
38
 
38
39
  if (result.passed || !result.attempted) {
39
40
  if (warningIndex != null && warningIndex >= 0 && warningIndex < lines.length) {
@@ -46,7 +47,7 @@ function applySync(content, parsedTasks, results) {
46
47
  if (warningIndex != null && warningIndex >= 0 && warningIndex < lines.length) {
47
48
  lines[warningIndex] = warningText;
48
49
  } else {
49
- lines.splice(lineIndex + 1, 0, warningText);
50
+ lines.splice(lastChildLineIndex + 1, 0, warningText);
50
51
  offset += 1;
51
52
  }
52
53
  }
@@ -206,6 +206,49 @@ function hasKnownFileExtension(token) {
206
206
  return KNOWN_FILE_EXTENSIONS.has(token.slice(lastDot).toLowerCase());
207
207
  }
208
208
 
209
+ function isAsciiAlphaNumeric(char) {
210
+ if (!char || char.length === 0) return false;
211
+ const code = char.charCodeAt(0);
212
+ return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
213
+ }
214
+
215
+ function isPathTokenCharacter(char) {
216
+ return isAsciiAlphaNumeric(char) || char === '.' || char === '_' || char === '-' || char === '/' || char === '\\';
217
+ }
218
+
219
+ function stripTrailingPathPunctuation(token) {
220
+ let result = String(token || '');
221
+ while (result.length > 0) {
222
+ const lastChar = result[result.length - 1];
223
+ if (lastChar !== '.' && lastChar !== ',' && lastChar !== ';' && lastChar !== ':' && lastChar !== '!' && lastChar !== '?' && lastChar !== ')') {
224
+ break;
225
+ }
226
+ result = result.slice(0, -1);
227
+ }
228
+ return result;
229
+ }
230
+
231
+ function collectPathishTokens(text) {
232
+ const tokens = [];
233
+ let current = '';
234
+ const source = String(text || '');
235
+ for (let index = 0; index < source.length; index += 1) {
236
+ const char = source[index];
237
+ if (isPathTokenCharacter(char)) {
238
+ current += char;
239
+ continue;
240
+ }
241
+ if (current) {
242
+ tokens.push(stripTrailingPathPunctuation(current));
243
+ current = '';
244
+ }
245
+ }
246
+ if (current) {
247
+ tokens.push(stripTrailingPathPunctuation(current));
248
+ }
249
+ return tokens.filter(Boolean);
250
+ }
251
+
209
252
  function extractExplicitPaths(text) {
210
253
  const results = new Set();
211
254
  const quoted = String(text).match(/`([^`]+)`/g) || [];
@@ -218,7 +261,7 @@ function extractExplicitPaths(text) {
218
261
 
219
262
  const pathTokens = String(text).match(/([A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+)/g) || [];
220
263
  for (const raw of pathTokens) {
221
- const token = raw.replace(/[.,;:!?)]+$/, '');
264
+ const token = stripTrailingPathPunctuation(raw);
222
265
  if (isLikelyPath(token)) results.add(token);
223
266
  }
224
267
 
@@ -235,7 +278,7 @@ function extractStandaloneFilenames(text) {
235
278
  STANDALONE_FILE_RE.lastIndex = 0;
236
279
  let m = STANDALONE_FILE_RE.exec(String(text));
237
280
  while (m) {
238
- const token = m[1].replace(/[.,;:!?)]+$/, '');
281
+ const token = stripTrailingPathPunctuation(m[1]);
239
282
  if (hasKnownFileExtension(token) && !token.startsWith('.')) {
240
283
  results.add(token);
241
284
  }
@@ -576,6 +619,204 @@ function findArtifactEvidence(taskText, fileIndex) {
576
619
  return { files: files.slice(0, 20), heuristicArtifacts };
577
620
  }
578
621
 
622
+ function unionArrays(...values) {
623
+ return Array.from(new Set(values.flat().filter(Boolean)));
624
+ }
625
+
626
+ function isTestPath(relativePath) {
627
+ return /(^|\/)(__tests__|tests)\//.test(relativePath) || /\.test\.|\.spec\.|_test\.go$/.test(relativePath);
628
+ }
629
+
630
+ function extractEvidencePaths(evidenceText) {
631
+ const paths = new Set();
632
+ for (const rawCandidate of collectPathishTokens(evidenceText)) {
633
+ const candidate = rawCandidate.split('\\').join('/');
634
+ if (!candidate.includes('/') || candidate.includes('*') || candidate.includes('?')) {
635
+ continue;
636
+ }
637
+ if (!hasKnownFileExtension(candidate)) {
638
+ continue;
639
+ }
640
+ paths.add(candidate.replace(/^\.\//, ''));
641
+ }
642
+ return Array.from(paths).sort((left, right) => left.localeCompare(right));
643
+ }
644
+
645
+ function evidenceLineHasPassingSummary(evidenceText) {
646
+ const text = String(evidenceText || '');
647
+ if (/\b\d+\s*\/\s*\d+\s+tests?\s+passing\b/i.test(text)) {
648
+ return true;
649
+ }
650
+ if (/\b(?:vitest|jest|npm test|pnpm test|yarn test|bun test)\b.*\b(?:pass(?:ing|ed)?|green|success(?:ful|fully)?)\b/i.test(text)) {
651
+ return true;
652
+ }
653
+ return false;
654
+ }
655
+
656
+ function evidenceSummaryImpliesTests(evidenceText) {
657
+ return /\btests?\b/i.test(String(evidenceText || '')) ||
658
+ /\b(?:vitest|jest|npm test|pnpm test|yarn test|bun test)\b/i.test(String(evidenceText || ''));
659
+ }
660
+
661
+ function evaluateAuthoritativeEvidence(task, fileIndex) {
662
+ const evidenceLines = Array.isArray(task.evidenceLines) ? task.evidenceLines : [];
663
+ if (evidenceLines.length === 0) {
664
+ return {
665
+ active: false,
666
+ passed: false,
667
+ confidence: null,
668
+ referencedPaths: [],
669
+ matchedPaths: [],
670
+ summaryMatches: [],
671
+ summaryImpliesTests: false,
672
+ hasExtractedPaths: false
673
+ };
674
+ }
675
+
676
+ const referencedPaths = unionArrays(...evidenceLines.map((line) => extractEvidencePaths(line.text)));
677
+ const matchedPaths = referencedPaths.length > 0 ? findFilesByPathHints(referencedPaths, fileIndex) : [];
678
+ const summaryMatches = evidenceLines
679
+ .filter((line) => evidenceLineHasPassingSummary(line.text))
680
+ .map((line) => line.text);
681
+ const summaryImpliesTests = summaryMatches.some((line) => evidenceSummaryImpliesTests(line));
682
+ const matchedTestPaths = matchedPaths.filter((relativePath) => isTestPath(relativePath));
683
+ const matchedNonTestPaths = matchedPaths.filter((relativePath) => !isTestPath(relativePath));
684
+
685
+ if (referencedPaths.length > 0) {
686
+ if (matchedPaths.length === 0) {
687
+ return {
688
+ active: true,
689
+ passed: false,
690
+ confidence: 'low',
691
+ referencedPaths,
692
+ matchedPaths: [],
693
+ summaryMatches: [],
694
+ summaryImpliesTests: false,
695
+ hasExtractedPaths: true,
696
+ reasons: [`evidence file(s) not found: ${referencedPaths.join(', ')}`]
697
+ };
698
+ }
699
+
700
+ return {
701
+ active: true,
702
+ passed: true,
703
+ confidence: matchedTestPaths.length > 0 && matchedNonTestPaths.length > 0 ? 'high' : 'medium',
704
+ referencedPaths,
705
+ matchedPaths,
706
+ summaryMatches: [],
707
+ summaryImpliesTests: matchedTestPaths.length > 0,
708
+ hasExtractedPaths: true,
709
+ reasons: []
710
+ };
711
+ }
712
+
713
+ if (summaryMatches.length > 0) {
714
+ return {
715
+ active: true,
716
+ passed: true,
717
+ confidence: 'medium',
718
+ referencedPaths: [],
719
+ matchedPaths: [],
720
+ summaryMatches,
721
+ summaryImpliesTests,
722
+ hasExtractedPaths: false,
723
+ reasons: []
724
+ };
725
+ }
726
+
727
+ return {
728
+ active: true,
729
+ passed: false,
730
+ confidence: null,
731
+ referencedPaths: [],
732
+ matchedPaths: [],
733
+ summaryMatches: [],
734
+ summaryImpliesTests: false,
735
+ hasExtractedPaths: false,
736
+ reasons: []
737
+ };
738
+ }
739
+
740
+ function applyAuthoritativeEvidence(evidence, authoritativeEvidence, fileIndex) {
741
+ evidence.authoritative = authoritativeEvidence.passed;
742
+ evidence.authoritativeFiles = authoritativeEvidence.matchedPaths;
743
+ evidence.authoritativeSummaries = authoritativeEvidence.summaryMatches;
744
+ evidence.files = unionArrays(evidence.files, authoritativeEvidence.matchedPaths);
745
+
746
+ const indexedFiles = new Map(fileIndex.map((file) => [file.relativePath, file]));
747
+ for (const relativePath of authoritativeEvidence.matchedPaths) {
748
+ const file = indexedFiles.get(relativePath);
749
+ if (!file) {
750
+ continue;
751
+ }
752
+ if (file.isTestFile || isTestPath(relativePath)) {
753
+ evidence.test = true;
754
+ evidence.testFiles = unionArrays(evidence.testFiles, [relativePath]);
755
+ continue;
756
+ }
757
+ if (CODE_EXTENSIONS.has(file.ext)) {
758
+ evidence.code = true;
759
+ evidence.codeFiles = unionArrays(evidence.codeFiles, [relativePath]);
760
+ continue;
761
+ }
762
+ evidence.artifact = true;
763
+ evidence.artifactFiles = unionArrays(evidence.artifactFiles, [relativePath]);
764
+ }
765
+
766
+ if (authoritativeEvidence.summaryImpliesTests) {
767
+ evidence.test = true;
768
+ }
769
+ }
770
+
771
+ function countStrongEvidenceCategories(taskText, evidence) {
772
+ if (isDocTask(taskText)) {
773
+ return {
774
+ categories: [
775
+ ...(evidence.artifact ? ['artifact'] : []),
776
+ ...(evidence.files.length > 0 ? ['referenced-file'] : []),
777
+ ...(evidence.code ? ['code'] : []),
778
+ ...(evidence.test ? ['test'] : [])
779
+ ]
780
+ };
781
+ }
782
+
783
+ const categories = [];
784
+ if (evidence.code) {
785
+ categories.push('code');
786
+ }
787
+ if (evidence.test) {
788
+ categories.push('test');
789
+ }
790
+ if (evidence.files.length > 0 || evidence.symbols.length > 0 || evidence.structuralEvidence === true) {
791
+ categories.push('feature-surface');
792
+ }
793
+ return { categories };
794
+ }
795
+
796
+ function findNegativeImplementationSignals(candidatePaths, fileIndex) {
797
+ if (!Array.isArray(candidatePaths) || candidatePaths.length === 0) {
798
+ return [];
799
+ }
800
+
801
+ const indexedFiles = new Map(fileIndex.map((file) => [file.relativePath, file]));
802
+ const negativeSignals = [
803
+ /\bnot implemented\b/i,
804
+ /throw\s+new\s+Error\s*\(\s*['"`][^'"`]*not implemented/i
805
+ ];
806
+
807
+ const matches = [];
808
+ for (const relativePath of unionArrays(candidatePaths)) {
809
+ const file = indexedFiles.get(relativePath);
810
+ if (!file || file.isTestFile || !CODE_EXTENSIONS.has(file.ext)) {
811
+ continue;
812
+ }
813
+ if (negativeSignals.some((pattern) => pattern.test(file.content))) {
814
+ matches.push(relativePath);
815
+ }
816
+ }
817
+ return matches.sort((left, right) => left.localeCompare(right));
818
+ }
819
+
579
820
  function extractTaskNamespace(taskId) {
580
821
  if (!taskId) return null;
581
822
  const match = String(taskId).match(/^([a-z][a-z0-9]*)-/);
@@ -771,6 +1012,7 @@ function validateTask(task, context, config, plugins) {
771
1012
  const pathHints = extractExplicitPaths(task.text);
772
1013
  const standaloneFilenames = extractStandaloneFilenames(task.text);
773
1014
  const symbolHints = extractSymbolHints(task.text);
1015
+ const authoritativeEvidence = evaluateAuthoritativeEvidence(task, context.fileIndex);
774
1016
 
775
1017
  const filesFromPaths = findFilesByPathHints(pathHints, context.fileIndex);
776
1018
  const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex);
@@ -800,12 +1042,19 @@ function validateTask(task, context, config, plugins) {
800
1042
  heuristicArtifacts,
801
1043
  structuralEvidence: structuralCheck.applicable ? structuralCheck.passed : null,
802
1044
  structuralFiles: structuralCheck.structuralFiles,
1045
+ authoritative: false,
1046
+ authoritativeFiles: [],
1047
+ authoritativeSummaries: []
803
1048
  };
1049
+ applyAuthoritativeEvidence(evidence, authoritativeEvidence, context.fileIndex);
804
1050
 
805
1051
  const reasons = [];
806
1052
  if (pathHints.length > 0 && filesFromPaths.length === 0) {
807
1053
  reasons.push(`missing referenced file(s): ${pathHints.join(', ')}`);
808
1054
  }
1055
+ if (Array.isArray(authoritativeEvidence.reasons) && authoritativeEvidence.reasons.length > 0) {
1056
+ reasons.push(...authoritativeEvidence.reasons);
1057
+ }
809
1058
  if (symbolHints.length > 0 && filesFromSymbols.length === 0) {
810
1059
  reasons.push(`missing symbol(s): ${symbolHints.join(', ')}`);
811
1060
  }
@@ -818,9 +1067,9 @@ function validateTask(task, context, config, plugins) {
818
1067
 
819
1068
  const hasEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
820
1069
  const hasWeakEvidence = filesFromWeakPathTokens.length > 0;
821
- if (!hasEvidence && !hasWeakEvidence && !structuralCheck.applicable) {
1070
+ if (!hasEvidence && !hasWeakEvidence && !structuralCheck.applicable && !authoritativeEvidence.hasExtractedPaths && !authoritativeEvidence.passed) {
822
1071
  reasons.push('no code, test, or artifact evidence found');
823
- } else if (!hasEvidence && !hasWeakEvidence && structuralCheck.applicable && structuralCheck.passed) {
1072
+ } else if (!hasEvidence && !hasWeakEvidence && structuralCheck.applicable && structuralCheck.passed && !authoritativeEvidence.hasExtractedPaths && !authoritativeEvidence.passed) {
824
1073
  reasons.push('no code, test, or artifact evidence found');
825
1074
  } else if (!hasEvidence && hasWeakEvidence) {
826
1075
  if (weakPathContentTokens.length === 0) {
@@ -834,9 +1083,11 @@ function validateTask(task, context, config, plugins) {
834
1083
  const configuredRules = Array.isArray(config.validators) ? config.validators : [];
835
1084
  const pluginRules = collectPluginContributions(plugins || [], 'registerValidators', context);
836
1085
  let overrideResult = null;
1086
+ let hasRuleGrantedEvidence = false;
837
1087
  for (const rule of [...configuredRules, ...pluginRules]) {
838
1088
  const ruleResult = evaluateRule(rule, task, context);
839
1089
  if (ruleResult.evidence && Object.keys(ruleResult.evidence).length > 0) {
1090
+ hasRuleGrantedEvidence = true;
840
1091
  Object.assign(evidence, mergeRuleEvidence(evidence, ruleResult.evidence));
841
1092
  }
842
1093
  if (!ruleResult.passed) {
@@ -856,7 +1107,7 @@ function validateTask(task, context, config, plugins) {
856
1107
  }
857
1108
  }
858
1109
 
859
- if (requiresTest && !evidence.test) {
1110
+ if (requiresTest && !evidence.test && !authoritativeEvidence.passed) {
860
1111
  reasons.push('missing test evidence');
861
1112
  }
862
1113
 
@@ -866,12 +1117,67 @@ function validateTask(task, context, config, plugins) {
866
1117
  uniqueReasons = Array.isArray(overrideResult.reasons) ? Array.from(new Set(overrideResult.reasons)) : [];
867
1118
  }
868
1119
 
869
- const attempted = hasStrongEvidence || hasWeakEvidence || pathHints.length > 0 || symbolHints.length > 0;
1120
+ const attempted = hasStrongEvidence || hasWeakEvidence || pathHints.length > 0 || symbolHints.length > 0 || authoritativeEvidence.active;
1121
+ const { categories: strongEvidenceCategories } = countStrongEvidenceCategories(task.text, evidence);
1122
+ const strongEvidenceCount = strongEvidenceCategories.length;
1123
+ const hasDirectReferencePass = filesFromPaths.length > 0 || filesFromSymbols.length > 0;
1124
+ const hasArtifactTaskPass = evidence.artifact && (
1125
+ isDocTask(task.text) ||
1126
+ evidence.heuristicArtifacts.length > 0 ||
1127
+ filesFromPaths.some((relativePath) => !CODE_EXTENSIONS.has(path.extname(relativePath).toLowerCase()))
1128
+ );
1129
+ const hasTrustedRuleEvidencePass = hasRuleGrantedEvidence && uniqueReasons.length === 0;
1130
+ const meetsStrongThreshold = !isDocTask(task.text) && strongEvidenceCount >= 2;
1131
+
1132
+ let confidence = 'low';
1133
+ if (authoritativeEvidence.passed) {
1134
+ confidence = authoritativeEvidence.confidence || 'medium';
1135
+ } else if (meetsStrongThreshold) {
1136
+ confidence = 'high';
1137
+ } else if (strongEvidenceCount === 1 || hasDirectReferencePass || hasArtifactTaskPass || hasTrustedRuleEvidencePass) {
1138
+ confidence = 'medium';
1139
+ }
1140
+
1141
+ const negativeSignalMatches = findNegativeImplementationSignals(
1142
+ unionArrays(
1143
+ evidence.codeFiles,
1144
+ evidence.files,
1145
+ evidence.symbols,
1146
+ evidence.weakPathFiles,
1147
+ evidence.structuralFiles
1148
+ ),
1149
+ context.fileIndex
1150
+ );
1151
+ if (negativeSignalMatches.length > 0) {
1152
+ uniqueReasons.push(`negative implementation signal found in matched evidence: ${negativeSignalMatches.join(', ')}`);
1153
+ uniqueReasons = Array.from(new Set(uniqueReasons));
1154
+ }
1155
+
1156
+ let passed = authoritativeEvidence.passed || hasDirectReferencePass || hasArtifactTaskPass || hasTrustedRuleEvidencePass || meetsStrongThreshold;
1157
+ if (task.warningText && passed && !authoritativeEvidence.passed && !meetsStrongThreshold) {
1158
+ passed = false;
1159
+ uniqueReasons.push(task.warningText);
1160
+ uniqueReasons = Array.from(new Set(uniqueReasons));
1161
+ }
1162
+ if (negativeSignalMatches.length > 0) {
1163
+ passed = false;
1164
+ }
870
1165
 
871
- const evidenceCount = [evidence.code, evidence.test, evidence.artifact].filter(Boolean).length;
872
- let confidence = evidenceCount >= 2 ? 'high' : evidenceCount === 1 ? 'medium' : 'low';
873
- if (confidence === 'low' && hasWeakEvidence) {
1166
+ const shouldPreserveCheckedTask =
1167
+ task.checked &&
1168
+ !passed &&
1169
+ !authoritativeEvidence.active &&
1170
+ pathHints.length === 0 &&
1171
+ symbolHints.length === 0 &&
1172
+ !hasDirectReferencePass &&
1173
+ evidence.structuralEvidence !== false &&
1174
+ negativeSignalMatches.length === 0;
1175
+ let preservedCheckedState = false;
1176
+ if (shouldPreserveCheckedTask) {
1177
+ passed = true;
874
1178
  confidence = 'low';
1179
+ uniqueReasons = [];
1180
+ preservedCheckedState = true;
875
1181
  }
876
1182
 
877
1183
  // True when the only passing evidence is artifact/doc files and the task is not a doc task.
@@ -880,14 +1186,15 @@ function validateTask(task, context, config, plugins) {
880
1186
 
881
1187
  return {
882
1188
  taskId: task.id,
883
- passed: overrideResult ? overrideResult.passed !== false : uniqueReasons.length === 0,
1189
+ passed: overrideResult ? overrideResult.passed !== false : (passed && uniqueReasons.length === 0),
884
1190
  confidence,
885
1191
  reasons: uniqueReasons,
886
1192
  evidence,
887
1193
  evidenceIsDocOnly,
888
1194
  requiresTest,
889
1195
  hasEvidence: hasStrongEvidence || hasWeakEvidence,
890
- attempted
1196
+ attempted,
1197
+ preservedCheckedState
891
1198
  };
892
1199
  }
893
1200
 
@@ -945,6 +1252,9 @@ function applyMinimumConfidence(results, minimumConfidence) {
945
1252
  const minRank = CONFIDENCE_RANK[minimumConfidence] ?? 0;
946
1253
  if (minRank === 0) return;
947
1254
  for (const result of Object.values(results)) {
1255
+ if (result.preservedCheckedState) {
1256
+ continue;
1257
+ }
948
1258
  if ((CONFIDENCE_RANK[result.confidence] ?? 0) < minRank) {
949
1259
  result.passed = false;
950
1260
  result.reasons = [