@aperant/framework 0.7.0 → 0.7.4

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 (151) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/agents/apt-planner.md +12 -0
  3. package/agents/apt-pr-review-fixer.md +13 -9
  4. package/bin/apt-tools.mjs +7 -0
  5. package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -1
  6. package/dist/cli/ci-watch/stop-matrix.mjs +16 -0
  7. package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -1
  8. package/dist/cli/commands/ci-watch.d.mts +11 -0
  9. package/dist/cli/commands/ci-watch.d.mts.map +1 -1
  10. package/dist/cli/commands/ci-watch.mjs +108 -2
  11. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  12. package/dist/cli/commands/features-audit.d.mts +24 -0
  13. package/dist/cli/commands/features-audit.d.mts.map +1 -1
  14. package/dist/cli/commands/features-audit.mjs +159 -5
  15. package/dist/cli/commands/features-audit.mjs.map +1 -1
  16. package/dist/cli/commands/health-check.d.mts +16 -0
  17. package/dist/cli/commands/health-check.d.mts.map +1 -1
  18. package/dist/cli/commands/health-check.mjs +118 -2
  19. package/dist/cli/commands/health-check.mjs.map +1 -1
  20. package/dist/cli/commands/init.d.mts +9 -0
  21. package/dist/cli/commands/init.d.mts.map +1 -1
  22. package/dist/cli/commands/init.mjs +49 -4
  23. package/dist/cli/commands/init.mjs.map +1 -1
  24. package/dist/cli/commands/modes.d.mts.map +1 -1
  25. package/dist/cli/commands/modes.mjs +11 -0
  26. package/dist/cli/commands/modes.mjs.map +1 -1
  27. package/dist/cli/commands/pr-review-audit-fixer.d.mts +13 -0
  28. package/dist/cli/commands/pr-review-audit-fixer.d.mts.map +1 -1
  29. package/dist/cli/commands/pr-review-audit-fixer.mjs +18 -5
  30. package/dist/cli/commands/pr-review-audit-fixer.mjs.map +1 -1
  31. package/dist/cli/commands/task.d.mts.map +1 -1
  32. package/dist/cli/commands/task.mjs +84 -5
  33. package/dist/cli/commands/task.mjs.map +1 -1
  34. package/dist/cli/commands/validate-evidence.d.mts +24 -2
  35. package/dist/cli/commands/validate-evidence.d.mts.map +1 -1
  36. package/dist/cli/commands/validate-evidence.mjs +154 -17
  37. package/dist/cli/commands/validate-evidence.mjs.map +1 -1
  38. package/dist/cli/commands/vitest-doctor.d.mts +2 -0
  39. package/dist/cli/commands/vitest-doctor.d.mts.map +1 -0
  40. package/dist/cli/commands/vitest-doctor.mjs +168 -0
  41. package/dist/cli/commands/vitest-doctor.mjs.map +1 -0
  42. package/dist/cli/config/gitignore-drift.d.mts +23 -1
  43. package/dist/cli/config/gitignore-drift.d.mts.map +1 -1
  44. package/dist/cli/config/gitignore-drift.mjs +81 -3
  45. package/dist/cli/config/gitignore-drift.mjs.map +1 -1
  46. package/dist/cli/config/load.d.mts +56 -2
  47. package/dist/cli/config/load.d.mts.map +1 -1
  48. package/dist/cli/config/load.mjs +192 -2
  49. package/dist/cli/config/load.mjs.map +1 -1
  50. package/dist/cli/consistency/parse-review.mjs +7 -0
  51. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  52. package/dist/cli/dispatch.d.mts.map +1 -1
  53. package/dist/cli/dispatch.mjs +24 -2
  54. package/dist/cli/dispatch.mjs.map +1 -1
  55. package/dist/cli/gate/gates/gitignore-in-sync.d.mts +1 -1
  56. package/dist/cli/gate/gates/gitignore-in-sync.d.mts.map +1 -1
  57. package/dist/cli/gate/gates/gitignore-in-sync.mjs +5 -2
  58. package/dist/cli/gate/gates/gitignore-in-sync.mjs.map +1 -1
  59. package/dist/cli/gate/gates/review-clean.d.mts +5 -1
  60. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  61. package/dist/cli/gate/gates/review-clean.mjs +23 -18
  62. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  63. package/dist/cli/gate/gates/verify-approved.d.mts +49 -1
  64. package/dist/cli/gate/gates/verify-approved.d.mts.map +1 -1
  65. package/dist/cli/gate/gates/verify-approved.mjs +93 -14
  66. package/dist/cli/gate/gates/verify-approved.mjs.map +1 -1
  67. package/dist/cli/help.d.mts.map +1 -1
  68. package/dist/cli/help.mjs +7 -2
  69. package/dist/cli/help.mjs.map +1 -1
  70. package/dist/cli/install/runtime-detect.d.mts +13 -0
  71. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  72. package/dist/cli/install/runtime-detect.mjs +9 -0
  73. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  74. package/dist/cli/task/index-md.d.mts.map +1 -1
  75. package/dist/cli/task/index-md.mjs +14 -2
  76. package/dist/cli/task/index-md.mjs.map +1 -1
  77. package/dist/plugin/.claude-plugin/plugin.json +11 -2
  78. package/dist/plugin/agents/apt-improver.md +99 -0
  79. package/dist/plugin/agents/apt-planner.md +127 -10
  80. package/dist/plugin/agents/apt-pr-review-fixer.md +13 -9
  81. package/dist/plugin/skills/apt/SKILL.md +1 -0
  82. package/dist/plugin/skills/apt-close-task/SKILL.md +63 -1
  83. package/dist/plugin/skills/apt-debug/SKILL.md +39 -6
  84. package/dist/plugin/skills/apt-debug/appendices/diagnose-discipline.md +119 -0
  85. package/dist/plugin/skills/apt-diagram/SKILL.md +378 -0
  86. package/dist/plugin/skills/apt-diagram/appendices/design-discipline.md +97 -0
  87. package/dist/plugin/skills/apt-discuss/SKILL.md +72 -5
  88. package/dist/plugin/skills/apt-discuss/appendices/grill-discipline.md +104 -0
  89. package/dist/plugin/skills/apt-discuss/appendices/zoom-out-helper.md +79 -0
  90. package/dist/plugin/skills/apt-execute/SKILL.md +57 -5
  91. package/dist/plugin/skills/apt-execute/appendices/tdd-mode.md +107 -0
  92. package/dist/plugin/skills/apt-improve/DEEPENING.md +84 -0
  93. package/dist/plugin/skills/apt-improve/INTERFACE-DESIGN.md +97 -0
  94. package/dist/plugin/skills/apt-improve/LANGUAGE.md +104 -0
  95. package/dist/plugin/skills/apt-improve/SKILL.md +141 -0
  96. package/dist/plugin/skills/apt-plan/SKILL.md +171 -4
  97. package/dist/plugin/skills/apt-plan/adapters/conductor.md +98 -0
  98. package/dist/plugin/skills/apt-pr-review/SKILL.md +57 -18
  99. package/dist/plugin/skills/apt-prototype/LOGIC.md +109 -0
  100. package/dist/plugin/skills/apt-prototype/SKILL.md +143 -0
  101. package/dist/plugin/skills/apt-prototype/UI.md +90 -0
  102. package/dist/plugin/skills/apt-quick/SKILL.md +49 -8
  103. package/dist/plugin/skills/apt-release-notes/SKILL.md +193 -0
  104. package/dist/plugin/skills/apt-release-notes/appendices/persona-voice.md +59 -0
  105. package/dist/plugin/skills/apt-review/SKILL.md +2 -0
  106. package/dist/plugin/skills/apt-run/SKILL.md +32 -4
  107. package/dist/plugin/skills/apt-setup/SKILL.md +308 -6
  108. package/dist/plugin/skills/apt-ship/SKILL.md +122 -1
  109. package/dist/plugin/skills/apt-spar/SKILL.md +315 -0
  110. package/dist/plugin/skills/apt-triage/AGENT-BRIEF.md +84 -0
  111. package/dist/plugin/skills/apt-triage/OUT-OF-SCOPE.md +75 -0
  112. package/dist/plugin/skills/apt-triage/SKILL.md +169 -0
  113. package/dist/plugin/skills/apt-update/SKILL.md +77 -10
  114. package/dist/plugin/skills/apt-verify/SKILL.md +3 -0
  115. package/dist/plugin/skills/apt-verify-proof/SKILL.md +10 -5
  116. package/dist/plugin/skills/apt-watch-ci/SKILL.md +166 -0
  117. package/dist/plugin/skills/apt-zoom-out/SKILL.md +130 -0
  118. package/package.json +1 -1
  119. package/prompts/conductor-framework-context.md +63 -0
  120. package/prompts/conductor-system.md +11 -0
  121. package/skills/apt-close-task/SKILL.md +1 -0
  122. package/skills/apt-discuss/SKILL.md +47 -5
  123. package/skills/apt-execute/SKILL.md +9 -0
  124. package/skills/apt-plan/SKILL.md +12 -0
  125. package/skills/apt-pr-review/SKILL.md +19 -3
  126. package/skills/apt-quick/SKILL.md +19 -8
  127. package/skills/apt-researcher.md +1 -0
  128. package/skills/apt-setup/SKILL.md +33 -2
  129. package/skills/apt-verify-proof/SKILL.md +7 -5
  130. package/src/cli/ci-watch/stop-matrix.mjs +17 -0
  131. package/src/cli/commands/ci-watch.mjs +113 -2
  132. package/src/cli/commands/features-audit.mjs +164 -5
  133. package/src/cli/commands/health-check.mjs +115 -2
  134. package/src/cli/commands/init.mjs +52 -4
  135. package/src/cli/commands/modes.mjs +11 -0
  136. package/src/cli/commands/pr-review-audit-fixer.mjs +18 -5
  137. package/src/cli/commands/task.mjs +83 -5
  138. package/src/cli/commands/validate-evidence.mjs +158 -17
  139. package/src/cli/commands/vitest-doctor.mjs +173 -0
  140. package/src/cli/config/gitignore-drift.mjs +74 -3
  141. package/src/cli/config/load.mjs +188 -2
  142. package/src/cli/consistency/parse-review.mjs +6 -0
  143. package/src/cli/dispatch.mjs +23 -2
  144. package/src/cli/gate/gates/gitignore-in-sync.mjs +5 -2
  145. package/src/cli/gate/gates/review-clean.mjs +24 -19
  146. package/src/cli/gate/gates/verify-approved.mjs +97 -14
  147. package/src/cli/help.mjs +7 -2
  148. package/src/cli/install/runtime-detect.mjs +9 -0
  149. package/src/cli/task/index-md.mjs +15 -2
  150. package/templates/config.json +2 -7
  151. package/workflows/verify-proof.md +8 -3
@@ -166,7 +166,12 @@ export function isAnnotationLine(line, sourceWindow) {
166
166
  */
167
167
  export function parseFindingsMarkdown(markdown) {
168
168
  const findings = []
169
- const headerRx = /^## Finding:\s*([A-Z]+-\d+)\s*--\s*(.+)$/gm
169
+ // BUG-032: widen to accept H2|H3, optional `Finding:` prefix, and any of
170
+ // ASCII `--`, em-dash `—`, or en-dash `–` as the id/title separator. The
171
+ // validator-agent's consolidated `validated.md` shape uses H3 + em-dash
172
+ // with no `Finding:` prefix; the specialist shape uses H2 + `Finding:` +
173
+ // ASCII `--`. Both must round-trip through the same parser.
174
+ const headerRx = /^#{2,3}\s+(?:Finding:\s*)?([A-Z]+-\d+)\s*(?:--|—|–)\s*(.+)$/gm
170
175
  const matches = [...markdown.matchAll(headerRx)]
171
176
  for (let i = 0; i < matches.length; i++) {
172
177
  const m = matches[i]
@@ -202,23 +207,45 @@ function parseFindingBlock(body, id, title, start, end) {
202
207
  // FRAMEWORK-BUG-001 surfaced by /apt:pr-review on PR #68 in 2026-04).
203
208
  // `(?:-\d+)?` consumes the trailing range so it doesn't leak into the
204
209
  // file capture.
210
+ // BUG-032: the validator-agent's `validated.md` carries the cited path
211
+ // in `- **Original**:` instead of `- **File**:`. Map either spelling
212
+ // onto `file`/`line`; specialist shape continues to write to `file`.
213
+ const fileField = fields.file ?? fields.original
205
214
  let file = null
206
215
  let line = null
207
- if (fields.file) {
208
- const fileMatch = /^`?([^`:]+?)(?::(\d+)(?:-\d+)?)?`?\s*$/.exec(fields.file)
216
+ let lineRangeFromField = null
217
+ if (fileField) {
218
+ const fileMatch = /^`?([^`:]+?)(?::(\d+)(?:-(\d+))?)?`?\s*$/.exec(fileField)
209
219
  if (fileMatch) {
210
220
  file = fileMatch[1].trim()
211
221
  line = fileMatch[2] ? Number(fileMatch[2]) : null
222
+ if (fileMatch[2] && fileMatch[3]) {
223
+ lineRangeFromField = [Number(fileMatch[2]), Number(fileMatch[3])]
224
+ }
212
225
  }
213
226
  }
214
227
 
215
228
  // Description block: everything between `### Description` and the next `###`.
216
229
  const description = extractSection(body, 'Description') ?? ''
217
230
 
218
- // Evidence code block: first triple-backticked block after `### Evidence`.
231
+ // Evidence code block: first triple-backticked block after `### Evidence`
232
+ // (specialist shape). BUG-032: validator-consolidated `validated.md`
233
+ // shape carries evidence as a `- **Evidence**:` field whose value is the
234
+ // fenced block immediately following the field line. Fall back to the
235
+ // first fenced block after that field marker when the section heading
236
+ // form yields nothing.
219
237
  const evidenceSection = extractSection(body, 'Evidence') ?? ''
238
+ let evidence
220
239
  const fenced = /```[^\n]*\n([\s\S]*?)```/.exec(evidenceSection)
221
- const evidence = fenced ? fenced[1] : evidenceSection
240
+ if (fenced) {
241
+ evidence = fenced[1]
242
+ } else if (evidenceSection) {
243
+ evidence = evidenceSection
244
+ } else {
245
+ const fieldEvidenceRx = /-\s*\*\*Evidence\*\*:\s*(?:\n+)```[^\n]*\n([\s\S]*?)```/i
246
+ const fieldFenced = fieldEvidenceRx.exec(body)
247
+ evidence = fieldFenced ? fieldFenced[1] : (fields.evidence ?? '')
248
+ }
222
249
 
223
250
  // Suggested Fix section (prose + optional fenced snippets). Unlike
224
251
  // Evidence we keep the whole section — snippets AND surrounding
@@ -231,9 +258,11 @@ function parseFindingBlock(body, id, title, start, end) {
231
258
  const rangeMatch = /Lines? examined[^\d]*(\d+)\s*-\s*(\d+)/i.exec(contextSection)
232
259
  const lineRange = rangeMatch
233
260
  ? [Number(rangeMatch[1]), Number(rangeMatch[2])]
234
- : line
235
- ? [line, line]
236
- : null
261
+ : lineRangeFromField
262
+ ? lineRangeFromField
263
+ : line
264
+ ? [line, line]
265
+ : null
237
266
 
238
267
  // Cross-file evidence (optional): `### Cross-File Evidence` block.
239
268
  // Each file gets a `- **File**: path` + fenced snippet.
@@ -294,11 +323,64 @@ export function detectReferencedFiles(finding) {
294
323
  return [...tokens].sort()
295
324
  }
296
325
 
326
+ /**
327
+ * BUG-014 fuzzy-evidence helpers — token-set Jaccard + line-anchor count.
328
+ *
329
+ * Tokenize on word characters so whitespace, punctuation, and operator
330
+ * spacing don't perturb similarity. Same tokenizer runs on both sides so
331
+ * the ratio is symmetric.
332
+ */
333
+ export function tokenizeForJaccard(text) {
334
+ if (!text) return []
335
+ return (
336
+ String(text)
337
+ .toLowerCase()
338
+ .match(/[a-z0-9_$]+/g) ?? []
339
+ )
340
+ }
341
+
342
+ export function jaccardSimilarity(a, b) {
343
+ const setA = new Set(tokenizeForJaccard(a))
344
+ const setB = new Set(tokenizeForJaccard(b))
345
+ if (setA.size === 0 && setB.size === 0) return 1
346
+ if (setA.size === 0 || setB.size === 0) return 0
347
+ let intersection = 0
348
+ for (const t of setA) if (setB.has(t)) intersection++
349
+ const union = setA.size + setB.size - intersection
350
+ return intersection / union
351
+ }
352
+
353
+ /**
354
+ * Count evidence lines that appear as literal substrings of the cited
355
+ * file range (after `normalizeForLiteralMatch`). Used as the
356
+ * anchor-count mitigation against token-stuffing under fuzzy mode.
357
+ */
358
+ export function countExactLineAnchors(evidenceLines, target) {
359
+ const normTarget = normalizeForLiteralMatch(target)
360
+ let count = 0
361
+ for (const line of evidenceLines) {
362
+ const normLine = normalizeForLiteralMatch(line)
363
+ if (!normLine) continue
364
+ if (normTarget.includes(normLine)) count++
365
+ }
366
+ return count
367
+ }
368
+
297
369
  /**
298
370
  * Check a single finding against the literal-substring rule. Returns
299
371
  * a decision object; caller applies it to the markdown.
372
+ *
373
+ * Optional `opts.allowApprox` enables BUG-014 opt-in fuzzy fallback —
374
+ * when the literal check fails, compute Jaccard ≥ `approxFloor` on the
375
+ * cited range AND require ≥2 exact line anchors in the cited range
376
+ * (token-stuffing mitigation). On success, emit downgraded status
377
+ * `dismissed_evidence_approx_confirmed` unless `opts.llmValidatorConfirmed`
378
+ * is also set, in which case status becomes `confirmed_valid_approx`.
300
379
  */
301
- export function checkFinding(finding, worktreeRoot) {
380
+ export function checkFinding(finding, worktreeRoot, opts = {}) {
381
+ const allowApprox = Boolean(opts.allowApprox)
382
+ const approxFloor = typeof opts.approxFloor === 'number' ? opts.approxFloor : 0.85
383
+ const llmValidatorConfirmed = Boolean(opts.llmValidatorConfirmed)
302
384
  // Rule 1: evidence must be a literal substring of the cited file range.
303
385
  if (!finding.file) {
304
386
  return {
@@ -361,6 +443,10 @@ export function checkFinding(finding, worktreeRoot) {
361
443
  const normTarget = normalizeForLiteralMatch(target)
362
444
  const normWholeEvidence = normalizeForLiteralMatch(keptLines.join('\n'))
363
445
 
446
+ // Capture the first literal-mismatch reason so the fuzzy fallback can
447
+ // reuse it as the rejection note when fuzzy ALSO fails.
448
+ let literalRejectNote = null
449
+
364
450
  if (normTarget.includes(normWholeEvidence)) {
365
451
  // Fast path: the kept evidence (joined back) is still a literal
366
452
  // contiguous substring of the cited range. This is the common
@@ -370,17 +456,43 @@ export function checkFinding(finding, worktreeRoot) {
370
456
  // (e.g. `code line A\n// ... 10 more\ncode line B`). Match each
371
457
  // kept line individually against the cited range. Every kept
372
458
  // line must appear; any miss is a paraphrase / fabrication and
373
- // the whole finding is rejected.
459
+ // the whole finding is rejected — unless the caller opted into
460
+ // fuzzy fallback (BUG-014).
374
461
  for (const line of keptLines) {
375
462
  const normLine = normalizeForLiteralMatch(line)
376
463
  if (!normLine) continue
377
464
  if (!normTarget.includes(normLine)) {
378
- return {
379
- status: 'dismissed_evidence_not_literal',
380
- note: `Evidence not found as literal substring of ${finding.file} lines ${safeStart}-${safeEnd}: line "${normLine.slice(0, 120)}" missing from cited range.`,
381
- }
465
+ literalRejectNote = `Evidence not found as literal substring of ${finding.file} lines ${safeStart}-${safeEnd}: line "${normLine.slice(0, 120)}" missing from cited range.`
466
+ break
467
+ }
468
+ }
469
+ }
470
+
471
+ // BUG-014: opt-in fuzzy fallback. Default behaviour (allowApprox=false)
472
+ // preserves the anti-fabrication guarantee — literal mismatch returns
473
+ // `dismissed_evidence_not_literal` unchanged. When the caller passes
474
+ // `--allow-approx-evidence`, attempt Jaccard ≥ floor AND ≥2 exact
475
+ // line anchors in the cited range (mitigates token-stuffing). On
476
+ // success, downgrade status to `dismissed_evidence_approx_confirmed`
477
+ // unless the caller also passed `--llm-validator-confirmed`, in
478
+ // which case promote to `confirmed_valid_approx`.
479
+ if (literalRejectNote) {
480
+ if (!allowApprox) {
481
+ return { status: 'dismissed_evidence_not_literal', note: literalRejectNote }
482
+ }
483
+ const similarity = jaccardSimilarity(keptLines.join('\n'), target)
484
+ const anchors = countExactLineAnchors(keptLines, target)
485
+ if (similarity < approxFloor || anchors < 2) {
486
+ return {
487
+ status: 'dismissed_evidence_not_literal',
488
+ note: `${literalRejectNote} Fuzzy fallback also rejected (jaccard=${similarity.toFixed(3)} floor=${approxFloor}, anchors=${anchors}/2).`,
382
489
  }
383
490
  }
491
+ const approxNote = `Approximate match accepted under --allow-approx-evidence (jaccard=${similarity.toFixed(3)} ≥ ${approxFloor}, anchors=${anchors} ≥ 2).`
492
+ if (llmValidatorConfirmed) {
493
+ return { status: 'confirmed_valid_approx', note: approxNote }
494
+ }
495
+ return { status: 'dismissed_evidence_approx_confirmed', note: approxNote }
384
496
  }
385
497
 
386
498
  // Rule 2: multi-file references require Cross-File Evidence.
@@ -490,6 +602,18 @@ export function cmdValidateEvidence(findingsDir, extraArgs) {
490
602
  const worktreeRoot = resolve(worktree)
491
603
  if (!existsSync(worktreeRoot)) return err(`Worktree not found: ${worktreeRoot}`)
492
604
 
605
+ // BUG-014: opt-in fuzzy-evidence flags. Defaults preserve the
606
+ // anti-fabrication guarantee — literal substring is still the only
607
+ // matcher when no flag is set.
608
+ const allowApprox = flags.get('allow-approx-evidence') === true
609
+ const approxFloorRaw = flags.get('approx-floor')
610
+ const approxFloor = typeof approxFloorRaw === 'string' ? Number(approxFloorRaw) : 0.85
611
+ if (allowApprox && (!Number.isFinite(approxFloor) || approxFloor <= 0 || approxFloor > 1)) {
612
+ return err(`Invalid --approx-floor value: ${approxFloorRaw} (expected 0 < n ≤ 1)`)
613
+ }
614
+ const llmValidatorConfirmed = flags.get('llm-validator-confirmed') === true
615
+ const checkOpts = { allowApprox, approxFloor, llmValidatorConfirmed }
616
+
493
617
  const files = readdirSync(dir).filter((f) => f.endsWith('.md'))
494
618
  const summary = {
495
619
  status: 'ok',
@@ -506,13 +630,21 @@ export function cmdValidateEvidence(findingsDir, extraArgs) {
506
630
  const full = join(dir, f)
507
631
  const md = readFileSync(full, 'utf-8')
508
632
  const findings = parseFindingsMarkdown(md)
633
+
634
+ // BUG-032: always record the file in `processed_files`. The
635
+ // previous `continue` on empty findings caused validated.md to
636
+ // silently no-op when the header regex didn't match, leaving
637
+ // downstream gates with `processed_files: []` and no audit trail
638
+ // that the file was even read.
639
+ summary.processed_files.push({ file: f, findings_count: findings.length })
640
+
509
641
  if (findings.length === 0) continue
510
642
 
511
643
  const decisions = []
512
644
  for (const finding of findings) {
513
- const decision = checkFinding(finding, worktreeRoot)
645
+ const decision = checkFinding(finding, worktreeRoot, checkOpts)
514
646
  decisions.push({ finding, decision })
515
- if (decision.status === 'confirmed_valid') {
647
+ if (decision.status === 'confirmed_valid' || decision.status === 'confirmed_valid_approx') {
516
648
  summary.validated++
517
649
  } else {
518
650
  summary.rejected++
@@ -530,7 +662,16 @@ export function cmdValidateEvidence(findingsDir, extraArgs) {
530
662
  if (rewritten !== md) {
531
663
  writeFileSync(full, rewritten, 'utf-8')
532
664
  }
533
- summary.processed_files.push(f)
665
+ }
666
+
667
+ // BUG-032: surface parser-shape drift early. If we read N markdown
668
+ // files but extracted zero findings, warn on stderr so future schema
669
+ // changes don't silently no-op the gate.
670
+ const totalFindings = summary.processed_files.reduce((acc, e) => acc + (e.findings_count ?? 0), 0)
671
+ if (files.length > 0 && totalFindings === 0) {
672
+ process.stderr.write(
673
+ `WARNING: validate-evidence read ${files.length} markdown file(s) but extracted 0 findings — parser shape mismatch?\n`,
674
+ )
534
675
  }
535
676
 
536
677
  return ok(summary)
@@ -0,0 +1,173 @@
1
+ /**
2
+ * commands/vitest-doctor.mjs — FRAMEWORK-BUG-026 diagnostic subcommand.
3
+ *
4
+ * Globs every `vitest.config.{ts,js,mjs}` under the project root (skipping
5
+ * node_modules), parses each for the two shim signals the framework
6
+ * centralizes in `packages/framework/test-utils/vitest.shared.mjs`:
7
+ *
8
+ * - `NODE_ENV='test'` (process.env.NODE_ENV set OR defineVitestShim active)
9
+ * - `oxc.jsx.runtime='automatic'` (jsxAutomaticShim active)
10
+ *
11
+ * Reports a matrix and warns when a workspace has JSX-rendering tests
12
+ * (any `*.test.tsx` under `src/`) but no jsxAutomaticShim coverage —
13
+ * that's the BUG-026 first-test paper cut.
14
+ *
15
+ * Exit codes:
16
+ * 0 — no warnings (or only informational matrix output)
17
+ * 1 — at least one workspace has JSX-rendering tests without
18
+ * jsxAutomaticShim coverage
19
+ */
20
+
21
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
22
+ import { join, relative, resolve } from 'node:path'
23
+ import { parseFlags } from '../util/args.mjs'
24
+ import { err, exitWith, ok } from '../util/result.mjs'
25
+
26
+ const VITEST_CONFIG_NAMES = new Set(['vitest.config.ts', 'vitest.config.js', 'vitest.config.mjs'])
27
+
28
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'out', '.next', '.aperant'])
29
+
30
+ export function cmdVitestDoctor(projectDir, extraArgs) {
31
+ if (!projectDir) return err('Usage: apt-tools vitest doctor <project-dir> [--format json|md]')
32
+ const root = resolve(projectDir)
33
+ if (!existsSync(root)) return err(`project-dir not found: ${root}`)
34
+ const flags = parseFlags(extraArgs ?? [])
35
+ const format = (flags.get('format') || 'json').toLowerCase()
36
+
37
+ const configs = findVitestConfigs(root)
38
+ const matrix = configs.map((configPath) => inspectConfig(root, configPath))
39
+ const warnings = matrix
40
+ .filter((row) => row.has_jsx_rendering_tests && !row.jsx_automatic_shim)
41
+ .map((row) => ({
42
+ workspace: row.workspace,
43
+ reason:
44
+ 'has JSX-rendering tests under src/ but vitest.config does not enable jsxAutomaticShim',
45
+ }))
46
+
47
+ const envelope = {
48
+ status: 'ok',
49
+ command: 'vitest-doctor',
50
+ project_dir: root,
51
+ configs_found: matrix.length,
52
+ matrix,
53
+ warnings,
54
+ }
55
+
56
+ if (format === 'md') {
57
+ envelope.markdown = renderMarkdown(matrix, warnings)
58
+ }
59
+
60
+ if (warnings.length > 0) {
61
+ // Exit code 1 — surface drift so the user knows to wire the shim.
62
+ return exitWith(envelope, 1)
63
+ }
64
+ return ok(envelope)
65
+ }
66
+
67
+ function findVitestConfigs(root) {
68
+ const out = []
69
+ walk(root, root, out)
70
+ return out
71
+ }
72
+
73
+ function walk(rootAbs, dirAbs, out) {
74
+ let names
75
+ try {
76
+ names = readdirSync(dirAbs)
77
+ } catch {
78
+ return
79
+ }
80
+ for (const name of names) {
81
+ if (SKIP_DIRS.has(name)) continue
82
+ const abs = join(dirAbs, name)
83
+ let st
84
+ try {
85
+ st = statSync(abs)
86
+ } catch {
87
+ continue
88
+ }
89
+ if (st.isDirectory()) {
90
+ walk(rootAbs, abs, out)
91
+ } else if (st.isFile() && VITEST_CONFIG_NAMES.has(name)) {
92
+ out.push(abs)
93
+ }
94
+ }
95
+ }
96
+
97
+ function inspectConfig(rootAbs, configPath) {
98
+ const workspace = relative(rootAbs, configPath)
99
+ const dir = join(configPath, '..')
100
+ let body = ''
101
+ try {
102
+ body = readFileSync(configPath, 'utf-8')
103
+ } catch {
104
+ return {
105
+ workspace,
106
+ node_env_shim: false,
107
+ jsx_automatic_shim: false,
108
+ has_jsx_rendering_tests: false,
109
+ error: 'read-failed',
110
+ }
111
+ }
112
+ // NODE_ENV shim: either the literal `process.env.NODE_ENV = 'test'`
113
+ // pattern OR `defineVitestShim(` (any non-explicit-false arg).
114
+ const node_env_shim =
115
+ /process\.env\.NODE_ENV\s*=\s*['"`]test['"`]/.test(body) ||
116
+ /defineVitestShim\([^)]*\)/.test(body)
117
+ // jsxAutomaticShim: literal `oxc.jsx.runtime` = 'automatic' OR
118
+ // `jsxAutomaticShim: true` inside a defineVitestShim() arg.
119
+ const jsx_automatic_shim =
120
+ /oxc\s*:\s*\{[^}]*jsx[^}]*runtime\s*:\s*['"`]automatic['"`]/s.test(body) ||
121
+ /jsxAutomaticShim\s*:\s*true/.test(body)
122
+ const has_jsx_rendering_tests = workspaceHasJsxRenderingTests(dir)
123
+ return { workspace, node_env_shim, jsx_automatic_shim, has_jsx_rendering_tests }
124
+ }
125
+
126
+ function workspaceHasJsxRenderingTests(workspaceDir) {
127
+ const srcDir = join(workspaceDir, 'src')
128
+ if (!existsSync(srcDir)) return false
129
+ return scanForTsxTest(srcDir)
130
+ }
131
+
132
+ function scanForTsxTest(dirAbs) {
133
+ let names
134
+ try {
135
+ names = readdirSync(dirAbs)
136
+ } catch {
137
+ return false
138
+ }
139
+ for (const name of names) {
140
+ if (SKIP_DIRS.has(name)) continue
141
+ const abs = join(dirAbs, name)
142
+ let st
143
+ try {
144
+ st = statSync(abs)
145
+ } catch {
146
+ continue
147
+ }
148
+ if (st.isDirectory()) {
149
+ if (scanForTsxTest(abs)) return true
150
+ } else if (st.isFile() && /\.test\.tsx$/.test(name)) {
151
+ return true
152
+ }
153
+ }
154
+ return false
155
+ }
156
+
157
+ function renderMarkdown(matrix, warnings) {
158
+ const lines = ['## vitest-doctor matrix', '']
159
+ lines.push('| workspace | NODE_ENV shim | jsxAutomaticShim | has JSX tests |')
160
+ lines.push('|-----------|---------------|------------------|---------------|')
161
+ for (const r of matrix) {
162
+ lines.push(
163
+ `| \`${r.workspace}\` | ${r.node_env_shim ? '✓' : '✗'} | ${r.jsx_automatic_shim ? '✓' : '✗'} | ${r.has_jsx_rendering_tests ? '✓' : '—'} |`,
164
+ )
165
+ }
166
+ if (warnings.length > 0) {
167
+ lines.push('', '## Warnings', '')
168
+ for (const w of warnings) {
169
+ lines.push(`- \`${w.workspace}\` — ${w.reason}`)
170
+ }
171
+ }
172
+ return lines.join('\n')
173
+ }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { existsSync, readFileSync } from 'node:fs'
16
16
  import { join } from 'node:path'
17
+ import { detectWorktree } from '../git/worktree-detect.mjs'
17
18
  import { resolveSharePolicy } from './share-policy.mjs'
18
19
  import { buildAperantGitignoreTemplate } from './upgrade-gitignore.mjs'
19
20
 
@@ -35,11 +36,66 @@ const SKIP_COMMANDS = new Set([
35
36
  '-h',
36
37
  ])
37
38
 
39
+ /**
40
+ * BUG-028 — resolve the shared (main-repo) root for the dispatch hook.
41
+ *
42
+ * Mirrors `coordination/agent-identity.mjs::resolveSharedRoot` but does NOT
43
+ * depend on that module. The CoordinationStore seam is `@internal` (R8) —
44
+ * `bin/apt-tools.mjs` and its dispatch hooks must reach git through
45
+ * `git/worktree-detect.mjs` directly, not through `coordination/*`.
46
+ *
47
+ * @internal
48
+ */
49
+ function resolveSharedRootForDrift(projectDir) {
50
+ try {
51
+ const wt = detectWorktree(projectDir)
52
+ return wt.is_worktree && wt.main_repo_root ? wt.main_repo_root : projectDir
53
+ } catch {
54
+ return projectDir
55
+ }
56
+ }
57
+
58
+ /**
59
+ * BUG-028 — pretty-print the first few missing / extra lines as a
60
+ * unified-diff snippet for the gate's `reason` field. Capped at
61
+ * `maxLines` per side so the reason stays short enough for terminal
62
+ * output. Returns the empty string when both sides match.
63
+ */
64
+ export function buildGitignoreDiffSummary(expected, actual, maxLines = 3) {
65
+ const expSet = new Set(String(expected).split('\n'))
66
+ const actSet = new Set(String(actual).split('\n'))
67
+ const missing = []
68
+ for (const line of expSet) {
69
+ if (line === '') continue
70
+ if (!actSet.has(line)) {
71
+ missing.push(line)
72
+ if (missing.length >= maxLines) break
73
+ }
74
+ }
75
+ const extra = []
76
+ for (const line of actSet) {
77
+ if (line === '') continue
78
+ if (!expSet.has(line)) {
79
+ extra.push(line)
80
+ if (extra.length >= maxLines) break
81
+ }
82
+ }
83
+ if (missing.length === 0 && extra.length === 0) return ''
84
+ const parts = []
85
+ for (const line of missing) parts.push(`+ ${line}`)
86
+ for (const line of extra) parts.push(`- ${line}`)
87
+ return parts.join('\n')
88
+ }
89
+
38
90
  /**
39
91
  * Pure comparison primitive — returns whether the on-disk `.aperant/.gitignore`
40
92
  * differs from the policy-expected template. Returns `{ drifted: false }` when
41
93
  * the `.aperant/` dir or file is absent (nothing to compare).
42
94
  *
95
+ * BUG-028: when drifted, embeds a unified-diff summary (≤3 missing/extra
96
+ * lines per side) in `reason` so the user sees exactly what drifted
97
+ * without re-running `apt-tools config upgrade-gitignore --dry-run`.
98
+ *
43
99
  * @internal
44
100
  */
45
101
  export function checkGitignoreDrift(projectRoot) {
@@ -65,7 +121,16 @@ export function checkGitignoreDrift(projectRoot) {
65
121
  } catch {
66
122
  return { drifted: false, reason: 'read-failed' }
67
123
  }
68
- return { drifted: actual !== expected, reason: null }
124
+ const drifted = actual !== expected
125
+ if (!drifted) return { drifted: false, reason: null }
126
+ const diff = buildGitignoreDiffSummary(expected, actual)
127
+ return {
128
+ drifted: true,
129
+ reason: diff
130
+ ? `.aperant/.gitignore drift detected:\n${diff}`
131
+ : '.aperant/.gitignore drift detected (whitespace-only)',
132
+ diff,
133
+ }
69
134
  }
70
135
 
71
136
  /**
@@ -74,15 +139,21 @@ export function checkGitignoreDrift(projectRoot) {
74
139
  * - `command` is not in the hot-path allowlist
75
140
  * - `.aperant/.gitignore` exists AND does not match the expected template
76
141
  *
142
+ * BUG-028: resolves the shared (main-repo) root internally so the same drift
143
+ * case produces the same verdict regardless of cwd context. Pre-fix this
144
+ * inspected `cwd` directly, producing a warning from one cwd context and a
145
+ * clean envelope from another for the same on-disk drift.
146
+ *
77
147
  * Never throws.
78
148
  *
79
149
  * @internal
80
150
  */
81
- export function emitDriftWarningIfAny(projectRoot, command) {
151
+ export function emitDriftWarningIfAny(cwd, command) {
82
152
  try {
83
153
  if (process.env.APT_SUPPRESS_GITIGNORE_DRIFT === '1') return
84
154
  if (!command || SKIP_COMMANDS.has(command)) return
85
- const { drifted } = checkGitignoreDrift(projectRoot)
155
+ const sharedRoot = resolveSharedRootForDrift(cwd)
156
+ const { drifted } = checkGitignoreDrift(sharedRoot)
86
157
  if (drifted) {
87
158
  process.stderr.write(
88
159
  '[apt-tools] warning: .aperant/.gitignore is out of sync with share policy — run `apt-tools config upgrade-gitignore . --dry-run` to see the diff.\n',