@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,185 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Skill registry — machine-readable array of all forge skills, used by forge.skill.validate and forge.create. Also provides discoverPackSkills helper for project-declared skill packs (RFC-0539).</purpose>
4
+ <non-goals>
5
+ <item>Do not include third-party skills — only forge-managed skills belong here.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-0374: initial ForgeSkillEntry interface and FORGE_SKILLS registry (17 migrated + 3 meta = 20).</item>
10
+ <item>Added fo-add-tests skill (11th fo skill) — absorbs tdd + PBT conventions from RFC-0347.</item>
11
+ <item>Skill consolidation: added fo-architecture, fo-handoff, fo-triage, fo-qa (15 fo skills). Removed handoff, improve-codebase-architecture, to-spec from shared (absorbed into fo-). Shared skills reduced to 4 (grilling, my-preferences, windows-ai-tooling, writing-great-skills).</item>
12
+ <item>Renamed skill prefix wg- → fo- across all skills, paths, and categories. Added fo-idea-status, fo-idea-i-just-want-to-see-the-plan, fo-idea-i-just-want-to-see-the-result (18 fo skills total).</item>
13
+ <item>Added fo-doc-audit skill (19th fo skill) — centralized documentation audit replacing inline AGENTS/README update steps in fo-fix and fo-idea-implement.</item>
14
+ <item>RFC-0395: added fo-spec-ingest skill (20th fo skill) — spec package ingest/authoring with validation, grilling, and acceptance gate.</item>
15
+ <item>Added fo-session-retro skill (21st fo skill) — session-end insight triage, categorizes discoveries and routes to AGENTS.md / ADR / DNA / forge / memory.</item>
16
+ <item>RFC-0523: expanded concerns enum from binary (document-only | implementation) to four-level taxonomy (read-only | document-only | content-mutation | code-mutation). All 30 skills reclassified.</item>
17
+ <item>RFC-0524: added optional knowledge?: string[] field for cumulative knowledge system. Added knowledge arrays to fo-site-scan and grilling.</item>
18
+ <item>RFC-0538: added fo-compass-annotate skill (22nd fo skill) — full-lifecycle Compass header management replacing removed compass.annotate, compass.clear, compass.markup.migrate, compass.invariant.add kernel commands.</item>
19
+ <item>RFC-0539: added discoverPackSkills helper and PackSkillEntry interface for project-declared skill packs. Removed mission-complete and fo-site-scan entries (relocated to warpgogol-skills as wg- pack skills).</item>
20
+ </CHANGE_SUMMARY>
21
+ */
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { parse as parseYaml } from "yaml";
26
+ import type { ForgeConfig } from "./config/forge-config.ts";
27
+
28
+ /**
29
+ * Every entry MUST be portable — it runs in any forge-bootstrapped project
30
+ * using only forge commands, forge bindings, and standard project files.
31
+ * Ecosystem-bound skills belong in project skill packs (RFC-0539), not here.
32
+ */
33
+ export interface ForgeSkillEntry {
34
+ name: string;
35
+ category: "fo" | "shared" | "meta";
36
+ invocation: "user" | "model";
37
+ concerns: "read-only" | "document-only" | "content-mutation" | "code-mutation";
38
+ dependsOn: string[];
39
+ path: string;
40
+ knowledge?: string[];
41
+ triggers?: string[];
42
+ }
43
+
44
+ /**
45
+ * A skill discovered in a project-declared skill pack (RFC-0539).
46
+ * Unlike ForgeSkillEntry, pack skills are discovered at runtime by scanning
47
+ * the pack directory rather than being statically registered.
48
+ */
49
+ export interface PackSkillEntry {
50
+ name: string;
51
+ pack: string;
52
+ dir: string;
53
+ path: string;
54
+ knowledge?: string[];
55
+ }
56
+
57
+ /**
58
+ * Discover skills in project-declared skill packs by scanning each pack's
59
+ * directory for `<prefix>-<name>/SKILL.md` entries (RFC-0539).
60
+ *
61
+ * This is the single source of truth for pack skill discovery — used by
62
+ * forge.create, forge.skill.validate, forge.doctor, and forge.skill.list.
63
+ */
64
+ export function discoverPackSkills(workspaceRoot: string, config: ForgeConfig): PackSkillEntry[] {
65
+ const result: PackSkillEntry[] = [];
66
+ if (!config.skillPacks) return result;
67
+
68
+ for (const pack of config.skillPacks) {
69
+ const packDir = path.resolve(workspaceRoot, pack.dir);
70
+ if (!fs.existsSync(packDir)) continue;
71
+
72
+ const entries = fs.readdirSync(packDir, { withFileTypes: true });
73
+ for (const entry of entries) {
74
+ if (!entry.isDirectory()) continue;
75
+ if (!entry.name.startsWith(`${pack.prefix}-`)) continue;
76
+
77
+ const skillMdPath = path.join(packDir, entry.name, "SKILL.md");
78
+ if (!fs.existsSync(skillMdPath)) continue;
79
+
80
+ const relPath = path.relative(packDir, skillMdPath).replace(/\\/g, "/");
81
+ const skillEntry: PackSkillEntry = {
82
+ name: entry.name,
83
+ pack: pack.prefix,
84
+ dir: pack.dir,
85
+ path: relPath,
86
+ };
87
+
88
+ // Extract knowledge files from frontmatter if present
89
+ const content = fs.readFileSync(skillMdPath, "utf8");
90
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
91
+ if (fmMatch) {
92
+ try {
93
+ const fm = parseYaml(fmMatch[1]) as Record<string, unknown>;
94
+ if (Array.isArray(fm["knowledge"])) {
95
+ skillEntry.knowledge = (fm["knowledge"] as unknown[]).filter(
96
+ (v): v is string => typeof v === "string",
97
+ );
98
+ }
99
+ } catch {
100
+ // Frontmatter parse error — SKILL-01 will catch this in validation
101
+ }
102
+ }
103
+
104
+ result.push(skillEntry);
105
+ }
106
+ }
107
+
108
+ return result;
109
+ }
110
+
111
+ /**
112
+ * Auto-discover forge skills by scanning the skills/ directory for SKILL.md
113
+ * files and parsing their frontmatter. This replaces the hand-maintained
114
+ * FORGE_SKILLS array — the frontmatter is the single source of truth.
115
+ *
116
+ * Skills are organized as skills/{category}/{name}/SKILL.md.
117
+ */
118
+ function discoverForgeSkills(skillsRoot: string): ForgeSkillEntry[] {
119
+ const result: ForgeSkillEntry[] = [];
120
+ if (!fs.existsSync(skillsRoot)) return result;
121
+
122
+ for (const categoryDir of fs.readdirSync(skillsRoot, { withFileTypes: true })) {
123
+ if (!categoryDir.isDirectory()) continue;
124
+ const category = categoryDir.name;
125
+ const catDirPath = path.join(skillsRoot, category);
126
+
127
+ for (const skillDir of fs.readdirSync(catDirPath, { withFileTypes: true })) {
128
+ if (!skillDir.isDirectory()) continue;
129
+ const skillMdPath = path.join(catDirPath, skillDir.name, "SKILL.md");
130
+ if (!fs.existsSync(skillMdPath)) continue;
131
+
132
+ const content = fs.readFileSync(skillMdPath, "utf8");
133
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
134
+ if (!fmMatch) continue;
135
+
136
+ let fm: Record<string, unknown>;
137
+ try {
138
+ fm = parseYaml(fmMatch[1]) as Record<string, unknown>;
139
+ } catch {
140
+ continue;
141
+ }
142
+
143
+ const relPath = path.relative(skillsRoot, skillMdPath).replace(/\\/g, "/");
144
+ result.push({
145
+ name: fm["name"] as string,
146
+ category: fm["category"] as ForgeSkillEntry["category"],
147
+ invocation: fm["invocation"] as ForgeSkillEntry["invocation"],
148
+ concerns: fm["concerns"] as ForgeSkillEntry["concerns"],
149
+ dependsOn: Array.isArray(fm["dependsOn"])
150
+ ? (fm["dependsOn"] as unknown[]).filter((v): v is string => typeof v === "string")
151
+ : [],
152
+ path: `skills/${relPath}`,
153
+ ...(Array.isArray(fm["knowledge"])
154
+ ? {
155
+ knowledge: (fm["knowledge"] as unknown[]).filter(
156
+ (v): v is string => typeof v === "string",
157
+ ),
158
+ }
159
+ : {}),
160
+ ...(Array.isArray(fm["triggers"])
161
+ ? {
162
+ triggers: (fm["triggers"] as unknown[]).filter(
163
+ (v): v is string => typeof v === "string",
164
+ ),
165
+ }
166
+ : {}),
167
+ });
168
+ }
169
+ }
170
+
171
+ return result.sort((a, b) => a.name.localeCompare(b.name));
172
+ }
173
+
174
+ function resolveSkillsRoot(): string {
175
+ const candidates = [
176
+ path.resolve(import.meta.dirname, "..", "skills"),
177
+ path.resolve(import.meta.dirname, "..", "..", "skills"),
178
+ ];
179
+ for (const candidate of candidates) {
180
+ if (fs.existsSync(candidate)) return candidate;
181
+ }
182
+ return candidates[0];
183
+ }
184
+ const SKILLS_ROOT = resolveSkillsRoot();
185
+ export const FORGE_SKILLS: ForgeSkillEntry[] = discoverForgeSkills(SKILLS_ROOT);
@@ -0,0 +1,37 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Define the Zod frontmatter schema used by forge skill validation commands.</purpose>
4
+ <non-goals>
5
+ <item>Do not validate SKILL.md body content — that is handled by pattern matching in the validator.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-0374: initial skillFrontmatterSchema with name, description, invocation, category, concerns, dependsOn, languagePolicy.</item>
10
+ <item>RFC-0393: added optional bindings field (requires/optional arrays of binding keys).</item>
11
+ <item>RFC-0523: expanded concerns enum from binary to four-level taxonomy (read-only | document-only | content-mutation | code-mutation).</item>
12
+ <item>RFC-0524: added optional knowledge field (array of file names relative to SKILL.md directory).</item>
13
+ <item>RFC-0548: added optional triggers field (array of natural-language trigger phrases, max 5 entries, each 5-100 chars) for intent-to-skill routing.</item>
14
+ </CHANGE_SUMMARY>
15
+ */
16
+
17
+ import { z } from "zod";
18
+
19
+ export const skillFrontmatterSchema = z.object({
20
+ name: z.string().min(1),
21
+ description: z.string().min(1).max(200),
22
+ invocation: z.enum(["user", "model"]),
23
+ category: z.enum(["fo", "shared", "meta"]),
24
+ concerns: z.enum(["read-only", "document-only", "content-mutation", "code-mutation"]),
25
+ dependsOn: z.array(z.string()).default([]),
26
+ languagePolicy: z.literal("ref(PREFERENCES.md)"),
27
+ bindings: z
28
+ .object({
29
+ requires: z.array(z.string()).default([]),
30
+ optional: z.array(z.string()).default([]),
31
+ })
32
+ .optional(),
33
+ knowledge: z.array(z.string()).optional(),
34
+ triggers: z.array(z.string().min(5).max(100)).max(5).optional(),
35
+ });
36
+
37
+ export type SkillFrontmatter = z.infer<typeof skillFrontmatterSchema>;
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ evaluateAcceptanceCriteria,
4
+ extractAcceptanceCriteriaSection,
5
+ } from "../../os/rfc/handlers/validate-rules.ts";
6
+
7
+ const BODY_WITH_SECTION = `# RFC-0001: Test
8
+
9
+ ## Context
10
+
11
+ Some context.
12
+
13
+ ## Acceptance criteria
14
+
15
+ - [x] First criterion (evidence: src/foo.ts:10, unit test)
16
+ - [x] Second criterion (evidence: src/bar.ts:20, integration test)
17
+ - [ ] Third criterion
18
+
19
+ ## Risks
20
+
21
+ None.
22
+ `;
23
+
24
+ const BODY_NO_SECTION = `# RFC-0001: Test
25
+
26
+ ## Context
27
+
28
+ No acceptance criteria section here.
29
+ `;
30
+
31
+ const BODY_ALL_CHECKED_WITH_EVIDENCE = `# RFC-0001: Test
32
+
33
+ ## Acceptance criteria
34
+
35
+ - [x] First (evidence: src/a.ts:1, test)
36
+ - [x] Second (evidence: src/b.ts:2, test)
37
+
38
+ ## Risks
39
+
40
+ None.
41
+ `;
42
+
43
+ const BODY_CHECKED_WITHOUT_EVIDENCE = `# RFC-0001: Test
44
+
45
+ ## Acceptance criteria
46
+
47
+ - [x] First criterion without evidence
48
+ - [x] Second (evidence: src/b.ts:2, test)
49
+
50
+ ## Risks
51
+
52
+ None.
53
+ `;
54
+
55
+ describe("extractAcceptanceCriteriaSection", () => {
56
+ it("extracts the section text when present", () => {
57
+ const section = extractAcceptanceCriteriaSection(BODY_WITH_SECTION);
58
+ expect(section).toBeDefined();
59
+ expect(section).toContain("- [x] First criterion");
60
+ expect(section).toContain("- [ ] Third criterion");
61
+ });
62
+
63
+ it("returns undefined when section is absent", () => {
64
+ const section = extractAcceptanceCriteriaSection(BODY_NO_SECTION);
65
+ expect(section).toBeUndefined();
66
+ });
67
+ });
68
+
69
+ describe("evaluateAcceptanceCriteria", () => {
70
+ it("counts checked and unchecked criteria", () => {
71
+ const result = evaluateAcceptanceCriteria(BODY_WITH_SECTION);
72
+ expect(result.totalChecked).toBe(2);
73
+ expect(result.totalUnchecked).toBe(1);
74
+ expect(result.uncheckedLines).toHaveLength(1);
75
+ expect(result.uncheckedLines[0]).toBe("- [ ] Third criterion");
76
+ });
77
+
78
+ it("returns zeros when section is absent", () => {
79
+ const result = evaluateAcceptanceCriteria(BODY_NO_SECTION);
80
+ expect(result.totalChecked).toBe(0);
81
+ expect(result.totalUnchecked).toBe(0);
82
+ expect(result.uncheckedLines).toEqual([]);
83
+ expect(result.checkedWithoutEvidence).toEqual([]);
84
+ });
85
+
86
+ it("detects checked criteria without evidence", () => {
87
+ const result = evaluateAcceptanceCriteria(BODY_CHECKED_WITHOUT_EVIDENCE);
88
+ expect(result.totalChecked).toBe(2);
89
+ expect(result.checkedWithoutEvidence).toHaveLength(1);
90
+ expect(result.checkedWithoutEvidence[0]).toContain("First criterion without evidence");
91
+ });
92
+
93
+ it("returns empty checkedWithoutEvidence when all have evidence", () => {
94
+ const result = evaluateAcceptanceCriteria(BODY_ALL_CHECKED_WITH_EVIDENCE);
95
+ expect(result.totalChecked).toBe(2);
96
+ expect(result.totalUnchecked).toBe(0);
97
+ expect(result.checkedWithoutEvidence).toEqual([]);
98
+ });
99
+ });
@@ -0,0 +1,368 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { execFileSync } from "node:child_process";
6
+
7
+ import { runAdrImplementStamp } from "../../os/adr/handlers/implement-stamp.ts";
8
+ import type { ForgeCommandInput, ForgeRuntimeContext, ForgeLogger } from "../../src/types.ts";
9
+
10
+ // ─── Test helpers ─────────────────────────────────────────────────────────────
11
+
12
+ const noopLogger: ForgeLogger = {
13
+ section: () => {},
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ success: () => {},
18
+ };
19
+
20
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
21
+ return {
22
+ workspaceRoot,
23
+ logger: noopLogger,
24
+ dryRun: false,
25
+ outputFormat: "json",
26
+ };
27
+ }
28
+
29
+ function makeInput(id: string, commit: string, dryRun?: boolean): ForgeCommandInput {
30
+ return {
31
+ argv: [],
32
+ flags: {
33
+ id,
34
+ "implementation-commit": commit,
35
+ ...(dryRun ? { "dry-run": true } : {}),
36
+ },
37
+ };
38
+ }
39
+
40
+ const ADR_BODY = (id: string, status: string): string => `---
41
+ id: ${id}
42
+ title: "Test ADR"
43
+ status: ${status}
44
+ scope: workspace
45
+ decider: human:test
46
+ createdAt: 2026-08-01
47
+ updatedAt: 2026-08-01
48
+ ---
49
+
50
+ # ${id}: Test ADR
51
+
52
+ ## Context
53
+
54
+ Test context.
55
+
56
+ ## Decision
57
+
58
+ Test decision.
59
+
60
+ ## Justification
61
+
62
+ Test justification.
63
+
64
+ ## Consequences
65
+
66
+ Test consequences.
67
+
68
+ ## Evolution
69
+
70
+ Test evolution.
71
+ `;
72
+
73
+ async function makeGitRepo(dir: string): Promise<void> {
74
+ execFileSync("git", ["init"], { cwd: dir, timeout: 5000 });
75
+ execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, timeout: 5000 });
76
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: dir, timeout: 5000 });
77
+ }
78
+
79
+ async function commitAll(dir: string, message: string): Promise<string> {
80
+ execFileSync("git", ["add", "-A"], { cwd: dir, timeout: 5000 });
81
+ execFileSync("git", ["commit", "-m", message], { cwd: dir, timeout: 5000 });
82
+ return execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, timeout: 5000 }).toString().trim();
83
+ }
84
+
85
+ // ─── Tests ────────────────────────────────────────────────────────────────────
86
+
87
+ describe("adr.implement.stamp", () => {
88
+ let workspaceRoot: string;
89
+
90
+ beforeEach(async () => {
91
+ workspaceRoot = await mkdtemp(join(tmpdir(), "adr-stamp-test-"));
92
+ await mkdir(join(workspaceRoot, "docs", "adrs"), { recursive: true });
93
+ await makeGitRepo(workspaceRoot);
94
+ });
95
+
96
+ afterEach(async () => {
97
+ await rm(workspaceRoot, { recursive: true, force: true });
98
+ });
99
+
100
+ // ── ADR-IMP-01: status checks ───────────────────────────────────────────────
101
+
102
+ it("rejects ADR with status superseded", async () => {
103
+ await writeFile(
104
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
105
+ ADR_BODY("ADR-0001", "superseded"),
106
+ );
107
+ const sha = await commitAll(workspaceRoot, "initial");
108
+
109
+ const result = await runAdrImplementStamp(
110
+ makeInput("ADR-0001", sha),
111
+ makeContext(workspaceRoot),
112
+ );
113
+
114
+ expect(result.data?.status).toBe("fail");
115
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-01")).toBe(true);
116
+ expect(result.exitCode).toBe(1);
117
+ });
118
+
119
+ it("rejects ADR with status reviewing", async () => {
120
+ await writeFile(
121
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
122
+ ADR_BODY("ADR-0001", "reviewing"),
123
+ );
124
+ const sha = await commitAll(workspaceRoot, "initial");
125
+
126
+ const result = await runAdrImplementStamp(
127
+ makeInput("ADR-0001", sha),
128
+ makeContext(workspaceRoot),
129
+ );
130
+
131
+ expect(result.data?.status).toBe("fail");
132
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-01")).toBe(true);
133
+ });
134
+
135
+ it("rejects ADR with status implemented (already done)", async () => {
136
+ await writeFile(
137
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
138
+ ADR_BODY("ADR-0001", "implemented"),
139
+ );
140
+ const sha = await commitAll(workspaceRoot, "initial");
141
+
142
+ const result = await runAdrImplementStamp(
143
+ makeInput("ADR-0001", sha),
144
+ makeContext(workspaceRoot),
145
+ );
146
+
147
+ expect(result.data?.status).toBe("fail");
148
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-01")).toBe(true);
149
+ });
150
+
151
+ it("rejects ADR not found", async () => {
152
+ await writeFile(join(workspaceRoot, "dummy.txt"), "test");
153
+ const sha = await commitAll(workspaceRoot, "initial");
154
+
155
+ const result = await runAdrImplementStamp(
156
+ makeInput("ADR-9999", sha),
157
+ makeContext(workspaceRoot),
158
+ );
159
+
160
+ expect(result.data?.status).toBe("fail");
161
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-01")).toBe(true);
162
+ });
163
+
164
+ // ── ADR-IMP-03: commit validation ───────────────────────────────────────────
165
+
166
+ it("rejects commit not reachable from HEAD", async () => {
167
+ await writeFile(
168
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
169
+ ADR_BODY("ADR-0001", "accepted"),
170
+ );
171
+ await commitAll(workspaceRoot, "initial commit ADR-0001");
172
+
173
+ // Create a divergent commit
174
+ await writeFile(join(workspaceRoot, "dummy.txt"), "test");
175
+ const sha1 = await commitAll(workspaceRoot, "first");
176
+ execFileSync("git", ["reset", "--hard", "HEAD~1"], { cwd: workspaceRoot, timeout: 5000 });
177
+ await writeFile(join(workspaceRoot, "other.txt"), "test");
178
+ await commitAll(workspaceRoot, "second");
179
+
180
+ const result = await runAdrImplementStamp(
181
+ makeInput("ADR-0001", sha1),
182
+ makeContext(workspaceRoot),
183
+ );
184
+
185
+ expect(result.data?.status).toBe("fail");
186
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-03")).toBe(true);
187
+ });
188
+
189
+ it("rejects commit that does not reference ADR id", async () => {
190
+ await writeFile(
191
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
192
+ ADR_BODY("ADR-0001", "accepted"),
193
+ );
194
+ await commitAll(workspaceRoot, "add ADR");
195
+
196
+ await writeFile(join(workspaceRoot, "code.ts"), "console.log('hello');");
197
+ const sha = await commitAll(workspaceRoot, "add code without ADR reference");
198
+
199
+ const result = await runAdrImplementStamp(
200
+ makeInput("ADR-0001", sha),
201
+ makeContext(workspaceRoot),
202
+ );
203
+
204
+ expect(result.data?.status).toBe("fail");
205
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-03")).toBe(true);
206
+ });
207
+
208
+ it("passes when commit message references ADR id", async () => {
209
+ await writeFile(
210
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
211
+ ADR_BODY("ADR-0001", "accepted"),
212
+ );
213
+ await commitAll(workspaceRoot, "add ADR-0001");
214
+
215
+ await writeFile(join(workspaceRoot, "code.ts"), "console.log('hello');");
216
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
217
+
218
+ const result = await runAdrImplementStamp(
219
+ makeInput("ADR-0001", sha),
220
+ makeContext(workspaceRoot),
221
+ );
222
+
223
+ expect(result.data?.status).toBe("pass");
224
+ expect(result.exitCode).toBe(0);
225
+ });
226
+
227
+ it("passes when commit changed files contain ADR slug", async () => {
228
+ await writeFile(
229
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
230
+ ADR_BODY("ADR-0001", "accepted"),
231
+ );
232
+ await commitAll(workspaceRoot, "add ADR-0001");
233
+
234
+ // Commit touches the ADR file itself (slug "adr-0001" is in the path)
235
+ await writeFile(
236
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
237
+ ADR_BODY("ADR-0001", "accepted") + "\n<!-- update -->\n",
238
+ );
239
+ const sha = await commitAll(workspaceRoot, "update adr-0001 file");
240
+
241
+ const result = await runAdrImplementStamp(
242
+ makeInput("ADR-0001", sha),
243
+ makeContext(workspaceRoot),
244
+ );
245
+
246
+ expect(result.data?.status).toBe("pass");
247
+ });
248
+
249
+ // ── ADR-IMP-04: file cleanliness ─────────────────────────────────────────────
250
+
251
+ it("rejects when ADR file has uncommitted changes", async () => {
252
+ await writeFile(
253
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
254
+ ADR_BODY("ADR-0001", "accepted"),
255
+ );
256
+ const sha = await commitAll(workspaceRoot, "add ADR-0001");
257
+
258
+ // Make uncommitted change to the ADR file
259
+ await writeFile(
260
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
261
+ ADR_BODY("ADR-0001", "accepted") + "\n<!-- dirty -->\n",
262
+ );
263
+
264
+ const result = await runAdrImplementStamp(
265
+ makeInput("ADR-0001", sha),
266
+ makeContext(workspaceRoot),
267
+ );
268
+
269
+ expect(result.data?.status).toBe("fail");
270
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-04")).toBe(true);
271
+ });
272
+
273
+ // ── ADR-IMP-05: concurrent lock ──────────────────────────────────────────────
274
+
275
+ it("rejects when a concurrent lock is held", async () => {
276
+ await writeFile(
277
+ join(workspaceRoot, "docs", "adrs", "adr-0001-test.md"),
278
+ ADR_BODY("ADR-0001", "accepted"),
279
+ );
280
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
281
+
282
+ // Pre-create the lock file to simulate a concurrent stamp
283
+ await mkdir(join(workspaceRoot, ".adr-locks"), { recursive: true });
284
+ await writeFile(
285
+ join(workspaceRoot, ".adr-locks", "adr-0001.lock"),
286
+ JSON.stringify({ pid: 99999, acquiredAt: new Date().toISOString() }),
287
+ );
288
+
289
+ const result = await runAdrImplementStamp(
290
+ makeInput("ADR-0001", sha),
291
+ makeContext(workspaceRoot),
292
+ );
293
+
294
+ expect(result.data?.status).toBe("fail");
295
+ expect(result.data?.violations.some((v) => v.rule === "ADR-IMP-05")).toBe(true);
296
+ });
297
+
298
+ // ── Dry-run ──────────────────────────────────────────────────────────────────
299
+
300
+ it("dry-run checks preconditions without mutating file", async () => {
301
+ const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0001-test.md");
302
+ await writeFile(adrPath, ADR_BODY("ADR-0001", "accepted"));
303
+ await commitAll(workspaceRoot, "add ADR-0001");
304
+
305
+ await writeFile(join(workspaceRoot, "code.ts"), "console.log('hello');");
306
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
307
+
308
+ const originalContent = await readFile(adrPath, "utf-8");
309
+
310
+ const result = await runAdrImplementStamp(
311
+ makeInput("ADR-0001", sha, true),
312
+ makeContext(workspaceRoot),
313
+ );
314
+
315
+ expect(result.data?.status).toBe("pass");
316
+ expect(result.data?.data?.adrId).toBe("ADR-0001");
317
+
318
+ // File should not be mutated
319
+ const afterContent = await readFile(adrPath, "utf-8");
320
+ expect(afterContent).toBe(originalContent);
321
+ });
322
+
323
+ // ── Atomic stamp: accepted → implemented ─────────────────────────────────────
324
+
325
+ it("atomically stamps accepted ADR as implemented", async () => {
326
+ const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0001-test.md");
327
+ await writeFile(adrPath, ADR_BODY("ADR-0001", "accepted"));
328
+ await commitAll(workspaceRoot, "add ADR-0001");
329
+
330
+ await writeFile(join(workspaceRoot, "code.ts"), "console.log('hello');");
331
+ const sha = await commitAll(workspaceRoot, "implement: ADR-0001 add feature");
332
+
333
+ const result = await runAdrImplementStamp(
334
+ makeInput("ADR-0001", sha),
335
+ makeContext(workspaceRoot),
336
+ );
337
+
338
+ expect(result.data?.status).toBe("pass");
339
+ expect(result.data?.data?.adrId).toBe("ADR-0001");
340
+ expect(result.data?.data?.implementationCommit).toBe(sha);
341
+ expect(result.exitCode).toBe(0);
342
+
343
+ // Verify file was mutated
344
+ const afterContent = await readFile(adrPath, "utf-8");
345
+ expect(afterContent).toContain("status: implemented");
346
+ expect(afterContent).toContain("implementedAt:");
347
+ expect(afterContent).toContain("updatedAt:");
348
+ });
349
+
350
+ // ── Post-hoc ADR: proposed → implemented ─────────────────────────────────────
351
+
352
+ it("stamps proposed ADR as implemented (post-hoc)", async () => {
353
+ const adrPath = join(workspaceRoot, "docs", "adrs", "adr-0002-test.md");
354
+ await writeFile(adrPath, ADR_BODY("ADR-0002", "proposed"));
355
+ const sha = await commitAll(workspaceRoot, "add ADR-0002");
356
+
357
+ const result = await runAdrImplementStamp(
358
+ makeInput("ADR-0002", sha),
359
+ makeContext(workspaceRoot),
360
+ );
361
+
362
+ expect(result.data?.status).toBe("pass");
363
+ expect(result.data?.data?.adrId).toBe("ADR-0002");
364
+
365
+ const afterContent = await readFile(adrPath, "utf-8");
366
+ expect(afterContent).toContain("status: implemented");
367
+ });
368
+ });