@warpgogol/forge 4.2.2 → 4.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/AGENTS.md +6 -4
  2. package/bin/cli.ts +8 -0
  3. package/dist/bin/cli.js +8 -0
  4. package/dist/bin/cli.js.map +1 -1
  5. package/dist/os/compass/compass.module.d.ts.map +1 -1
  6. package/dist/os/compass/compass.module.js +9 -4
  7. package/dist/os/compass/compass.module.js.map +1 -1
  8. package/dist/os/compass/handlers/compass-audit-handler.d.ts +1 -0
  9. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  10. package/dist/os/compass/handlers/compass-audit-handler.js +3 -0
  11. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  12. package/dist/os/compass/handlers/compass-change-summary-handler.d.ts.map +1 -1
  13. package/dist/os/compass/handlers/compass-change-summary-handler.js +36 -6
  14. package/dist/os/compass/handlers/compass-change-summary-handler.js.map +1 -1
  15. package/dist/os/compass/handlers/compass-inventory-handler.d.ts +2 -1
  16. package/dist/os/compass/handlers/compass-inventory-handler.d.ts.map +1 -1
  17. package/dist/os/compass/handlers/compass-inventory-handler.js +54 -7
  18. package/dist/os/compass/handlers/compass-inventory-handler.js.map +1 -1
  19. package/dist/os/compass/handlers/compass-inventory.d.ts +17 -0
  20. package/dist/os/compass/handlers/compass-inventory.d.ts.map +1 -1
  21. package/dist/os/compass/handlers/compass-inventory.js +277 -12
  22. package/dist/os/compass/handlers/compass-inventory.js.map +1 -1
  23. package/dist/os/core/core.module.d.ts.map +1 -1
  24. package/dist/os/core/core.module.js +26 -0
  25. package/dist/os/core/core.module.js.map +1 -1
  26. package/dist/os/core/handlers/file-size-lint.d.ts +8 -0
  27. package/dist/os/core/handlers/file-size-lint.d.ts.map +1 -0
  28. package/dist/os/core/handlers/file-size-lint.js +166 -0
  29. package/dist/os/core/handlers/file-size-lint.js.map +1 -0
  30. package/dist/os/core/handlers/public-surface.d.ts.map +1 -1
  31. package/dist/os/core/handlers/public-surface.js +4 -3
  32. package/dist/os/core/handlers/public-surface.js.map +1 -1
  33. package/os/compass/compass.module.ts +12 -7
  34. package/os/compass/handlers/compass-audit-handler.ts +4 -0
  35. package/os/compass/handlers/compass-change-summary-handler.ts +45 -6
  36. package/os/compass/handlers/compass-inventory-handler.ts +78 -9
  37. package/os/compass/handlers/compass-inventory.ts +339 -13
  38. package/os/compass/handlers/tests/compass-v2-contract.test.ts +239 -0
  39. package/os/core/core.module.ts +28 -0
  40. package/os/core/handlers/file-size-lint.ts +200 -0
  41. package/os/core/handlers/public-surface.ts +4 -3
  42. package/package.json +12 -12
  43. package/skills/fo/fo-compass-annotate/SKILL.md +13 -11
  44. package/skills/fo/fo-compass-annotate/templates/audit-prompt.md +7 -1
  45. package/skills/fo/fo-compass-annotate/templates/header-format.md +33 -11
  46. package/skills/fo/fo-compass-annotate/templates/header-prompt.md +12 -1
  47. package/skills/shared/extension-e2e-mitm/SKILL.md +53 -0
  48. package/skills/shared/extension-e2e-mitm/fixture-proxy.ts +113 -0
  49. package/src/tests/create.test.ts +17 -17
  50. package/src/tests/file-size-lint.test.ts +181 -0
  51. package/src/tests/public-surface.test.ts +13 -4
  52. package/src/tests/scaffold-project.test.ts +1 -1
@@ -17,12 +17,19 @@ contract block specs alongside built-in MODULE_CONTRACT and CHANGE_SUMMARY check
17
17
  <item>RFC-0556: moved from @warpgogol/site-kernel-checks to @warpgogol/forge for autonomous mode.</item>
18
18
  <item>Game extensions: added COMPASS-SYNTAX-01 diagnostic validating comment syntax per file type (.gd needs # prefix, .tscn/.tres need ; prefix, .ts/.cs need block comment).</item>
19
19
  <item>RFC-0943: added COMPASS-PLUGIN-01/02/03 diagnostics for pack-declared contract block specs from forge.plugin.yaml extensionPoints.</item>
20
+ <item>RFC-1094: --mode warning|error on compass.validate; mode-aware v2 diagnostics (KD/ORDER/PURPOSE) with severity field; inventory data.entries now carries full entries and XML gains v2 fields.</item>
20
21
  </CHANGE_SUMMARY>
21
22
  */
22
23
 
23
24
  import { resolve } from "node:path";
24
25
  import { readFile } from "node:fs/promises";
25
- import { createCompassInventoryEntries, type CompassInventoryEntry } from "./compass-inventory.ts";
26
+ import {
27
+ createCompassInventoryEntries,
28
+ evaluateV2Rules,
29
+ getEntrySource,
30
+ resolveCompassMode,
31
+ type CompassInventoryEntry,
32
+ } from "./compass-inventory.ts";
26
33
  import { resolveCompassScanRoot } from "./resolve-scan-root.ts";
27
34
  import { writeFileIfChanged } from "../../../src/utils/fs-idempotent.ts";
28
35
  import { loadForgeConfig } from "../../../src/config/forge-config.ts";
@@ -201,6 +208,21 @@ function renderInventoryXml(
201
208
  lines.push(
202
209
  ` <has-change-summary>${entry.hasChangeSummary ? "true" : "false"}</has-change-summary>`,
203
210
  );
211
+ lines.push(
212
+ ` <has-key-decisions>${entry.hasKeyDecisions ? "true" : "false"}</has-key-decisions>`,
213
+ );
214
+ lines.push(
215
+ ` <key-decisions-item-count>${entry.keyDecisionsItemCount}</key-decisions-item-count>`,
216
+ );
217
+ lines.push(
218
+ ` <key-decisions-required>${entry.keyDecisionsRequired ? "true" : "false"}</key-decisions-required>`,
219
+ );
220
+ lines.push(
221
+ ` <change-summary-item-count>${entry.changeSummaryItemCount}</change-summary-item-count>`,
222
+ );
223
+ if (entry.historyIds.length > 0) {
224
+ lines.push(` <history-ids>${escapeXml(entry.historyIds.join(", "))}</history-ids>`);
225
+ }
204
226
  lines.push(
205
227
  ` <has-ai-invariant>${entry.hasAiInvariant ? "true" : "false"}</has-ai-invariant>`,
206
228
  );
@@ -233,7 +255,11 @@ export async function runCompassInventory(
233
255
  input: ForgeCommandInput,
234
256
  context: ForgeRuntimeContext,
235
257
  ): Promise<
236
- ForgeCommandResult<{ entries: number; outputPath: string; summary: CompassInventorySummary }>
258
+ ForgeCommandResult<{
259
+ entries: CompassInventoryEntry[];
260
+ outputPath: string;
261
+ summary: CompassInventorySummary;
262
+ }>
237
263
  > {
238
264
  const scanRoot = resolveCompassScanRoot(input, context) ?? context.workspaceRoot;
239
265
  const entries = await createCompassInventoryEntries(scanRoot, input);
@@ -248,7 +274,7 @@ export async function runCompassInventory(
248
274
  `[compass.inventory] dry-run active — skipped writing ${INVENTORY_OUTPUT_PATH}`,
249
275
  );
250
276
  return {
251
- data: { entries: entries.length, outputPath: INVENTORY_OUTPUT_PATH, summary },
277
+ data: { entries, outputPath: INVENTORY_OUTPUT_PATH, summary },
252
278
  summary: `[compass.inventory] previewed ${INVENTORY_OUTPUT_PATH}`,
253
279
  nextSteps: [
254
280
  {
@@ -264,7 +290,7 @@ export async function runCompassInventory(
264
290
  await writeFileIfChanged(outputPath, xml);
265
291
 
266
292
  return {
267
- data: { entries: entries.length, outputPath, summary },
293
+ data: { entries, outputPath, summary },
268
294
  summary: `[compass.inventory] ${context.dryRun ? "previewed" : "wrote"} ${INVENTORY_OUTPUT_PATH}`,
269
295
  nextSteps: context.dryRun
270
296
  ? [
@@ -300,6 +326,15 @@ export async function runCompassValidation(
300
326
  }>
301
327
  > {
302
328
  const scanRoot = resolveCompassScanRoot(input, context);
329
+ let mode: ReturnType<typeof resolveCompassMode>;
330
+ try {
331
+ mode = resolveCompassMode(input);
332
+ } catch {
333
+ context.logger.error(
334
+ `[compass.validate] invalid --mode value: ${String(input.flags["mode"])} (expected warning|error)`,
335
+ );
336
+ return { exitCode: 1, summary: "invalid --mode value" };
337
+ }
303
338
  const entries = await createCompassInventoryEntries(context.workspaceRoot, input, scanRoot);
304
339
  const summary = summarizeInventory(entries);
305
340
  const failures = entries.filter(
@@ -311,13 +346,45 @@ export async function runCompassValidation(
311
346
 
312
347
  const diagnostics: Array<{
313
348
  ruleId: string;
314
- severity: string;
349
+ severity: "warning" | "error";
315
350
  file: string;
316
351
  message: string;
317
352
  fix: string;
318
353
  pack?: string;
319
354
  }> = [];
320
355
 
356
+ // RFC-1094: v2 rules are mode-aware — warnings in `warning` mode (default
357
+ // during the migration window), errors in `error` mode. Owned subset:
358
+ // COMPASS-KD-*, COMPASS-ORDER-01, COMPASS-PURPOSE-*. CS-* rules are emitted
359
+ // by compass.changesummary.validate.
360
+ const V2_VALIDATE_RULE_PREFIXES = ["COMPASS-KD-", "COMPASS-ORDER-", "COMPASS-PURPOSE-"];
361
+ const v2Severity = mode === "error" ? "error" : "warning";
362
+ const authoredEntries = entries.filter(
363
+ (entry) => entry.authoringStatus === "authored" && entry.requiredScaffolding !== "none",
364
+ );
365
+ for (const entry of authoredEntries) {
366
+ const absPath = resolve(context.workspaceRoot, entry.path);
367
+ const source = getEntrySource(entry) ?? (await readFile(absPath, "utf8"));
368
+ for (const v2 of evaluateV2Rules(entry, source)) {
369
+ if (!V2_VALIDATE_RULE_PREFIXES.some((prefix) => v2.ruleId.startsWith(prefix))) {
370
+ continue;
371
+ }
372
+ const log = `[compass.validate] ${v2.ruleId}: ${entry.path}: ${v2.message}`;
373
+ if (v2Severity === "error") {
374
+ context.logger.error(log);
375
+ } else {
376
+ context.logger.warn(log);
377
+ }
378
+ diagnostics.push({
379
+ ruleId: v2.ruleId,
380
+ severity: v2Severity,
381
+ file: entry.path,
382
+ message: v2.message,
383
+ fix: v2.fix,
384
+ });
385
+ }
386
+ }
387
+
321
388
  for (const failure of failures) {
322
389
  for (const violation of failure.violations) {
323
390
  let ruleId = "COMPASS-CONTRACT-01";
@@ -477,7 +544,9 @@ export async function runCompassValidation(
477
544
  }
478
545
  }
479
546
 
480
- const hasFailures = diagnostics.length > 0;
547
+ const hasErrors = diagnostics.some((d) => d.severity === "error");
548
+ const warningCount =
549
+ diagnostics.length - diagnostics.filter((d) => d.severity === "error").length;
481
550
 
482
551
  return {
483
552
  data: {
@@ -486,9 +555,9 @@ export async function runCompassValidation(
486
555
  summary,
487
556
  diagnostics,
488
557
  },
489
- exitCode: hasFailures ? 1 : 0,
490
- summary: hasFailures
558
+ exitCode: hasErrors ? 1 : 0,
559
+ summary: hasErrors
491
560
  ? undefined
492
- : `[compass.validate] OK (${summary.authoredFiles} authored files checked)`,
561
+ : `[compass.validate] OK (${summary.authoredFiles} authored files checked${warningCount > 0 ? `, ${warningCount} v2 warnings` : ""})`,
493
562
  };
494
563
  }
@@ -16,6 +16,7 @@ for Compass source-file inventory.</purpose>
16
16
  <item>RFC-0556: moved canonical implementation from @warpgogol/site-kernel to @warpgogol/forge for autonomous mode.</item>
17
17
  <item>Game extensions: added .cs, .tscn, .tres, .gd to SOURCE_EXTENSIONS; createCompassInventoryEntries now reads forge.yaml compass.fileExtensions at runtime and merges with hardcoded set.</item>
18
18
  <item>Added .md to SOURCE_EXTENSIONS for SKILL.md Compass coverage; detectAuthoringStatus excludes non-SKILL.md markdown files.</item>
19
+ <item>RFC-1094: v2 contract — KEY_DECISIONS/history parsing, new inventory fields, evaluateV2Rules, deriveFileTokens, resolveCompassMode, shared GOVERNANCE_ID_RE.</item>
19
20
  </CHANGE_SUMMARY>
20
21
  */
21
22
 
@@ -84,7 +85,6 @@ const HIGH_RISK_EXACT_RELATIVE_PATHS = new Set([
84
85
  "packages/os/site-kernel/src/runtime.ts",
85
86
  "packages/os/site-kernel/src/types.ts",
86
87
  ]);
87
- const REQUIRED_MARKERS = ["<MODULE_CONTRACT>", "<CHANGE_SUMMARY>"] as const;
88
88
  function forbiddenMarkerPattern(tagName: string): RegExp {
89
89
  return new RegExp(`<${tagName}\\b`);
90
90
  }
@@ -96,6 +96,55 @@ const FORBIDDEN_PATTERNS: Array<{ name: string; regex: RegExp }> = [
96
96
  { name: "COMPASS_BLOCK", regex: new RegExp(`</?${"COMPASS_BLOCK"}\\b`) },
97
97
  ];
98
98
 
99
+ // RFC-1094: v2 contract constants. GOVERNANCE_ID_RE is the canonical
100
+ // governance-ID pattern shared with the change-summary handler.
101
+ export const GOVERNANCE_ID_RE = /\b([A-Z][A-Z0-9]*-)+\d+\b/;
102
+ const GOVERNANCE_ID_PREFIX_RE = /^([A-Z][A-Z0-9]*-)+\d+\b/;
103
+ const HISTORY_ID_RE = /^([A-Z][A-Z0-9]*-)+\d+$/;
104
+ const HISTORY_ID_PARTS_RE = /^(([A-Z][A-Z0-9]*-)+)(\d+)$/;
105
+ const TODO_PLACEHOLDER_RE = /^TODO\b/i;
106
+
107
+ const GENERIC_STEMS = new Set([
108
+ "index",
109
+ "styles",
110
+ "style",
111
+ "local",
112
+ "global",
113
+ "utils",
114
+ "helpers",
115
+ "types",
116
+ "constants",
117
+ "readme",
118
+ ]);
119
+ const SYMBOL_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts"]);
120
+ const EXPORTED_SYMBOL_RE =
121
+ /export\s+(?:async\s+)?(?:function|const|let|class|interface|type|enum)\s+(\w+)/g;
122
+
123
+ const PURPOSE_BOILERPLATE_PATTERNS: RegExp[] = [
124
+ /^(this file|the file|a file|this module|a module)\b/i,
125
+ /^(utility|utilities|helper|helpers|misc)\b/i,
126
+ /^(todo|placeholder|fixme)\b/i,
127
+ ];
128
+
129
+ const KEY_DECISIONS_MAX_ITEMS = 7;
130
+ const KEY_DECISIONS_MAX_WORDS = 20;
131
+ const CHANGE_SUMMARY_MAX_ITEMS = 5;
132
+
133
+ export type CompassValidationMode = "warning" | "error";
134
+
135
+ export interface CompassV2Diagnostic {
136
+ ruleId: string;
137
+ message: string;
138
+ fix: string;
139
+ }
140
+
141
+ export function resolveCompassMode(input: ForgeCommandInput): CompassValidationMode {
142
+ const raw = input.flags["mode"];
143
+ if (raw === undefined) return "warning";
144
+ if (raw === "warning" || raw === "error") return raw;
145
+ throw new Error(`--mode must be "warning" or "error", got "${String(raw)}"`);
146
+ }
147
+
99
148
  type CompassWorkspaceKind = "app" | "package" | "service";
100
149
  type CompassRiskClass = "high" | "medium" | "low";
101
150
  type CompassComplexity = "non-trivial" | "trivial";
@@ -116,6 +165,11 @@ export interface CompassInventoryEntry {
116
165
  nonEmptyLineCount: number;
117
166
  hasModuleContract: boolean;
118
167
  hasChangeSummary: boolean;
168
+ hasKeyDecisions: boolean;
169
+ keyDecisionsItemCount: number;
170
+ keyDecisionsRequired: boolean;
171
+ changeSummaryItemCount: number;
172
+ historyIds: string[];
119
173
  hasAiInvariant: boolean;
120
174
  hasPurpose: boolean;
121
175
  hasNonGoals: boolean;
@@ -478,23 +532,274 @@ function detectComplianceViolations(
478
532
  return violations;
479
533
  }
480
534
 
535
+ interface CompassBlockParse {
536
+ hasModuleContract: boolean;
537
+ hasKeyDecisions: boolean;
538
+ hasChangeSummary: boolean;
539
+ keyDecisionsItems: string[];
540
+ changeSummaryItems: string[];
541
+ historyIds: string[];
542
+ historyRaw: string | null;
543
+ purposeText: string;
544
+ positions: {
545
+ moduleContract: number;
546
+ keyDecisions: number;
547
+ changeSummary: number;
548
+ };
549
+ }
550
+
551
+ function extractItems(block: string): string[] {
552
+ const items: string[] = [];
553
+ const re = /<item>([\s\S]*?)<\/item>/g;
554
+ let m: RegExpExecArray | null;
555
+ while ((m = re.exec(block)) !== null) {
556
+ items.push(m[1]!.trim());
557
+ }
558
+ return items;
559
+ }
560
+
561
+ function parseCompassBlocks(source: string): CompassBlockParse {
562
+ const positions = {
563
+ moduleContract: source.indexOf("<MODULE_CONTRACT>"),
564
+ keyDecisions: source.indexOf("<KEY_DECISIONS>"),
565
+ changeSummary: source.indexOf("<CHANGE_SUMMARY>"),
566
+ };
567
+
568
+ const contractBlock = extractBlockContent(source, "MODULE_CONTRACT");
569
+ const keyDecisionsBlock = extractBlockContent(source, "KEY_DECISIONS");
570
+ const changeSummaryBlock = extractBlockContent(source, "CHANGE_SUMMARY");
571
+
572
+ let purposeText = "";
573
+ if (contractBlock) {
574
+ const purposeBlock = extractBlockContent(contractBlock, "purpose");
575
+ if (purposeBlock) {
576
+ purposeText = purposeBlock.replace(/<[^>]+>/g, " ").trim();
577
+ }
578
+ }
579
+
580
+ let historyRaw: string | null = null;
581
+ let historyIds: string[] = [];
582
+ if (changeSummaryBlock) {
583
+ const historyMatch = changeSummaryBlock.match(/<history>([\s\S]*?)<\/history>/);
584
+ if (historyMatch) {
585
+ historyRaw = historyMatch[1]!.trim();
586
+ historyIds = historyRaw
587
+ .split(",")
588
+ .map((token) => token.trim())
589
+ .filter(Boolean);
590
+ }
591
+ }
592
+
593
+ return {
594
+ hasModuleContract: positions.moduleContract >= 0,
595
+ hasKeyDecisions: positions.keyDecisions >= 0,
596
+ hasChangeSummary: positions.changeSummary >= 0,
597
+ keyDecisionsItems: keyDecisionsBlock ? extractItems(keyDecisionsBlock) : [],
598
+ changeSummaryItems: changeSummaryBlock ? extractItems(changeSummaryBlock) : [],
599
+ historyIds,
600
+ historyRaw,
601
+ purposeText,
602
+ positions,
603
+ };
604
+ }
605
+
606
+ function splitIdentifier(name: string): string[] {
607
+ return name
608
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
609
+ .replace(/[_-]+/g, " ")
610
+ .toLowerCase()
611
+ .split(" ")
612
+ .filter(Boolean);
613
+ }
614
+
615
+ export function deriveFileTokens(pathFromRoot: string, source: string): Set<string> {
616
+ const segments = pathFromRoot.split("/").filter(Boolean);
617
+ const filename = segments[segments.length - 1] ?? "";
618
+ const dotIndex = filename.lastIndexOf(".");
619
+ const stem = dotIndex > 0 ? filename.slice(0, dotIndex) : filename;
620
+ const extension = dotIndex > 0 ? filename.slice(dotIndex) : "";
621
+
622
+ const tokens = new Set<string>();
623
+ for (const segment of stem.split("-").filter(Boolean)) {
624
+ tokens.add(segment.toLowerCase());
625
+ }
626
+
627
+ if (SYMBOL_EXTENSIONS.has(extension)) {
628
+ for (const match of source.matchAll(EXPORTED_SYMBOL_RE)) {
629
+ for (const word of splitIdentifier(match[1]!)) {
630
+ tokens.add(word);
631
+ }
632
+ }
633
+ }
634
+
635
+ if (GENERIC_STEMS.has(stem.toLowerCase())) {
636
+ const parent = segments[segments.length - 2] ?? "";
637
+ for (const segment of parent.split("-").filter(Boolean)) {
638
+ tokens.add(segment.toLowerCase());
639
+ }
640
+ }
641
+
642
+ return tokens;
643
+ }
644
+
645
+ function validateHistoryIds(historyIds: string[]): string | null {
646
+ for (const token of historyIds) {
647
+ if (!HISTORY_ID_RE.test(token)) {
648
+ return `<history> contains a non-ID token: "${token}"`;
649
+ }
650
+ }
651
+ if (new Set(historyIds).size !== historyIds.length) {
652
+ return "<history> contains duplicate IDs";
653
+ }
654
+ const lastSeenByNamespace = new Map<string, number>();
655
+ for (const token of historyIds) {
656
+ const parts = token.match(HISTORY_ID_PARTS_RE)!;
657
+ const namespace = parts[1]!.replace(/-$/, "");
658
+ const numeric = Number(parts[3]);
659
+ const last = lastSeenByNamespace.get(namespace);
660
+ if (last !== undefined && numeric <= last) {
661
+ return `<history> IDs are not in per-namespace ascending numeric order (${token} after ${namespace}-${last})`;
662
+ }
663
+ lastSeenByNamespace.set(namespace, numeric);
664
+ }
665
+ return null;
666
+ }
667
+
668
+ export function evaluateV2Rules(
669
+ entry: Pick<CompassInventoryEntry, "path" | "keyDecisionsRequired">,
670
+ source: string,
671
+ ): CompassV2Diagnostic[] {
672
+ const parse = parseCompassBlocks(source);
673
+ const diagnostics: CompassV2Diagnostic[] = [];
674
+
675
+ const orderedPositions = [
676
+ parse.positions.moduleContract,
677
+ parse.positions.keyDecisions,
678
+ parse.positions.changeSummary,
679
+ ].filter((position) => position >= 0);
680
+ if (orderedPositions.length >= 2) {
681
+ const sorted = [...orderedPositions].sort((a, b) => a - b);
682
+ const inOrder = orderedPositions.every((position, index) => position === sorted[index]);
683
+ if (!inOrder) {
684
+ diagnostics.push({
685
+ ruleId: "COMPASS-ORDER-01",
686
+ message:
687
+ "Compass blocks out of canonical order (MODULE_CONTRACT → KEY_DECISIONS → CHANGE_SUMMARY)",
688
+ fix: "fix: reorder blocks to MODULE_CONTRACT, KEY_DECISIONS, CHANGE_SUMMARY",
689
+ });
690
+ }
691
+ }
692
+
693
+ if (entry.keyDecisionsRequired && !parse.hasKeyDecisions) {
694
+ diagnostics.push({
695
+ ruleId: "COMPASS-KD-01",
696
+ message: "missing KEY_DECISIONS (required for medium/high riskClass)",
697
+ fix: "fix: add a KEY_DECISIONS block with 1-7 current-state design items",
698
+ });
699
+ }
700
+ if (parse.hasKeyDecisions) {
701
+ const items = parse.keyDecisionsItems;
702
+ const realItems = items.filter((item) => item.length > 0 && !TODO_PLACEHOLDER_RE.test(item));
703
+ if (items.length === 0 || realItems.length === 0) {
704
+ diagnostics.push({
705
+ ruleId: "COMPASS-KD-02",
706
+ message: "KEY_DECISIONS block is empty or contains only TODO placeholder items",
707
+ fix: "fix: write real current-state decisions or remove the block",
708
+ });
709
+ }
710
+ for (const item of items) {
711
+ if (countWords(item) > KEY_DECISIONS_MAX_WORDS) {
712
+ diagnostics.push({
713
+ ruleId: "COMPASS-KD-03",
714
+ message: `KEY_DECISIONS item exceeds ${KEY_DECISIONS_MAX_WORDS} words`,
715
+ fix: "fix: compress the item to a single decision statement",
716
+ });
717
+ }
718
+ if (GOVERNANCE_ID_PREFIX_RE.test(item)) {
719
+ diagnostics.push({
720
+ ruleId: "COMPASS-KD-05",
721
+ message:
722
+ "KEY_DECISIONS item starts with a governance-ID prefix — history belongs in CHANGE_SUMMARY",
723
+ fix: "fix: rewrite the item as a current-state decision without an ID prefix",
724
+ });
725
+ }
726
+ }
727
+ if (items.length > KEY_DECISIONS_MAX_ITEMS) {
728
+ diagnostics.push({
729
+ ruleId: "COMPASS-KD-04",
730
+ message: `KEY_DECISIONS has ${items.length} items (cap is ${KEY_DECISIONS_MAX_ITEMS})`,
731
+ fix: "fix: keep only the decisions that still govern the file",
732
+ });
733
+ }
734
+ }
735
+
736
+ if (parse.purposeText.length > 0) {
737
+ if (PURPOSE_BOILERPLATE_PATTERNS.some((pattern) => pattern.test(parse.purposeText))) {
738
+ diagnostics.push({
739
+ ruleId: "COMPASS-PURPOSE-01",
740
+ message: "<purpose> matches a boilerplate pattern",
741
+ fix: "fix: write a specific purpose naming what this file does",
742
+ });
743
+ }
744
+ const tokens = deriveFileTokens(entry.path, source);
745
+ const purposeWords = new Set(
746
+ parse.purposeText
747
+ .toLowerCase()
748
+ .split(/[^a-z0-9]+/)
749
+ .filter(Boolean),
750
+ );
751
+ const hasToken = [...tokens].some((token) => purposeWords.has(token));
752
+ if (!hasToken) {
753
+ diagnostics.push({
754
+ ruleId: "COMPASS-PURPOSE-02",
755
+ message: "<purpose> contains no file-derived token (stem, parent dir, or exported symbol)",
756
+ fix: "fix: mention the file's own name or an exported symbol in <purpose>",
757
+ });
758
+ }
759
+ }
760
+
761
+ if (parse.hasChangeSummary) {
762
+ if (parse.changeSummaryItems.length > CHANGE_SUMMARY_MAX_ITEMS) {
763
+ diagnostics.push({
764
+ ruleId: "COMPASS-CS-05",
765
+ message: `CHANGE_SUMMARY has ${parse.changeSummaryItems.length} items (cap is ${CHANGE_SUMMARY_MAX_ITEMS}); collapse oldest into <history>`,
766
+ fix: "fix: keep the 5 newest items and collapse older IDs into <history>",
767
+ });
768
+ }
769
+ for (const item of parse.changeSummaryItems) {
770
+ if (!GOVERNANCE_ID_RE.test(item)) {
771
+ diagnostics.push({
772
+ ruleId: "COMPASS-CS-06",
773
+ message: `CHANGE_SUMMARY item lacks a governance-ID reference: "${item.slice(0, 60)}"`,
774
+ fix: "fix: prefix the item with its RFC/ADR/ticket ID or remove it",
775
+ });
776
+ }
777
+ }
778
+ if (parse.historyRaw !== null) {
779
+ const historyError = validateHistoryIds(parse.historyIds);
780
+ if (historyError) {
781
+ diagnostics.push({
782
+ ruleId: "COMPASS-CS-07",
783
+ message: historyError,
784
+ fix: "fix: <history> carries comma-separated governance IDs only, deduplicated, per-namespace ascending",
785
+ });
786
+ }
787
+ }
788
+ }
789
+
790
+ return diagnostics;
791
+ }
792
+
481
793
  function detectMarkup(source: string) {
482
- const hasModuleContract = source.includes(REQUIRED_MARKERS[0]);
483
- const hasChangeSummary = source.includes(REQUIRED_MARKERS[1]);
794
+ const parse = parseCompassBlocks(source);
484
795
  const hasAiInvariant = /@ai-invariant\b/.test(source);
485
796
 
486
- let hasPurpose = false;
797
+ const hasPurpose = countWords(parse.purposeText) >= 10;
487
798
  let hasNonGoals = false;
488
799
 
489
- if (hasModuleContract) {
800
+ if (parse.hasModuleContract) {
490
801
  const contractBlock = extractBlockContent(source, "MODULE_CONTRACT");
491
802
  if (contractBlock) {
492
- const purposeBlock = extractBlockContent(contractBlock, "purpose");
493
- if (purposeBlock) {
494
- const purposeText = purposeBlock.replace(/<[^>]+>/g, " ");
495
- hasPurpose = countWords(purposeText) >= 10;
496
- }
497
-
498
803
  const nonGoalsBlock = extractBlockContent(contractBlock, "non-goals");
499
804
  if (nonGoalsBlock) {
500
805
  hasNonGoals = (nonGoalsBlock.match(/<item>/g) ?? []).length >= 1;
@@ -510,8 +815,12 @@ function detectMarkup(source: string) {
510
815
  }
511
816
 
512
817
  return {
513
- hasModuleContract,
514
- hasChangeSummary,
818
+ hasModuleContract: parse.hasModuleContract,
819
+ hasChangeSummary: parse.hasChangeSummary,
820
+ hasKeyDecisions: parse.hasKeyDecisions,
821
+ keyDecisionsItemCount: parse.keyDecisionsItems.length,
822
+ changeSummaryItemCount: parse.changeSummaryItems.length,
823
+ historyIds: parse.historyIds,
515
824
  hasAiInvariant,
516
825
  hasPurpose,
517
826
  hasNonGoals,
@@ -519,6 +828,13 @@ function detectMarkup(source: string) {
519
828
  };
520
829
  }
521
830
 
831
+ const entrySources = new WeakMap<CompassInventoryEntry, string>();
832
+
833
+ /** Returns the source text captured during `createCompassInventoryEntries`, if still cached. */
834
+ export function getEntrySource(entry: CompassInventoryEntry): string | undefined {
835
+ return entrySources.get(entry);
836
+ }
837
+
522
838
  export async function createCompassInventoryEntries(
523
839
  workspaceRoot: string,
524
840
  input: ForgeCommandInput,
@@ -546,6 +862,10 @@ export async function createCompassInventoryEntries(
546
862
  );
547
863
  const requiredScaffolding = detectRequiredScaffolding(authoringStatus);
548
864
  const markup = detectMarkup(source);
865
+ const keyDecisionsRequired =
866
+ authoringStatus === "authored" &&
867
+ requiredScaffolding === "standard" &&
868
+ (riskClass === "medium" || riskClass === "high");
549
869
  const nonEmptyLineCount = source.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
550
870
  const candidate: CompassInventoryEntry = {
551
871
  path: pathFromRoot,
@@ -564,6 +884,11 @@ export async function createCompassInventoryEntries(
564
884
  nonEmptyLineCount,
565
885
  hasModuleContract: markup.hasModuleContract,
566
886
  hasChangeSummary: markup.hasChangeSummary,
887
+ hasKeyDecisions: markup.hasKeyDecisions,
888
+ keyDecisionsItemCount: markup.keyDecisionsItemCount,
889
+ keyDecisionsRequired,
890
+ changeSummaryItemCount: markup.changeSummaryItemCount,
891
+ historyIds: markup.historyIds,
567
892
  hasAiInvariant: markup.hasAiInvariant,
568
893
  hasPurpose: markup.hasPurpose,
569
894
  hasNonGoals: markup.hasNonGoals,
@@ -573,6 +898,7 @@ export async function createCompassInventoryEntries(
573
898
  };
574
899
  candidate.violations = detectComplianceViolations(candidate);
575
900
  candidate.compliant = candidate.violations.length === 0;
901
+ entrySources.set(candidate, source);
576
902
  entries.push(candidate);
577
903
  }
578
904