bmad-plus 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +14 -14
  3. package/SECURITY.md +62 -0
  4. package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/_http.py +68 -24
  5. package/package.json +1 -1
  6. package/readme-international/README.de.md +14 -14
  7. package/readme-international/README.es.md +14 -14
  8. package/readme-international/README.fr.md +14 -14
  9. package/src/bmad-plus/agents/agent-quality/SKILL.md +1 -1
  10. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +26 -5
  11. package/src/bmad-plus/packs/pack-seo/SKILL.md +3 -1
  12. package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +2 -2
  13. package/src/bmad-plus/packs/pack-seo/requirements.txt +1 -1
  14. package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +72 -30
  15. package/src/bmad-plus/packs/pack-seo/scripts/seo_crawl.py +36 -24
  16. package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +179 -59
  17. package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +5 -6
  18. package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +176 -14
  19. package/src/bmad-plus/packs/pack-shield/README.md +12 -0
  20. package/src/bmad-plus/packs/pack-shield/SKILL.md +7 -1
  21. package/src/bmad-plus/packs/pack-shield/review-rules/access-control.md +10 -0
  22. package/src/bmad-plus/packs/pack-shield/review-rules/ai-integrations.md +10 -0
  23. package/src/bmad-plus/packs/pack-shield/review-rules/change-and-supply-chain.md +10 -0
  24. package/src/bmad-plus/packs/pack-shield/review-rules/cryptography.md +10 -0
  25. package/src/bmad-plus/packs/pack-shield/review-rules/index.yaml +134 -0
  26. package/src/bmad-plus/packs/pack-shield/review-rules/logging.md +10 -0
  27. package/src/bmad-plus/packs/pack-shield/review-rules/personal-data.md +10 -0
  28. package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register-template.yaml +53 -0
  29. package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register.md +32 -0
  30. package/src/bmad-plus/packs/pack-shield/shared/assurance-case-template.yaml +87 -0
  31. package/src/bmad-plus/packs/pack-shield/shared/assurance-case.md +50 -0
  32. package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +24 -1
  33. package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +1 -0
  34. package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +5 -4
  35. package/tools/build/generate-adapters.js +7 -0
  36. package/tools/build/generate.js +14 -0
  37. package/tools/cli/bmad-plus-cli.js +2 -0
  38. package/tools/cli/commands/ai-register.js +63 -0
  39. package/tools/cli/commands/assurance.js +162 -0
  40. package/tools/cli/commands/review.js +141 -7
  41. package/tools/cli/lib/ai-register.js +393 -0
  42. package/tools/cli/lib/assurance.js +822 -0
  43. package/tools/cli/lib/control-refs.js +132 -0
  44. package/tools/cli/lib/installation-health.js +17 -0
  45. package/tools/cli/lib/packs.js +60 -2
  46. package/tools/cli/lib/page-origins.js +582 -0
  47. package/tools/cli/lib/review-rules.js +124 -26
  48. package/tools/cli/lib/review.js +493 -10
  49. package/tools/cli/lib/uat.js +22 -5
  50. package/tools/cli/review-rules/index.yaml +9 -0
@@ -14,7 +14,7 @@ const path = require('node:path');
14
14
  const crypto = require('node:crypto');
15
15
  const { spawnSync } = require('node:child_process');
16
16
  const { matchesAny } = require('./glob');
17
- const { redactFields } = require('./redact');
17
+ const { redact, redactFields } = require('./redact');
18
18
  const rules = require('./review-rules');
19
19
 
20
20
  const SCOPE_SCHEMA = 'bmad-plus/review-scope/1';
@@ -39,6 +39,17 @@ const DISPOSITIONS = ['confirmed', 'refuted', 'unresolved'];
39
39
  const CONFIDENCE = ['high', 'medium', 'low'];
40
40
  const OUTCOMES = ['completed', 'failed', 'waived'];
41
41
 
42
+ /**
43
+ * Why a review session ended. Only `completed` lets the gate reach a verdict; every other
44
+ * reason keeps it incomplete, whatever the coverage says.
45
+ */
46
+ const STOP_REASONS = ['completed', 'budget', 'time-limit', 'failure-streak', 'interrupted'];
47
+ const ATTEMPT_OUTCOMES = ['completed', 'failed'];
48
+ /** Consecutive failed attempts after which a unit is abandoned rather than retried again. */
49
+ const STRIKES = 3;
50
+ const RUN_KEYS = ['stop', 'detail', 'passes', 'attempts', 'tokens', 'durationMs'];
51
+ const ATTEMPT_KEYS = ['unit', 'group', 'outcome', 'reason'];
52
+
42
53
  /** Files whose content must never reach a review packet, whatever an include pattern says. */
43
54
  const SECRET_PATTERNS = [
44
55
  '**/.env',
@@ -195,6 +206,14 @@ function planUnits(selected, limits = UNIT_LIMITS) {
195
206
  }));
196
207
  }
197
208
 
209
+ function contentDigest(projectDir, file) {
210
+ try {
211
+ return sha256(fs.readFileSync(path.join(projectDir, file)));
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
198
217
  /**
199
218
  * The review scope: what changed between base and head (or the working tree), which files
200
219
  * are selected, and why every other changed file is not. Secrets are excluded before any
@@ -234,12 +253,24 @@ function buildScope(projectDir, options = {}) {
234
253
  }
235
254
  }
236
255
  changed.sort((a, b) => a.path.localeCompare(b.path));
256
+ // The review's own evidence is not part of the change under review: writing findings must
257
+ // never alter the scope they answer.
258
+ const evidenceDir = path
259
+ .relative(projectDir, path.resolve(projectDir, options.outputDir || DEFAULT_DIR))
260
+ .split(path.sep)
261
+ .join('/');
262
+ const ownEvidence = (file) =>
263
+ evidenceDir !== '' &&
264
+ !evidenceDir.startsWith('..') &&
265
+ !path.isAbsolute(evidenceDir) &&
266
+ (file === evidenceDir || file.startsWith(`${evidenceDir}/`));
267
+ const candidates = changed.filter((entry) => !ownEvidence(entry.path));
237
268
 
238
269
  const include = options.include || [];
239
270
  const exclude = options.exclude || [];
240
271
  const selected = [];
241
272
  const excluded = [];
242
- for (const entry of changed) {
273
+ for (const entry of candidates) {
243
274
  const file = entry.path;
244
275
  let reason = null;
245
276
  if (matchesAny(file, SECRET_PATTERNS)) reason = 'secret';
@@ -262,10 +293,19 @@ function buildScope(projectDir, options = {}) {
262
293
  const effort = options.effort || 'medium';
263
294
  if (!EFFORTS[effort]) throw new Error(`effort must be one of ${Object.keys(EFFORTS).join('|')}`);
264
295
  const ruleset = options.ruleset || rules.loadRuleset(projectDir);
265
- for (const item of selected) item.rules = rules.rulesFor(ruleset, item.path);
296
+ for (const item of selected) {
297
+ item.rules = rules.rulesFor(ruleset, item.path);
298
+ // Kept only when a rule names controls, so a scope without any keeps its digest.
299
+ const controls = rules.controlsOf(ruleset, item.rules).map(({ id }) => id);
300
+ if (controls.length) item.controls = controls;
301
+ // A commit fixes the content under review; a working tree does not, so its bytes are sealed.
302
+ if (options.workspace) item.sha256 = contentDigest(projectDir, item.path);
303
+ }
266
304
  const identity = {
267
305
  base,
268
306
  head: head || 'WORKTREE',
307
+ // The ref as given, so a continuation can tell whether it still names the reviewed commit.
308
+ headRef: options.workspace ? null : options.head || 'HEAD',
269
309
  mergeBase,
270
310
  workspace: Boolean(options.workspace),
271
311
  include,
@@ -273,10 +313,17 @@ function buildScope(projectDir, options = {}) {
273
313
  effort,
274
314
  rulesSha256: ruleset.sha256,
275
315
  };
276
- const units = planUnits(selected, options.unitLimits).map((unit) => ({
277
- ...unit,
278
- rules: [...new Set(unit.paths.flatMap((p) => selected.find((s) => s.path === p).rules))],
279
- }));
316
+ const units = planUnits(selected, options.unitLimits).map((unit) => {
317
+ const ids = [...new Set(unit.paths.flatMap((p) => selected.find((s) => s.path === p).rules))];
318
+ const controls = rules.controlsOf(ruleset, ids).map(({ id }) => id);
319
+ // Rule groups let parallel reviewers split one unit by rule family.
320
+ return {
321
+ ...unit,
322
+ rules: ids,
323
+ groups: rules.groupsOf(ruleset, ids),
324
+ ...(controls.length ? { controls } : {}),
325
+ };
326
+ });
280
327
  const lines = selected.reduce((n, item) => n + item.added + item.removed, 0);
281
328
  const plan = reviewPlan(effort, lines, units.length);
282
329
  const body = { identity, selected, excluded, units, plan };
@@ -285,7 +332,7 @@ function buildScope(projectDir, options = {}) {
285
332
  id: options.id,
286
333
  ...body,
287
334
  totals: {
288
- changed: changed.length,
335
+ changed: candidates.length,
289
336
  selected: selected.length,
290
337
  excluded: excluded.length,
291
338
  lines,
@@ -311,6 +358,7 @@ const FINDING_KEYS = [
311
358
  'refutation',
312
359
  'fix',
313
360
  'rule',
361
+ 'controls',
314
362
  ];
315
363
 
316
364
  /** Unknown values are errors, never coerced: a wrong enum is a wrong finding. */
@@ -357,6 +405,21 @@ function validateFindings(doc, scope) {
357
405
  // A finding may name the checklist rule that led to it; the rule must apply to that file.
358
406
  if (finding.rule !== undefined && entry && !(entry.rules || []).includes(finding.rule))
359
407
  errors.push(`${at}: rule "${finding.rule}" does not apply to ${finding.path}`);
408
+ // It may name the controls it breaks: only those the rules for that file examine.
409
+ if (finding.controls !== undefined) {
410
+ if (
411
+ !Array.isArray(finding.controls) ||
412
+ !finding.controls.length ||
413
+ !finding.controls.every((control) => typeof control === 'string')
414
+ )
415
+ errors.push(`${at}: controls must be a non-empty list of control ids`);
416
+ else if (entry)
417
+ for (const control of finding.controls)
418
+ if (!(entry.controls || []).includes(control))
419
+ errors.push(
420
+ `${at}: control "${control}" is not examined by the rules for ${finding.path}`
421
+ );
422
+ }
360
423
  }
361
424
  return errors;
362
425
  }
@@ -442,6 +505,148 @@ const REDACTED_FIELDS = [
442
505
 
443
506
  // ── Coverage and gate ─────────────────────────────────────────────────────────
444
507
 
508
+ const isCount = (value) => Number.isInteger(value) && value >= 0;
509
+ const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
510
+
511
+ /** What an attempt worked on: a whole unit (`u2`) or one rule group of it (`u2/data`). */
512
+ const attemptKey = (attempt) => (attempt.group ? `${attempt.unit}/${attempt.group}` : attempt.unit);
513
+ const unitOfKey = (key) => key.split('/')[0];
514
+
515
+ /**
516
+ * The three-strikes rule over an ordered attempt log: work whose attempts failed three
517
+ * times in a row is exhausted, and any later attempt of it is a violation. A whole unit
518
+ * and each of its groups overlap: exhausting one blocks the other.
519
+ */
520
+ function strikeLedger(attempts) {
521
+ const streak = new Map();
522
+ const exhausted = new Set();
523
+ const violations = [];
524
+ const blocked = (attempt) =>
525
+ exhausted.has(attempt.unit) ||
526
+ (attempt.group
527
+ ? exhausted.has(attemptKey(attempt))
528
+ : [...exhausted].some((key) => unitOfKey(key) === attempt.unit));
529
+ for (const [index, attempt] of attempts.entries()) {
530
+ const key = attemptKey(attempt);
531
+ if (blocked(attempt)) {
532
+ violations.push({ index, unit: key });
533
+ continue;
534
+ }
535
+ const failures = attempt.outcome === 'failed' ? (streak.get(key) || 0) + 1 : 0;
536
+ streak.set(key, failures);
537
+ if (failures >= STRIKES) exhausted.add(key);
538
+ }
539
+ return { exhausted, violations, units: new Set([...exhausted].map(unitOfKey)) };
540
+ }
541
+
542
+ const unitIndex = (scope) =>
543
+ new Map(scope.units.flatMap((unit) => unit.paths.map((file) => [file, unit.id])));
544
+
545
+ /** Units whose review completed: in one attempt, or in one completed attempt per rule group. */
546
+ function completedUnits(scope, attempts) {
547
+ const done = new Set(attempts.filter((a) => a.outcome === 'completed').map(attemptKey));
548
+ return new Set(
549
+ scope.units
550
+ .filter(
551
+ (unit) =>
552
+ done.has(unit.id) ||
553
+ (Array.isArray(unit.groups) &&
554
+ unit.groups.length > 0 &&
555
+ unit.groups.every((group) => done.has(`${unit.id}/${group.id}`)))
556
+ )
557
+ .map((unit) => unit.id)
558
+ );
559
+ }
560
+
561
+ /**
562
+ * The run record of a review: why it stopped, what it observed (passes run, units
563
+ * attempted, and tokens or duration only when the host reports them — never estimated),
564
+ * and the ordered log of unit attempts. When the log is not empty it is the whole story:
565
+ * a file is completed only if an attempt of its unit completed.
566
+ */
567
+ function validateRun(run, scope, items) {
568
+ if (!isRecord(run)) return ['run must be an object'];
569
+ const errors = [];
570
+ for (const key of Object.keys(run))
571
+ if (!RUN_KEYS.includes(key)) errors.push(`run: unknown key "${key}"`);
572
+ if (!STOP_REASONS.includes(run.stop))
573
+ errors.push(`run: stop "${run.stop}" is not one of ${STOP_REASONS.join('|')}`);
574
+ if (run.detail !== undefined && typeof run.detail !== 'string')
575
+ errors.push('run: detail is text');
576
+ else if (STOP_REASONS.includes(run.stop) && run.stop !== 'completed' && !run.detail?.trim())
577
+ errors.push(`run: a review stopped by ${run.stop} says why in detail`);
578
+ if (!isCount(run.passes)) errors.push('run: passes is the number of passes actually run');
579
+ else if (run.stop === 'completed' && run.passes < 1)
580
+ errors.push('run: a completed review ran at least one pass');
581
+ for (const key of ['tokens', 'durationMs'])
582
+ if (run[key] !== undefined && !isCount(run[key]))
583
+ errors.push(`run: ${key} is a non-negative integer reported by the host, or absent`);
584
+ if (!Array.isArray(run.attempts)) {
585
+ errors.push('run: attempts lists every unit attempt, in order (empty when none was logged)');
586
+ return errors;
587
+ }
588
+ const units = new Map(scope.units.map((unit) => [unit.id, unit]));
589
+ for (const [index, attempt] of run.attempts.entries()) {
590
+ const at = `run: attempt ${index + 1}`;
591
+ if (!isRecord(attempt)) {
592
+ errors.push(`${at}: must be an object`);
593
+ continue;
594
+ }
595
+ for (const key of Object.keys(attempt))
596
+ if (!ATTEMPT_KEYS.includes(key)) errors.push(`${at}: unknown key "${key}"`);
597
+ const unit = units.get(attempt.unit);
598
+ if (!unit) errors.push(`${at}: unit "${attempt.unit}" is not in the scope`);
599
+ else if (
600
+ attempt.group !== undefined &&
601
+ !(unit.groups || []).some((group) => group.id === attempt.group)
602
+ )
603
+ errors.push(`${at}: group "${attempt.group}" is not a rule group of unit ${unit.id}`);
604
+ if (!ATTEMPT_OUTCOMES.includes(attempt.outcome))
605
+ errors.push(
606
+ `${at}: outcome "${attempt.outcome}" is not one of ${ATTEMPT_OUTCOMES.join('|')}`
607
+ );
608
+ if (attempt.reason !== undefined && typeof attempt.reason !== 'string')
609
+ errors.push(`${at}: reason is text`);
610
+ else if (attempt.outcome === 'failed' && !attempt.reason?.trim())
611
+ errors.push(`${at}: a failed attempt needs a reason`);
612
+ }
613
+ if (errors.length) return errors;
614
+
615
+ const { exhausted, violations } = strikeLedger(run.attempts);
616
+ for (const { index, unit } of violations)
617
+ errors.push(
618
+ `run: attempt ${index + 1} retries unit ${unit} after ${STRIKES} consecutive failures — it is abandoned in this review`
619
+ );
620
+ if (run.stop === 'failure-streak' && !exhausted.size)
621
+ errors.push(`run: failure-streak needs a unit with ${STRIKES} consecutive failed attempts`);
622
+ if (run.attempts.length) {
623
+ const unitOf = unitIndex(scope);
624
+ const done = completedUnits(scope, run.attempts);
625
+ for (const item of items)
626
+ if (item.outcome === 'completed' && unitOf.has(item.path) && !done.has(unitOf.get(item.path)))
627
+ errors.push(
628
+ `${item.path}: completed in coverage, but unit ${unitOf.get(item.path)} was not completed — as a whole or in every rule group`
629
+ );
630
+ }
631
+ return errors;
632
+ }
633
+
634
+ /** What the review actually consumed, as reported; null when no run was recorded. */
635
+ function observedUsage(scope, run) {
636
+ if (!isRecord(run)) return null;
637
+ const attempts = Array.isArray(run.attempts) ? run.attempts.filter(isRecord) : [];
638
+ return {
639
+ passes: isCount(run.passes) ? run.passes : null,
640
+ plannedPasses: scope.plan ? scope.plan.passes : null,
641
+ units: scope.units.length,
642
+ unitsAttempted: new Set(attempts.map((a) => a.unit)).size,
643
+ attempts: attempts.length,
644
+ failedAttempts: attempts.filter((a) => a.outcome === 'failed').length,
645
+ ...(isCount(run.tokens) ? { tokens: run.tokens } : {}),
646
+ ...(isCount(run.durationMs) ? { durationMs: run.durationMs } : {}),
647
+ };
648
+ }
649
+
445
650
  function validateCoverage(doc, scope) {
446
651
  const errors = [];
447
652
  if (!doc || doc.schema !== COVERAGE_SCHEMA) errors.push(`schema must be "${COVERAGE_SCHEMA}"`);
@@ -461,12 +666,13 @@ function validateCoverage(doc, scope) {
461
666
  )
462
667
  errors.push(`${item.path}: a ${item.outcome} file needs a reason`);
463
668
  }
669
+ if (doc && doc.run !== undefined) errors.push(...validateRun(doc.run, scope, doc.items || []));
464
670
  return errors;
465
671
  }
466
672
 
467
673
  /**
468
674
  * The review verdict. `incomplete` whenever a selected file was not accounted for, failed,
469
- * or the evidence is invalid; otherwise `findings` or `clean` — the latter only means no
675
+ * the review stopped before the end, or the evidence is invalid; otherwise `findings` or `clean` — the latter only means no
470
676
  * confirmed or unresolved finding within a fully covered scope.
471
677
  */
472
678
  function reviewGate({ scope, findings, coverage, anchored }) {
@@ -489,6 +695,17 @@ function reviewGate({ scope, findings, coverage, anchored }) {
489
695
  );
490
696
  if (failed.length)
491
697
  reasons.push(`${failed.length} file(s) whose review failed: ${failed.slice(0, 5).join(', ')}`);
698
+ const run = coverage && isRecord(coverage.run) ? coverage.run : null;
699
+ // A review that stopped early is never clean, even when every file happens to be listed.
700
+ if (run && STOP_REASONS.includes(run.stop) && run.stop !== 'completed')
701
+ reasons.push(
702
+ `the review stopped before the end (${run.stop}): ${typeof run.detail === 'string' ? run.detail.trim() : ''}`
703
+ );
704
+ if (run && Array.isArray(run.attempts)) {
705
+ const { exhausted } = strikeLedger(run.attempts.filter(isRecord));
706
+ for (const unit of exhausted)
707
+ reasons.push(`unit ${unit} abandoned after ${STRIKES} consecutive failed attempts`);
708
+ }
492
709
  const open = ((anchored && anchored.findings) || []).filter((f) => f.disposition !== 'refuted');
493
710
  const unlocated = open.filter((f) => f.location.status !== 'located');
494
711
  if (unlocated.length)
@@ -504,7 +721,11 @@ function reviewGate({ scope, findings, coverage, anchored }) {
504
721
  const status = reasons.length ? 'incomplete' : open.length ? 'findings' : 'clean';
505
722
  return {
506
723
  status,
507
- reasons,
724
+ // Reasons echo host-written text (a stop detail, an invalid value), and they reach the
725
+ // terminal, the CI log and the check document alike: redacted once, here.
726
+ reasons: reasons.map((reason) => redact(reason).text),
727
+ stop: run && STOP_REASONS.includes(run.stop) ? run.stop : null,
728
+ usage: observedUsage(scope, run),
508
729
  coverage: {
509
730
  selected: total,
510
731
  completed: done,
@@ -525,6 +746,257 @@ function reviewGate({ scope, findings, coverage, anchored }) {
525
746
  };
526
747
  }
527
748
 
749
+ // ── Check result for CI ───────────────────────────────────────────────────────
750
+
751
+ const CHECK_SCHEMA = 'bmad-plus/review-check/1';
752
+ const GATE_EXIT = { clean: 0, findings: 1, incomplete: 2 };
753
+ const ANNOTATION_LEVEL = { critical: 'failure', high: 'failure', medium: 'warning', low: 'notice' };
754
+ /** The GitHub check-runs API accepts at most 50 annotations per request. */
755
+ const MAX_ANNOTATIONS = 50;
756
+ const MAX_SUMMARY_ROWS = 50;
757
+
758
+ const cell = (value) =>
759
+ String(value)
760
+ .replace(/[|\\`]/g, '\\$&')
761
+ .replace(/\s+/g, ' ');
762
+
763
+ /**
764
+ * The gate verdict as a stable document a CI step consumes without parsing prose: the
765
+ * disposition and its exit code, the reasons, the counts, the scope it answers, the open
766
+ * findings with their anchored lines, and a `github` block shaped like the check-runs API
767
+ * (`conclusion` and `output` with a Markdown summary and annotations). Free text is passed
768
+ * through the redaction floor; the document carries no timestamp, so the same evidence
769
+ * always yields the same bytes.
770
+ */
771
+ function checkResult({ id, scope, verdict, anchored }) {
772
+ const rank = (severity) => SEVERITIES.indexOf(severity);
773
+ const open = ((anchored && anchored.findings) || [])
774
+ .filter((f) => f.disposition !== 'refuted')
775
+ .map((f) => ({
776
+ id: f.id,
777
+ path: f.path,
778
+ severity: f.severity,
779
+ category: f.category,
780
+ confidence: f.confidence,
781
+ disposition: f.disposition,
782
+ ...(f.rule !== undefined ? { rule: f.rule } : {}),
783
+ ...(f.controls !== undefined ? { controls: f.controls } : {}),
784
+ anchor: f.location.status,
785
+ lineStart: f.location.status === 'located' ? f.location.lineStart : null,
786
+ lineEnd: f.location.status === 'located' ? f.location.lineEnd : null,
787
+ message: f.content,
788
+ }))
789
+ .sort(
790
+ (a, b) =>
791
+ rank(a.severity) - rank(b.severity) ||
792
+ a.path.localeCompare(b.path) ||
793
+ (a.lineStart || 0) - (b.lineStart || 0) ||
794
+ String(a.id).localeCompare(String(b.id))
795
+ );
796
+ const reasons = verdict.reasons.map((reason) => redact(reason).text);
797
+ const located = open.filter((f) => f.anchor === 'located');
798
+ const annotations = located.slice(0, MAX_ANNOTATIONS).map((f) => ({
799
+ path: f.path,
800
+ start_line: f.lineStart,
801
+ end_line: f.lineEnd,
802
+ annotation_level: ANNOTATION_LEVEL[f.severity],
803
+ title: `${f.severity} ${f.category} (${f.id})`,
804
+ message: f.message,
805
+ }));
806
+ const { coverage, findings } = verdict;
807
+ const summary = [
808
+ `**${verdict.status}**: ${coverage.completed}/${coverage.selected} selected file(s) completed, ${coverage.waived} waived, ${coverage.failed} failed, ${coverage.missing} missing; ${findings.open} open finding(s), ${findings.refuted} refuted.`,
809
+ `Scope \`${scope.sha256.slice(0, 12)}\` (${scope.identity.effort || 'medium'} effort)${verdict.stop ? `, stopped: ${verdict.stop}` : ''}.`,
810
+ ...(reasons.length ? ['', ...reasons.map((reason) => `- ${cell(reason)}`)] : []),
811
+ ...(open.length
812
+ ? [
813
+ '',
814
+ '| Severity | Category | Finding | Location |',
815
+ '|---|---|---|---|',
816
+ ...open
817
+ .slice(0, MAX_SUMMARY_ROWS)
818
+ .map(
819
+ (f) =>
820
+ `| ${f.severity} | ${f.category} | ${cell(f.id)} | ${cell(f.path)}${f.lineStart ? `:${f.lineStart}` : ` (${f.anchor})`} |`
821
+ ),
822
+ ...(open.length > MAX_SUMMARY_ROWS
823
+ ? [`\n${open.length - MAX_SUMMARY_ROWS} more in the check document.`]
824
+ : []),
825
+ ]
826
+ : []),
827
+ ...(verdict.status === 'clean'
828
+ ? [
829
+ '',
830
+ 'Clean means no open finding within a fully covered scope; it does not prove the code correct.',
831
+ ]
832
+ : []),
833
+ ].join('\n');
834
+ const body = {
835
+ id,
836
+ scopeSha256: scope.sha256,
837
+ identity: {
838
+ base: scope.identity.base,
839
+ head: scope.identity.head,
840
+ mergeBase: scope.identity.mergeBase,
841
+ workspace: scope.identity.workspace,
842
+ effort: scope.identity.effort || null,
843
+ rulesSha256: scope.identity.rulesSha256 || null,
844
+ },
845
+ status: verdict.status,
846
+ exitCode: GATE_EXIT[verdict.status],
847
+ reasons,
848
+ stop: verdict.stop,
849
+ usage: verdict.usage,
850
+ coverage,
851
+ findings,
852
+ open,
853
+ github: {
854
+ name: `bmad-plus review ${id}`,
855
+ conclusion: verdict.status === 'clean' ? 'success' : 'failure',
856
+ output: {
857
+ title: `Review ${verdict.status}: ${findings.open} open finding(s), ${coverage.completed}/${coverage.selected} file(s) completed`,
858
+ summary,
859
+ annotations,
860
+ },
861
+ annotationsOmitted: located.length - annotations.length,
862
+ },
863
+ };
864
+ return { schema: CHECK_SCHEMA, ...body, sha256: sha256(JSON.stringify(body)) };
865
+ }
866
+
867
+ // ── Continuing an interrupted review ──────────────────────────────────────────
868
+
869
+ const CONTINUE_SCHEMA = 'bmad-plus/review-continue/1';
870
+ const short = (sha) => String(sha).slice(0, 12);
871
+
872
+ /**
873
+ * Why a sealed scope no longer describes the code: its head ref names another commit, the
874
+ * review rules changed, or the change itself did (for a working tree, any selected byte).
875
+ * Empty when the scope rebuilt now is exactly the scope that was sealed.
876
+ */
877
+ function scopeDrift(projectDir, scope, options = {}) {
878
+ const { identity } = scope;
879
+ // Since continuation, every scope carries headRef (null for a working tree); an older one,
880
+ // commit or working tree, lacks the key along with the per-file digests it relies on.
881
+ if (!('headRef' in identity) || (!identity.workspace && !identity.headRef))
882
+ return ['the scope was sealed without its head ref, before continuation existed'];
883
+ const drift = [];
884
+ if (!identity.workspace) {
885
+ let now = null;
886
+ try {
887
+ now = resolveCommit(projectDir, identity.headRef);
888
+ } catch {
889
+ // Reported just below: a ref that no longer resolves has moved too.
890
+ }
891
+ if (now !== identity.head)
892
+ drift.push(
893
+ now
894
+ ? `${identity.headRef} moved from ${short(identity.head)} to ${short(now)}`
895
+ : `${identity.headRef} no longer resolves to a commit`
896
+ );
897
+ }
898
+ let rebuilt;
899
+ try {
900
+ rebuilt = buildScope(projectDir, {
901
+ id: scope.id,
902
+ base: identity.base,
903
+ head: identity.workspace ? undefined : identity.headRef,
904
+ workspace: identity.workspace,
905
+ include: identity.include,
906
+ exclude: identity.exclude,
907
+ effort: identity.effort,
908
+ ruleset: options.ruleset,
909
+ outputDir: options.outputDir,
910
+ });
911
+ } catch (error) {
912
+ return [...drift, `the scope cannot be rebuilt: ${error.message}`];
913
+ }
914
+ if (rebuilt.sha256 === scope.sha256 || drift.length) return drift;
915
+ if (rebuilt.identity.rulesSha256 !== identity.rulesSha256) drift.push('the review rules changed');
916
+ // Rules are compared above; here only what each file is and how it changed.
917
+ const describe = (list) =>
918
+ new Map(list.map((item) => [item.path, JSON.stringify({ ...item, rules: undefined })]));
919
+ const sealed = describe(scope.selected);
920
+ const current = describe(rebuilt.selected);
921
+ const moved = [...new Set([...sealed.keys(), ...current.keys()])]
922
+ .filter((file) => sealed.get(file) !== current.get(file))
923
+ .sort();
924
+ if (moved.length)
925
+ drift.push(
926
+ `${moved.length} file(s) changed since the scope was sealed: ${moved.slice(0, 5).join(', ')}${moved.length > 5 ? '…' : ''}`
927
+ );
928
+ if (!drift.length) drift.push('the scope rebuilt now differs from the sealed one');
929
+ return drift;
930
+ }
931
+
932
+ /**
933
+ * What an interrupted review still owes against its unchanged scope: selected files never
934
+ * accounted for or whose review failed, grouped back into their units; files of units
935
+ * abandoned under the three-strikes rule, which this review will not retry; open findings
936
+ * to requote; and the passes the plan still asks for when the run stopped early.
937
+ */
938
+ function remainingWork({ scope, coverage, anchored, findingErrors = [] }) {
939
+ const accounted = new Map(((coverage && coverage.items) || []).map((item) => [item.path, item]));
940
+ const run = coverage && isRecord(coverage.run) ? coverage.run : null;
941
+ const attempts = run && Array.isArray(run.attempts) ? run.attempts.filter(isRecord) : [];
942
+ const { units: exhausted } = strikeLedger(attempts);
943
+ const done = new Set(attempts.filter((a) => a.outcome === 'completed').map(attemptKey));
944
+ const unitOf = unitIndex(scope);
945
+ const files = [];
946
+ const abandoned = [];
947
+ for (const item of scope.selected) {
948
+ const entry = accounted.get(item.path);
949
+ if (entry && entry.outcome !== 'failed') continue;
950
+ const record = {
951
+ path: item.path,
952
+ unit: unitOf.get(item.path),
953
+ state: entry ? 'failed' : 'missing',
954
+ };
955
+ (exhausted.has(record.unit) ? abandoned : files).push(record);
956
+ }
957
+ const pending = new Set(files.map((file) => file.path));
958
+ const units = scope.units
959
+ .map((unit) => ({
960
+ id: unit.id,
961
+ paths: unit.paths.filter((file) => pending.has(file)),
962
+ rules: unit.rules,
963
+ ...(unit.controls ? { controls: unit.controls } : {}),
964
+ // Rule groups another reviewer already completed are not handed out again.
965
+ ...(unit.groups
966
+ ? { groups: unit.groups.filter((group) => !done.has(`${unit.id}/${group.id}`)) }
967
+ : {}),
968
+ }))
969
+ .filter((unit) => unit.paths.length);
970
+ const requote = ((anchored && anchored.findings) || [])
971
+ .filter((f) => f.disposition !== 'refuted' && f.location.status !== 'located')
972
+ .map((f) => ({ id: f.id, path: f.path, status: f.location.status }));
973
+ const usage = observedUsage(scope, run);
974
+ const stopped = run && run.stop !== 'completed';
975
+ const passes =
976
+ stopped && usage.passes !== null && usage.plannedPasses !== null
977
+ ? Math.max(0, usage.plannedPasses - usage.passes)
978
+ : 0;
979
+ return {
980
+ schema: CONTINUE_SCHEMA,
981
+ id: scope.id,
982
+ scopeSha256: scope.sha256,
983
+ previous: run ? { stop: STOP_REASONS.includes(run.stop) ? run.stop : null, usage } : null,
984
+ counts: {
985
+ files: files.length,
986
+ units: units.length,
987
+ abandoned: abandoned.length,
988
+ requote: requote.length,
989
+ findingErrors: findingErrors.length,
990
+ passes,
991
+ },
992
+ units,
993
+ files,
994
+ abandoned,
995
+ requote,
996
+ findingErrors,
997
+ };
998
+ }
999
+
528
1000
  // ── Comparing two reviews ─────────────────────────────────────────────────────
529
1001
 
530
1002
  const COMPARE_SCHEMA = 'bmad-plus/review-compare/1';
@@ -602,6 +1074,7 @@ function layout(projectDir, dir = DEFAULT_DIR, id) {
602
1074
  coverage: path.join(root, 'coverage.json'),
603
1075
  checklist: path.join(root, 'checklist.md'),
604
1076
  compare: path.join(root, 'compare.json'),
1077
+ continue: path.join(root, 'continue.json'),
605
1078
  };
606
1079
  }
607
1080
 
@@ -617,6 +1090,8 @@ module.exports = {
617
1090
  DISPOSITIONS,
618
1091
  CONFIDENCE,
619
1092
  OUTCOMES,
1093
+ STOP_REASONS,
1094
+ STRIKES,
620
1095
  SECRET_PATTERNS,
621
1096
  GENERATED_PATTERNS,
622
1097
  parseNumstat,
@@ -626,7 +1101,15 @@ module.exports = {
626
1101
  locate,
627
1102
  anchorFindings,
628
1103
  validateCoverage,
1104
+ validateRun,
1105
+ strikeLedger,
629
1106
  reviewGate,
1107
+ CHECK_SCHEMA,
1108
+ GATE_EXIT,
1109
+ checkResult,
1110
+ CONTINUE_SCHEMA,
1111
+ scopeDrift,
1112
+ remainingWork,
630
1113
  reviewPlan,
631
1114
  findingKey,
632
1115
  compareReviews,