@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,200 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Shared pre-check utility for the forge pinned-files protection system (RFC-0733).
4
+ Loads the pinned manifest from .forge/pinned.yaml and provides an isPinned lookup
5
+ that all archive handlers call before moving files. Manifest loading is cached
6
+ per invocation — loadPinnedManifest is called once and the result is reused.</purpose>
7
+ <non-goals>
8
+ <item>Does not implement validation logic (git diff parsing, violation detection) — use pinned-validate.ts.</item>
9
+ <item>Does not implement manifest creation — use pinned-init.ts.</item>
10
+ <item>Does not enforce overrides or audit logging — that is pinned-validate.ts responsibility.</item>
11
+ </non-goals>
12
+ </MODULE_CONTRACT>
13
+ <CHANGE_SUMMARY>
14
+ <item>RFC-0733: initial pinned-check utility — loadPinnedManifest, isPinned, checkFilesForPinned.</item>
15
+ <item>Gap fix: add isIntraDirMove to exempt moves within the same pinned directory (e.g. rfc.archive moves docs/rfcs/x.md → docs/rfcs/archive/implemented/x.md).</item>
16
+ </CHANGE_SUMMARY>
17
+ */
18
+
19
+ import fs from "node:fs/promises";
20
+ import path from "node:path";
21
+ import { parse as parseYaml } from "yaml";
22
+ import type { PinnedEntry, PinnedManifest, PinnedMode, PinnedViolation } from "./pinned-types.ts";
23
+
24
+ const PINNED_DIR = ".forge";
25
+ const PINNED_FILE = "pinned.yaml";
26
+
27
+ /**
28
+ * Path to the pinned manifest relative to the repository root.
29
+ */
30
+ export const PINNED_MANIFEST_PATH = path.join(PINNED_DIR, PINNED_FILE);
31
+
32
+ /**
33
+ * Error thrown when the manifest exists but is malformed (invalid YAML or missing required fields).
34
+ */
35
+ export class PinnedManifestMalformedError extends Error {
36
+ constructor(message: string) {
37
+ super(message);
38
+ this.name = "PinnedManifestMalformedError";
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Load and parse the pinned manifest from .forge/pinned.yaml.
44
+ * Returns null if the file does not exist (protection is opt-in).
45
+ * Throws PinnedManifestMalformedError if the file exists but is not valid YAML
46
+ * or does not contain a `pinned` array.
47
+ */
48
+ export async function loadPinnedManifest(repoRoot: string): Promise<PinnedManifest | null> {
49
+ const manifestPath = path.join(repoRoot, PINNED_MANIFEST_PATH);
50
+
51
+ let content: string;
52
+ try {
53
+ content = await fs.readFile(manifestPath, "utf8");
54
+ } catch {
55
+ return null;
56
+ }
57
+
58
+ let parsed: unknown;
59
+ try {
60
+ parsed = parseYaml(content);
61
+ } catch (err) {
62
+ throw new PinnedManifestMalformedError(
63
+ `pinned.yaml is not valid YAML: ${String((err as Error).message)}`,
64
+ );
65
+ }
66
+
67
+ if (!parsed || typeof parsed !== "object") {
68
+ throw new PinnedManifestMalformedError(
69
+ "pinned.yaml must contain a top-level object with a `pinned` array",
70
+ );
71
+ }
72
+
73
+ const obj = parsed as Record<string, unknown>;
74
+ if (!Array.isArray(obj["pinned"])) {
75
+ throw new PinnedManifestMalformedError("pinned.yaml is missing required `pinned` array field");
76
+ }
77
+
78
+ const entries: PinnedEntry[] = [];
79
+ for (const raw of obj["pinned"]) {
80
+ if (!raw || typeof raw !== "object") {
81
+ throw new PinnedManifestMalformedError(
82
+ "pinned.yaml: each entry must be an object with path, mode, and reason",
83
+ );
84
+ }
85
+ const entry = raw as Record<string, unknown>;
86
+ const entryPath = entry["path"];
87
+ const entryMode = entry["mode"];
88
+ const entryReason = entry["reason"];
89
+
90
+ if (typeof entryPath !== "string" || !entryPath) {
91
+ throw new PinnedManifestMalformedError(
92
+ "pinned.yaml: each entry must have a non-empty `path` string",
93
+ );
94
+ }
95
+ if (entryMode !== "protect" && entryMode !== "freeze") {
96
+ throw new PinnedManifestMalformedError(
97
+ `pinned.yaml: entry "${entryPath}" has invalid mode "${String(entryMode)}" — must be "protect" or "freeze"`,
98
+ );
99
+ }
100
+ if (typeof entryReason !== "string" || !entryReason) {
101
+ throw new PinnedManifestMalformedError(
102
+ `pinned.yaml: entry "${entryPath}" must have a non-empty reason string`,
103
+ );
104
+ }
105
+
106
+ entries.push({
107
+ path: entryPath,
108
+ mode: entryMode as PinnedMode,
109
+ reason: entryReason,
110
+ });
111
+ }
112
+
113
+ return { pinned: entries };
114
+ }
115
+
116
+ /**
117
+ * Check if a relative file path matches a pinned entry.
118
+ *
119
+ * Path matching rules:
120
+ * - Exact match: if the entry path equals the file path, it matches.
121
+ * - Directory match: if the entry path ends with `/`, it matches any file
122
+ * that starts with the directory prefix (recursive).
123
+ *
124
+ * Returns the matching PinnedEntry or null if no match.
125
+ */
126
+ export function isPinned(manifest: PinnedManifest, relPath: string): PinnedEntry | null {
127
+ const normalizedRelPath = relPath.replace(/\\/g, "/");
128
+
129
+ for (const entry of manifest.pinned) {
130
+ const normalizedEntryPath = entry.path.replace(/\\/g, "/");
131
+
132
+ if (normalizedEntryPath.endsWith("/")) {
133
+ if (normalizedRelPath.startsWith(normalizedEntryPath)) {
134
+ return entry;
135
+ }
136
+ } else if (normalizedRelPath === normalizedEntryPath) {
137
+ return entry;
138
+ }
139
+ }
140
+
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * Check if a move from sourceRel to destRel stays within the same pinned directory.
146
+ * This exempts intra-directory moves (e.g. rfc.archive moves
147
+ * docs/rfcs/rfc-0076.md → docs/rfcs/archive/implemented/rfc-0076.md)
148
+ * from the pinned pre-check, since the file hasn't left the protected directory.
149
+ *
150
+ * Only applies to directory-pinned entries (path ends with `/`).
151
+ * Returns true if the same pinned directory entry matches both source and dest.
152
+ */
153
+ export function isIntraDirMove(
154
+ manifest: PinnedManifest,
155
+ sourceRel: string,
156
+ destRel: string,
157
+ ): boolean {
158
+ const normalizedSource = sourceRel.replace(/\\/g, "/");
159
+ const normalizedDest = destRel.replace(/\\/g, "/");
160
+
161
+ for (const entry of manifest.pinned) {
162
+ const normalizedEntryPath = entry.path.replace(/\\/g, "/");
163
+ if (normalizedEntryPath.endsWith("/")) {
164
+ if (
165
+ normalizedSource.startsWith(normalizedEntryPath) &&
166
+ normalizedDest.startsWith(normalizedEntryPath)
167
+ ) {
168
+ return true;
169
+ }
170
+ }
171
+ }
172
+
173
+ return false;
174
+ }
175
+
176
+ /**
177
+ * Batch-check multiple file paths against the manifest.
178
+ * Returns violations for files that match a pinned entry.
179
+ * The operation type is determined by the caller (delete, move, or modify).
180
+ */
181
+ export function checkFilesForPinned(
182
+ manifest: PinnedManifest,
183
+ files: Array<{ relPath: string; operation: PinnedViolation["operation"] }>,
184
+ ): PinnedViolation[] {
185
+ const violations: PinnedViolation[] = [];
186
+
187
+ for (const { relPath, operation } of files) {
188
+ const entry = isPinned(manifest, relPath);
189
+ if (entry) {
190
+ violations.push({
191
+ path: relPath,
192
+ mode: entry.mode,
193
+ operation,
194
+ reason: entry.reason,
195
+ });
196
+ }
197
+ }
198
+
199
+ return violations;
200
+ }
@@ -0,0 +1,333 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>forge pinned.init — creates .forge/pinned.yaml with default foundation
4
+ entries, installs pre-commit hook, adds audit log to .gitignore, and optionally
5
+ generates CI workflow. Idempotent: re-running merges defaults with existing entries.</purpose>
6
+ <non-goals>
7
+ <item>Does not validate the manifest — use pinned.validate.</item>
8
+ <item>Does not enforce protection on archive commands — that is pinned-check.ts.</item>
9
+ </non-goals>
10
+ </MODULE_CONTRACT>
11
+ <CHANGE_SUMMARY>
12
+ <item>RFC-0733: initial pinned.init handler with manifest creation, hook installation, gitignore update, and CI workflow generation.</item>
13
+ </CHANGE_SUMMARY>
14
+ */
15
+
16
+ import fs from "node:fs/promises";
17
+ import path from "node:path";
18
+ import { stringify as stringifyYaml } from "yaml";
19
+ import type {
20
+ ForgeCommandInput,
21
+ ForgeCommandResult,
22
+ ForgeRuntimeContext,
23
+ } from "../../../src/types.ts";
24
+ import { writeFileIfChanged } from "../../../src/utils/fs-idempotent.ts";
25
+ import { PINNED_MANIFEST_PATH, loadPinnedManifest } from "./pinned-check.ts";
26
+ import type { PinnedEntry, PinnedManifest } from "./pinned-types.ts";
27
+
28
+ const PRE_COMMIT_HOOK_MARKER = "# forge:pinned-check";
29
+ const AUDIT_LOG_REL_PATH = ".forge/pinned-audit.log";
30
+
31
+ /**
32
+ * Default pinned entries for a forge-consuming repository.
33
+ * These are re-added by pinned.init if missing from an existing manifest.
34
+ */
35
+ export const DEFAULT_PINNED_ENTRIES: PinnedEntry[] = [
36
+ {
37
+ path: ".forge/pinned.yaml",
38
+ mode: "freeze",
39
+ reason: "Self-protection — manifest must not be tampered with",
40
+ },
41
+ {
42
+ path: "docs/rfcs/rfc-0000-template.md",
43
+ mode: "freeze",
44
+ reason: "RFC template — required for rfc.create",
45
+ },
46
+ {
47
+ path: "docs/adrs/adr-0000-template.md",
48
+ mode: "freeze",
49
+ reason: "ADR template — required for adr.create",
50
+ },
51
+ {
52
+ path: "docs/audits/audit-0000-template.md",
53
+ mode: "freeze",
54
+ reason: "Audit template — required for audit creation",
55
+ },
56
+ {
57
+ path: "docs/plans/plan-0000-template.md",
58
+ mode: "freeze",
59
+ reason: "Plan template — required for plan creation",
60
+ },
61
+ {
62
+ path: "docs/architecture-dna.md",
63
+ mode: "freeze",
64
+ reason: "Architecture DNA invariants — foundational governance document",
65
+ },
66
+ {
67
+ path: "forge.yaml",
68
+ mode: "protect",
69
+ reason: "Forge configuration — required for all forge commands",
70
+ },
71
+ {
72
+ path: "package.json",
73
+ mode: "protect",
74
+ reason: "Root package.json — workspace manifest",
75
+ },
76
+ {
77
+ path: "pnpm-workspace.yaml",
78
+ mode: "protect",
79
+ reason: "Workspace definition",
80
+ },
81
+ {
82
+ path: "AGENTS.md",
83
+ mode: "protect",
84
+ reason: "Agent rules manifest",
85
+ },
86
+ {
87
+ path: "PREFERENCES.md",
88
+ mode: "protect",
89
+ reason: "Operator preferences",
90
+ },
91
+ {
92
+ path: "docs/rfcs/",
93
+ mode: "protect",
94
+ reason: "RFC directory — structural foundation",
95
+ },
96
+ {
97
+ path: "docs/adrs/",
98
+ mode: "protect",
99
+ reason: "ADR directory — structural foundation",
100
+ },
101
+ {
102
+ path: "docs/audits/",
103
+ mode: "protect",
104
+ reason: "Audit directory — structural foundation",
105
+ },
106
+ {
107
+ path: "docs/plans/",
108
+ mode: "protect",
109
+ reason: "Plan directory — structural foundation",
110
+ },
111
+ {
112
+ path: "docs/sessions/",
113
+ mode: "protect",
114
+ reason: "Session directory — structural foundation",
115
+ },
116
+ {
117
+ path: "docs/explorations/",
118
+ mode: "protect",
119
+ reason: "Exploration directory — structural foundation",
120
+ },
121
+ {
122
+ path: "docs/specs/",
123
+ mode: "protect",
124
+ reason: "Spec snapshots — immutable vendored content",
125
+ },
126
+ {
127
+ path: "missions/",
128
+ mode: "protect",
129
+ reason: "Missions directory — structural foundation",
130
+ },
131
+ ];
132
+
133
+ const PRE_COMMIT_HOOK_SCRIPT = `#!/bin/sh
134
+ ${PRE_COMMIT_HOOK_MARKER}
135
+ # Installed by forge pinned.init — do not remove this marker block.
136
+ # Pinned-files protection: blocks commits that delete/move/modify pinned files.
137
+ # To override: FORGE_PINNED_OVERRIDE=path1,path2 git commit
138
+ forge pinned.validate || exit 1
139
+ `;
140
+
141
+ const CI_WORKFLOW_TEMPLATE = `name: pinned-files
142
+ on: [push, pull_request]
143
+ jobs:
144
+ validate:
145
+ runs-on: ubuntu-latest
146
+ steps:
147
+ - uses: actions/checkout@v4
148
+ with:
149
+ fetch-depth: 2
150
+ - run: npm install -g @warpgogol/forge
151
+ - run: forge pinned.validate --mode ci --json
152
+ `;
153
+
154
+ /**
155
+ * Merge default entries with existing manifest entries.
156
+ * - Re-adds missing default entries (forge expects them).
157
+ * - Never overwrites or removes custom entries.
158
+ */
159
+ function mergeManifest(existing: PinnedManifest | null, defaults: PinnedEntry[]): PinnedManifest {
160
+ if (!existing) {
161
+ return { pinned: [...defaults] };
162
+ }
163
+
164
+ const existingPaths = new Set(existing.pinned.map((e) => e.path));
165
+ const merged = [...existing.pinned];
166
+
167
+ for (const def of defaults) {
168
+ if (!existingPaths.has(def.path)) {
169
+ merged.push(def);
170
+ }
171
+ }
172
+
173
+ return { pinned: merged };
174
+ }
175
+
176
+ /**
177
+ * Install or merge the pre-commit hook.
178
+ * If a hook exists, appends the forge check with a marker for idempotent re-installation.
179
+ */
180
+ async function installPreCommitHook(
181
+ repoRoot: string,
182
+ ): Promise<"created" | "updated" | "unchanged"> {
183
+ const hookPath = path.join(repoRoot, ".git", "hooks", "pre-commit");
184
+
185
+ let existingContent: string;
186
+ try {
187
+ existingContent = await fs.readFile(hookPath, "utf8");
188
+ } catch {
189
+ // No existing hook — create new
190
+ await fs.mkdir(path.dirname(hookPath), { recursive: true });
191
+ await writeFileIfChanged(hookPath, PRE_COMMIT_HOOK_SCRIPT);
192
+ await fs.chmod(hookPath, 0o755);
193
+ return "created";
194
+ }
195
+
196
+ // Check if forge hook is already present
197
+ if (existingContent.includes(PRE_COMMIT_HOOK_MARKER)) {
198
+ return "unchanged";
199
+ }
200
+
201
+ // Append forge hook to existing hook
202
+ const mergedContent = existingContent + "\n" + PRE_COMMIT_HOOK_SCRIPT;
203
+ await writeFileIfChanged(hookPath, mergedContent);
204
+ await fs.chmod(hookPath, 0o755);
205
+ return "updated";
206
+ }
207
+
208
+ /**
209
+ * Add .forge/pinned-audit.log to .gitignore (or append to existing).
210
+ */
211
+ async function addToGitignore(repoRoot: string): Promise<"created" | "updated" | "unchanged"> {
212
+ const gitignorePath = path.join(repoRoot, ".gitignore");
213
+ const entry = AUDIT_LOG_REL_PATH;
214
+
215
+ let existingContent: string;
216
+ try {
217
+ existingContent = await fs.readFile(gitignorePath, "utf8");
218
+ } catch {
219
+ // No .gitignore — create new
220
+ await writeFileIfChanged(gitignorePath, entry + "\n");
221
+ return "created";
222
+ }
223
+
224
+ // Check if entry already exists
225
+ const lines = existingContent.split("\n");
226
+ if (lines.some((l) => l.trim() === entry)) {
227
+ return "unchanged";
228
+ }
229
+
230
+ // Append entry
231
+ const mergedContent = existingContent.trimEnd() + "\n" + entry + "\n";
232
+ await writeFileIfChanged(gitignorePath, mergedContent);
233
+ return "updated";
234
+ }
235
+
236
+ /**
237
+ * Generate CI workflow file.
238
+ */
239
+ async function generateCiWorkflow(repoRoot: string): Promise<"created" | "unchanged"> {
240
+ const workflowPath = path.join(repoRoot, ".github", "workflows", "pinned-check.yml");
241
+ await fs.mkdir(path.dirname(workflowPath), { recursive: true });
242
+ await writeFileIfChanged(workflowPath, CI_WORKFLOW_TEMPLATE);
243
+ return "created";
244
+ }
245
+
246
+ export interface PinnedInitResult {
247
+ command: "pinned.init";
248
+ status: "ok";
249
+ manifestPath: string;
250
+ manifestAction: "created" | "merged" | "unchanged";
251
+ entriesCount: number;
252
+ hookAction: "created" | "updated" | "unchanged";
253
+ gitignoreAction: "created" | "updated" | "unchanged";
254
+ ciWorkflowAction: "created" | "unchanged" | "skipped";
255
+ }
256
+
257
+ export async function runPinnedInit(
258
+ input: ForgeCommandInput,
259
+ context: ForgeRuntimeContext,
260
+ ): Promise<ForgeCommandResult<PinnedInitResult>> {
261
+ const { workspaceRoot, logger, outputFormat } = context;
262
+ const ci = input.flags["ci"] === true;
263
+
264
+ // Load existing manifest (if any)
265
+ let existingManifest: PinnedManifest | null = null;
266
+ try {
267
+ existingManifest = await loadPinnedManifest(workspaceRoot);
268
+ } catch {
269
+ // Malformed manifest — will be overwritten with defaults
270
+ existingManifest = null;
271
+ }
272
+
273
+ // Merge with defaults
274
+ const merged = mergeManifest(existingManifest, DEFAULT_PINNED_ENTRIES);
275
+
276
+ // Determine action
277
+ const manifestAction: "created" | "merged" | "unchanged" =
278
+ existingManifest === null
279
+ ? "created"
280
+ : merged.pinned.length > existingManifest.pinned.length
281
+ ? "merged"
282
+ : "unchanged";
283
+
284
+ // Write manifest
285
+ const manifestFullPath = path.join(workspaceRoot, PINNED_MANIFEST_PATH);
286
+ await fs.mkdir(path.dirname(manifestFullPath), { recursive: true });
287
+ const manifestContent = stringifyYaml(merged);
288
+ await writeFileIfChanged(manifestFullPath, manifestContent);
289
+
290
+ // Install pre-commit hook
291
+ const hookAction = await installPreCommitHook(workspaceRoot);
292
+
293
+ // Add to .gitignore
294
+ const gitignoreAction = await addToGitignore(workspaceRoot);
295
+
296
+ // Optional CI workflow
297
+ let ciWorkflowAction: "created" | "unchanged" | "skipped" = "skipped";
298
+ if (ci) {
299
+ ciWorkflowAction = await generateCiWorkflow(workspaceRoot);
300
+ }
301
+
302
+ const result: PinnedInitResult = {
303
+ command: "pinned.init",
304
+ status: "ok",
305
+ manifestPath: PINNED_MANIFEST_PATH,
306
+ manifestAction,
307
+ entriesCount: merged.pinned.length,
308
+ hookAction,
309
+ gitignoreAction,
310
+ ciWorkflowAction,
311
+ };
312
+
313
+ if (outputFormat === "pretty") {
314
+ logger.success(
315
+ `pinned.init: manifest ${manifestAction} (${merged.pinned.length} entries), hook ${hookAction}, gitignore ${gitignoreAction}` +
316
+ (ci ? `, CI workflow ${ciWorkflowAction}` : ""),
317
+ );
318
+ }
319
+
320
+ return {
321
+ data: result,
322
+ summary: `Manifest ${manifestAction} with ${merged.pinned.length} entries, hook ${hookAction}, gitignore ${gitignoreAction}`,
323
+ nextSteps: [
324
+ {
325
+ action: "Commit .forge/pinned.yaml and .gitignore changes",
326
+ kind: "required",
327
+ },
328
+ ...(ci
329
+ ? [{ action: "Commit .github/workflows/pinned-check.yml", kind: "required" as const }]
330
+ : []),
331
+ ],
332
+ };
333
+ }
@@ -0,0 +1,50 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>TypeScript contracts for the forge pinned-files protection system (RFC-0733).
4
+ Defines the manifest, entry, violation, and result types used by pinned.init,
5
+ pinned.validate, and the shared pre-check utility.</purpose>
6
+ <non-goals>
7
+ <item>Does not implement validation logic — use pinned-check.ts and pinned-validate.ts.</item>
8
+ <item>Does not implement manifest creation — use pinned-init.ts.</item>
9
+ </non-goals>
10
+ </MODULE_CONTRACT>
11
+ <CHANGE_SUMMARY>
12
+ <item>RFC-0733: initial pinned-files type contracts — PinnedEntry, PinnedManifest, PinnedViolation, PinnedValidateOptions, PinnedValidateResult.</item>
13
+ </CHANGE_SUMMARY>
14
+ */
15
+
16
+ export type PinnedMode = "protect" | "freeze";
17
+
18
+ export type PinnedOperation = "delete" | "move" | "modify";
19
+
20
+ export interface PinnedEntry {
21
+ path: string;
22
+ mode: PinnedMode;
23
+ reason: string;
24
+ }
25
+
26
+ export interface PinnedManifest {
27
+ pinned: PinnedEntry[];
28
+ }
29
+
30
+ export interface PinnedViolation {
31
+ path: string;
32
+ mode: PinnedMode;
33
+ operation: PinnedOperation;
34
+ reason: string;
35
+ }
36
+
37
+ export type PinnedValidateMode = "staged" | "ci";
38
+
39
+ export interface PinnedValidateOptions {
40
+ allowPinnedOverride?: string[];
41
+ mode?: PinnedValidateMode;
42
+ json?: boolean;
43
+ }
44
+
45
+ export interface PinnedValidateResult {
46
+ command: "pinned.validate";
47
+ status: "pass" | "fail";
48
+ violations: PinnedViolation[];
49
+ overrides: string[];
50
+ }