roadmapsmith 0.9.28 → 0.9.29

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roadmapsmith",
3
- "version": "0.9.28",
3
+ "version": "0.9.29",
4
4
  "description": "One-command evidence-backed ROADMAP.md generator and sync tool for AI coding agents, with shared RoadmapSmith plugin skills for Codex and Claude plus the roadmapsmith status readiness surface.",
5
5
  "author": {
6
6
  "name": "PapiScholz"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roadmapsmith",
3
- "version": "0.9.28",
3
+ "version": "0.9.29",
4
4
  "description": "One-command evidence-backed ROADMAP.md generator and sync tool for AI coding agents, with shared RoadmapSmith plugin skills for Codex and Claude plus the roadmapsmith status readiness surface.",
5
5
  "author": {
6
6
  "name": "PapiScholz"
package/README.md CHANGED
@@ -92,6 +92,19 @@ roadmapsmith maintain # existing repo
92
92
 
93
93
  Use the lower-level commands only when you want manual control over generation, validation, or sync.
94
94
 
95
+ ## Deterministic completion
96
+
97
+ For unchecked implementation tasks, related files and matching test names are candidate signals only. `maintain` completes a task only from explicit `Evidence:` or typed child verification metadata:
98
+
99
+ ```markdown
100
+ - [ ] Enable ESLint builds
101
+ - Verify: kind=property; file=next.config.js; key=ignoreDuringBuilds; equals=false
102
+ - [ ] Prevent double submit
103
+ - Verify: kind=behavior; source=src/login.tsx; test=src/__tests__/login.test.tsx; case=disables submit; trigger=fireEvent.click; assertion=toBeDisabled
104
+ ```
105
+
106
+ `contains`, `property`, and `endpoints` are static checks. A behavioral check also requires a fresh `validation.testReports` entry using `format: "vitest-json"`; RoadmapSmith reads reports but never executes tests. Missing proof produces a `Verification recipe:` and a warning rather than a checked box. A fresh passing report is persisted as `Test evidence: file=<test>; case=<case>; status=PASS; verifiedAt=<ISO timestamp>` and becomes stale when it predates the referenced source or test file.
107
+
95
108
  ## Host Support Today
96
109
 
97
110
  | Host | Current support |
@@ -181,14 +194,13 @@ The repo does not remove user-global skills automatically. Use the `doctor` outp
181
194
  - Uses stable task IDs: `<!-- rs:task=<slug> -->`.
182
195
  - Sync marks `[x]` only when validation passes.
183
196
  - `sync --audit` currently runs sync and then prints a mismatch summary; it is not yet a dedicated read-only audit mode.
184
- - Validation evidence gate:
185
- - code OR test OR artifact evidence required.
186
- - test evidence required for code tasks when test frameworks are detected.
197
+ - For unchecked implementation tasks, heuristic code/test/path matches are candidates only. Completion requires explicit `Evidence:`, a trusted validator rule, or typed deterministic `Verify:` metadata.
198
+ - `Verify:` supports `contains`, `property`, `endpoints`, and `behavior`; behavior additionally requires a fresh configured Vitest JSON report or a fresh `Test evidence:` annotation.
187
199
  - Validation failures in sync write warning lines:
188
200
  - `- ⚠️ attempted but validation failed: <reason>` when there is concrete attempt evidence
189
201
  - `- ⚠️ no implementation evidence found yet: <reason>` when there is not
190
202
  - Preserves unmanaged markdown content by updating only the managed roadmap block.
191
- - `validate` emits structured diagnostics in human and JSON output (`FAIL:NOT_IMPLEMENTED`, `FAIL:NO_TEST`, `FAIL:MISSING_REFERENCE`, and `WARN:STALE_EVIDENCE`).
203
+ - `validate` emits structured diagnostics in human and JSON output, including `FAIL:NOT_IMPLEMENTED`, `FAIL:NO_TEST`, `FAIL:MISSING_REFERENCE`, `FAIL:WRONG_VALUE`, `FAIL:PARTIAL`, `WARN:STALE_EVIDENCE`, `WARN:REQUIRES_HUMAN_EVIDENCE`, `WARN:NO_STATIC_SIGNAL`, `WARN:HAS_EXPLICIT_PENDING`, and `WARN:STALE_TEST_REPORT`.
192
204
  - `update --task <id> --evidence <text>` writes only when the evidence validates at high confidence; otherwise the roadmap is unchanged.
193
205
 
194
206
  ## Defaults
package/bin/cli.js CHANGED
@@ -48,7 +48,9 @@ function formatResultLine(task, result) {
48
48
  const diagnostics = Array.isArray(result.diagnostics) ? result.diagnostics : [];
49
49
  const primaryError = diagnostics.find((item) => item.severity === 'error');
50
50
  const warnings = diagnostics.filter((item) => item.severity === 'warning');
51
- const status = primaryError ? `FAIL:${primaryError.code}` : (result.passed ? 'PASS' : 'FAIL');
51
+ const status = primaryError
52
+ ? `FAIL:${primaryError.code}`
53
+ : (result.passed ? 'PASS' : (warnings[0] ? `WARN:${warnings[0].code}` : 'FAIL'));
52
54
  const parts = [...result.reasons];
53
55
  warnings.forEach((item) => parts.push(`WARN:${item.code} ${item.message}`));
54
56
  const reason = parts.length > 0 ? ` :: ${parts.join('; ')}` : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roadmapsmith",
3
- "version": "0.9.28",
3
+ "version": "0.9.29",
4
4
  "description": "One-command evidence-backed ROADMAP.md generator and sync tool for AI coding agents, with shared RoadmapSmith plugin skills for Codex and Claude plus the roadmapsmith status readiness surface.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -17,4 +17,4 @@ Use this skill only when the host exposes or the user explicitly invokes `/roadm
17
17
  - `/roadmap-status`
18
18
  - `/roadmap-init`, `/roadmap-generate`, `/roadmap-validate`, `/roadmap-update`, `/roadmap-audit`, and `/roadmap-setup`
19
19
  3. When the user explicitly invokes `/roadmap-sync <action>`, route to the matching CLI-backed action without changing semantics and mention the migration path to `/roadmap <action>` or the direct `/roadmap-*` command.
20
- 4. Preserve the operating rules for evidence-backed roadmap maintenance and checklist synchronization.
20
+ 4. Preserve the operating rules for evidence-backed roadmap maintenance and checklist synchronization: heuristic file/token matches may diagnose candidates, but only explicit `Evidence:` or typed `Verify:` checks may complete an unchecked implementation task. Never claim a behavioral task is complete without fresh test evidence or human verification.
package/skills.json CHANGED
@@ -28,67 +28,67 @@
28
28
  "name": "roadmap",
29
29
  "path": "skills/roadmap",
30
30
  "description": "Native slash palette for RoadmapSmith commands and recommended entrypoints across supported hosts.",
31
- "version": "0.9.28"
31
+ "version": "0.9.29"
32
32
  },
33
33
  {
34
34
  "name": "roadmap-zero",
35
35
  "path": "skills/roadmap-zero",
36
36
  "description": "Native slash entrypoint for the one-command Zero Mode CLI workflow.",
37
- "version": "0.9.28"
37
+ "version": "0.9.29"
38
38
  },
39
39
  {
40
40
  "name": "roadmap-maintain",
41
41
  "path": "skills/roadmap-maintain",
42
42
  "description": "Native slash entrypoint for the preserve-first generate + sync + audit flow.",
43
- "version": "0.9.28"
43
+ "version": "0.9.29"
44
44
  },
45
45
  {
46
46
  "name": "roadmap-status",
47
47
  "path": "skills/roadmap-status",
48
48
  "description": "Native slash readiness check grounded in roadmapsmith status JSON.",
49
- "version": "0.9.28"
49
+ "version": "0.9.29"
50
50
  },
51
51
  {
52
52
  "name": "roadmap-init",
53
53
  "path": "skills/roadmap-init",
54
54
  "description": "Native slash entrypoint for creating ROADMAP.md and AGENTS.md.",
55
- "version": "0.9.28"
55
+ "version": "0.9.29"
56
56
  },
57
57
  {
58
58
  "name": "roadmap-generate",
59
59
  "path": "skills/roadmap-generate",
60
60
  "description": "Native slash entrypoint for managed roadmap updates that require --full-regen before destructive replacement.",
61
- "version": "0.9.28"
61
+ "version": "0.9.29"
62
62
  },
63
63
  {
64
64
  "name": "roadmap-validate",
65
65
  "path": "skills/roadmap-validate",
66
66
  "description": "Native slash entrypoint for evidence-backed roadmap validation.",
67
- "version": "0.9.28"
67
+ "version": "0.9.29"
68
68
  },
69
69
  {
70
70
  "name": "roadmap-update",
71
71
  "path": "skills/roadmap-update",
72
72
  "description": "Native slash entrypoint for evidence-backed sync and verified single-task completion.",
73
- "version": "0.9.28"
73
+ "version": "0.9.29"
74
74
  },
75
75
  {
76
76
  "name": "roadmap-sync",
77
77
  "path": "skills/roadmap-sync",
78
78
  "description": "DEPRECATED legacy compatibility root; use roadmap-maintain or roadmap-update.",
79
- "version": "0.9.28"
79
+ "version": "0.9.29"
80
80
  },
81
81
  {
82
82
  "name": "roadmap-audit",
83
83
  "path": "skills/roadmap-audit",
84
84
  "description": "Native slash entrypoint for the current sync-plus-audit workflow.",
85
- "version": "0.9.28"
85
+ "version": "0.9.29"
86
86
  },
87
87
  {
88
88
  "name": "roadmap-setup",
89
89
  "path": "skills/roadmap-setup",
90
90
  "description": "Native slash entrypoint for generating RoadmapSmith host integration files.",
91
- "version": "0.9.28"
91
+ "version": "0.9.29"
92
92
  }
93
93
  ]
94
94
  }
package/src/config.js CHANGED
@@ -31,7 +31,9 @@ const DEFAULT_CONFIG = {
31
31
  doneCriteria: []
32
32
  },
33
33
  validation: {
34
- minimumConfidence: 'low'
34
+ minimumConfidence: 'low',
35
+ testReports: [],
36
+ recipeCommand: ''
35
37
  },
36
38
  milestones: [
37
39
  { version: 'v0.1', goal: 'Foundation baseline complete' },
@@ -95,7 +97,10 @@ function mergeConfig(userConfig) {
95
97
  },
96
98
  validation: {
97
99
  ...DEFAULT_CONFIG.validation,
98
- ...((userConfig && userConfig.validation) || {})
100
+ ...((userConfig && userConfig.validation) || {}),
101
+ testReports: userConfig && userConfig.validation && Array.isArray(userConfig.validation.testReports)
102
+ ? userConfig.validation.testReports
103
+ : DEFAULT_CONFIG.validation.testReports
99
104
  }
100
105
  };
101
106
  }
package/src/io.js CHANGED
@@ -13,7 +13,9 @@ const DEFAULT_IGNORED_DIRS = new Set([
13
13
  '.turbo',
14
14
  '.cache',
15
15
  'dist',
16
+ 'dist-electron',
16
17
  'build',
18
+ 'out',
17
19
  'coverage',
18
20
  'target',
19
21
  'node_modules',
@@ -89,6 +89,26 @@ function parseEvidenceLine(content) {
89
89
  return content.slice(9).trim();
90
90
  }
91
91
 
92
+ function parsePrefixedChildLine(content, prefix) {
93
+ const normalizedPrefix = `${prefix}:`;
94
+ if (content.slice(0, normalizedPrefix.length).toLowerCase() !== normalizedPrefix.toLowerCase()) {
95
+ return null;
96
+ }
97
+ return content.slice(normalizedPrefix.length).trim();
98
+ }
99
+
100
+ function parseVerifyLine(content) {
101
+ return parsePrefixedChildLine(content, 'Verify');
102
+ }
103
+
104
+ function parseTestEvidenceLine(content) {
105
+ return parsePrefixedChildLine(content, 'Test evidence');
106
+ }
107
+
108
+ function parseVerificationRecipeLine(content) {
109
+ return parsePrefixedChildLine(content, 'Verification recipe');
110
+ }
111
+
92
112
  function parseWarningLine(content) {
93
113
  if (!content.startsWith(WARNING_PREFIX)) return null;
94
114
  let normalized = content.slice(WARNING_PREFIX.length).trim();
@@ -132,6 +152,10 @@ function parseRoadmap(content) {
132
152
  let warningLineIndex = null;
133
153
  let warningText = null;
134
154
  const evidenceLines = [];
155
+ const verifyLines = [];
156
+ const testEvidenceLines = [];
157
+ const explicitPendingItems = [];
158
+ let verificationRecipeLineIndex = null;
135
159
  const blockedByIds = [];
136
160
  let lastChildLineIndex = index;
137
161
  for (let childIndex = index + 1; childIndex < lines.length; childIndex += 1) {
@@ -162,6 +186,28 @@ function parseRoadmap(content) {
162
186
  });
163
187
  }
164
188
 
189
+ const verifyText = parseVerifyLine(childBullet.content);
190
+ if (verifyText != null) {
191
+ verifyLines.push({ lineIndex: childIndex, text: verifyText, raw: childLine });
192
+ }
193
+
194
+ const testEvidenceText = parseTestEvidenceLine(childBullet.content);
195
+ if (testEvidenceText != null) {
196
+ testEvidenceLines.push({ lineIndex: childIndex, text: testEvidenceText, raw: childLine });
197
+ }
198
+
199
+ const verificationRecipeText = parseVerificationRecipeLine(childBullet.content);
200
+ if (verificationRecipeText != null) {
201
+ verificationRecipeLineIndex = childIndex;
202
+ }
203
+
204
+ if (childBullet.content.startsWith('❌')) {
205
+ explicitPendingItems.push({
206
+ lineIndex: childIndex,
207
+ text: childBullet.content.slice('❌'.length).trim()
208
+ });
209
+ }
210
+
165
211
  const warningTextValue = parseWarningLine(childBullet.content);
166
212
  if (warningTextValue != null) {
167
213
  warningLineIndex = childIndex;
@@ -190,6 +236,10 @@ function parseRoadmap(content) {
190
236
  warningLineIndex,
191
237
  warningText,
192
238
  evidenceLines,
239
+ verifyLines,
240
+ testEvidenceLines,
241
+ verificationRecipeLineIndex,
242
+ explicitPendingItems,
193
243
  blockedByIds,
194
244
  markerId,
195
245
  noTest,
package/src/sync/index.js CHANGED
@@ -112,6 +112,23 @@ function shouldPreserveExistingWarning(existingReason, newReason) {
112
112
  return cleanNew === 'validation failed' && cleanExisting && cleanExisting !== cleanNew;
113
113
  }
114
114
 
115
+ function formatVerificationRecipe(indent, recipe) {
116
+ return `${indent} - Verification recipe: ${recipe}`;
117
+ }
118
+
119
+ function formatTestEvidence(indent, evidence) {
120
+ return `${indent} - Test evidence: ${evidence}`;
121
+ }
122
+
123
+ function findVerificationRecipeIndex(lines, taskLineIndex) {
124
+ for (let index = taskLineIndex + 1; index < lines.length; index += 1) {
125
+ const line = lines[index];
126
+ if (!line.trim() || /^\s*#{2,}\s/.test(line) || /^\s*- \[[ xX]\]\s/.test(line)) break;
127
+ if (/^\s*- Verification recipe:/i.test(line)) return index;
128
+ }
129
+ return null;
130
+ }
131
+
115
132
  function applySync(content, parsedTasks, results) {
116
133
  const parsed = parseRoadmap(content);
117
134
  const lines = [...parsed.lines];
@@ -142,6 +159,19 @@ function applySync(content, parsedTasks, results) {
142
159
  lines.splice(warningIndex, 1);
143
160
  offset -= 1;
144
161
  }
162
+ const recipeIndex = findVerificationRecipeIndex(lines, lineIndex);
163
+ if (recipeIndex != null && recipeIndex >= 0 && recipeIndex < lines.length) {
164
+ lines.splice(recipeIndex, 1);
165
+ offset -= 1;
166
+ }
167
+ if (
168
+ result.generatedTestEvidence &&
169
+ (!Array.isArray(task.testEvidenceLines) || task.testEvidenceLines.length === 0)
170
+ ) {
171
+ const insertionIndex = Math.max(lineIndex + 1, (task.lastChildLineIndex + offset) + 1);
172
+ lines.splice(insertionIndex, 0, formatTestEvidence(task.indent || '', result.generatedTestEvidence));
173
+ offset += 1;
174
+ }
145
175
  if (
146
176
  result.staleEvidenceResolved &&
147
177
  (!Array.isArray(task.evidenceLines) || task.evidenceLines.length === 0) &&
@@ -154,10 +184,7 @@ function applySync(content, parsedTasks, results) {
154
184
  lines.splice(insertionIndex, 0, `${task.indent || ''} - Evidence: ${result.discoveredEvidence}`);
155
185
  offset += 1;
156
186
  }
157
- continue;
158
- }
159
-
160
- if (warningIndex != null && warningIndex >= 0 && warningIndex < lines.length) {
187
+ } else if (warningIndex != null && warningIndex >= 0 && warningIndex < lines.length) {
161
188
  const existingReason = normalizeWarningReason(lines[warningIndex]);
162
189
  const newReason = reason || 'validation failed';
163
190
  if (!shouldPreserveExistingWarning(existingReason, newReason)) {
@@ -167,6 +194,23 @@ function applySync(content, parsedTasks, results) {
167
194
  lines.splice(lastChildLineIndex + 1, 0, warningText);
168
195
  offset += 1;
169
196
  }
197
+
198
+ const recipeIndex = findVerificationRecipeIndex(lines, lineIndex);
199
+ if (result.passed) {
200
+ continue;
201
+ }
202
+ if (result.verificationRecipe) {
203
+ const recipeLine = formatVerificationRecipe(task.indent || '', result.verificationRecipe);
204
+ if (recipeIndex != null && recipeIndex >= 0 && recipeIndex < lines.length) {
205
+ lines[recipeIndex] = recipeLine;
206
+ } else {
207
+ lines.splice(lastChildLineIndex + 1, 0, recipeLine);
208
+ offset += 1;
209
+ }
210
+ } else if (recipeIndex != null && recipeIndex >= 0 && recipeIndex < lines.length) {
211
+ lines.splice(recipeIndex, 1);
212
+ offset -= 1;
213
+ }
170
214
  }
171
215
 
172
216
  return ensureTrailingNewline(lines.join('\n'));
@@ -13,6 +13,7 @@ const CODE_EXTENSIONS = new Set([
13
13
  ]);
14
14
  const TRANSLATION_DIR_SEGMENTS = ['locale', 'locales', 'i18n', 'translations'];
15
15
  const DEFAULT_EXCLUDED_PATH_PREFIXES = ['.claude/', '.agent/', 'roadmap-skill/'];
16
+ const GENERATED_OUTPUT_PREFIXES = ['dist-electron/', 'dist/', 'build/', 'out/', '.next/', 'coverage/'];
16
17
 
17
18
  // "docs" omitted from DOC_HINTS — it is a path prefix in scan tasks, not a doc-authoring keyword.
18
19
  const DOC_HINTS = ['readme', 'changelog', 'documentation', 'spec', 'diagram', 'runbook'];
@@ -151,6 +152,11 @@ function isMostlyUiStrings(content) {
151
152
  return stringLikeLines / lines.length > 0.8;
152
153
  }
153
154
 
155
+ function isGeneratedOutputPath(relativePath) {
156
+ const normalized = normalizePathForMatch(relativePath);
157
+ return GENERATED_OUTPUT_PREFIXES.some((prefix) => normalized.startsWith(prefix));
158
+ }
159
+
154
160
  function readFileIndex(projectRoot, files, config) {
155
161
  const index = [];
156
162
  for (const relativePath of files) {
@@ -180,6 +186,7 @@ function readFileIndex(projectRoot, files, config) {
180
186
  absolutePath,
181
187
  ext,
182
188
  content,
189
+ generatedOutput: isGeneratedOutputPath(relativePath),
183
190
  isTestFile: /(^|\/)(__tests__|tests)\//.test(relativePath) || /\.test\.|\.spec\.|_test\.go$/.test(relativePath)
184
191
  });
185
192
  }
@@ -631,6 +638,7 @@ function findFilesBySymbols(symbolHints, fileIndex) {
631
638
  for (const symbol of symbolHints) {
632
639
  const regex = new RegExp(`\\b${escapeRegExp(symbol)}\\b`, 'i');
633
640
  for (const file of fileIndex) {
641
+ if (file.generatedOutput) continue;
634
642
  if (!CODE_EXTENSIONS.has(file.ext)) {
635
643
  continue;
636
644
  }
@@ -650,6 +658,7 @@ function findFilesByTaskPathTokens(taskText, fileIndex, pathDerivedTokens = new
650
658
 
651
659
  const matches = new Set();
652
660
  for (const file of fileIndex) {
661
+ if (file.generatedOutput) continue;
653
662
  const pathSegments = normalizePathForMatch(file.relativePath).split('/').filter(Boolean);
654
663
  for (const token of tokens) {
655
664
  if (pathSegments.some((segment) => segment === token || segment.includes(token))) {
@@ -744,7 +753,7 @@ function findCodeEvidence(taskText, fileIndex, pathDerivedTokens = new Set()) {
744
753
 
745
754
  const matches = [];
746
755
  for (const file of fileIndex) {
747
- if (!CODE_EXTENSIONS.has(file.ext) || file.isTestFile) {
756
+ if (file.generatedOutput || !CODE_EXTENSIONS.has(file.ext) || file.isTestFile) {
748
757
  continue;
749
758
  }
750
759
 
@@ -819,7 +828,7 @@ function findTestEvidence(taskText, fileIndex, referencedPaths = []) {
819
828
  const matches = [];
820
829
 
821
830
  for (const file of fileIndex) {
822
- if (!file.isTestFile) continue;
831
+ if (file.generatedOutput || !file.isTestFile) continue;
823
832
 
824
833
  // A test file counts as evidence only when it imports a module whose path contains
825
834
  // one of the task's meaningful tokens. Content-keyword matching is intentionally absent:
@@ -897,6 +906,7 @@ function findArtifactEvidence(taskText, fileIndex) {
897
906
  ];
898
907
 
899
908
  for (const file of fileIndex) {
909
+ if (file.generatedOutput) continue;
900
910
  if (artifactPatterns.some((pattern) => pattern.test(file.relativePath)) && !files.includes(file.relativePath)) {
901
911
  files.push(file.relativePath);
902
912
  }
@@ -1273,6 +1283,303 @@ function evaluateRule(rule, task, context) {
1273
1283
  };
1274
1284
  }
1275
1285
 
1286
+ function parseVerificationFields(text) {
1287
+ const fields = {};
1288
+ for (const part of String(text || '').split(';')) {
1289
+ const separator = part.indexOf('=');
1290
+ if (separator <= 0) continue;
1291
+ const key = part.slice(0, separator).trim().toLowerCase();
1292
+ let value = part.slice(separator + 1).trim();
1293
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
1294
+ value = value.slice(1, -1);
1295
+ }
1296
+ if (key && value) fields[key] = value;
1297
+ }
1298
+ return fields;
1299
+ }
1300
+
1301
+ function stripCodeComments(content) {
1302
+ const source = String(content || '');
1303
+ let result = '';
1304
+ let quote = null;
1305
+ let escaped = false;
1306
+ let lineHasContent = false;
1307
+
1308
+ for (let index = 0; index < source.length; index += 1) {
1309
+ const char = source[index];
1310
+ const next = source[index + 1];
1311
+
1312
+ if (quote) {
1313
+ result += char;
1314
+ if (escaped) {
1315
+ escaped = false;
1316
+ } else if (char === '\\') {
1317
+ escaped = true;
1318
+ } else if (char === quote) {
1319
+ quote = null;
1320
+ }
1321
+ if (char === '\n') lineHasContent = false;
1322
+ continue;
1323
+ }
1324
+
1325
+ if (char === '"' || char === "'" || char === '`') {
1326
+ quote = char;
1327
+ result += char;
1328
+ lineHasContent = true;
1329
+ continue;
1330
+ }
1331
+
1332
+ if (char === '/' && next === '*') {
1333
+ const closeIndex = source.indexOf('*/', index + 2);
1334
+ index = closeIndex < 0 ? source.length : closeIndex + 1;
1335
+ continue;
1336
+ }
1337
+
1338
+ if (char === '/' && next === '/') {
1339
+ const newlineIndex = source.indexOf('\n', index + 2);
1340
+ if (newlineIndex < 0) break;
1341
+ result += '\n';
1342
+ lineHasContent = false;
1343
+ index = newlineIndex;
1344
+ continue;
1345
+ }
1346
+
1347
+ if (char === '#' && !lineHasContent) {
1348
+ const newlineIndex = source.indexOf('\n', index + 1);
1349
+ if (newlineIndex < 0) break;
1350
+ result += '\n';
1351
+ lineHasContent = false;
1352
+ index = newlineIndex;
1353
+ continue;
1354
+ }
1355
+
1356
+ result += char;
1357
+ if (char === '\n') {
1358
+ lineHasContent = false;
1359
+ } else if (!/\s/.test(char)) {
1360
+ lineHasContent = true;
1361
+ }
1362
+ }
1363
+
1364
+ return result;
1365
+ }
1366
+
1367
+ function findIndexedFile(relativePath, context) {
1368
+ const normalized = normalizeReferencedPath(relativePath);
1369
+ return context.fileIndex.find((file) => !file.generatedOutput && (
1370
+ normalizeReferencedPath(file.relativePath) === normalized ||
1371
+ normalizeReferencedPath(file.relativePath).endsWith('/' + normalized)
1372
+ ));
1373
+ }
1374
+
1375
+ function readTestReportRecords(projectRoot, validationConfig) {
1376
+ const reports = Array.isArray(validationConfig && validationConfig.testReports)
1377
+ ? validationConfig.testReports
1378
+ : [];
1379
+ const records = [];
1380
+
1381
+ function visit(value, reportPath, mtimeMs) {
1382
+ if (!value || typeof value !== 'object') return;
1383
+ if (Array.isArray(value)) {
1384
+ value.forEach((entry) => visit(entry, reportPath, mtimeMs));
1385
+ return;
1386
+ }
1387
+ const status = String(value.status || value.state || value.result || '').toLowerCase();
1388
+ const file = value.file || value.filepath || value.testFile || value.nameFile;
1389
+ const name = value.fullName || value.fullname || value.name || value.title;
1390
+ if (file && name && /^(pass|passed|success)$/.test(status)) {
1391
+ records.push({ file: String(file), name: String(name), reportPath, mtimeMs });
1392
+ }
1393
+ Object.values(value).forEach((entry) => visit(entry, reportPath, mtimeMs));
1394
+ }
1395
+
1396
+ for (const report of reports) {
1397
+ if (!report || report.format !== 'vitest-json' || !report.path) continue;
1398
+ const reportPath = path.resolve(projectRoot, report.path);
1399
+ try {
1400
+ const payload = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
1401
+ const mtimeMs = fs.statSync(reportPath).mtimeMs;
1402
+ visit(payload, reportPath, mtimeMs);
1403
+ } catch {
1404
+ // A missing or malformed optional report is simply unavailable evidence.
1405
+ }
1406
+ }
1407
+ return records;
1408
+ }
1409
+
1410
+ function timestampIsFresh(timestamp, files) {
1411
+ const verifiedAt = Date.parse(timestamp || '');
1412
+ if (!Number.isFinite(verifiedAt)) return false;
1413
+ return files.every((file) => {
1414
+ try {
1415
+ return verifiedAt >= fs.statSync(file.absolutePath).mtimeMs;
1416
+ } catch {
1417
+ return false;
1418
+ }
1419
+ });
1420
+ }
1421
+
1422
+ function findFreshTestProof(task, fields, context) {
1423
+ const testFile = findIndexedFile(fields.test || fields.file, context);
1424
+ const sourceFile = fields.source ? findIndexedFile(fields.source, context) : null;
1425
+ const caseName = fields.case;
1426
+ if (!testFile || !caseName) return { passed: false, available: false };
1427
+ const freshnessFiles = [testFile, ...(sourceFile ? [sourceFile] : [])];
1428
+ const evidenceLines = Array.isArray(task.testEvidenceLines) ? task.testEvidenceLines : [];
1429
+ for (const line of evidenceLines) {
1430
+ const evidence = parseVerificationFields(line.text);
1431
+ if (String(evidence.status || '').toUpperCase() !== 'PASS') continue;
1432
+ if (normalizeReferencedPath(evidence.file) !== normalizeReferencedPath(testFile.relativePath)) continue;
1433
+ if (evidence.case !== caseName) continue;
1434
+ if (timestampIsFresh(evidence.verifiedat, freshnessFiles)) {
1435
+ return { passed: true, available: true };
1436
+ }
1437
+ return { passed: false, available: true, stale: true };
1438
+ }
1439
+ for (const record of context.testReportRecords || []) {
1440
+ if (!referencedPathMatches(record.file, testFile.relativePath) || !record.name.includes(caseName)) continue;
1441
+ if (record.mtimeMs >= Math.max(...freshnessFiles.map((file) => fs.statSync(file.absolutePath).mtimeMs))) {
1442
+ return {
1443
+ passed: true,
1444
+ available: true,
1445
+ generatedEvidence: `file=${testFile.relativePath}; case=${caseName}; status=PASS; verifiedAt=${new Date(record.mtimeMs).toISOString()}`
1446
+ };
1447
+ }
1448
+ return { passed: false, available: true, stale: true };
1449
+ }
1450
+ return { passed: false, available: false };
1451
+ }
1452
+
1453
+ function testCoversEndpoint(testFile, route, context) {
1454
+ const normalizedRoute = String(route).replace(/\[([^\]]+)\]/g, '$1').toLowerCase();
1455
+ const normalizedContent = testFile.content.replace(/\[([^\]]+)\]/g, '$1').toLowerCase();
1456
+ if (normalizedContent.includes(normalizedRoute)) return true;
1457
+ const source = findIndexedFile(`src/app${route}/route.ts`, context);
1458
+ if (source && normalizedContent.includes(path.basename(source.relativePath, source.ext).toLowerCase())) return true;
1459
+ const segments = normalizedRoute.split('/').filter((segment) => segment.length >= 3);
1460
+ return segments.length > 0 && segments.every((segment) => normalizedContent.includes(segment));
1461
+ }
1462
+
1463
+ function findVerificationRecipe(task, context) {
1464
+ const patterns = [
1465
+ /disabled\s*=\s*\{[^}]+\}/i,
1466
+ /<(?:dialog|alertdialog)\b[^>]*\bopen\s*=/i,
1467
+ /abortcontroller|abortsignal\.timeout/i,
1468
+ /router\.push\s*\(/i
1469
+ ];
1470
+ for (const file of context.fileIndex) {
1471
+ if (file.generatedOutput || file.isTestFile || !CODE_EXTENSIONS.has(file.ext)) continue;
1472
+ const match = patterns.map((pattern) => pattern.exec(file.content)).find(Boolean);
1473
+ if (!match) continue;
1474
+ const line = file.content.slice(0, match.index).split(/\r?\n/).length;
1475
+ const command = context.config.validation && context.config.validation.recipeCommand
1476
+ ? `; run ${String(context.config.validation.recipeCommand).replace('{testFile}', '<test-file>')}`
1477
+ : '';
1478
+ return `${file.relativePath}:${line} inspect ${match[0].trim()}${command}`;
1479
+ }
1480
+ return null;
1481
+ }
1482
+
1483
+ function isBehavioralTask(taskText) {
1484
+ return /\b(mostrar|deshabilitar|confirmar|notificar|redirigir|imprimir|show|disable|confirm|notify|redirect|print)\b/i.test(String(taskText || ''));
1485
+ }
1486
+
1487
+ function evaluateDeterministicVerification(task, context) {
1488
+ const verifyLines = Array.isArray(task.verifyLines) ? task.verifyLines : [];
1489
+ if (verifyLines.length === 0) {
1490
+ if (!isBehavioralTask(task.text)) {
1491
+ return { applicable: false, passed: false, reasons: [], diagnostics: [], recipe: null };
1492
+ }
1493
+ const recipe = findVerificationRecipe(task, context);
1494
+ return {
1495
+ applicable: false,
1496
+ passed: false,
1497
+ reasons: [],
1498
+ diagnostics: [{
1499
+ code: recipe ? 'REQUIRES_HUMAN_EVIDENCE' : 'NO_STATIC_SIGNAL',
1500
+ severity: 'warning',
1501
+ message: recipe ? 'behavioral task requires explicit human or test evidence' : 'no static implementation signal was found for behavioral task'
1502
+ }],
1503
+ recipe
1504
+ };
1505
+ }
1506
+ const reasons = [];
1507
+ const diagnostics = [];
1508
+ let generatedTestEvidence = null;
1509
+
1510
+ for (const line of verifyLines) {
1511
+ const fields = parseVerificationFields(line.text);
1512
+ const kind = fields.kind;
1513
+ if (kind === 'contains' || kind === 'property') {
1514
+ const file = findIndexedFile(fields.file, context);
1515
+ if (!file) {
1516
+ reasons.push(`missing referenced file(s): ${fields.file || '<unspecified>'}`);
1517
+ continue;
1518
+ }
1519
+ const content = stripCodeComments(file.content);
1520
+ if (kind === 'contains') {
1521
+ if (!fields.expected || !content.includes(fields.expected)) {
1522
+ reasons.push(`no content match in ${file.relativePath}: expected ${fields.expected || '<unspecified>'}`);
1523
+ }
1524
+ continue;
1525
+ }
1526
+ const keyPattern = new RegExp(`\\b${escapeRegExp(fields.key || '')}\\s*:`);
1527
+ const exactPattern = new RegExp(`\\b${escapeRegExp(fields.key || '')}\\s*:\\s*${escapeRegExp(fields.equals || '')}(?=\\s*[,}\\n])`);
1528
+ if (!exactPattern.test(content)) {
1529
+ reasons.push(keyPattern.test(content)
1530
+ ? `wrong value for ${fields.key} in ${file.relativePath}: expected ${fields.equals}`
1531
+ : `no content match in ${file.relativePath}: expected ${fields.key}: ${fields.equals}`);
1532
+ }
1533
+ continue;
1534
+ }
1535
+ if (kind === 'endpoints') {
1536
+ const routes = String(fields.routes || '').split(',').map((value) => value.trim()).filter(Boolean);
1537
+ const tests = context.fileIndex.filter((file) => !file.generatedOutput && file.isTestFile);
1538
+ const covered = routes.filter((route) => tests.some((file) => testCoversEndpoint(file, route, context)));
1539
+ if (covered.length !== routes.length) {
1540
+ const missing = routes.filter((route) => !covered.includes(route));
1541
+ reasons.push(`partial endpoint coverage ${covered.length}/${routes.length}: missing ${missing.join(', ')}`);
1542
+ }
1543
+ continue;
1544
+ }
1545
+ if (kind === 'behavior') {
1546
+ const source = findIndexedFile(fields.source, context);
1547
+ const testFile = findIndexedFile(fields.test, context);
1548
+ const testContent = testFile ? testFile.content : '';
1549
+ const sourceReference = source && testContent.includes(path.basename(source.relativePath, source.ext));
1550
+ const exercise = fields.trigger && testContent.includes(fields.trigger);
1551
+ const assertion = fields.assertion && testContent.includes(fields.assertion);
1552
+ const namedCase = fields.case && testContent.includes(fields.case);
1553
+ const proof = findFreshTestProof(task, fields, context);
1554
+ if (!source || !testFile || !sourceReference || !exercise || !assertion || !namedCase) {
1555
+ diagnostics.push({ code: 'REQUIRES_HUMAN_EVIDENCE', severity: 'warning', message: 'behavior verification needs a source reference, named test, trigger, and assertion' });
1556
+ continue;
1557
+ }
1558
+ if (proof.stale) {
1559
+ diagnostics.push({ code: 'STALE_TEST_REPORT', severity: 'warning', message: `test result for ${testFile.relativePath} predates the verified source` });
1560
+ continue;
1561
+ }
1562
+ if (!proof.passed) {
1563
+ diagnostics.push({ code: 'REQUIRES_HUMAN_EVIDENCE', severity: 'warning', message: `no fresh passing result for ${testFile.relativePath}` });
1564
+ continue;
1565
+ }
1566
+ generatedTestEvidence = proof.generatedEvidence || generatedTestEvidence;
1567
+ continue;
1568
+ }
1569
+ reasons.push(`unsupported verification kind: ${kind || '<unspecified>'}`);
1570
+ }
1571
+
1572
+ const passed = reasons.length === 0 && diagnostics.length === 0;
1573
+ return {
1574
+ applicable: true,
1575
+ passed,
1576
+ reasons,
1577
+ diagnostics,
1578
+ generatedTestEvidence,
1579
+ recipe: passed ? null : findVerificationRecipe(task, context)
1580
+ };
1581
+ }
1582
+
1276
1583
  function buildValidationContext(projectRoot, config, plugins) {
1277
1584
  const files = walkFiles(projectRoot);
1278
1585
  const fileIndex = readFileIndex(projectRoot, files, config);
@@ -1286,7 +1593,8 @@ function buildValidationContext(projectRoot, config, plugins) {
1286
1593
  files,
1287
1594
  fileIndex,
1288
1595
  pathHintResolver,
1289
- testFrameworks
1596
+ testFrameworks,
1597
+ testReportRecords: readTestReportRecords(projectRoot, config.validation)
1290
1598
  };
1291
1599
  }
1292
1600
 
@@ -1298,6 +1606,15 @@ function diagnosticCodeForReason(reason) {
1298
1606
  if (normalized.includes('missing test evidence')) {
1299
1607
  return 'NO_TEST';
1300
1608
  }
1609
+ if (normalized.includes('wrong value')) {
1610
+ return 'WRONG_VALUE';
1611
+ }
1612
+ if (normalized.includes('partial endpoint coverage')) {
1613
+ return 'PARTIAL';
1614
+ }
1615
+ if (normalized.includes('no content match')) {
1616
+ return 'NOT_IMPLEMENTED';
1617
+ }
1301
1618
  if (
1302
1619
  normalized.includes('no code, test, or artifact evidence found') ||
1303
1620
  normalized.includes('implementation task requires evidence line') ||
@@ -1327,6 +1644,10 @@ function buildDiagnostics(reasons, options = {}) {
1327
1644
  message: 'historical validation warning conflicts with fresh repository evidence'
1328
1645
  });
1329
1646
  }
1647
+ for (const diagnostic of options.extra || []) {
1648
+ if (!diagnostic || !diagnostic.code || diagnostics.some((item) => item.code === diagnostic.code)) continue;
1649
+ diagnostics.push(diagnostic);
1650
+ }
1330
1651
  return diagnostics;
1331
1652
  }
1332
1653
 
@@ -1348,6 +1669,8 @@ function validateTask(task, context, config, plugins) {
1348
1669
  const standaloneFilenames = extractStandaloneFilenames(task.text);
1349
1670
  const symbolHints = extractSymbolHints(task.text);
1350
1671
  const authoritativeEvidence = evaluateAuthoritativeEvidence(task, context.pathHintResolver);
1672
+ const deterministicVerification = evaluateDeterministicVerification(task, context);
1673
+ const hasExplicitPendingItems = Array.isArray(task.explicitPendingItems) && task.explicitPendingItems.length > 0;
1351
1674
 
1352
1675
  const filesFromPaths = findFilesByPathHints(pathHints, context.pathHintResolver);
1353
1676
  const filesFromPurePathHints = findFilesByPathHints(purePathHints, context.pathHintResolver);
@@ -1424,7 +1747,8 @@ function validateTask(task, context, config, plugins) {
1424
1747
  context.testFrameworks.length > 0 &&
1425
1748
  isCodeTask(task.text) &&
1426
1749
  !isDocTask(task.text) &&
1427
- !isHttpExpectationTask(task.text);
1750
+ !isHttpExpectationTask(task.text) &&
1751
+ !(task.verifyLines || []).some((line) => ['contains', 'property', 'endpoints', 'behavior'].includes(parseVerificationFields(line.text).kind));
1428
1752
  const configuredRules = Array.isArray(config.validators) ? config.validators : [];
1429
1753
  const pluginRules = collectPluginContributions(plugins || [], 'registerValidators', context);
1430
1754
  let overrideResult = null;
@@ -1458,6 +1782,18 @@ function validateTask(task, context, config, plugins) {
1458
1782
 
1459
1783
  let uniqueReasons = Array.from(new Set(reasons));
1460
1784
 
1785
+ if (deterministicVerification.passed) {
1786
+ uniqueReasons = uniqueReasons.filter((reason) => (
1787
+ reason !== 'no code, test, or artifact evidence found' &&
1788
+ reason !== 'missing test evidence' &&
1789
+ !reason.startsWith('weak path-')
1790
+ ));
1791
+ }
1792
+
1793
+ if (deterministicVerification.applicable && deterministicVerification.reasons.length > 0) {
1794
+ uniqueReasons = Array.from(new Set([...uniqueReasons, ...deterministicVerification.reasons]));
1795
+ }
1796
+
1461
1797
  if (overrideResult) {
1462
1798
  uniqueReasons = Array.isArray(overrideResult.reasons) ? Array.from(new Set(overrideResult.reasons)) : [];
1463
1799
  }
@@ -1516,7 +1852,15 @@ function validateTask(task, context, config, plugins) {
1516
1852
  const hasFreshRepositoryEvidence = hasStrongEvidence || hasWeakEvidence;
1517
1853
  let staleEvidenceDetected = false;
1518
1854
  let staleEvidenceResolved = false;
1519
- let passed = authoritativeEvidence.passed || hasArtifactTaskPass || hasTrustedRuleEvidencePass || meetsStrongThreshold;
1855
+ // Heuristic proximity remains useful to locate candidates, but is never sufficient to
1856
+ // complete an unchecked task. Completion requires human Evidence, a trusted rule, or a
1857
+ // typed deterministic verifier.
1858
+ let passed = authoritativeEvidence.passed || hasTrustedRuleEvidencePass || deterministicVerification.passed;
1859
+
1860
+ if (!task.checked && !authoritativeEvidence.passed && !hasTrustedRuleEvidencePass && !deterministicVerification.passed && hasHighConfidenceImplementationEvidence) {
1861
+ uniqueReasons.push('implementation task requires deterministic Verify metadata or explicit Evidence to be marked complete');
1862
+ uniqueReasons = Array.from(new Set(uniqueReasons));
1863
+ }
1520
1864
 
1521
1865
  if (task.warningText && !task.checked && hasFreshRepositoryEvidence && !authoritativeEvidence.passed) {
1522
1866
  staleEvidenceDetected = true;
@@ -1531,7 +1875,7 @@ function validateTask(task, context, config, plugins) {
1531
1875
 
1532
1876
  // Historical warnings are only cleared by independent, high-confidence repository evidence.
1533
1877
  if (task.warningText && !task.checked && passed && !authoritativeEvidence.passed) {
1534
- if (hasHighConfidenceImplementationEvidence && negativeSignalMatches.length === 0 && uniqueReasons.length === 0) {
1878
+ if ((deterministicVerification.passed || hasHighConfidenceImplementationEvidence) && negativeSignalMatches.length === 0 && uniqueReasons.length === 0) {
1535
1879
  staleEvidenceResolved = true;
1536
1880
  } else {
1537
1881
  passed = false;
@@ -1544,6 +1888,16 @@ function validateTask(task, context, config, plugins) {
1544
1888
  passed = false;
1545
1889
  }
1546
1890
 
1891
+ const extraDiagnostics = [...deterministicVerification.diagnostics];
1892
+ if (hasExplicitPendingItems) {
1893
+ passed = false;
1894
+ extraDiagnostics.push({
1895
+ code: 'HAS_EXPLICIT_PENDING',
1896
+ severity: 'warning',
1897
+ message: `task declares pending item(s): ${task.explicitPendingItems.map((item) => item.text || 'pending').join(', ')}`
1898
+ });
1899
+ }
1900
+
1547
1901
  // Unchecked implementation tasks need explicit evidence or high-confidence implementation
1548
1902
  // evidence. Weak token overlap, direct file references, or code-only matches are not enough.
1549
1903
  if (
@@ -1552,6 +1906,7 @@ function validateTask(task, context, config, plugins) {
1552
1906
  isImplementationTask(task.text) &&
1553
1907
  !authoritativeEvidence.passed &&
1554
1908
  !hasTrustedRuleEvidencePass &&
1909
+ !deterministicVerification.passed &&
1555
1910
  !hasArtifactTaskPass &&
1556
1911
  !hasHighConfidenceImplementationEvidence
1557
1912
  ) {
@@ -1588,13 +1943,13 @@ function validateTask(task, context, config, plugins) {
1588
1943
  // Used by auditValidation to flag implementation tasks that pass solely via documentation.
1589
1944
  const evidenceIsDocOnly = !evidence.code && !evidence.test && evidence.artifact && !isDocTask(task.text);
1590
1945
 
1591
- const finalPassed = overrideResult ? overrideResult.passed !== false : (passed && uniqueReasons.length === 0);
1946
+ const finalPassed = overrideResult ? (overrideResult.passed !== false && !hasExplicitPendingItems) : (passed && uniqueReasons.length === 0 && !hasExplicitPendingItems);
1592
1947
  return {
1593
1948
  taskId: task.id,
1594
1949
  passed: finalPassed,
1595
1950
  confidence,
1596
1951
  reasons: uniqueReasons,
1597
- diagnostics: buildDiagnostics(uniqueReasons, { staleEvidence: staleEvidenceDetected }),
1952
+ diagnostics: buildDiagnostics(uniqueReasons, { staleEvidence: staleEvidenceDetected, extra: extraDiagnostics }),
1598
1953
  evidence,
1599
1954
  evidenceIsDocOnly,
1600
1955
  requiresTest,
@@ -1602,7 +1957,9 @@ function validateTask(task, context, config, plugins) {
1602
1957
  attempted,
1603
1958
  preservedCheckedState,
1604
1959
  staleEvidenceResolved,
1605
- discoveredEvidence: staleEvidenceResolved ? buildDiscoveredEvidenceLine(evidence) : null
1960
+ discoveredEvidence: staleEvidenceResolved ? buildDiscoveredEvidenceLine(evidence) : null,
1961
+ verificationRecipe: deterministicVerification.recipe,
1962
+ generatedTestEvidence: deterministicVerification.generatedTestEvidence || null
1606
1963
  };
1607
1964
  }
1608
1965