@opengsd/gsd-core 1.19.0 → 1.20.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 (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.compact.md +5 -5
  4. package/agents/gsd-code-fixer.md +9 -4
  5. package/agents/gsd-code-reviewer.compact.md +49 -8
  6. package/agents/gsd-code-reviewer.md +53 -14
  7. package/agents/gsd-executor.md +8 -4
  8. package/commands/gsd/update.md +5 -2
  9. package/gsd-core/bin/gsd-tools.cjs +73 -23
  10. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  11. package/gsd-core/bin/lib/capability-registry.cjs +284 -93
  12. package/gsd-core/bin/lib/capability-validator.cjs +15 -0
  13. package/gsd-core/bin/lib/commands.cjs +27 -17
  14. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  15. package/gsd-core/bin/lib/core-utils.cjs +11 -0
  16. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  17. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  18. package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
  19. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  20. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  21. package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
  22. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
  23. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  24. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  25. package/gsd-core/bin/lib/init.cjs +180 -67
  26. package/gsd-core/bin/lib/io.cjs +6 -0
  27. package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
  28. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  29. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  30. package/gsd-core/bin/lib/milestone.cjs +18 -50
  31. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  32. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  33. package/gsd-core/bin/lib/phase-id.cjs +609 -35
  34. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
  35. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  36. package/gsd-core/bin/lib/phase.cjs +2990 -666
  37. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  38. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  39. package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
  40. package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
  41. package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
  42. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  43. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
  44. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  45. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  46. package/gsd-core/bin/lib/roadmap-parser.cjs +730 -144
  47. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  48. package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
  49. package/gsd-core/bin/lib/roadmap.cjs +358 -106
  50. package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
  51. package/gsd-core/bin/lib/smart-entry.cjs +22 -11
  52. package/gsd-core/bin/lib/state-contract.cjs +11 -7
  53. package/gsd-core/bin/lib/state-document.cjs +332 -23
  54. package/gsd-core/bin/lib/state-transition.cjs +39 -10
  55. package/gsd-core/bin/lib/state.cjs +498 -179
  56. package/gsd-core/bin/lib/template.cjs +2 -2
  57. package/gsd-core/bin/lib/validate.cjs +3 -2
  58. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  59. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  60. package/gsd-core/bin/lib/verify.cjs +2 -2
  61. package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
  62. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  63. package/gsd-core/references/cicd-review-patterns.md +45 -0
  64. package/gsd-core/references/necessity-review.md +48 -0
  65. package/gsd-core/workflows/code-review-fix.md +46 -5
  66. package/gsd-core/workflows/code-review.md +3 -0
  67. package/gsd-core/workflows/execute-phase.md +1 -1
  68. package/gsd-core/workflows/execute-plan.md +1 -1
  69. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  70. package/gsd-core/workflows/plan-phase.md +1 -1
  71. package/gsd-core/workflows/pr-branch.md +2 -0
  72. package/gsd-core/workflows/reapply-patches.md +104 -0
  73. package/gsd-core/workflows/review.md +54 -141
  74. package/gsd-core/workflows/secure-phase.md +1 -1
  75. package/gsd-core/workflows/settings-advanced.md +57 -9
  76. package/gsd-core/workflows/ship.md +12 -0
  77. package/hooks/dist/gsd-graphify-update.sh +6 -0
  78. package/hooks/dist/gsd-statusline.js +3 -2
  79. package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
  80. package/hooks/dist/gsd-workflow-guard.js +2 -1
  81. package/hooks/dist/gsd-worktree-path-guard.js +4 -4
  82. package/hooks/dist/lib/git-probe.js +16 -0
  83. package/hooks/gsd-graphify-update.sh +6 -0
  84. package/hooks/gsd-statusline.js +3 -2
  85. package/hooks/gsd-windsurf-pre-write.js +4 -4
  86. package/hooks/gsd-workflow-guard.js +2 -1
  87. package/hooks/gsd-worktree-path-guard.js +4 -4
  88. package/hooks/lib/git-probe.js +16 -0
  89. package/package.json +3 -3
  90. package/pi/package-agents/gsd-code-fixer.md +9 -4
  91. package/pi/package-agents/gsd-code-reviewer.md +53 -14
  92. package/pi/package-agents/gsd-executor.md +8 -4
  93. package/pi/package-agents/manifest.json +3 -3
  94. package/pi/package-skills/gsd-update/SKILL.md +4 -1
  95. package/scripts/ci-next-health.cjs +246 -23
  96. package/scripts/docs-guard-registry.cjs +2 -0
  97. package/scripts/gen-features.cjs +14 -2
  98. package/scripts/lib/drift-scan.cjs +27 -0
  99. package/scripts/lib/macos-conformance-tier.generated.cjs +6 -0
  100. package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
  101. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  102. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  103. package/scripts/mutation-matrix.cjs +23 -1
  104. package/scripts/sync-next-version.cjs +38 -6
  105. package/skills/gsd-update/SKILL.md +5 -2
  106. package/vscode/package.json +1 -1
@@ -30,7 +30,7 @@ const state_document_cjs_1 = require("./state-document.cjs");
30
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
31
31
  const frontmatterMod = require("./frontmatter.cjs");
32
32
  const { extractFrontmatter, stripFrontmatter } = frontmatterMod;
33
- const markdown_table_cjs_1 = require("./markdown-table.cjs");
33
+ const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
34
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
35
35
  const verificationMod = require("./verification.cjs");
36
36
  const { isPhaseComplete, isVerificationStatus, VERIFICATION_STATUS } = verificationMod;
@@ -41,6 +41,9 @@ const { phaseKeyFromDir, phaseKeyFromProse, parentPhaseKey } = phaseIdMod;
41
41
  const roadmapParserMod = require("./roadmap-parser.cjs");
42
42
  const { getMilestonePhaseFilter, isMilestoneShippedInRoadmap } = roadmapParserMod;
43
43
  const workstream_inventory_builder_cjs_1 = require("./workstream-inventory-builder.cjs");
44
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
45
+ const phaseLocatorMod = require("./phase-locator.cjs");
46
+ const { phaseDirectoryOptions } = phaseLocatorMod;
44
47
  // ─── Implementation ───────────────────────────────────────────────────────────
45
48
  function workstreamsRoot(cwd) {
46
49
  return node_path_1.default.join(planningRoot(cwd), 'workstreams');
@@ -75,7 +78,7 @@ function countRoadmapPhases(roadmapPath, fallbackCount, cwd, ws, versionOverride
75
78
  // `null` -> 0 (fallback), `undefined` -> 3. A non-bracket workstream
76
79
  // resolves to a value that compiles the same base grammar `null` did, so
77
80
  // its count is unchanged (verified against a legacy control).
78
- const filter = getMilestonePhaseFilter(cwd, versionOverride ?? null, undefined, ws ?? null);
81
+ const filter = getMilestonePhaseFilter(cwd, versionOverride ?? null, undefined, ws ?? null, phaseDirectoryOptions(cwd, undefined, undefined, ws ?? null));
79
82
  // A pass-all degrade (phaseCount 0) means the window declared no phases —
80
83
  // fall back rather than reporting a confident zero.
81
84
  return filter.phaseCount > 0 ? filter.phaseCount : fallbackCount;
@@ -98,25 +101,22 @@ function countRoadmapPhases(roadmapPath, fallbackCount, cwd, ws, versionOverride
98
101
  * `phaseKeyFromDir` uses for directories, so a `| 01. … |` row and a `1-slug`
99
102
  * directory cannot land in different key spaces (the padding-asymmetry defect).
100
103
  */
101
- function parseRoadmapProgressRows(roadmapPath) {
102
- let content;
103
- try {
104
- content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
105
- }
106
- catch {
107
- return []; /* no roadmap */
108
- }
104
+ function parseRoadmapProgressRows(content, directoryContext) {
109
105
  // Milestone-ATTRIBUTING shape first. Both shapes carry `Plans Complete`, so
110
106
  // probing that column first would pick a flat table appearing earlier in the
111
107
  // document over a milestone-grouped one later — every row would come back
112
108
  // unattributed and be treated as current-milestone, silently over-including.
113
- const table = (0, markdown_table_cjs_1.findTableWithColumns)(content, ['Phase', 'Milestone'])
114
- ?? (0, markdown_table_cjs_1.findTableWithColumns)(content, ['Phase', 'Plans Complete']);
115
- if (!table)
116
- return [];
109
+ // #5761: the roadmap-progress owner picks the table (heading-scoped, so a
110
+ // decoy table elsewhere cannot be mistaken for it); this reader only supplies
111
+ // the probe order. A table outside a table-less `## Progress` section has
112
+ // always been read here (#5692: no behavior change), hence `sectionMiss`.
113
+ const { rows: tableRows } = (0, roadmap_progress_cjs_1.locateRoadmapProgress)(content, {
114
+ columns: [['Phase', 'Milestone'], ['Phase', 'Plans Complete']],
115
+ sectionMiss: 'document',
116
+ });
117
117
  const rows = [];
118
- for (const row of table.rows) {
119
- const key = phaseKeyFromProse(row['Phase']);
118
+ for (const row of tableRows) {
119
+ const key = phaseKeyFromProse(row['Phase'], directoryContext);
120
120
  if (key === null)
121
121
  continue;
122
122
  const cell = (row['Milestone'] ?? '').trim();
@@ -428,12 +428,18 @@ function inspectWorkstream(cwd, name, options = {}) {
428
428
  const writeDiagnostic = options.writeDiagnostic ?? ((message) => process.stderr.write(message));
429
429
  const p = planningPaths(cwd, name);
430
430
  const phaseDirNames = readSubdirectories(p.phases);
431
+ let roadmap = '';
432
+ try {
433
+ roadmap = node_fs_1.default.readFileSync(p.roadmap, 'utf8');
434
+ }
435
+ catch { /* no roadmap */ }
436
+ const directoryContext = phaseDirectoryOptions(cwd, undefined, roadmap, name);
431
437
  // #2562: scope progress to the CURRENT milestone. Membership and the
432
438
  // denominator are derived in ONE key space (`phaseKeyFromDir` /
433
439
  // `phaseKeyFromProse`, both from the phase-id owner module) so the two sides
434
440
  // of the rollup cannot disagree.
435
441
  const currentVersion = readCurrentMilestoneVersion(p.state, p.roadmap);
436
- const progressRows = parseRoadmapProgressRows(p.roadmap);
442
+ const progressRows = parseRoadmapProgressRows(roadmap, directoryContext);
437
443
  // Phase keys the ROADMAP attributes to the current milestone. A row whose
438
444
  // Milestone cell is blank or malformed is INCLUDED rather than dropped: a
439
445
  // phase we cannot attribute must still be visible to the rollup. Dropping it
@@ -458,7 +464,7 @@ function inspectWorkstream(cwd, name, options = {}) {
458
464
  // it is exactly the caller that cannot set `GSD_WORKSTREAM`; spelling `null`
459
465
  // pinned every workstream to the legacy grammar and made `headingScoped`
460
466
  // unreachable (`phaseCount > 0` never held) on a bracket workstream.
461
- const headingFilter = getMilestonePhaseFilter(cwd, currentVersion, undefined, name);
467
+ const headingFilter = getMilestonePhaseFilter(cwd, currentVersion, undefined, name, directoryContext);
462
468
  const headingScoped = headingFilter.versionScoped && headingFilter.phaseCount > 0;
463
469
  // Phase keys the ROADMAP attributes to some OTHER milestone. A row carrying an
464
470
  // explicit version that is not the current one is a positive claim by a prior
@@ -507,7 +513,7 @@ function inspectWorkstream(cwd, name, options = {}) {
507
513
  const isDirInCurrentMilestone = (dir) => {
508
514
  if (!scoped)
509
515
  return true;
510
- const key = phaseKeyFromDir(dir);
516
+ const key = phaseKeyFromDir(dir, undefined, directoryContext);
511
517
  if (currentMilestoneKeys.has(key))
512
518
  return true;
513
519
  const parent = parentPhaseKey(key);
@@ -563,7 +569,7 @@ function inspectWorkstream(cwd, name, options = {}) {
563
569
  }
564
570
  return {
565
571
  directory: dir,
566
- phaseKey: phaseKeyFromDir(dir),
572
+ phaseKey: phaseKeyFromDir(dir, undefined, directoryContext),
567
573
  mtimeMs: phaseDirMtime(phaseDir),
568
574
  planCount: counts.planCount,
569
575
  summaryCount: counts.summaryCount,
@@ -29,6 +29,14 @@
29
29
  * # documented #934 advisory posture, but
30
30
  * # its coverage is ALWAYS headline-reported.
31
31
  *
32
+ * [--hunks] # pre-merge mode (#4435): per-hunk stock vs
33
+ * # user text, content hash, recommendation
34
+ * # and exception reasons; always exits 0
35
+ * [--decisions <path>] # gate mode only (#4435): JSON file of human
36
+ * # keep/retire/rewrite decisions bound to
37
+ * # hunk content hashes; retire/rewrite drop
38
+ * # that hunk's required lines from the gate
39
+ *
32
40
  * Exit codes (default gate mode):
33
41
  * 0 — every user-added line is present in the merged file (gate passes)
34
42
  * 1 — at least one missing line in at least one file (gate fails); outranks
@@ -60,12 +68,15 @@ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
60
68
  // release's writer dropped). Same module the installer's preserve-check uses,
61
69
  // so the two readers cannot drift apart again.
62
70
  const { findPristineByHash, findPristineInGit } = require('./lib/pristine-baseline.cjs');
71
+ // #4435: line matcher (Myers, edit-distance capped) used to split a backup into
72
+ // per-hunk user/stock regions and to locate upstream changes over the baseline.
73
+ const { matchLines } = require('./lib/markdown-line-diff.cjs');
63
74
 
64
75
  const SIGNIFICANT_MIN_CHARS = 12;
65
76
  const GSD_HOOK_VERSION_LINE_RE = /^(?:\/\/|#)\s*gsd-hook-version:\s*\S+\s*$/i;
66
77
 
67
78
  function parseArgs(argv) {
68
- const opts = { patchesDir: null, configDir: null, pristineDir: null, json: false, classify: false, minBaselineCoverage: null };
79
+ const opts = { patchesDir: null, configDir: null, pristineDir: null, json: false, classify: false, minBaselineCoverage: null, hunks: false, decisionsPath: null };
69
80
  for (let i = 0; i < argv.length; i++) {
70
81
  const arg = argv[i];
71
82
  if (arg === '--patches-dir') opts.patchesDir = argv[++i];
@@ -73,7 +84,12 @@ function parseArgs(argv) {
73
84
  else if (arg === '--pristine-dir') opts.pristineDir = argv[++i];
74
85
  else if (arg === '--json') opts.json = true;
75
86
  else if (arg === '--classify') opts.classify = true;
76
- else if (arg === '--min-baseline-coverage') {
87
+ else if (arg === '--hunks') opts.hunks = true;
88
+ else if (arg === '--decisions') {
89
+ const raw = argv[++i];
90
+ if (raw === undefined) throw new ExitError(2, '--decisions requires a path');
91
+ opts.decisionsPath = raw;
92
+ } else if (arg === '--min-baseline-coverage') {
77
93
  const raw = argv[++i];
78
94
  if (raw === undefined) {
79
95
  throw new ExitError(2, '--min-baseline-coverage requires a value between 0 and 1');
@@ -85,7 +101,7 @@ function parseArgs(argv) {
85
101
  opts.minBaselineCoverage = value;
86
102
  } else if (arg === '--help' || arg === '-h') {
87
103
  process.stdout.write(
88
- 'usage: verify-reapply-patches.cjs --patches-dir <path> --config-dir <path> [--pristine-dir <path>] [--json] [--classify] [--min-baseline-coverage <0..1>]\n',
104
+ 'usage: verify-reapply-patches.cjs --patches-dir <path> --config-dir <path> [--pristine-dir <path>] [--json] [--classify] [--hunks] [--decisions <path>] [--min-baseline-coverage <0..1>]\n',
89
105
  );
90
106
  throw new ExitError(0);
91
107
  } else {
@@ -469,7 +485,279 @@ function coverageHeadline(covered, total) {
469
485
  return unverified > 0 ? `${base} (${unverified} unverified)` : base;
470
486
  }
471
487
 
472
- function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashes, skillsRedirect }) {
488
+ // ── #4435: per-hunk model ────────────────────────────────────────────────
489
+ const HUNK_REASON = Object.freeze({
490
+ BASELINE_UNCONFIRMED: 'baseline_unconfirmed',
491
+ DIFF_TOO_LARGE: 'diff_too_large',
492
+ ALREADY_UPSTREAM: 'already_upstream',
493
+ PARTIALLY_UPSTREAM: 'partially_upstream',
494
+ ANCHOR_ORPHANED: 'anchor_orphaned',
495
+ REGION_CHANGED_UPSTREAM: 'region_changed_upstream',
496
+ });
497
+ const HUNK_RECOMMENDATION = Object.freeze({ KEEP: 'keep', RETIRE: 'retire', REVIEW: 'review' });
498
+ const DISPOSITION = Object.freeze({ KEEP: 'keep', RETIRE: 'retire', REWRITE: 'rewrite' });
499
+
500
+ /**
501
+ * #4435: split a backup into hunks against the pristine baseline. Pure. The
502
+ * union of every hunk's required_lines equals computeUserAddedLines(backup,
503
+ * pristine) — same significance + normalization predicate.
504
+ */
505
+ function computeHunks(fileKey, backupContent, pristineContent) {
506
+ const B = backupContent.split(/\r?\n/);
507
+ if (!pristineContent) {
508
+ const required = computeUserAddedLines(backupContent, null);
509
+ if (required.length === 0) return [];
510
+ return numberHunks(fileKey, [{
511
+ kind: 'file',
512
+ user_lines: B.filter(isSignificantLine),
513
+ stock_lines: [],
514
+ required_lines: required,
515
+ anchor_before: null,
516
+ anchor_after: null,
517
+ stock_range: null,
518
+ diff_too_large: false,
519
+ }]);
520
+ }
521
+ const P = pristineContent.split(/\r?\n/);
522
+ const nP = P.map(normalizeUpstreamOwnedLine);
523
+ const nB = B.map(normalizeUpstreamOwnedLine);
524
+ const pristineSet = new Set(nP);
525
+ const isRequired = (line) =>
526
+ isSignificantLine(line) && !pristineSet.has(normalizeUpstreamOwnedLine(line));
527
+ const m = matchLines(nP, nB);
528
+ if (m === null) {
529
+ const required = B.filter(isRequired);
530
+ if (required.length === 0) return [];
531
+ return numberHunks(fileKey, [{
532
+ kind: 'replacement',
533
+ user_lines: required,
534
+ stock_lines: [],
535
+ required_lines: required.slice(),
536
+ anchor_before: null,
537
+ anchor_after: null,
538
+ stock_range: null,
539
+ diff_too_large: true,
540
+ }]);
541
+ }
542
+ const hunks = [];
543
+ let prevP = -1;
544
+ let run = [];
545
+ const flush = (s, e) => {
546
+ const userLines = run;
547
+ run = [];
548
+ const stockLines = P.slice(s, e);
549
+ if (!userLines.some(isSignificantLine) && !stockLines.some(isSignificantLine)) return;
550
+ const kind = userLines.length && stockLines.length ? 'replacement' : userLines.length ? 'addition' : 'deletion';
551
+ let before = null;
552
+ for (let i = s - 1; i >= 0; i--) {
553
+ if (isSignificantLine(P[i])) { before = P[i]; break; }
554
+ }
555
+ let after = null;
556
+ for (let i = e; i < P.length; i++) {
557
+ if (isSignificantLine(P[i])) { after = P[i]; break; }
558
+ }
559
+ hunks.push({
560
+ kind,
561
+ user_lines: userLines,
562
+ stock_lines: stockLines,
563
+ required_lines: userLines.filter(isRequired),
564
+ anchor_before: before,
565
+ anchor_after: after,
566
+ stock_range: [s, e],
567
+ diff_too_large: false,
568
+ });
569
+ };
570
+ for (let j = 0; j < B.length; j++) {
571
+ if (m[j] === -1) {
572
+ run.push(B[j]);
573
+ } else {
574
+ flush(prevP + 1, m[j]);
575
+ prevP = m[j];
576
+ }
577
+ }
578
+ flush(prevP + 1, P.length);
579
+ return numberHunks(fileKey, hunks);
580
+ }
581
+
582
+ function numberHunks(fileKey, hunks) {
583
+ return hunks.map((h, i) => ({
584
+ hunk_id: i + 1,
585
+ hunk_hash: sha256(JSON.stringify([fileKey, h.kind, h.user_lines, h.stock_lines, h.anchor_before, h.anchor_after, h.stock_range])),
586
+ kind: h.kind,
587
+ user_lines: h.user_lines,
588
+ stock_lines: h.stock_lines,
589
+ required_lines: h.required_lines,
590
+ anchor_before: h.anchor_before,
591
+ anchor_after: h.anchor_after,
592
+ stock_range: h.stock_range,
593
+ diff_too_large: h.diff_too_large,
594
+ }));
595
+ }
596
+
597
+ /**
598
+ * #4435: annotate hunks with presence in the fresh install, closed-vocabulary
599
+ * reasons and a recommendation. Pure; strips stock_range / diff_too_large.
600
+ */
601
+ function annotateHunks(hunks, { pristineContent, installedContent, baselineConfirmed }) {
602
+ const installed = installedContent == null ? '' : installedContent;
603
+ const iLines = new Set(installed.split(/\r?\n/).map((l) => normalizeUpstreamOwnedLine(l).trim()));
604
+ let upstreamUnknown = false;
605
+ const intervals = [];
606
+ if (pristineContent && hunks.some((h) => h.stock_range)) {
607
+ const P = pristineContent.split(/\r?\n/);
608
+ const nP = P.map(normalizeUpstreamOwnedLine);
609
+ const nI = installed.split(/\r?\n/).map(normalizeUpstreamOwnedLine);
610
+ const u = matchLines(nP, nI);
611
+ if (u === null) {
612
+ upstreamUnknown = true;
613
+ } else {
614
+ let prevP = -1;
615
+ let pendingInsert = false;
616
+ for (let k = 0; k < nI.length; k++) {
617
+ if (u[k] === -1) {
618
+ pendingInsert = true;
619
+ } else {
620
+ if (pendingInsert || u[k] > prevP + 1) intervals.push([prevP + 1, u[k]]);
621
+ pendingInsert = false;
622
+ prevP = u[k];
623
+ }
624
+ }
625
+ if (pendingInsert || P.length > prevP + 1) intervals.push([prevP + 1, P.length]);
626
+ }
627
+ }
628
+ const touches = ([s, e]) => intervals.some(([a, b]) => a <= e && s <= b);
629
+ const inInstall = (l) => iLines.has(normalizeUpstreamOwnedLine(l).trim());
630
+
631
+ return hunks.map((h) => {
632
+ const required = h.required_lines;
633
+ const n = required.filter((l) => installed.includes(l)).length;
634
+ const present = required.length === 0 ? 'n/a' : n === required.length ? 'all' : n === 0 ? 'none' : 'some';
635
+ const reasons = [];
636
+ if (!baselineConfirmed) reasons.push(HUNK_REASON.BASELINE_UNCONFIRMED);
637
+ if (h.diff_too_large || (upstreamUnknown && h.stock_range)) reasons.push(HUNK_REASON.DIFF_TOO_LARGE);
638
+ const stockSig = h.stock_lines.filter(isSignificantLine);
639
+ if (
640
+ (h.kind !== 'deletion' && present === 'all') ||
641
+ (h.kind === 'deletion' && stockSig.length > 0 && stockSig.every((l) => !inInstall(l)))
642
+ ) reasons.push(HUNK_REASON.ALREADY_UPSTREAM);
643
+ if (present === 'some') reasons.push(HUNK_REASON.PARTIALLY_UPSTREAM);
644
+ const anchors = [h.anchor_before, h.anchor_after].filter(Boolean);
645
+ if (anchors.length > 0 && anchors.every((a) => !inInstall(a))) reasons.push(HUNK_REASON.ANCHOR_ORPHANED);
646
+ if (h.stock_range && !upstreamUnknown && touches(h.stock_range)) reasons.push(HUNK_REASON.REGION_CHANGED_UPSTREAM);
647
+ let recommendation;
648
+ if (reasons.includes(HUNK_REASON.BASELINE_UNCONFIRMED) || reasons.includes(HUNK_REASON.DIFF_TOO_LARGE)) {
649
+ recommendation = HUNK_RECOMMENDATION.REVIEW;
650
+ } else if (reasons.includes(HUNK_REASON.ALREADY_UPSTREAM)) {
651
+ recommendation = HUNK_RECOMMENDATION.RETIRE;
652
+ } else if (reasons.length > 0) {
653
+ recommendation = HUNK_RECOMMENDATION.REVIEW;
654
+ } else {
655
+ recommendation = HUNK_RECOMMENDATION.KEEP;
656
+ }
657
+ const { stock_range: _sr, diff_too_large: _dtl, ...rest } = h;
658
+ return { ...rest, present_in_install: present, reasons, recommendation, exception: recommendation !== HUNK_RECOMMENDATION.KEEP };
659
+ });
660
+ }
661
+
662
+ /**
663
+ * #4435: per-file hunk record shared by --hunks and --decisions validation.
664
+ */
665
+ function buildFileHunks({ relPath, patchesDir, configDir, pristineDir, pristineHashes, skillsRedirect }) {
666
+ const out = { file: relPath, baseline: 'none', reason: null, hunks: [], installedContent: null, pristineContent: null };
667
+ const backupPath = path.join(patchesDir, relPath);
668
+ if (!fs.existsSync(backupPath) || !fs.statSync(backupPath).isFile()) return out;
669
+ let backupContent;
670
+ try {
671
+ backupContent = fs.readFileSync(backupPath, 'utf8');
672
+ } catch {
673
+ out.reason = REASON.FAIL_READ_ERROR;
674
+ return out;
675
+ }
676
+ const installedPath = resolveInstalledPath(configDir, relPath, skillsRedirect);
677
+ try {
678
+ const st = fs.statSync(installedPath);
679
+ if (!st.isFile()) out.reason = REASON.FAIL_INSTALLED_NOT_REGULAR_FILE;
680
+ else out.installedContent = fs.readFileSync(installedPath, 'utf8');
681
+ } catch (err) {
682
+ out.reason = err && err.code === 'ENOENT' ? REASON.FAIL_INSTALLED_MISSING : REASON.FAIL_READ_ERROR;
683
+ }
684
+ const { resolution, content } = resolvePristineBaseline({ relPath, configDir, pristineDir, pristineHashes });
685
+ let pristine = null;
686
+ if (resolution === PRISTINE_RESOLUTION.VALIDATED) {
687
+ out.baseline = 'validated';
688
+ pristine = content;
689
+ } else if (resolution === PRISTINE_RESOLUTION.UNVALIDATED) {
690
+ out.baseline = 'unvalidated';
691
+ pristine = content;
692
+ } else if (resolution === PRISTINE_RESOLUTION.DRIFTED) {
693
+ out.reason = REASON.OK_PRISTINE_DRIFT_DETECTED;
694
+ } else if (resolution === PRISTINE_RESOLUTION.ABSENT_RECORDED) {
695
+ out.reason = REASON.OK_NO_BASELINE;
696
+ }
697
+ out.pristineContent = pristine;
698
+ out.hunks = computeHunks(relPath.replace(/\\/g, '/'), backupContent, pristine);
699
+ return out;
700
+ }
701
+
702
+ /**
703
+ * #4435: strict loader for the human decisions file. Every malformed shape is
704
+ * a usage error (exit 2) — a decision file is a trust boundary.
705
+ */
706
+ function loadDecisions(decisionsPath) {
707
+ let raw;
708
+ try {
709
+ raw = fs.readFileSync(decisionsPath, 'utf8');
710
+ } catch (err) {
711
+ throw new ExitError(2, `cannot read decisions file: ${decisionsPath}${err && err.code ? ` (${err.code})` : ''}`);
712
+ }
713
+ let parsed;
714
+ try {
715
+ parsed = JSON.parse(raw);
716
+ } catch {
717
+ throw new ExitError(2, `decisions file is not valid JSON: ${decisionsPath}`);
718
+ }
719
+ const isPlain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
720
+ if (!isPlain(parsed) || !Array.isArray(parsed.decisions)) {
721
+ throw new ExitError(2, 'decisions file must be an object with a "decisions" array');
722
+ }
723
+ const dispositions = new Set(Object.values(DISPOSITION));
724
+ const byKey = new Map();
725
+ parsed.decisions.forEach((d, i) => {
726
+ if (!isPlain(d)) throw new ExitError(2, `invalid decision at index ${i}: not an object`);
727
+ if (typeof d.file !== 'string' || d.file === '') throw new ExitError(2, `invalid decision at index ${i}: file must be a non-empty string`);
728
+ if (typeof d.hunk_hash !== 'string' || !/^[0-9a-f]{64}$/.test(d.hunk_hash)) {
729
+ throw new ExitError(2, `invalid decision at index ${i}: hunk_hash must be 64 lowercase hex characters`);
730
+ }
731
+ if (typeof d.disposition !== 'string' || !dispositions.has(d.disposition)) {
732
+ throw new ExitError(2, `invalid decision at index ${i}: disposition must be one of ${[...dispositions].join(', ')}`);
733
+ }
734
+ // #4435: a rewrite must carry the user's replacement text so the gate can
735
+ // verify it landed; keep/retire must not carry one.
736
+ let replacement = null;
737
+ if (d.disposition === DISPOSITION.REWRITE) {
738
+ if (!Array.isArray(d.replacement_lines) || d.replacement_lines.length === 0 || !d.replacement_lines.every((x) => typeof x === 'string')) {
739
+ throw new ExitError(2, `invalid decision at index ${i}: rewrite requires a non-empty replacement_lines array of strings`);
740
+ }
741
+ replacement = d.replacement_lines.slice();
742
+ } else if (Object.prototype.hasOwnProperty.call(d, 'replacement_lines')) {
743
+ throw new ExitError(2, `invalid decision at index ${i}: replacement_lines is allowed only for rewrite`);
744
+ }
745
+ const file = d.file.replace(/\\/g, '/');
746
+ const key = `${file}\0${d.hunk_hash}`;
747
+ const prior = byKey.get(key);
748
+ if (prior) {
749
+ if (prior.disposition !== d.disposition
750
+ || JSON.stringify(prior.replacement_lines) !== JSON.stringify(replacement)) {
751
+ throw new ExitError(2, `conflicting decisions for ${file} ${d.hunk_hash}`);
752
+ }
753
+ return;
754
+ }
755
+ byKey.set(key, { file, hunk_hash: d.hunk_hash, disposition: d.disposition, replacement_lines: replacement });
756
+ });
757
+ return [...byKey.values()];
758
+ }
759
+
760
+ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashes, skillsRedirect, retiredHashes, extraRequired }) {
473
761
  const backupPath = path.join(patchesDir, relPath);
474
762
  const installedPath = resolveInstalledPath(configDir, relPath, skillsRedirect);
475
763
  const result = { file: relPath, status: 'ok', missing: [], reason: null };
@@ -535,7 +823,16 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe
535
823
  // content (null for OVERBROAD) feeds computeUserAddedLines, whose no-pristine
536
824
  // branch is the over-broad fallback.
537
825
 
538
- const userAdded = computeUserAddedLines(backupContent, pristineContent);
826
+ let userAdded = computeUserAddedLines(backupContent, pristineContent);
827
+ // #4435: a hunk retired/rewritten by a recorded human decision (bound to its
828
+ // content hash) stops being required; every other hunk's lines still are.
829
+ if (retiredHashes && retiredHashes.size > 0) {
830
+ userAdded = computeHunks(relPath.replace(/\\/g, '/'), backupContent, pristineContent)
831
+ .filter((h) => !retiredHashes.has(h.hunk_hash))
832
+ .flatMap((h) => h.required_lines);
833
+ }
834
+ // #4435: a rewrite's user-supplied replacement must be present in the merge.
835
+ if (extraRequired && extraRequired.length > 0) userAdded = userAdded.concat(extraRequired);
539
836
  if (userAdded.length === 0) {
540
837
  // Backup and pristine match exactly (or no significant content) — nothing
541
838
  // to verify but also nothing to lose. Report as ok with diagnostic code.
@@ -654,6 +951,12 @@ function classifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHas
654
951
 
655
952
  function main() {
656
953
  const opts = parseArgs(process.argv.slice(2));
954
+ if (opts.hunks && opts.classify) {
955
+ throw new ExitError(2, '--hunks and --classify are mutually exclusive');
956
+ }
957
+ if (opts.decisionsPath !== null && (opts.hunks || opts.classify)) {
958
+ throw new ExitError(2, '--decisions applies only to the post-merge gate');
959
+ }
657
960
  if (!opts.patchesDir || !opts.configDir) {
658
961
  throw new ExitError(2, '--patches-dir and --config-dir are required');
659
962
  }
@@ -672,6 +975,50 @@ function main() {
672
975
  // `home` override outside configDir (codex global → $HOME/.agents/skills).
673
976
  const skillsRedirect = resolveSkillsRedirect(opts.configDir);
674
977
 
978
+ // #4435: --hunks is the pre-merge per-hunk presentation mode. It recommends;
979
+ // it never decides and never gates (always exit 0).
980
+ if (opts.hunks) {
981
+ const fileRecords = files.map((relPath) => {
982
+ const rec = buildFileHunks({
983
+ relPath,
984
+ patchesDir: opts.patchesDir,
985
+ configDir: opts.configDir,
986
+ pristineDir: opts.pristineDir,
987
+ pristineHashes,
988
+ skillsRedirect,
989
+ });
990
+ return {
991
+ file: rec.file,
992
+ baseline: rec.baseline,
993
+ reason: rec.reason,
994
+ hunks: annotateHunks(rec.hunks, {
995
+ pristineContent: rec.pristineContent,
996
+ installedContent: rec.installedContent,
997
+ baselineConfirmed: rec.baseline === 'validated',
998
+ }),
999
+ };
1000
+ });
1001
+ const hunksTotal = fileRecords.reduce((n, f) => n + f.hunks.length, 0);
1002
+ const exceptionsTotal = fileRecords.reduce((n, f) => n + f.hunks.filter((h) => h.exception).length, 0);
1003
+ if (opts.json) {
1004
+ process.stdout.write(
1005
+ JSON.stringify({ checked: fileRecords.length, hunks_total: hunksTotal, exceptions_total: exceptionsTotal, files: fileRecords }, null, 2) + '\n',
1006
+ );
1007
+ } else {
1008
+ process.stdout.write(`# Reapply Patch Hunks (#4435)\n\n`);
1009
+ process.stdout.write(`Checked: ${fileRecords.length} file(s)\n`);
1010
+ process.stdout.write(`Hunks: ${hunksTotal} (${exceptionsTotal} exception(s))\n`);
1011
+ for (const f of fileRecords) {
1012
+ for (const h of f.hunks) {
1013
+ if (h.exception) {
1014
+ process.stdout.write(`- ${f.file} hunk ${h.hunk_id} [${h.recommendation}] ${h.reasons.join(', ')}\n`);
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+ return 0;
1020
+ }
1021
+
675
1022
  // Bug #4136: --classify is the pre-merge mode the workflow runs in Step 4
676
1023
  // to decide which files NOT to merge. It is informational — always exit 0 —
677
1024
  // because the binding enforcement stays with the post-merge gate below;
@@ -720,6 +1067,47 @@ function main() {
720
1067
  return 0;
721
1068
  }
722
1069
 
1070
+ // #4435: validate every decision against the current hunks BEFORE verifying.
1071
+ // A decision whose hash no longer matches a hunk is stale and aborts (exit 2).
1072
+ let decisions = null;
1073
+ const retiredByFile = new Map();
1074
+ const retiredList = [];
1075
+ const rewriteLinesByFile = new Map();
1076
+ if (opts.decisionsPath !== null) {
1077
+ decisions = loadDecisions(opts.decisionsPath);
1078
+ const fileSet = new Set(files.map((f) => f.replace(/\\/g, '/')));
1079
+ const relByKey = new Map(files.map((f) => [f.replace(/\\/g, '/'), f]));
1080
+ const hunkCache = new Map();
1081
+ for (const d of decisions) {
1082
+ if (!fileSet.has(d.file)) {
1083
+ throw new ExitError(2, `decision names a file not in the patches dir: ${d.file}`);
1084
+ }
1085
+ if (!hunkCache.has(d.file)) {
1086
+ hunkCache.set(d.file, buildFileHunks({
1087
+ relPath: relByKey.get(d.file),
1088
+ patchesDir: opts.patchesDir,
1089
+ configDir: opts.configDir,
1090
+ pristineDir: opts.pristineDir,
1091
+ pristineHashes,
1092
+ skillsRedirect,
1093
+ }).hunks);
1094
+ }
1095
+ const hunk = hunkCache.get(d.file).find((h) => h.hunk_hash === d.hunk_hash);
1096
+ if (!hunk) {
1097
+ throw new ExitError(2, `stale decision: hunk ${d.hunk_hash} no longer matches ${d.file} (the backup or baseline changed since it was shown)`);
1098
+ }
1099
+ if (d.disposition === DISPOSITION.RETIRE || d.disposition === DISPOSITION.REWRITE) {
1100
+ if (!retiredByFile.has(d.file)) retiredByFile.set(d.file, new Set());
1101
+ retiredByFile.get(d.file).add(d.hunk_hash);
1102
+ retiredList.push({ file: d.file, hunk_id: hunk.hunk_id, hunk_hash: d.hunk_hash, disposition: d.disposition });
1103
+ if (d.disposition === DISPOSITION.REWRITE) {
1104
+ if (!rewriteLinesByFile.has(d.file)) rewriteLinesByFile.set(d.file, []);
1105
+ rewriteLinesByFile.get(d.file).push(...d.replacement_lines.filter(isSignificantLine));
1106
+ }
1107
+ }
1108
+ }
1109
+ }
1110
+
723
1111
  const results = files.map((relPath) =>
724
1112
  verifyFile({
725
1113
  relPath,
@@ -728,6 +1116,8 @@ function main() {
728
1116
  pristineDir: opts.pristineDir,
729
1117
  pristineHashes,
730
1118
  skillsRedirect,
1119
+ retiredHashes: retiredByFile.get(relPath.replace(/\\/g, '/')),
1120
+ extraRequired: rewriteLinesByFile.get(relPath.replace(/\\/g, '/')),
731
1121
  }),
732
1122
  );
733
1123
 
@@ -770,9 +1160,9 @@ function main() {
770
1160
  }
771
1161
 
772
1162
  if (opts.json) {
773
- process.stdout.write(
774
- JSON.stringify({ checked: results.length, failures: failures.length, drifted, drifted_files, no_baseline, no_baseline_files, baseline_covered, results }, null, 2) + '\n',
775
- );
1163
+ const report = { checked: results.length, failures: failures.length, drifted, drifted_files, no_baseline, no_baseline_files, baseline_covered, results };
1164
+ if (decisions) report.decisions = { applied: decisions.length, retired: retiredList };
1165
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
776
1166
  } else {
777
1167
  process.stdout.write(`# Hunk Verification Gate (#2969)\n\n`);
778
1168
  process.stdout.write(`Checked: ${results.length} file(s)\n`);
@@ -780,7 +1170,18 @@ function main() {
780
1170
  // #4135: the coverage headline is printed on EVERY run, before any
781
1171
  // per-file detail, so a near-zero-coverage green run can never render
782
1172
  // identically to a fully-verified one.
783
- process.stdout.write(`${coverageHeadline(baseline_covered, results.length)}\n\n`);
1173
+ process.stdout.write(`${coverageHeadline(baseline_covered, results.length)}\n`);
1174
+ if (decisions) {
1175
+ process.stdout.write(`Decisions: ${decisions.length} applied, ${retiredList.length} hunk(s) retired by user decision\n`);
1176
+ }
1177
+ process.stdout.write('\n');
1178
+ if (decisions && retiredList.length > 0) {
1179
+ process.stdout.write(`## Hunks retired or rewritten by user decision\n\n`);
1180
+ for (const r of retiredList) {
1181
+ process.stdout.write(`- ${r.file} hunk ${r.hunk_id} (${r.disposition})\n`);
1182
+ }
1183
+ process.stdout.write('\n');
1184
+ }
784
1185
  if (baseline_covered < results.length) {
785
1186
  if (!opts.pristineDir) {
786
1187
  process.stdout.write(`No --pristine-dir: over-broad fallback — nothing was verified against a pristine baseline (#2998).\n\n`);
@@ -821,5 +1222,5 @@ if (require.main === module) {
821
1222
  runMain(main);
822
1223
  }
823
1224
 
824
- module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, classifyFile, walk, REASON, CLASSIFICATION, PRISTINE_RESOLUTION, resolvePristineBaseline, readPristineHashes, sha256, resolveInstalledPath, resolveSkillsRedirect, coverageHeadline, classifyBaselineCoverage };
1225
+ module.exports = { computeHunks, annotateHunks, buildFileHunks, HUNK_REASON, HUNK_RECOMMENDATION, DISPOSITION, computeUserAddedLines, isSignificantLine, verifyFile, classifyFile, walk, REASON, CLASSIFICATION, PRISTINE_RESOLUTION, resolvePristineBaseline, readPristineHashes, sha256, resolveInstalledPath, resolveSkillsRedirect, coverageHeadline, classifyBaselineCoverage };
825
1226
 
@@ -0,0 +1,45 @@
1
+ # CI/CD Pipeline Review Patterns
2
+
3
+ Rubric for the **CI/CD pipelines** entry of `gsd-code-reviewer`'s language-aware checks. Read at `standard` and `deep` depth only, when a file in scope is a pipeline file. These defects rarely fail a test. They show up at release or deploy time, as a broken release or a bad production deploy.
4
+
5
+ Pipeline files are recognized by path, not extension: `.github/workflows/*.yml` / `*.yaml`, `Dockerfile*` / `Containerfile`, `docker-compose*.yml` / `compose*.yaml`, and deploy scripts (`deploy*.sh` and equivalents). A deploy script is also a Shell file: apply both sets of checks.
6
+
7
+ Adapted from the `ci-cd-reliability-architecture` skill in [l-gevity/l-gevity-skills](https://github.com/l-gevity/l-gevity-skills) (MIT), reduced to what is checkable from file contents at review time (#4101).
8
+
9
+ ## Checks
10
+
11
+ | Check | Signal | Why it matters | Severity |
12
+ |---|---|---|---|
13
+ | **Non-idempotent step** | `npm install` where a `package-lock.json` exists (should be `npm ci`); `pip install` with no lock or pinned requirements; `mkdir` without `-p`; an unguarded `rm`; delete-then-create of a live resource | A re-run produces a different result, or fails part-way and leaves the target broken | Warning |
14
+ | **Missing timeout** | A job with `runs-on:` and no job-level `timeout-minutes:` | A stuck job holds its runner until the 6-hour default | Warning |
15
+ | **Missing concurrency guard** | A deploy workflow with no `concurrency` group and `cancel-in-progress: true` | Two pushes run in parallel; the older run can finish last and deploy over the newer one | Warning |
16
+ | **Per-environment rebuild** | The same commit is built separately for each environment instead of build-once/promote | The artifact that reaches production is not the artifact that passed CI | Warning |
17
+ | **Deploy without health check or rollback** | A deploy step with no post-deploy health check and no rollback path | A bad deploy stays live until a human notices | Warning |
18
+ | **Load-bearing `continue-on-error`** | `continue-on-error: true` on a step whose success later steps assume | The failure is masked; later steps run on a broken state and the job reports green | Warning |
19
+ | **Static cloud credentials** | Long-lived cloud keys stored as secrets for a provider that supports OIDC (`permissions: id-token: write`) | A leaked key stays valid until someone rotates it; OIDC tokens expire per run | Warning |
20
+ | **Unpinned third-party action** | `uses: owner/action@main`, `@master` or `@latest` | An unpinned ref lets the action's code change between two runs of the same commit | Warning |
21
+
22
+ A hardcoded secret in a pipeline file stays Critical under the existing secrets rule. Style-level pipeline findings (step naming, step ordering, redundant checkout options) are Info.
23
+
24
+ ## Evidence rule
25
+
26
+ Cite the line. For the concurrency, rebuild and rollback checks, also name what makes the workflow a deploy workflow (the trigger, the environment, the deploy command): those checks are about deploys, not every workflow.
27
+
28
+ ## Negative space: NOT findings
29
+
30
+ - **Global tool installs.** `npm install -g <tool>` installs a tool, not the project's dependency tree. A repository with no `package-lock.json` has nothing for `npm ci` to read.
31
+ - **Reusable workflow calls.** A job that calls a reusable workflow (`uses:` at the job level) cannot set `timeout-minutes`; the called workflow owns its timeouts.
32
+ - **Pinned refs.** A version tag (`@v4`) or a full commit SHA is a pin. Local actions (`./path`) and `docker://` references are not third-party action refs.
33
+ - **CI-only workflows.** `cancel-in-progress` is required on deploy workflows. On a workflow that only runs tests it is an optimization, not a Warning.
34
+ - **Informational steps.** `continue-on-error` on a step nothing depends on (a coverage upload, a notification) masks nothing.
35
+ - **Providers without OIDC.** A secret for a provider that does not support OIDC is not a static-credential finding.
36
+
37
+ ## Example
38
+
39
+ ```markdown
40
+ ### WR-02: Deploy workflow lacks concurrency guard — stale commit can deploy last
41
+
42
+ **File:** `.github/workflows/deploy.yml:8`
43
+ **Issue:** Two pushes in quick succession run in parallel; the older run can finish after the newer one and overwrite production.
44
+ **Fix:** Add a `concurrency` group keyed on the ref, with `cancel-in-progress: true`.
45
+ ```