@warpgogol/forge 2.21.6 → 2.21.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AGENTS.md +27 -1
  2. package/os/adr/adr-0000-template.md +8 -0
  3. package/os/adr/handlers/validate.test.ts +203 -0
  4. package/os/adr/handlers/validate.ts +54 -1
  5. package/os/adr/types.ts +7 -0
  6. package/os/compass/handlers/compass-inventory-handler.ts +11 -1
  7. package/os/compass/handlers/compass-inventory.ts +10 -0
  8. package/os/core/handlers/validate.ts +56 -5
  9. package/os/naming/naming-convention.ts +9 -0
  10. package/os/plugin/plugin.module.ts +1 -1
  11. package/os/rfc/acceptance.ts +133 -4
  12. package/os/rfc/handlers/implement-stamp.ts +14 -1
  13. package/os/rfc/handlers/validate-rules-rfc0997.test.ts +394 -0
  14. package/os/rfc/handlers/validate-rules-rfc1006.test.ts +478 -0
  15. package/os/rfc/handlers/validate-rules.ts +450 -9
  16. package/os/rfc/handlers/validate.ts +20 -2
  17. package/os/rfc/rfc-0000-template.md +24 -8
  18. package/os/rfc/rfc.module.ts +28 -0
  19. package/os/rfc/types.ts +72 -6
  20. package/os/rfc/verification-evidence.ts +5 -4
  21. package/os/rfc/verification-refresh.test.ts +320 -0
  22. package/os/rfc/verification-refresh.ts +216 -0
  23. package/os/session/handlers/save.ts +10 -0
  24. package/os/spec/spec-validate.test.ts +59 -0
  25. package/os/spec/spec-validate.ts +6 -4
  26. package/package.json +2 -1
  27. package/skills/fo/fo-handoff/SKILL.md +15 -6
  28. package/skills/fo/fo-idea-audit/SKILL.md +1 -1
  29. package/skills/fo/fo-idea-create-rfc/SKILL.md +1 -1
  30. package/skills/fo/fo-idea-create-rfc/acceptance-criteria-standard.md +75 -0
  31. package/skills/fo/fo-idea-implement/SKILL.md +3 -2
  32. package/src/compass/contract-registry.ts +25 -6
  33. package/src/index.ts +1 -1
  34. package/src/onboarding/doctor.ts +1 -1
  35. package/src/registry.ts +1 -1
  36. package/src/tests/acceptance-probe-kinds.test.ts +262 -0
  37. package/src/tests/plugin-manifest.test.ts +1 -1
  38. package/src/tests/session-handlers.test.ts +29 -0
  39. package/src/types/werkstatt-engine-shims.d.ts +0 -21
  40. package/src/types/werkstatt-shared-shims.d.ts +68 -147
  41. /package/src/plugin/{ForgePluginManifest.ts → forge-plugin-manifest.ts} +0 -0
@@ -10,11 +10,13 @@
10
10
  <item>RFC-0722: add RFC-DIR-01 directory structure warning rule for unsanctioned subdirectories.</item>
11
11
  <item>RFC-0755: add V-RFC-33 frontmatter YAML parseability check (checkFrontmatterYamlParse helper).</item>
12
12
  <item>RFC-0795: add V-33 dependsOn referential integrity/self-dependency/rejected-dependency and V-34 batch slug format rules.</item>
13
+ <item>RFC-0997: add V-35 probe→criterion referential integrity, V-36 criterion identifier discipline, V-37 evidence mechanism validity, and computeProbeCoverage for non-blocking coverage reports.</item>
14
+ <item>RFC-1006: add V-38 document readiness completeness, V-39 non-atomic criterion, V-40 unbounded quantity, V-41 weasel verb, V-42 criterion versioning annotation format. Add evaluateDocumentReadiness and extend evaluateAcceptanceCriteria with supersession tracking and reject-checklist scans.</item>
13
15
  </CHANGE_SUMMARY>
14
16
  */
15
17
 
16
18
  import path from "node:path";
17
- import { readFile } from "node:fs/promises";
19
+ import { readFile, stat } from "node:fs/promises";
18
20
  import { execFile } from "node:child_process";
19
21
 
20
22
  import { parse as yamlParse } from "yaml";
@@ -31,7 +33,10 @@ import {
31
33
  RFC_FULL_REQUIRED_SECTIONS,
32
34
  RFC_METADATA_CUTOFF,
33
35
  RFC_VERSION_BUMP_CUTOFF,
36
+ RFC_PROBE_BINDING_CUTOFF,
37
+ RFC_CRITERIA_CONTENT_CUTOFF,
34
38
  } from "../types.ts";
39
+ import type { ProbeCoverageReport, AcceptanceProbe } from "../types.ts";
35
40
  import { DNA_DOCS, AP_DOCS } from "./shared.ts";
36
41
 
37
42
  const DNA_DOC = DNA_DOCS[0]!;
@@ -63,6 +68,123 @@ export interface AcceptanceCriteriaEvaluation {
63
68
  totalUnchecked: number;
64
69
  uncheckedLines: string[];
65
70
  checkedWithoutEvidence: string[];
71
+ criterionIds: string[];
72
+ duplicateCriterionIds: string[];
73
+ linesWithoutId: string[];
74
+ supersededIds: string[];
75
+ nonAtomicViolations: { line: string; acId: string }[];
76
+ unboundedQuantityViolations: { line: string; acId: string; trigger: string }[];
77
+ weaselVerbViolations: { line: string; acId: string; trigger: string }[];
78
+ malformedSupersessionAnnotations: string[];
79
+ }
80
+
81
+ export interface DocumentReadinessEvaluation {
82
+ totalChecked: number;
83
+ totalUnchecked: number;
84
+ uncheckedLines: string[];
85
+ criterionIds: string[];
86
+ linesWithoutId: string[];
87
+ }
88
+
89
+ // ─── RFC-1006: reject checklist constants ──────────────────────────────────
90
+
91
+ const UNBOUNDED_QUANTITY_TRIGGERS = [
92
+ "fast",
93
+ "quick",
94
+ "scalable",
95
+ "reasonable load",
96
+ "most requests",
97
+ "efficiently",
98
+ "high performance",
99
+ "low latency",
100
+ "responsively",
101
+ "smoothly",
102
+ ] as const;
103
+
104
+ const WEASEL_VERBS = [
105
+ "handle gracefully",
106
+ "behave correctly",
107
+ "as appropriate",
108
+ "robust",
109
+ "user-friendly",
110
+ "works correctly",
111
+ "is reliable",
112
+ "gracefully handle",
113
+ "correctly handle",
114
+ "properly handle",
115
+ ] as const;
116
+
117
+ // Detects "SHALL <verb-phrase> and <verb-phrase>" — non-atomic criterion.
118
+ // Conservative: only fires when "and" appears after SHALL/SHOULD/MUST and
119
+ // is followed by a verb-like word (a-z, not a noun-phrase connector).
120
+ const NON_ATOMIC_PATTERN =
121
+ /\b(?:SHALL|SHOULD|MUST)\s+\S[^()]*?\band\s+(?:return|report|exit|emit|write|read|create|delete|update|persist|validate|reject|accept|log|send|store|load|fetch|process|register|enqueue|schedule|trigger|notify|redirect|render|display|show|hide|enable|disable|start|stop|restart|check|verify|ensure|prevent|allow|block|deny|grant|revoke|parse|serialize|deserialize|encode|decode|compress|decompress|encrypt|decrypt|hash|sign|verify|transform|convert|map|filter|sort|group|aggregate|count|sum|average|min|max|find|search|resolve|lookup)\b/i;
122
+
123
+ const SUPERSEDED_ANNOTATION_PATTERN =
124
+ /^>\s*Superseded\s+(AC-\d+)\s+\((\d{4}-\d{2}-\d{2})\):\s*(.+)$/;
125
+
126
+ /**
127
+ * Strip fenced code blocks (``` ... ```) from a markdown body.
128
+ * Prevents section extractors from matching headings inside code block examples.
129
+ * Pure function — no I/O, no side effects.
130
+ */
131
+ export function stripFencedCodeBlocks(body: string): string {
132
+ return body.replace(/```[\s\S]*?```/g, "");
133
+ }
134
+
135
+ /**
136
+ * Extract the raw document-readiness section text from an RFC body.
137
+ * Pure function — no I/O, no side effects.
138
+ */
139
+ export function extractDocumentReadinessSection(body: string): string | null {
140
+ const stripped = stripFencedCodeBlocks(body);
141
+ const match = stripped.match(/## Document readiness\s*\n([\s\S]*?)(?=\n## |\n*$)/);
142
+ return match ? match[1]! : null;
143
+ }
144
+
145
+ /**
146
+ * Evaluate document-readiness checkboxes (RFC-1006).
147
+ * Pure function — no I/O, no side effects.
148
+ */
149
+ export function evaluateDocumentReadiness(body: string): DocumentReadinessEvaluation {
150
+ const section = extractDocumentReadinessSection(body);
151
+ if (!section) {
152
+ return {
153
+ totalChecked: 0,
154
+ totalUnchecked: 0,
155
+ uncheckedLines: [],
156
+ criterionIds: [],
157
+ linesWithoutId: [],
158
+ };
159
+ }
160
+
161
+ const uncheckedLines = section
162
+ .split("\n")
163
+ .filter((line) => /^- \[ \]/.test(line))
164
+ .map((line) => line.trim());
165
+ const totalUnchecked = uncheckedLines.length;
166
+
167
+ const checkedLines = section.split("\n").filter((line) => /^- \[x\]/.test(line));
168
+
169
+ const checklistLines = section.split("\n").filter((line) => /^- \[[ x]\]/.test(line));
170
+ const criterionIds: string[] = [];
171
+ const linesWithoutId: string[] = [];
172
+ for (const line of checklistLines) {
173
+ const match = line.match(/^\s*- \[[ x]\]\s*DR-(\d+):/);
174
+ if (match) {
175
+ criterionIds.push(`DR-${match[1]}`);
176
+ } else {
177
+ linesWithoutId.push(line.trim());
178
+ }
179
+ }
180
+
181
+ return {
182
+ totalChecked: checkedLines.length,
183
+ totalUnchecked,
184
+ uncheckedLines,
185
+ criterionIds,
186
+ linesWithoutId,
187
+ };
66
188
  }
67
189
 
68
190
  /**
@@ -70,7 +192,8 @@ export interface AcceptanceCriteriaEvaluation {
70
192
  * Returns `undefined` when the section is absent.
71
193
  */
72
194
  export function extractAcceptanceCriteriaSection(body: string): string | undefined {
73
- const match = body.match(/## Acceptance criteria\s*\n([\s\S]*?)(?=\n## |\n*$)/);
195
+ const stripped = stripFencedCodeBlocks(body);
196
+ const match = stripped.match(/## Acceptance criteria\s*\n([\s\S]*?)(?=\n## |\n*$)/);
74
197
  return match?.[1];
75
198
  }
76
199
 
@@ -87,6 +210,14 @@ export function evaluateAcceptanceCriteria(body: string): AcceptanceCriteriaEval
87
210
  totalUnchecked: 0,
88
211
  uncheckedLines: [],
89
212
  checkedWithoutEvidence: [],
213
+ criterionIds: [],
214
+ duplicateCriterionIds: [],
215
+ linesWithoutId: [],
216
+ supersededIds: [],
217
+ nonAtomicViolations: [],
218
+ unboundedQuantityViolations: [],
219
+ weaselVerbViolations: [],
220
+ malformedSupersessionAnnotations: [],
90
221
  };
91
222
  }
92
223
 
@@ -104,11 +235,149 @@ export function evaluateAcceptanceCriteria(body: string): AcceptanceCriteriaEval
104
235
  }
105
236
  }
106
237
 
238
+ const checklistLines = section.split("\n").filter((line) => /^- \[[ x]\]/.test(line));
239
+ const criterionIds: string[] = [];
240
+ const seenIds = new Set<string>();
241
+ const duplicateCriterionIds: string[] = [];
242
+ const linesWithoutId: string[] = [];
243
+ for (const line of checklistLines) {
244
+ const match = line.match(/^\s*- \[[ x]\]\s*AC-(\d+):/);
245
+ if (match) {
246
+ const id = `AC-${match[1]}`;
247
+ if (seenIds.has(id)) {
248
+ duplicateCriterionIds.push(id);
249
+ } else {
250
+ seenIds.add(id);
251
+ criterionIds.push(id);
252
+ }
253
+ } else {
254
+ linesWithoutId.push(line.trim());
255
+ }
256
+ }
257
+
258
+ // RFC-1006: detect supersession annotations and exclude superseded criteria
259
+ const sectionLines = section.split("\n");
260
+ const supersededIds: string[] = [];
261
+ const supersededSet = new Set<string>();
262
+ const malformedSupersessionAnnotations: string[] = [];
263
+
264
+ for (let i = 0; i < sectionLines.length; i++) {
265
+ const line = sectionLines[i]!;
266
+ const annMatch = line.match(SUPERSEDED_ANNOTATION_PATTERN);
267
+ if (annMatch) {
268
+ const acId = annMatch[1]!;
269
+ const dateStr = annMatch[2]!;
270
+ supersededIds.push(acId);
271
+ supersededSet.add(acId);
272
+ // Validate date format (already captured by regex, but check range)
273
+ const date = new Date(dateStr);
274
+ if (isNaN(date.getTime())) {
275
+ malformedSupersessionAnnotations.push(line.trim());
276
+ }
277
+ } else if (
278
+ /^>\s*Superseded\s+AC-/.test(line.trim()) &&
279
+ !SUPERSEDED_ANNOTATION_PATTERN.test(line.trim())
280
+ ) {
281
+ // Line starts with supersession annotation pattern but doesn't match full format
282
+ malformedSupersessionAnnotations.push(line.trim());
283
+ }
284
+ }
285
+
286
+ // Remove superseded criteria from unchecked count
287
+ const effectiveUncheckedLines = uncheckedLines.filter((line) => {
288
+ const match = line.match(/^\s*- \[ \]\s*AC-(\d+):/);
289
+ if (match) {
290
+ return !supersededSet.has(`AC-${match[1]}`);
291
+ }
292
+ return true;
293
+ });
294
+
295
+ // RFC-1006: reject checklist scans on criterion text
296
+ const nonAtomicViolations: { line: string; acId: string }[] = [];
297
+ const unboundedQuantityViolations: { line: string; acId: string; trigger: string }[] = [];
298
+ const weaselVerbViolations: { line: string; acId: string; trigger: string }[] = [];
299
+
300
+ for (const line of checklistLines) {
301
+ const acMatch = line.match(/^\s*- \[[ x]\]\s*(AC-\d+):/);
302
+ if (!acMatch) continue;
303
+ const acId = acMatch[1]!;
304
+ if (supersededSet.has(acId)) continue;
305
+
306
+ const text = line.toLowerCase();
307
+
308
+ // V-39: non-atomic criterion
309
+ if (NON_ATOMIC_PATTERN.test(line)) {
310
+ nonAtomicViolations.push({ line: line.trim(), acId });
311
+ }
312
+
313
+ // V-40: unbounded quantity
314
+ for (const trigger of UNBOUNDED_QUANTITY_TRIGGERS) {
315
+ if (text.includes(trigger)) {
316
+ unboundedQuantityViolations.push({ line: line.trim(), acId, trigger });
317
+ }
318
+ }
319
+
320
+ // V-41: weasel verbs
321
+ for (const trigger of WEASEL_VERBS) {
322
+ if (text.includes(trigger)) {
323
+ weaselVerbViolations.push({ line: line.trim(), acId, trigger });
324
+ }
325
+ }
326
+ }
327
+
107
328
  return {
108
329
  totalChecked: checkedLines.length,
109
- totalUnchecked,
110
- uncheckedLines,
330
+ totalUnchecked: effectiveUncheckedLines.length,
331
+ uncheckedLines: effectiveUncheckedLines,
111
332
  checkedWithoutEvidence,
333
+ criterionIds,
334
+ duplicateCriterionIds,
335
+ linesWithoutId,
336
+ supersededIds,
337
+ nonAtomicViolations,
338
+ unboundedQuantityViolations,
339
+ weaselVerbViolations,
340
+ malformedSupersessionAnnotations,
341
+ };
342
+ }
343
+
344
+ // ─── RFC-0997: probe-criterion binding and coverage ────────────────────────
345
+
346
+ /**
347
+ * Compute a non-blocking probe coverage report for an RFC.
348
+ * Pure function — no I/O, no side effects.
349
+ */
350
+ export function computeProbeCoverage(
351
+ criterionIds: string[],
352
+ acceptance: unknown,
353
+ ): ProbeCoverageReport {
354
+ const probes = Array.isArray(acceptance) ? (acceptance as AcceptanceProbe[]) : [];
355
+ const probeBackedCriteria = new Set<string>();
356
+ const unboundProbes: string[] = [];
357
+
358
+ for (const probe of probes) {
359
+ const criterion = probe.criterion;
360
+ if (typeof criterion === "string" && /^AC-\d+$/.test(criterion)) {
361
+ if (criterionIds.includes(criterion)) {
362
+ probeBackedCriteria.add(criterion);
363
+ } else {
364
+ unboundProbes.push(criterion);
365
+ }
366
+ } else {
367
+ unboundProbes.push(String(criterion ?? "(missing)"));
368
+ }
369
+ }
370
+
371
+ const uncoveredCriteria = criterionIds.filter((id) => !probeBackedCriteria.has(id));
372
+ const totalCriteria = criterionIds.length;
373
+ const probeBackedCount = probeBackedCriteria.size;
374
+
375
+ return {
376
+ totalCriteria,
377
+ probeBackedCriteria: probeBackedCount,
378
+ coverageRatio: totalCriteria > 0 ? probeBackedCount / totalCriteria : 0,
379
+ unboundProbes,
380
+ uncoveredCriteria,
112
381
  };
113
382
  }
114
383
 
@@ -431,9 +700,9 @@ export async function validateSingleRfc(
431
700
  }
432
701
 
433
702
  // V-14: acceptance criteria must have at least 3 checklist items
434
- const acceptanceMatch = body.match(/## Acceptance criteria\s*\n([\s\S]*?)(?=\n## |\n*$)/);
435
- if (acceptanceMatch) {
436
- const checklistItems = acceptanceMatch[1]!.match(/^- \[[ x]\]/gm);
703
+ const acceptanceSection = extractAcceptanceCriteriaSection(body);
704
+ if (acceptanceSection) {
705
+ const checklistItems = acceptanceSection.match(/^- \[[ x]\]/gm);
437
706
  const count = checklistItems?.length ?? 0;
438
707
  if (count < 3) {
439
708
  addViolation(
@@ -449,8 +718,8 @@ export async function validateSingleRfc(
449
718
  // V-26: implemented RFCs must have all acceptance criteria checked (RFC-0463)
450
719
  // V-27: every checked criterion must carry inline (evidence: ...) annotation (RFC-0463)
451
720
  // RFC-0476: both use the shared evaluateAcceptanceCriteria function.
452
- if (acceptanceMatch) {
453
- const criteriaEval = evaluateAcceptanceCriteria(body);
721
+ const criteriaEval = evaluateAcceptanceCriteria(body);
722
+ if (acceptanceSection) {
454
723
  if (status === "implemented" && criteriaEval.totalUnchecked > 0 && !isArchived) {
455
724
  addViolation(
456
725
  rfcId,
@@ -468,6 +737,178 @@ export async function validateSingleRfc(
468
737
  `checked acceptance criterion lacks inline (evidence: ...) annotation: "${line}"`,
469
738
  );
470
739
  }
740
+
741
+ // V-35/V-36/V-37: probe-criterion binding rules (RFC-0997, post-cutoff only)
742
+ const isPostCutoff = createdAt >= RFC_PROBE_BINDING_CUTOFF && !isArchived;
743
+ if (isPostCutoff) {
744
+ const acceptance = fm["acceptance"];
745
+ const probes = Array.isArray(acceptance) ? (acceptance as AcceptanceProbe[]) : [];
746
+
747
+ // V-35: every probe SHALL declare criterion referencing an existing AC-N id
748
+ for (let i = 0; i < probes.length; i++) {
749
+ const probe = probes[i]!;
750
+ const criterion = probe.criterion;
751
+ if (criterion === undefined || criterion === null) {
752
+ addViolation(
753
+ rfcId,
754
+ relFile,
755
+ "V-35",
756
+ `acceptance probe ${i} ("${probe.probe}") lacks required "criterion" field (post-cutoff RFCs must bind probes to AC-N criteria per RFC-0997)`,
757
+ );
758
+ } else if (typeof criterion !== "string" || !/^AC-\d+$/.test(criterion)) {
759
+ addViolation(
760
+ rfcId,
761
+ relFile,
762
+ "V-35",
763
+ `acceptance probe ${i} ("${probe.probe}") has criterion "${String(criterion)}" which does not match format "AC-N"`,
764
+ );
765
+ } else if (!criteriaEval.criterionIds.includes(criterion)) {
766
+ addViolation(
767
+ rfcId,
768
+ relFile,
769
+ "V-35",
770
+ `acceptance probe ${i} ("${probe.probe}") declares criterion "${criterion}" which does not exist in acceptance criteria`,
771
+ );
772
+ }
773
+ }
774
+
775
+ // V-36: every top-level checklist line SHALL start with a unique AC-N: identifier
776
+ for (const line of criteriaEval.linesWithoutId) {
777
+ addViolation(
778
+ rfcId,
779
+ relFile,
780
+ "V-36",
781
+ `acceptance criterion checklist line lacks "AC-N:" identifier: "${line}"`,
782
+ );
783
+ }
784
+ for (const dupId of criteriaEval.duplicateCriterionIds) {
785
+ addViolation(
786
+ rfcId,
787
+ relFile,
788
+ "V-36",
789
+ `duplicate acceptance criterion identifier "${dupId}" — each AC-N must be unique`,
790
+ );
791
+ }
792
+
793
+ // V-37: checked criteria evidence SHALL resolve
794
+ if (acceptanceSection) {
795
+ const checkedLines = acceptanceSection
796
+ .split("\n")
797
+ .filter((line: string) => /^- \[x\]/.test(line));
798
+ for (const line of checkedLines) {
799
+ const evidenceMatch = line.match(/\(evidence:\s*(.+?)\)/);
800
+ if (!evidenceMatch) continue;
801
+ const evidence = evidenceMatch[1]!.trim();
802
+
803
+ if (evidence.startsWith("probe:")) {
804
+ const refId = evidence.slice("probe:".length).trim();
805
+ const hasProbe = probes.some((p) => p.criterion === refId);
806
+ if (!criteriaEval.criterionIds.includes(refId)) {
807
+ addViolation(
808
+ rfcId,
809
+ relFile,
810
+ "V-37",
811
+ `evidence "probe:${refId}" references a criterion that does not exist in acceptance criteria`,
812
+ );
813
+ } else if (!hasProbe) {
814
+ addViolation(
815
+ rfcId,
816
+ relFile,
817
+ "V-37",
818
+ `evidence "probe:${refId}" references a criterion with no bound probe`,
819
+ );
820
+ }
821
+ } else if (evidence.startsWith("test:")) {
822
+ const testPath = evidence.slice("test:".length).trim();
823
+ try {
824
+ await stat(path.join(workspaceRoot, testPath));
825
+ } catch {
826
+ addViolation(
827
+ rfcId,
828
+ relFile,
829
+ "V-37",
830
+ `evidence "test:${testPath}" references a file that does not exist`,
831
+ );
832
+ }
833
+ } else if (/^[^:]+:\d+$/.test(evidence)) {
834
+ const lastColon = evidence.lastIndexOf(":");
835
+ const filePath = evidence.slice(0, lastColon).trim();
836
+ try {
837
+ await stat(path.join(workspaceRoot, filePath));
838
+ } catch {
839
+ addViolation(
840
+ rfcId,
841
+ relFile,
842
+ "V-37",
843
+ `evidence "${evidence}" references a file that does not exist`,
844
+ );
845
+ }
846
+ }
847
+ }
848
+ }
849
+ }
850
+ }
851
+
852
+ // V-38..V-42: criteria content rules (RFC-1006, post-cutoff only)
853
+ const isCriteriaPostCutoff = createdAt >= RFC_CRITERIA_CONTENT_CUTOFF && !isArchived;
854
+ if (isCriteriaPostCutoff) {
855
+ const severity: "error" | "warning" = status === "draft" ? "warning" : "error";
856
+
857
+ // V-38: document readiness completeness for accepted+ RFCs
858
+ const drEval = evaluateDocumentReadiness(body);
859
+ if (drEval.totalUnchecked > 0 && (status === "accepted" || status === "implemented")) {
860
+ addViolation(
861
+ rfcId,
862
+ relFile,
863
+ "V-38",
864
+ `status is "${status}" but ${drEval.totalUnchecked} document readiness criteria are unchecked in "## Document readiness" section.`,
865
+ severity,
866
+ );
867
+ }
868
+
869
+ // V-39: non-atomic criterion
870
+ for (const v of criteriaEval.nonAtomicViolations) {
871
+ addViolation(
872
+ rfcId,
873
+ relFile,
874
+ "V-39",
875
+ `acceptance criterion ${v.acId} is non-atomic (contains "and" joining two behaviors): "${v.line}". Split into separate criteria.`,
876
+ severity,
877
+ );
878
+ }
879
+
880
+ // V-40: unbounded quantity
881
+ for (const v of criteriaEval.unboundedQuantityViolations) {
882
+ addViolation(
883
+ rfcId,
884
+ relFile,
885
+ "V-40",
886
+ `acceptance criterion ${v.acId} contains unbounded quantity "${v.trigger}": state a specific number or reference the decision that will set it.`,
887
+ severity,
888
+ );
889
+ }
890
+
891
+ // V-41: weasel verb
892
+ for (const v of criteriaEval.weaselVerbViolations) {
893
+ addViolation(
894
+ rfcId,
895
+ relFile,
896
+ "V-41",
897
+ `acceptance criterion ${v.acId} contains weasel verb "${v.trigger}": replace with a specific observable behavior.`,
898
+ severity,
899
+ );
900
+ }
901
+
902
+ // V-42: malformed supersession annotation
903
+ for (const line of criteriaEval.malformedSupersessionAnnotations) {
904
+ addViolation(
905
+ rfcId,
906
+ relFile,
907
+ "V-42",
908
+ `malformed criterion supersession annotation (expected "> Superseded AC-N (YYYY-MM-DD): <reason>"): "${line}"`,
909
+ "error",
910
+ );
911
+ }
471
912
  }
472
913
 
473
914
  // V-15: title consistency
@@ -24,10 +24,16 @@ import type {
24
24
  ForgeCommandResult,
25
25
  ForgeRuntimeContext,
26
26
  } from "../../../src/types.ts";
27
- import type { RfcValidationViolation, RfcValidationResult, Marker } from "../types.ts";
28
- import { RFC_DIR, RFC_KNOWN_KEYS } from "../types.ts";
27
+ import type {
28
+ RfcValidationViolation,
29
+ RfcValidationResult,
30
+ Marker,
31
+ ProbeCoverageReport,
32
+ } from "../types.ts";
33
+ import { RFC_DIR, RFC_KNOWN_KEYS, RFC_PROBE_BINDING_CUTOFF } from "../types.ts";
29
34
  import { DNA_DOCS, AP_DOCS, loadInvariantIds } from "./shared.ts";
30
35
  import { collectRfcCommandLifecycleViolations } from "./lifecycle.ts";
36
+ import { evaluateAcceptanceCriteria, computeProbeCoverage } from "./validate-rules.ts";
31
37
  import { validateSingleRfc, checkFrontmatterYamlParse } from "./validate-rules.ts";
32
38
 
33
39
  export async function runRfcValidate(
@@ -60,6 +66,7 @@ export async function runRfcValidate(
60
66
 
61
67
  const violations: RfcValidationViolation[] = [];
62
68
  const allMarkers: Marker[] = [];
69
+ const coverage: Array<{ rfcId: string; report: ProbeCoverageReport }> = [];
63
70
 
64
71
  function addViolation(
65
72
  rfcId: string,
@@ -108,6 +115,16 @@ export async function runRfcValidate(
108
115
  seenFilenameNumbers,
109
116
  );
110
117
  allMarkers.push(...markers);
118
+
119
+ const fm = result.parsed.frontmatter;
120
+ const rfcId = String(fm["id"] ?? "");
121
+ const createdAt = String(fm["createdAt"] ?? "");
122
+ const isArchived = fileName.startsWith("archive/");
123
+ if (createdAt >= RFC_PROBE_BINDING_CUTOFF && !isArchived) {
124
+ const criteriaEval = evaluateAcceptanceCriteria(result.parsed.body);
125
+ const report = computeProbeCoverage(criteriaEval.criterionIds, fm["acceptance"]);
126
+ coverage.push({ rfcId, report });
127
+ }
111
128
  }
112
129
 
113
130
  const lifecycle = await collectRfcCommandLifecycleViolations(
@@ -161,6 +178,7 @@ export async function runRfcValidate(
161
178
  count: filesToValidate.length,
162
179
  violations,
163
180
  markers: allMarkers,
181
+ coverage: coverage.length > 0 ? coverage : undefined,
164
182
  },
165
183
  exitCode: hasErrors ? 1 : 0,
166
184
  summary: hasErrors
@@ -57,14 +57,18 @@ nonGoals: []
57
57
  # automatically inside build pipelines).
58
58
  # acceptance:
59
59
  # - probe: run
60
+ # criterion: AC-1
60
61
  # command: "pnpm exec forge some.command.validate"
61
62
  # expect:
62
63
  # exitCode: 0
63
64
  # - probe: file-exists
65
+ # criterion: AC-2
64
66
  # path: "packages/my-package/src/some-new-module.ts"
65
67
  # - probe: command-registered
68
+ # criterion: AC-3
66
69
  # name: "some.new.command"
67
70
  # - probe: file-contains
71
+ # criterion: AC-4
68
72
  # path: "AGENTS.md"
69
73
  # pattern: "Some new governance paragraph"
70
74
  ---
@@ -194,16 +198,28 @@ interface ExampleResult {
194
198
  Include: performance impact, false positive rate, maintenance burden,
195
199
  risk of agents misinterpreting this RFC. -->
196
200
 
201
+ ## Document readiness
202
+
203
+ <!-- Optional (RFC-1006). RFC-phase criteria: document quality gates that verify
204
+ the RFC itself is ready for acceptance. These are NOT EARS statements about
205
+ system behavior — they are checks that the document is complete.
206
+ Use DR-N: identifiers. V-38 enforces completeness for accepted+ post-cutoff RFCs.
207
+
208
+ - [ ] DR-1: Every considered alternative has a stated reason for rejection (evidence: file: <path:line>)
209
+ - [ ] DR-2: The RFC enumerates every existing artifact it touches (evidence: file: <path:line>)
210
+ - [ ] DR-3: The RFC states whether the decision is reversible and at what cost
211
+ -->
212
+
197
213
  ## Acceptance criteria
198
214
 
199
- - [ ] TypeScript types and interfaces defined in the relevant package
200
- - [ ] CLI command registered with correct name and scope
201
- - [ ] `--json` output format documented and stable
202
- - [ ] Integrated into appropriate pipeline (`build.check` or standalone)
203
- - [ ] Existing apps pass without changes (or migration path is documented)
204
- - [ ] `AGENTS.md` updated where agent behavior rules changed
205
- - [ ] Relevant Architecture DNA or spec docs link to this RFC
206
- - [ ] `rfc.validate` passes on this file before merging
215
+ <!-- Authoring standard: see packages/forge/skills/fo/fo-idea-create-rfc/acceptance-criteria-standard.md (RFC-0996).
216
+ Each criterion is a falsifiable claim about an observable artifact, written in EARS form with a stable AC-N: identifier.
217
+ Required mix: behavior, contract, negative, sync. 3–10 items. One claim = one checking mechanism. -->
218
+
219
+ - [ ] AC-1: WHEN `<command> --json` is invoked, THE command SHALL return a JSON object matching the documented output schema (evidence: probe:AC-1 or test: <path>)
220
+ - [ ] AC-2: THE `<command>` SHALL be registered in the kernel module with the correct name and scope (evidence: file: <module-path>)
221
+ - [ ] AC-3: IF `<command>` receives invalid input, THEN THE command SHALL report a blocking error and exit non-zero (evidence: test: <path/to/test>)
222
+ - [ ] AC-4: THE relevant `AGENTS.md` SHALL reference this RFC where agent behavior rules changed (evidence: file: <path:line>)
207
223
 
208
224
  ## Implementation notes for agents
209
225
 
@@ -34,6 +34,7 @@ export const forgeRfcModule: ForgeModule = {
34
34
  } = await import("./handlers.ts");
35
35
  const { runRfcAcceptanceRun } = await import("./acceptance.ts");
36
36
  const { runRfcVerificationEmit } = await import("./verification-evidence.ts");
37
+ const { runRfcVerificationRefresh } = await import("./verification-refresh.ts");
37
38
  const { runRfcDnaTraceValidate, runRfcDnaTraceGenerate } = await import("./dna-trace.ts");
38
39
  const { runRfcDecisionLogGenerate } = await import("./decision-log.ts");
39
40
  const { runRfcSupersedePropose } = await import("./handlers/supersede-propose.ts");
@@ -259,6 +260,33 @@ export const forgeRfcModule: ForgeModule = {
259
260
  execute: runRfcVerificationEmit,
260
261
  });
261
262
 
263
+ // ── rfc.verification.refresh ────────────────────────────────────────────
264
+ registry.registerCommand({
265
+ name: "rfc.verification.refresh",
266
+ description:
267
+ "RFC-0999: re-run acceptance probes for implemented RFC(s) and update verification " +
268
+ "evidence envelopes in-place. Preserves emittedAt, adds lastRefreshedAt, replaces " +
269
+ "probes[] with fresh results. Requires --id <rfc-id> or --all. Use --dry-run to " +
270
+ "run probes without writing envelope files.",
271
+ scope: "workspace",
272
+ mutatesState: true,
273
+ writes: ["docs/rfcs/verification/*.generated.yaml"],
274
+ reads: ["docs/rfcs/**/*.md", "docs/rfcs/verification/*.generated.yaml"],
275
+ cacheable: false,
276
+ flags: {
277
+ id: { kind: "string", description: "Target a single RFC by id (e.g. rfc-0330)." },
278
+ all: {
279
+ kind: "boolean",
280
+ description: "Refresh all implemented RFCs with existing evidence envelopes.",
281
+ },
282
+ "dry-run": {
283
+ kind: "boolean",
284
+ description: "Run probes and report results without writing envelope files.",
285
+ },
286
+ },
287
+ execute: runRfcVerificationRefresh,
288
+ });
289
+
262
290
  // ── rfc.dna.trace.validate ───────────────────────────────────────────────
263
291
  registry.registerCommand({
264
292
  name: "rfc.dna.trace.validate",