@warpgogol/forge 2.8.0 → 2.8.2

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 (271) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +23 -13
  3. package/README.uk.md +19 -12
  4. package/dist/os/session/handlers/validate.d.ts.map +1 -1
  5. package/dist/os/session/handlers/validate.js +23 -1
  6. package/dist/os/session/handlers/validate.js.map +1 -1
  7. package/dist/os/session/types.d.ts +31 -1
  8. package/dist/os/session/types.d.ts.map +1 -1
  9. package/dist/os/session/types.js +7 -0
  10. package/dist/os/session/types.js.map +1 -1
  11. package/os/adr/adr.module.ts +158 -0
  12. package/os/adr/frontmatter-io.ts +80 -0
  13. package/os/adr/handlers/archive.ts +244 -0
  14. package/os/adr/handlers/implement-stamp.ts +331 -0
  15. package/os/adr/handlers/list-create.ts +207 -0
  16. package/os/adr/handlers/validate.test.ts +232 -0
  17. package/os/adr/handlers/validate.ts +429 -0
  18. package/os/adr/index.ts +33 -0
  19. package/os/adr/types.ts +141 -0
  20. package/os/audit/audit.module.ts +48 -0
  21. package/os/audit/frontmatter-io.ts +84 -0
  22. package/os/audit/handlers/archive.ts +239 -0
  23. package/os/audit/index.ts +23 -0
  24. package/os/audit/types.ts +29 -0
  25. package/os/compass/compass.module.ts +186 -0
  26. package/os/compass/handlers/compass-audit-handler.ts +382 -0
  27. package/os/compass/handlers/compass-change-summary-handler.ts +272 -0
  28. package/os/compass/handlers/compass-inventory-handler.ts +294 -0
  29. package/os/compass/handlers/compass-inventory.ts +521 -0
  30. package/os/compass/handlers/git-revision.ts +137 -0
  31. package/os/compass/handlers/resolve-scan-root-workpiece.test.ts +65 -0
  32. package/os/compass/handlers/resolve-scan-root.ts +79 -0
  33. package/os/compass/index.ts +33 -0
  34. package/os/core/core.module.ts +948 -0
  35. package/os/core/handlers/assets-check.ts +122 -0
  36. package/os/core/handlers/assets-helpers.ts +174 -0
  37. package/os/core/handlers/assets-list.ts +99 -0
  38. package/os/core/handlers/build.ts +155 -0
  39. package/os/core/handlers/determinism-check.ts +354 -0
  40. package/os/core/handlers/dev.ts +127 -0
  41. package/os/core/handlers/invariant-engine.test.ts +681 -0
  42. package/os/core/handlers/knowledge-compact.ts +248 -0
  43. package/os/core/handlers/lifecycle-handlers.test.ts +524 -0
  44. package/os/core/handlers/note-frontmatter-validate.test.ts +93 -0
  45. package/os/core/handlers/note-link-validate.test.ts +117 -0
  46. package/os/core/handlers/note-orphan-detect.test.ts +94 -0
  47. package/os/core/handlers/package-health.test.ts +226 -0
  48. package/os/core/handlers/package-health.ts +231 -0
  49. package/os/core/handlers/pinned-check.ts +200 -0
  50. package/os/core/handlers/pinned-init.ts +333 -0
  51. package/os/core/handlers/pinned-types.ts +50 -0
  52. package/os/core/handlers/pinned-validate.ts +301 -0
  53. package/os/core/handlers/profile-resolve.ts +92 -0
  54. package/os/core/handlers/release-prepare.ts +294 -0
  55. package/os/core/handlers/release-publish.ts +211 -0
  56. package/os/core/handlers/validate.ts +262 -0
  57. package/os/core/handlers/workspace-deps.ts +55 -0
  58. package/os/core/index.ts +13 -0
  59. package/os/exploration/exploration.module.ts +90 -0
  60. package/os/exploration/frontmatter-io.ts +77 -0
  61. package/os/exploration/handlers/archive.ts +159 -0
  62. package/os/exploration/handlers/list.ts +77 -0
  63. package/os/exploration/handlers/show.ts +107 -0
  64. package/os/exploration/index.ts +25 -0
  65. package/os/exploration/types.ts +65 -0
  66. package/os/mission/handlers/archive.test.ts +391 -0
  67. package/os/mission/handlers/archive.ts +453 -0
  68. package/os/mission/index.ts +1 -0
  69. package/os/mission/mission.module.ts +48 -0
  70. package/os/mission/types.ts +41 -0
  71. package/os/naming/index.ts +14 -0
  72. package/os/naming/naming-convention.test.ts +163 -0
  73. package/os/naming/naming-convention.ts +378 -0
  74. package/os/naming/naming.module.ts +36 -0
  75. package/os/notes/index.ts +10 -0
  76. package/os/notes/notes.module.ts +109 -0
  77. package/os/plan/frontmatter-io.ts +82 -0
  78. package/os/plan/handlers/archive.ts +238 -0
  79. package/os/plan/index.ts +23 -0
  80. package/os/plan/plan.module.ts +48 -0
  81. package/os/plan/types.ts +29 -0
  82. package/os/program/discovery.ts +294 -0
  83. package/os/program/handlers/complete.ts +419 -0
  84. package/os/program/handlers/lease.ts +433 -0
  85. package/os/program/handlers/seal.ts +332 -0
  86. package/os/program/handlers/validate.ts +213 -0
  87. package/os/program/lease.ts +156 -0
  88. package/os/program/program.module.ts +212 -0
  89. package/os/program/schemas.ts +218 -0
  90. package/os/program/state.ts +264 -0
  91. package/os/rfc/acceptance.ts +344 -0
  92. package/os/rfc/decision-log.ts +310 -0
  93. package/os/rfc/dna-trace.ts +280 -0
  94. package/os/rfc/frontmatter-io.test.ts +132 -0
  95. package/os/rfc/frontmatter-io.ts +130 -0
  96. package/os/rfc/handlers/archive.ts +300 -0
  97. package/os/rfc/handlers/check.ts +238 -0
  98. package/os/rfc/handlers/implement-stamp.ts +515 -0
  99. package/os/rfc/handlers/index-graph.ts +237 -0
  100. package/os/rfc/handlers/lifecycle.test.ts +127 -0
  101. package/os/rfc/handlers/lifecycle.ts +249 -0
  102. package/os/rfc/handlers/list-create.ts +296 -0
  103. package/os/rfc/handlers/pipeline-status.ts +173 -0
  104. package/os/rfc/handlers/shared.ts +121 -0
  105. package/os/rfc/handlers/supersede-propose.ts +241 -0
  106. package/os/rfc/handlers/validate-rules.test.ts +891 -0
  107. package/os/rfc/handlers/validate-rules.ts +984 -0
  108. package/os/rfc/handlers/validate.ts +166 -0
  109. package/os/rfc/handlers.ts +19 -0
  110. package/os/rfc/index.ts +97 -0
  111. package/os/rfc/rfc.module.ts +416 -0
  112. package/os/rfc/types.ts +596 -0
  113. package/os/rfc/verification-evidence.ts +244 -0
  114. package/os/session/atif-parser.ts +166 -0
  115. package/os/session/frontmatter-io.ts +163 -0
  116. package/os/session/handlers/archive.ts +288 -0
  117. package/os/session/handlers/list.ts +174 -0
  118. package/os/session/handlers/save.ts +348 -0
  119. package/os/session/handlers/validate.ts +250 -0
  120. package/os/session/index.ts +50 -0
  121. package/os/session/session.module.ts +126 -0
  122. package/os/session/types.ts +203 -0
  123. package/os/spec/live-spec-list-show-validate.test.ts +257 -0
  124. package/os/spec/live-spec-list.ts +78 -0
  125. package/os/spec/live-spec-merge.test.ts +261 -0
  126. package/os/spec/live-spec-merge.ts +413 -0
  127. package/os/spec/live-spec-show.ts +117 -0
  128. package/os/spec/live-spec-types.ts +93 -0
  129. package/os/spec/live-spec-validate.ts +170 -0
  130. package/os/spec/spec-materialize.ts +390 -0
  131. package/os/spec/spec-schema.ts +174 -0
  132. package/os/spec/spec-status.ts +284 -0
  133. package/os/spec/spec-validate.ts +513 -0
  134. package/os/spec/spec.module.ts +135 -0
  135. package/os/werkstatt/handlers/lock.ts +168 -0
  136. package/os/werkstatt/handlers/schema.ts +47 -0
  137. package/os/werkstatt/handlers/werkstatt-lock-recover.ts +175 -0
  138. package/os/werkstatt/handlers/werkstatt-lock-status.ts +73 -0
  139. package/os/werkstatt/handlers/werkstatt-operation-validate.ts +104 -0
  140. package/os/werkstatt/index.ts +39 -0
  141. package/os/werkstatt/werkstatt.module.ts +62 -0
  142. package/os/workflow/handlers.ts +304 -0
  143. package/os/workflow/index.ts +28 -0
  144. package/os/workflow/types.ts +99 -0
  145. package/os/workflow/workflow.module.ts +54 -0
  146. package/package.json +3 -3
  147. package/profiles/godot-csharp.yaml +4 -4
  148. package/profiles/knowledge-typescript-turborepo.yaml +110 -0
  149. package/profiles/phaser-turborepo.yaml +5 -5
  150. package/skills/_shared/fo-session-summary.md +132 -16
  151. package/skills/fo/fo-add-tests/pbt-guide.md +13 -13
  152. package/skills/fo/fo-doc-audit/SKILL.md +6 -6
  153. package/skills/fo/fo-handoff/SKILL.md +5 -0
  154. package/skills/fo/fo-idea/SKILL.md +1 -1
  155. package/skills/fo/fo-review/SKILL.md +1 -1
  156. package/skills/fo/fo-session-retro/SKILL.md +8 -0
  157. package/skills/fo/fo-session-save/SKILL.md +8 -0
  158. package/src/cli-output.ts +59 -0
  159. package/src/config/__tests__/resolve-terminology.test.ts +45 -0
  160. package/src/config/forge-config.ts +524 -0
  161. package/src/forge-module.ts +39 -0
  162. package/src/index.ts +172 -0
  163. package/src/knowledge/__tests__/promote.test.ts +219 -0
  164. package/src/knowledge/__tests__/serialize.test.ts +192 -0
  165. package/src/knowledge/budgets.ts +202 -0
  166. package/src/knowledge/compact.ts +405 -0
  167. package/src/knowledge/index.ts +53 -0
  168. package/src/knowledge/parse.ts +227 -0
  169. package/src/knowledge/promote.ts +156 -0
  170. package/src/knowledge/schema.ts +112 -0
  171. package/src/knowledge/serialize.ts +72 -0
  172. package/src/migration-adapters/__tests__/ignored-files.test.ts +51 -0
  173. package/src/migration-adapters/__tests__/registry.test.ts +131 -0
  174. package/src/migration-adapters/__tests__/types.test.ts +86 -0
  175. package/src/migration-adapters/git-utils.ts +71 -0
  176. package/src/migration-adapters/ignored-files.ts +221 -0
  177. package/src/migration-adapters/index.ts +21 -0
  178. package/src/migration-adapters/node-typescript-pnpm/index.ts +189 -0
  179. package/src/migration-adapters/phaser-pnpm/index.ts +189 -0
  180. package/src/migration-adapters/registry.ts +61 -0
  181. package/src/migration-adapters/types.ts +68 -0
  182. package/src/onboarding/__tests__/workspace-discovery.test.ts +134 -0
  183. package/src/onboarding/agents-generate.ts +443 -0
  184. package/src/onboarding/create.ts +427 -0
  185. package/src/onboarding/doctor.ts +1297 -0
  186. package/src/onboarding/init.ts +337 -0
  187. package/src/onboarding/invariant-engine.ts +448 -0
  188. package/src/onboarding/memory-scaffold.ts +189 -0
  189. package/src/onboarding/nested-agents-generate.ts +93 -0
  190. package/src/onboarding/nested-agents-templates.ts +233 -0
  191. package/src/onboarding/profile-validate.ts +139 -0
  192. package/src/onboarding/scaffold-project.ts +299 -0
  193. package/src/onboarding/scaffold.ts +143 -0
  194. package/src/onboarding/upgrade.ts +558 -0
  195. package/src/onboarding/workspace-discovery.ts +167 -0
  196. package/src/profiles/__tests__/profile-schema.test.ts +228 -0
  197. package/src/profiles/__tests__/stack-profile.test.ts +190 -0
  198. package/src/profiles/__tests__/terminology-utils.test.ts +71 -0
  199. package/src/profiles/profile-schema.ts +350 -0
  200. package/src/profiles/stack-profile.ts +188 -0
  201. package/src/profiles/terminology-utils.ts +36 -0
  202. package/src/registry.ts +185 -0
  203. package/src/skill-schema.ts +37 -0
  204. package/src/tests/acceptance-criteria.test.ts +99 -0
  205. package/src/tests/adr-implement-stamp.test.ts +368 -0
  206. package/src/tests/agents-generate-domain.test.ts +317 -0
  207. package/src/tests/agents-generate.test.ts +458 -0
  208. package/src/tests/bindings-schema.test.ts +314 -0
  209. package/src/tests/budgets.test.ts +213 -0
  210. package/src/tests/cli-output.test.ts +196 -0
  211. package/src/tests/compact.test.ts +796 -0
  212. package/src/tests/create.test.ts +289 -0
  213. package/src/tests/doctor-autonomy.test.ts +98 -0
  214. package/src/tests/doctor-bindings.test.ts +286 -0
  215. package/src/tests/doctor-domain.test.ts +113 -0
  216. package/src/tests/exploration-handlers.test.ts +256 -0
  217. package/src/tests/fixtures/agents-generate-business-before.txt +288 -0
  218. package/src/tests/forge-config.test.ts +420 -0
  219. package/src/tests/fs-atomic.test.ts +96 -0
  220. package/src/tests/fs.test.ts +103 -0
  221. package/src/tests/generated-marker.test.ts +161 -0
  222. package/src/tests/hash.test.ts +37 -0
  223. package/src/tests/implement-stamp.test.ts +505 -0
  224. package/src/tests/init-bindings.test.ts +183 -0
  225. package/src/tests/knowledge-parse.test.ts +307 -0
  226. package/src/tests/knowledge-pbt.test.ts +164 -0
  227. package/src/tests/memory-scaffold.test.ts +192 -0
  228. package/src/tests/migration-adapters.test.ts +480 -0
  229. package/src/tests/package-files.test.ts +54 -0
  230. package/src/tests/pinned-check.test.ts +194 -0
  231. package/src/tests/pinned-init.test.ts +126 -0
  232. package/src/tests/profile-schema.test.ts +194 -0
  233. package/src/tests/profile-validate.test.ts +120 -0
  234. package/src/tests/program-lease.test.ts +245 -0
  235. package/src/tests/program-paths-extended.test.ts +117 -0
  236. package/src/tests/program-paths.test.ts +98 -0
  237. package/src/tests/program-property.test.ts +139 -0
  238. package/src/tests/program-schemas.test.ts +445 -0
  239. package/src/tests/program-spec-node.test.ts +250 -0
  240. package/src/tests/promote.test.ts +481 -0
  241. package/src/tests/registry.test.ts +73 -0
  242. package/src/tests/scaffold-project.test.ts +150 -0
  243. package/src/tests/session-handlers.test.ts +503 -0
  244. package/src/tests/session-pbt.test.ts +190 -0
  245. package/src/tests/skill-schema.test.ts +146 -0
  246. package/src/tests/skill-validate-knowledge.test.ts +26 -0
  247. package/src/tests/skill-validate.test.ts +254 -0
  248. package/src/tests/stack-profile.test.ts +221 -0
  249. package/src/tests/string-utils.test.ts +42 -0
  250. package/src/tests/upgrade.test.ts +428 -0
  251. package/src/tests/werkstatt-lock.test.ts +343 -0
  252. package/src/tests/workspace-discovery-domain.test.ts +112 -0
  253. package/src/tests/workspace-discovery.test.ts +135 -0
  254. package/src/types.ts +221 -0
  255. package/src/utils/__tests__/fs-idempotent.test.ts +73 -0
  256. package/src/utils/__tests__/fs-trash.test.ts +45 -0
  257. package/src/utils/fs-atomic.ts +93 -0
  258. package/src/utils/fs-idempotent.ts +41 -0
  259. package/src/utils/fs-trash-sync.ts +37 -0
  260. package/src/utils/fs-trash.ts +24 -0
  261. package/src/utils/fs.ts +74 -0
  262. package/src/utils/generated-marker.ts +166 -0
  263. package/src/utils/hash.ts +19 -0
  264. package/src/utils/index.ts +29 -0
  265. package/src/utils/string-utils.ts +19 -0
  266. package/src/validators/__tests__/note-orphan-detect.test.ts +94 -0
  267. package/src/validators/note-frontmatter-validate.ts +142 -0
  268. package/src/validators/note-link-validate.ts +145 -0
  269. package/src/validators/note-orphan-detect.ts +146 -0
  270. package/src/validators/port-validate.ts +97 -0
  271. package/src/validators/skill-validate.ts +831 -0
@@ -0,0 +1,448 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Generic invariant enforcement engine — reads check declarations from profile invariants and verifies files against them. Supports filename-pattern, file-contains, file-not-contains, and attribute-pattern check kinds.</purpose>
4
+ <non-goals>
5
+ <item>Do not import from @warpgogol/* — this module is portable.</item>
6
+ <item>Do not implement domain-specific validation logic — all rules are declared in profile YAML.</item>
7
+ </non-goals>
8
+ </MODULE_CONTRACT>
9
+ <CHANGE_SUMMARY>
10
+ <item>RFC-0675: initial invariant enforcement engine with three check kinds.</item>
11
+ <item>RFC-0691: add html-attribute-pattern check kind for HTML attribute value validation.</item>
12
+ <item>RFC-0694: replace html-attribute-pattern with attribute-pattern (elements array) for HTML+JSX support.</item>
13
+ <item>RFC-0808: add link-resolution, frontmatter-required, path-exclusion check kinds for note vault validation.</item>
14
+ <item>Add gitignore-entry and secret-scan check kinds for godot-csharp profile.</item>
15
+ </CHANGE_SUMMARY>
16
+ */
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { parse as parseYaml } from "yaml";
21
+ import type { StackProfile } from "../profiles/stack-profile.ts";
22
+ import type { ProfileInvariant } from "../profiles/profile-schema.ts";
23
+
24
+ export interface InvariantViolation {
25
+ invariantId: string;
26
+ severity: "error" | "warning";
27
+ rule: string;
28
+ file: string;
29
+ message: string;
30
+ }
31
+
32
+ export interface InvariantCheckResult {
33
+ invariantId: string;
34
+ severity: "error" | "warning";
35
+ rule: string;
36
+ checked: boolean;
37
+ violations: InvariantViolation[];
38
+ }
39
+
40
+ function matchGlob(filePath: string, glob: string): boolean {
41
+ const normalizedPath = filePath.replace(/\\/g, "/");
42
+ const globRegex = globToRegex(glob);
43
+ return globRegex.test(normalizedPath);
44
+ }
45
+
46
+ function globToRegex(glob: string): RegExp {
47
+ let pattern = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
48
+ // Handle **/ : matches zero or more directory segments
49
+ pattern = pattern.replace(/\*\*\//g, "@@GLOBSTAR@@");
50
+ // Handle remaining ** (not followed by /)
51
+ pattern = pattern.replace(/\*\*/g, "@@GLOBSTAR2@@");
52
+ // Handle single * (matches within a path segment, no /)
53
+ pattern = pattern.replace(/\*/g, "[^/]*");
54
+ pattern = pattern.replace(/\?/g, "[^/]");
55
+ // Restore globstars
56
+ pattern = pattern.replace(/@@GLOBSTAR@@/g, "(?:[^/]+/)*");
57
+ pattern = pattern.replace(/@@GLOBSTAR2@@/g, ".*");
58
+ // Handle brace expansion {a,b}
59
+ pattern = pattern.replace(/\\{([^}]+)\\}/g, (_match, content: string) => {
60
+ const options = content.split(",").map((s: string) => s.trim());
61
+ return `(${options.join("|")})`;
62
+ });
63
+ pattern = `^${pattern}$`;
64
+ return new RegExp(pattern);
65
+ }
66
+
67
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".turbo", "dist", ".cache"]);
68
+
69
+ function collectFiles(dir: string, baseDir: string, results: string[]): void {
70
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
71
+ for (const entry of entries) {
72
+ if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
73
+ const fullPath = path.join(dir, entry.name);
74
+ const relPath = path.relative(baseDir, fullPath).replace(/\\/g, "/");
75
+ if (entry.isDirectory()) {
76
+ collectFiles(fullPath, baseDir, results);
77
+ } else {
78
+ results.push(relPath);
79
+ }
80
+ }
81
+ }
82
+
83
+ function compilePattern(
84
+ invariant: ProfileInvariant,
85
+ pattern: string,
86
+ ): RegExp | { error: InvariantViolation } {
87
+ try {
88
+ return new RegExp(pattern);
89
+ } catch (err) {
90
+ const e = err as Error;
91
+ return {
92
+ error: {
93
+ invariantId: invariant.id,
94
+ severity: "warning",
95
+ rule: invariant.rule,
96
+ file: "",
97
+ message: `Invariant ${invariant.id} has invalid check pattern: ${e.message}`,
98
+ },
99
+ };
100
+ }
101
+ }
102
+
103
+ function filterByGlob(files: string[], glob: string): string[] {
104
+ return files.filter((f) => matchGlob(f, glob));
105
+ }
106
+
107
+ function escapeRegex(str: string): string {
108
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
109
+ }
110
+
111
+ function runCheck(
112
+ invariant: ProfileInvariant,
113
+ allFiles: string[],
114
+ workspaceRoot: string,
115
+ check: NonNullable<ProfileInvariant["check"]>,
116
+ ): InvariantViolation[] {
117
+ const glob = check.glob;
118
+ const pattern = check.pattern ?? check.negatedPattern;
119
+
120
+ if (check.kind === "gitignore-entry") {
121
+ const gitignorePath = path.join(workspaceRoot, ".gitignore");
122
+ try {
123
+ const content = fs.readFileSync(gitignorePath, "utf8");
124
+ const lines = content.split(/\r?\n/).map((l) => l.trim());
125
+ if (!lines.includes(pattern ?? "")) {
126
+ return [{
127
+ invariantId: invariant.id,
128
+ severity: invariant.severity,
129
+ rule: invariant.rule,
130
+ file: ".gitignore",
131
+ message: `.gitignore does not contain entry '${pattern}'`,
132
+ }];
133
+ }
134
+ } catch {
135
+ return [{
136
+ invariantId: invariant.id,
137
+ severity: invariant.severity,
138
+ rule: invariant.rule,
139
+ file: ".gitignore",
140
+ message: `.gitignore not found`,
141
+ }];
142
+ }
143
+ return [];
144
+ }
145
+
146
+ if (!glob) return [];
147
+
148
+ const matchedFiles = filterByGlob(allFiles, glob);
149
+
150
+ const needsPattern = check.kind === "filename-pattern" ||
151
+ check.kind === "file-contains" ||
152
+ check.kind === "file-not-contains" ||
153
+ check.kind === "attribute-pattern";
154
+ if (needsPattern && !pattern) return [];
155
+
156
+ const compiled = pattern ? compilePattern(invariant, pattern) : null;
157
+ if (compiled && "error" in compiled) return [compiled.error];
158
+ const regex = compiled && !("error" in compiled) ? compiled : null;
159
+
160
+ const violations: InvariantViolation[] = [];
161
+
162
+ switch (check.kind) {
163
+ case "filename-pattern":
164
+ for (const file of matchedFiles) {
165
+ const filename = path.basename(file);
166
+ if (!regex!.test(filename)) {
167
+ violations.push({
168
+ invariantId: invariant.id,
169
+ severity: invariant.severity,
170
+ rule: invariant.rule,
171
+ file,
172
+ message: `Filename '${filename}' does not match pattern '${pattern}'`,
173
+ });
174
+ }
175
+ }
176
+ break;
177
+ case "file-contains":
178
+ for (const file of matchedFiles) {
179
+ const fullPath = path.join(workspaceRoot, file);
180
+ try {
181
+ const content = fs.readFileSync(fullPath, "utf8");
182
+ if (!regex!.test(content)) {
183
+ violations.push({
184
+ invariantId: invariant.id,
185
+ severity: invariant.severity,
186
+ rule: invariant.rule,
187
+ file,
188
+ message: `File '${file}' does not contain pattern '${pattern}'`,
189
+ });
190
+ }
191
+ } catch {
192
+ // skip unreadable files
193
+ }
194
+ }
195
+ break;
196
+ case "file-not-contains":
197
+ for (const file of matchedFiles) {
198
+ const fullPath = path.join(workspaceRoot, file);
199
+ try {
200
+ const content = fs.readFileSync(fullPath, "utf8");
201
+ if (regex!.test(content)) {
202
+ violations.push({
203
+ invariantId: invariant.id,
204
+ severity: invariant.severity,
205
+ rule: invariant.rule,
206
+ file,
207
+ message: `File '${file}' contains forbidden pattern '${pattern}'`,
208
+ });
209
+ }
210
+ } catch {
211
+ // skip unreadable files
212
+ }
213
+ }
214
+ break;
215
+ case "attribute-pattern": {
216
+ const elements = check.elements;
217
+ const attribute = check.attribute;
218
+ if (!elements || elements.length === 0 || !attribute) {
219
+ violations.push({
220
+ invariantId: invariant.id,
221
+ severity: "warning",
222
+ rule: invariant.rule,
223
+ file: "",
224
+ message: `Invariant ${invariant.id} has attribute-pattern check without elements or attribute`,
225
+ });
226
+ break;
227
+ }
228
+ const elementAlternation = elements.map(escapeRegex).join("|");
229
+ const elementRegex = new RegExp(`<(${elementAlternation})[^>]*>`, "gi");
230
+ const attrRegex = new RegExp(
231
+ `${attribute}="([^"]*)"|${attribute}='([^']*)'`,
232
+ "i",
233
+ );
234
+ for (const file of matchedFiles) {
235
+ const fullPath = path.join(workspaceRoot, file);
236
+ try {
237
+ const content = fs.readFileSync(fullPath, "utf8");
238
+ elementRegex.lastIndex = 0;
239
+ let match: RegExpExecArray | null;
240
+ while ((match = elementRegex.exec(content)) !== null) {
241
+ const elementSnippet = match[0];
242
+ const matchedElement = match[1];
243
+ const attrMatch = attrRegex.exec(elementSnippet);
244
+ if (!attrMatch) continue;
245
+ const attrValue = attrMatch[1] ?? attrMatch[2] ?? "";
246
+ if (!regex!.test(attrValue)) {
247
+ violations.push({
248
+ invariantId: invariant.id,
249
+ severity: invariant.severity,
250
+ rule: invariant.rule,
251
+ file,
252
+ message: `Element <${matchedElement}> attribute '${attribute}' value '${attrValue}' does not match pattern '${pattern}'`,
253
+ });
254
+ }
255
+ }
256
+ } catch {
257
+ // skip unreadable files
258
+ }
259
+ }
260
+ break;
261
+ }
262
+ case "link-resolution": {
263
+ const allMdFiles = allFiles.filter((f) => f.endsWith(".md"));
264
+ const noteMap = new Map<string, string>();
265
+ for (const mdFile of allMdFiles) {
266
+ const basename = path.basename(mdFile, ".md");
267
+ noteMap.set(basename, mdFile);
268
+ const fullPath = path.join(workspaceRoot, mdFile);
269
+ try {
270
+ const content = fs.readFileSync(fullPath, "utf8");
271
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
272
+ if (fmMatch) {
273
+ try {
274
+ const fm = parseYaml(fmMatch[1]) as Record<string, unknown>;
275
+ if (fm.aliases && Array.isArray(fm.aliases)) {
276
+ for (const alias of fm.aliases) {
277
+ if (typeof alias === "string") noteMap.set(alias, mdFile);
278
+ }
279
+ }
280
+ } catch {
281
+ // skip malformed frontmatter
282
+ }
283
+ }
284
+ } catch {
285
+ // skip unreadable files
286
+ }
287
+ }
288
+ const linkRegex = /\[\[([^\]]+?)\]\]/g;
289
+ for (const file of matchedFiles) {
290
+ const fullPath = path.join(workspaceRoot, file);
291
+ try {
292
+ const content = fs.readFileSync(fullPath, "utf8");
293
+ const lines = content.split(/\r?\n/);
294
+ for (let i = 0; i < lines.length; i++) {
295
+ let match: RegExpExecArray | null;
296
+ linkRegex.lastIndex = 0;
297
+ while ((match = linkRegex.exec(lines[i])) !== null) {
298
+ const linkTarget = match[1].split("|")[0].split("#")[0].trim();
299
+ if (!linkTarget) continue;
300
+ if (!noteMap.has(linkTarget)) {
301
+ violations.push({
302
+ invariantId: invariant.id,
303
+ severity: invariant.severity,
304
+ rule: invariant.rule,
305
+ file,
306
+ message: `Wikilink target '${linkTarget}' not found in vault`,
307
+ });
308
+ }
309
+ }
310
+ }
311
+ } catch {
312
+ // skip unreadable files
313
+ }
314
+ }
315
+ break;
316
+ }
317
+ case "frontmatter-required": {
318
+ const fields = check.fields;
319
+ if (!fields || fields.length === 0) break;
320
+ for (const file of matchedFiles) {
321
+ const fullPath = path.join(workspaceRoot, file);
322
+ try {
323
+ const content = fs.readFileSync(fullPath, "utf8");
324
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
325
+ if (!fmMatch) {
326
+ violations.push({
327
+ invariantId: invariant.id,
328
+ severity: invariant.severity,
329
+ rule: invariant.rule,
330
+ file,
331
+ message: `File '${file}' has no frontmatter`,
332
+ });
333
+ break;
334
+ }
335
+ let fm: Record<string, unknown> = {};
336
+ try {
337
+ fm = parseYaml(fmMatch[1]) as Record<string, unknown>;
338
+ } catch {
339
+ violations.push({
340
+ invariantId: invariant.id,
341
+ severity: invariant.severity,
342
+ rule: invariant.rule,
343
+ file,
344
+ message: `File '${file}' has malformed frontmatter`,
345
+ });
346
+ break;
347
+ }
348
+ for (const field of fields) {
349
+ if (!(field in fm) || fm[field] == null || fm[field] === "") {
350
+ const hasH1 = /^\s*#\s+\S/.test(content.slice(fmMatch[0].length));
351
+ if (field === "title" && hasH1) continue;
352
+ violations.push({
353
+ invariantId: invariant.id,
354
+ severity: invariant.severity,
355
+ rule: invariant.rule,
356
+ file,
357
+ message: `File '${file}' missing required field '${field}'`,
358
+ });
359
+ }
360
+ }
361
+ } catch {
362
+ // skip unreadable files
363
+ }
364
+ }
365
+ break;
366
+ }
367
+ case "path-exclusion": {
368
+ for (const file of matchedFiles) {
369
+ violations.push({
370
+ invariantId: invariant.id,
371
+ severity: invariant.severity,
372
+ rule: invariant.rule,
373
+ file,
374
+ message: `File '${file}' matches excluded pattern`,
375
+ });
376
+ }
377
+ break;
378
+ }
379
+ case "secret-scan": {
380
+ const secretPatterns = [
381
+ /(?:api[_-]?key|apikey)["'\s]*[:=]["'\s]*["'`]([A-Za-z0-9_\-]{20,})["'`]/gi,
382
+ /(?:secret|token|password|passwd)["'\s]*[:=]["'\s]*["'`]([^"'`\s]{8,})["'`]/gi,
383
+ /-----BEGIN [A-Z]+ PRIVATE KEY-----/g,
384
+ /sk_[a-zA-Z0-9]{20,}/g,
385
+ /gh[pousr]_[A-Za-z0-9]{36,}/g,
386
+ /AKIA[0-9A-Z]{16}/g,
387
+ ];
388
+ for (const file of matchedFiles) {
389
+ const fullPath = path.join(workspaceRoot, file);
390
+ try {
391
+ const content = fs.readFileSync(fullPath, "utf8");
392
+ for (const secretPattern of secretPatterns) {
393
+ secretPattern.lastIndex = 0;
394
+ let match: RegExpExecArray | null;
395
+ while ((match = secretPattern.exec(content)) !== null) {
396
+ violations.push({
397
+ invariantId: invariant.id,
398
+ severity: invariant.severity,
399
+ rule: invariant.rule,
400
+ file,
401
+ message: `Potential secret detected: ${match[0].slice(0, 30)}...`,
402
+ });
403
+ }
404
+ }
405
+ } catch {
406
+ // skip unreadable files
407
+ }
408
+ }
409
+ break;
410
+ }
411
+ }
412
+
413
+ return violations;
414
+ }
415
+
416
+ export function checkInvariants(
417
+ profile: StackProfile,
418
+ workspaceRoot: string,
419
+ ): InvariantCheckResult[] {
420
+ if (!profile.invariants || profile.invariants.length === 0) {
421
+ return [];
422
+ }
423
+
424
+ const allFiles: string[] = [];
425
+ collectFiles(workspaceRoot, workspaceRoot, allFiles);
426
+
427
+ return profile.invariants.map((invariant) => {
428
+ if (!invariant.check) {
429
+ return {
430
+ invariantId: invariant.id,
431
+ severity: invariant.severity,
432
+ rule: invariant.rule,
433
+ checked: false,
434
+ violations: [],
435
+ };
436
+ }
437
+
438
+ const violations = runCheck(invariant, allFiles, workspaceRoot, invariant.check);
439
+
440
+ return {
441
+ invariantId: invariant.id,
442
+ severity: invariant.severity,
443
+ rule: invariant.rule,
444
+ checked: true,
445
+ violations,
446
+ };
447
+ });
448
+ }
@@ -0,0 +1,189 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Memory layer scaffold and health check (RFC-0664). Creates .agents/memory/MEMORY.md, .agents/memory/daily/.gitkeep, and a marker-delimited .gitignore block. Reports health for forge.doctor.</purpose>
4
+ <non-goals>
5
+ <item>Do not overwrite existing MEMORY.md or daily/ files — idempotent only.</item>
6
+ <item>Do not enforce the read discipline — that is advisory, via AGENTS.md rules.</item>
7
+ <item>Do not change doctor's exit code — memory-layer warnings are advisory only.</item>
8
+ </non-goals>
9
+ </MODULE_CONTRACT>
10
+ <CHANGE_SUMMARY>
11
+ <item>RFC-0664: initial memory layer scaffold and health check.</item>
12
+ </CHANGE_SUMMARY>
13
+ */
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { loadForgeConfig } from "../config/forge-config.ts";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Constants
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export const MEMORY_GITIGNORE_START = "# forge-agent-memory";
24
+ export const MEMORY_GITIGNORE_END = "# /forge-agent-memory";
25
+
26
+ export const DEFAULT_MEMORY_BUDGET = 4096;
27
+
28
+ const MEMORY_DIR = ".agents/memory";
29
+ const DAILY_DIR = "daily";
30
+ const MEMORY_MD = "MEMORY.md";
31
+ const GITKEEP = ".gitkeep";
32
+
33
+ const MEMORY_MD_TEMPLATE = `# Project Memory
34
+
35
+ Curated project context (RFC-0664). This file is versioned — daily logs in \`daily/\` are git-ignored.
36
+
37
+ ## Current focus
38
+
39
+ <!-- What is being worked on right now. One to three bullets max. -->
40
+
41
+ ## Decisions in flight
42
+
43
+ <!-- Decisions under discussion but not yet final. -->
44
+
45
+ ## Environment notes
46
+
47
+ <!-- Tool versions, environment quirks, known issues. -->
48
+ `;
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Types
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export interface MemoryScaffoldResult {
55
+ created: string[];
56
+ gitignoreUpdated: boolean;
57
+ skipped: string[];
58
+ }
59
+
60
+ export interface MemoryLayerHealth {
61
+ memoryMdChars: number;
62
+ budget: number;
63
+ gitignoreCoversDaily: boolean;
64
+ dailyFileCount: number;
65
+ memoryMdExists: boolean;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Scaffold
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function hasGitignoreBlock(content: string): boolean {
73
+ return content.includes(MEMORY_GITIGNORE_START) && content.includes(MEMORY_GITIGNORE_END);
74
+ }
75
+
76
+ function insertGitignoreBlock(existingContent: string): string {
77
+ const block = `${MEMORY_GITIGNORE_START}\n${MEMORY_DIR}/${DAILY_DIR}/\n${MEMORY_GITIGNORE_END}`;
78
+
79
+ if (existingContent.length === 0) {
80
+ return block + "\n";
81
+ }
82
+
83
+ // Ensure existing content ends with a newline before appending
84
+ const trimmed = existingContent.endsWith("\n") ? existingContent : existingContent + "\n";
85
+ return trimmed + "\n" + block + "\n";
86
+ }
87
+
88
+ export function scaffoldMemoryLayer(workspaceRoot: string): MemoryScaffoldResult {
89
+ const created: string[] = [];
90
+ const skipped: string[] = [];
91
+ let gitignoreUpdated = false;
92
+
93
+ const memoryDir = path.join(workspaceRoot, MEMORY_DIR);
94
+ const dailyDirPath = path.join(memoryDir, DAILY_DIR);
95
+ const memoryMdPath = path.join(memoryDir, MEMORY_MD);
96
+ const gitkeepPath = path.join(dailyDirPath, GITKEEP);
97
+
98
+ // Create .agents/memory/ directory
99
+ fs.mkdirSync(memoryDir, { recursive: true });
100
+ fs.mkdirSync(dailyDirPath, { recursive: true });
101
+
102
+ // MEMORY.md — create only if absent
103
+ if (fs.existsSync(memoryMdPath)) {
104
+ skipped.push(MEMORY_MD);
105
+ } else {
106
+ fs.writeFileSync(memoryMdPath, MEMORY_MD_TEMPLATE, "utf8");
107
+ created.push(`${MEMORY_DIR}/${MEMORY_MD}`);
108
+ }
109
+
110
+ // daily/.gitkeep — create only if absent
111
+ if (fs.existsSync(gitkeepPath)) {
112
+ skipped.push(`${DAILY_DIR}/${GITKEEP}`);
113
+ } else {
114
+ fs.writeFileSync(gitkeepPath, "", "utf8");
115
+ created.push(`${MEMORY_DIR}/${DAILY_DIR}/${GITKEEP}`);
116
+ }
117
+
118
+ // .gitignore — add marker block if absent
119
+ const gitignorePath = path.join(workspaceRoot, ".gitignore");
120
+ if (fs.existsSync(gitignorePath)) {
121
+ const existing = fs.readFileSync(gitignorePath, "utf8");
122
+ if (hasGitignoreBlock(existing)) {
123
+ // Block already present — no update needed
124
+ } else {
125
+ const updated = insertGitignoreBlock(existing);
126
+ fs.writeFileSync(gitignorePath, updated, "utf8");
127
+ gitignoreUpdated = true;
128
+ }
129
+ } else {
130
+ fs.writeFileSync(gitignorePath, `${MEMORY_GITIGNORE_START}\n${MEMORY_DIR}/${DAILY_DIR}/\n${MEMORY_GITIGNORE_END}\n`, "utf8");
131
+ gitignoreUpdated = true;
132
+ }
133
+
134
+ return { created, gitignoreUpdated, skipped };
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Health check
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function resolveMemoryBudget(workspaceRoot: string): number {
142
+ try {
143
+ const config = loadForgeConfig(workspaceRoot);
144
+ if (config.bindings?.memory?.budget !== undefined && config.bindings?.memory?.budget !== null) {
145
+ return config.bindings.memory.budget;
146
+ }
147
+ } catch {
148
+ // forge.yaml not loadable — use default
149
+ }
150
+ return DEFAULT_MEMORY_BUDGET;
151
+ }
152
+
153
+ export function checkMemoryLayerHealth(workspaceRoot: string): MemoryLayerHealth {
154
+ const memoryDir = path.join(workspaceRoot, MEMORY_DIR);
155
+ const dailyDirPath = path.join(memoryDir, DAILY_DIR);
156
+ const memoryMdPath = path.join(memoryDir, MEMORY_MD);
157
+ const gitignorePath = path.join(workspaceRoot, ".gitignore");
158
+
159
+ // MEMORY.md char count
160
+ let memoryMdChars = 0;
161
+ const memoryMdExists = fs.existsSync(memoryMdPath);
162
+ if (memoryMdExists) {
163
+ memoryMdChars = fs.readFileSync(memoryMdPath, "utf8").length;
164
+ }
165
+
166
+ // Daily file count (exclude .gitkeep)
167
+ let dailyFileCount = 0;
168
+ if (fs.existsSync(dailyDirPath)) {
169
+ const entries = fs.readdirSync(dailyDirPath);
170
+ dailyFileCount = entries.filter((e) => e !== GITKEEP).length;
171
+ }
172
+
173
+ // Gitignore coverage
174
+ let gitignoreCoversDaily = false;
175
+ if (fs.existsSync(gitignorePath)) {
176
+ const content = fs.readFileSync(gitignorePath, "utf8");
177
+ gitignoreCoversDaily = hasGitignoreBlock(content);
178
+ }
179
+
180
+ const budget = resolveMemoryBudget(workspaceRoot);
181
+
182
+ return {
183
+ memoryMdChars,
184
+ budget,
185
+ gitignoreCoversDaily,
186
+ dailyFileCount,
187
+ memoryMdExists,
188
+ };
189
+ }