@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,294 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Discovery and parsing for program manifests and packet files.
4
+ Reads YAML frontmatter from program.yaml and packet .md files, validates
5
+ against schemas, and computes normative source hashes.</purpose>
6
+ <non-goals>
7
+ <item>Do not import from @warpgogol/* — forge must remain dependency-free.</item>
8
+ <item>Do not mutate files — discovery is read-only.</item>
9
+ </non-goals>
10
+ </MODULE_CONTRACT>
11
+ <CHANGE_SUMMARY>
12
+ <item>RFC-0856: initial program/packet discovery and parsing.</item>
13
+ </CHANGE_SUMMARY>
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { createHash } from "node:crypto";
19
+ import { execSync } from "node:child_process";
20
+ import { parse as parseYaml } from "yaml";
21
+ import {
22
+ programManifestSchema,
23
+ programPacketSchema,
24
+ type ProgramManifest,
25
+ type ProgramPacket,
26
+ type ProgramPacketIndexEntry,
27
+ } from "./schemas.ts";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Program discovery
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface ProgramLocation {
34
+ programDir: string;
35
+ manifestPath: string;
36
+ manifest: ProgramManifest;
37
+ }
38
+
39
+ /**
40
+ * Discover a program by RFC id. Scans docs/plans/ for a program.yaml whose
41
+ * `program` field matches the given RFC id.
42
+ */
43
+ export function discoverProgram(workspaceRoot: string, programRfc: string): ProgramLocation | null {
44
+ const plansDir = path.join(workspaceRoot, "docs", "plans");
45
+ if (!fs.existsSync(plansDir)) return null;
46
+
47
+ for (const entry of fs.readdirSync(plansDir, { withFileTypes: true })) {
48
+ if (!entry.isDirectory()) continue;
49
+ const manifestPath = path.join(plansDir, entry.name, "program.yaml");
50
+ if (!fs.existsSync(manifestPath)) continue;
51
+
52
+ const raw = fs.readFileSync(manifestPath, "utf8");
53
+ const parsed = parseYaml(raw) as Record<string, unknown>;
54
+ if (parsed.program !== programRfc) continue;
55
+
56
+ const result = programManifestSchema.safeParse(parsed);
57
+ if (!result.success) {
58
+ throw new Error(
59
+ `PROGRAM-PACKET-01: malformed program manifest at ${manifestPath}: ${result.error.message}`,
60
+ );
61
+ }
62
+
63
+ return {
64
+ programDir: path.join(plansDir, entry.name),
65
+ manifestPath,
66
+ manifest: result.data,
67
+ };
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Packet parsing
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Parse a packet .md file: extract YAML frontmatter and validate against
79
+ * the forge/program-packet@1 schema.
80
+ */
81
+ export function parsePacketFile(packetPath: string): ProgramPacket {
82
+ const raw = fs.readFileSync(packetPath, "utf8");
83
+ const match = raw.match(/^---\n([\s\S]*?)\n---/);
84
+ if (!match) {
85
+ throw new Error(`PROGRAM-PACKET-01: missing frontmatter in ${packetPath}`);
86
+ }
87
+
88
+ const fm = parseYaml(match[1]) as Record<string, unknown>;
89
+ const result = programPacketSchema.safeParse(fm);
90
+ if (!result.success) {
91
+ throw new Error(
92
+ `PROGRAM-PACKET-01: malformed packet at ${packetPath}: ${result.error.message}`,
93
+ );
94
+ }
95
+
96
+ return result.data;
97
+ }
98
+
99
+ /**
100
+ * Find a packet by packetId within a program.
101
+ */
102
+ export function findPacketEntry(
103
+ manifest: ProgramManifest,
104
+ packetId: string,
105
+ ): ProgramPacketIndexEntry | null {
106
+ return manifest.packets.find((p) => p.packetId === packetId) ?? null;
107
+ }
108
+
109
+ /**
110
+ * Resolve the full path to a packet file.
111
+ */
112
+ export function resolvePacketPath(programDir: string, entry: ProgramPacketIndexEntry): string {
113
+ return path.join(programDir, entry.file);
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Normative source hashing
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Compute SHA-256 hex digest of a file's bytes.
122
+ */
123
+ export function fileSha256(filePath: string): string {
124
+ const bytes = fs.readFileSync(filePath);
125
+ return createHash("sha256").update(bytes).digest("hex");
126
+ }
127
+
128
+ /**
129
+ * Verify normative source hashes for a packet. Returns violations.
130
+ */
131
+ export function verifyNormativeSources(
132
+ workspaceRoot: string,
133
+ packet: ProgramPacket,
134
+ ): Array<{ path: string; expected: string; actual: string }> {
135
+ const violations: Array<{ path: string; expected: string; actual: string }> = [];
136
+
137
+ for (const source of packet.normativeSources) {
138
+ const fullPath = path.join(workspaceRoot, source.path);
139
+ if (!fs.existsSync(fullPath)) {
140
+ violations.push({
141
+ path: source.path,
142
+ expected: source.sha256,
143
+ actual: "<missing>",
144
+ });
145
+ continue;
146
+ }
147
+ const actual = fileSha256(fullPath);
148
+ if (actual !== source.sha256) {
149
+ violations.push({
150
+ path: source.path,
151
+ expected: source.sha256,
152
+ actual,
153
+ });
154
+ }
155
+ }
156
+
157
+ return violations;
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Git helpers (read-only)
162
+ // ---------------------------------------------------------------------------
163
+
164
+ export function gitHead(workspaceRoot: string): string {
165
+ return execSync("git rev-parse HEAD", {
166
+ cwd: workspaceRoot,
167
+ encoding: "utf8",
168
+ stdio: ["pipe", "pipe", "pipe"],
169
+ }).trim();
170
+ }
171
+
172
+ export function gitBranch(workspaceRoot: string): string {
173
+ return execSync("git branch --show-current", {
174
+ cwd: workspaceRoot,
175
+ encoding: "utf8",
176
+ stdio: ["pipe", "pipe", "pipe"],
177
+ }).trim();
178
+ }
179
+
180
+ export function gitIsClean(workspaceRoot: string): boolean {
181
+ const status = execSync("git status --porcelain", {
182
+ cwd: workspaceRoot,
183
+ encoding: "utf8",
184
+ stdio: ["pipe", "pipe", "pipe"],
185
+ }).trim();
186
+ return status.length === 0;
187
+ }
188
+
189
+ export function gitAncestorOf(
190
+ workspaceRoot: string,
191
+ ancestor: string,
192
+ descendant: string,
193
+ ): boolean {
194
+ try {
195
+ execSync(`git merge-base --is-ancestor ${ancestor} ${descendant}`, {
196
+ cwd: workspaceRoot,
197
+ encoding: "utf8",
198
+ stdio: ["pipe", "pipe", "pipe"],
199
+ });
200
+ return true;
201
+ } catch {
202
+ return false;
203
+ }
204
+ }
205
+
206
+ export function gitChangedFilesBetween(
207
+ workspaceRoot: string,
208
+ base: string,
209
+ head: string,
210
+ ): string[] {
211
+ try {
212
+ const output = execSync(`git diff --name-only ${base}..${head}`, {
213
+ cwd: workspaceRoot,
214
+ encoding: "utf8",
215
+ stdio: ["pipe", "pipe", "pipe"],
216
+ }).trim();
217
+ return output.length === 0 ? [] : output.split("\n");
218
+ } catch {
219
+ return [];
220
+ }
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Path matching (cross-platform)
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /**
228
+ * Normalize a path to forward slashes for cross-platform comparison.
229
+ */
230
+ export function normalizePath(p: string): string {
231
+ return p.replace(/\\/g, "/");
232
+ }
233
+
234
+ /**
235
+ * Resolve ".." and "." segments in a path without touching the filesystem.
236
+ * Returns the lexical normalization (e.g. "a/b/../c" → "a/c").
237
+ */
238
+ function resolveLexical(p: string): string {
239
+ const segments = p.split("/");
240
+ const result: string[] = [];
241
+ for (const seg of segments) {
242
+ if (seg === "..") {
243
+ result.pop();
244
+ } else if (seg !== "." && seg !== "") {
245
+ result.push(seg);
246
+ }
247
+ }
248
+ return result.join("/");
249
+ }
250
+
251
+ /**
252
+ * Check if a path matches a glob pattern. Supports:
253
+ * - `**` for recursive directory matching
254
+ * - `*` for single-level wildcard
255
+ * - exact prefix matching with trailing `/**`
256
+ */
257
+ export function pathMatchesGlob(filePath: string, pattern: string): boolean {
258
+ const normalized = resolveLexical(normalizePath(filePath));
259
+ const glob = resolveLexical(normalizePath(pattern));
260
+
261
+ // Exact match
262
+ if (normalized === glob) return true;
263
+
264
+ // /** suffix — prefix match
265
+ if (glob.endsWith("/**")) {
266
+ const prefix = glob.slice(0, -3);
267
+ return normalized === prefix || normalized.startsWith(prefix + "/");
268
+ }
269
+
270
+ // * wildcard matching (simplified)
271
+ const regexStr = glob
272
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
273
+ .replace(/\*\*/g, "<<<GLOBSTAR>>>")
274
+ .replace(/\*/g, "[^/]*")
275
+ .replace(/<<<GLOBSTAR>>>/g, ".*");
276
+ const regex = new RegExp(`^${regexStr}$`);
277
+ return regex.test(normalized);
278
+ }
279
+
280
+ /**
281
+ * Check if a file path is within the allowed files list.
282
+ */
283
+ export function isPathAllowed(filePath: string, allowedFiles: string[]): boolean {
284
+ const normalized = resolveLexical(normalizePath(filePath));
285
+ return allowedFiles.some((pattern) => pathMatchesGlob(normalized, pattern));
286
+ }
287
+
288
+ /**
289
+ * Check if a file path matches any forbidden pattern.
290
+ */
291
+ export function isPathForbidden(filePath: string, forbiddenFiles: string[]): boolean {
292
+ const normalized = resolveLexical(normalizePath(filePath));
293
+ return forbiddenFiles.some((pattern) => pathMatchesGlob(normalized, pattern));
294
+ }
@@ -0,0 +1,419 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Command handler for program.packet.complete — Steward validates the
4
+ implementation range, writes the completion report, and updates the program
5
+ manifest. Supports --bootstrap for packet 000 (RFC-0856).</purpose>
6
+ <non-goals>
7
+ <item>Do not commit — complete writes files only; canonical commit is separate.</item>
8
+ <item>Do not import from @warpgogol/* — forge must remain dependency-free.</item>
9
+ </non-goals>
10
+ </MODULE_CONTRACT>
11
+ <CHANGE_SUMMARY>
12
+ <item>RFC-0856: initial complete command handler.</item>
13
+ </CHANGE_SUMMARY>
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
19
+ import type {
20
+ ForgeCommandInput,
21
+ ForgeCommandResult,
22
+ ForgeRuntimeContext,
23
+ } from "../../../src/types.ts";
24
+ import { writeFileAtomic } from "../../../src/utils/fs-atomic.ts";
25
+ import { byteHash } from "../../../src/utils/hash.ts";
26
+ import {
27
+ discoverProgram,
28
+ findPacketEntry,
29
+ resolvePacketPath,
30
+ parsePacketFile,
31
+ gitHead,
32
+ gitIsClean,
33
+ gitAncestorOf,
34
+ gitChangedFilesBetween,
35
+ } from "../discovery.ts";
36
+ import { readLease, deleteLease, isLeaseStale, hashLeaseToken } from "../lease.ts";
37
+ import { validateCompleteTransition, type PacketViolation } from "../state.ts";
38
+ import type { ProgramManifest, ProgramPacketCompletion } from "../schemas.ts";
39
+
40
+ export interface CompleteResultData {
41
+ command: string;
42
+ status: "pass" | "fail";
43
+ program: string;
44
+ packetId: string;
45
+ filesModified: string[];
46
+ violations: PacketViolation[];
47
+ }
48
+
49
+ export async function runComplete(
50
+ input: ForgeCommandInput,
51
+ context: ForgeRuntimeContext,
52
+ ): Promise<ForgeCommandResult<CompleteResultData>> {
53
+ const program = input.flags.program as string | undefined;
54
+ const packetId = input.flags.packet as string | undefined;
55
+ const steward = input.flags.steward as string | undefined;
56
+ const leaseToken = input.flags["lease-token"] as string | undefined;
57
+ const implementationHead = input.flags["implementation-head"] as string | undefined;
58
+ const _idempotencyKey = input.flags["idempotency-key"] as string | undefined;
59
+ const isBootstrap = input.flags.bootstrap === true;
60
+
61
+ if (!program || !packetId || !steward) {
62
+ return completeFail(program ?? "<missing>", packetId ?? "<missing>", [
63
+ { rule: "PROGRAM-PACKET-01", message: "--program, --packet, and --steward are required" },
64
+ ]);
65
+ }
66
+
67
+ const { workspaceRoot } = context;
68
+
69
+ // Discover program
70
+ let programLocation;
71
+ try {
72
+ programLocation = discoverProgram(workspaceRoot, program);
73
+ } catch (err) {
74
+ return completeFail(program, packetId, [
75
+ { rule: "PROGRAM-PACKET-01", message: String((err as Error).message) },
76
+ ]);
77
+ }
78
+ if (!programLocation) {
79
+ return completeFail(program, packetId, [
80
+ { rule: "PROGRAM-PACKET-01", message: `program ${program} not found` },
81
+ ]);
82
+ }
83
+
84
+ const { manifest, manifestPath, programDir } = programLocation;
85
+ const entry = findPacketEntry(manifest, packetId);
86
+ if (!entry) {
87
+ return completeFail(program, packetId, [
88
+ { rule: "PROGRAM-PACKET-01", message: `packet ${packetId} not found` },
89
+ ]);
90
+ }
91
+
92
+ // --- Bootstrap path (packet 000 only) ---
93
+ if (isBootstrap) {
94
+ return bootstrapComplete(input, context, program, packetId, steward, programLocation);
95
+ }
96
+
97
+ // --- Normal completion path ---
98
+ if (!leaseToken || !implementationHead) {
99
+ return completeFail(program, packetId, [
100
+ {
101
+ rule: "PROGRAM-PACKET-01",
102
+ message: "--lease-token and --implementation-head are required (non-bootstrap)",
103
+ },
104
+ ]);
105
+ }
106
+
107
+ // Read lease
108
+ const lease = readLease(workspaceRoot, program, packetId);
109
+ if (!lease) {
110
+ return completeFail(program, packetId, [
111
+ { rule: "PROGRAM-PACKET-05", message: "no active lease found" },
112
+ ]);
113
+ }
114
+
115
+ // Verify token
116
+ const tokenHash = hashLeaseToken(leaseToken);
117
+ if (tokenHash !== lease.tokenHash) {
118
+ return completeFail(program, packetId, [
119
+ { rule: "PROGRAM-PACKET-11", message: "lease token mismatch" },
120
+ ]);
121
+ }
122
+
123
+ // Check lease is not stale
124
+ if (isLeaseStale(lease)) {
125
+ return completeFail(program, packetId, [
126
+ { rule: "PROGRAM-PACKET-05", message: "lease is stale — recover before completing" },
127
+ ]);
128
+ }
129
+
130
+ // Parse packet
131
+ let packet;
132
+ try {
133
+ packet = parsePacketFile(resolvePacketPath(programDir, entry));
134
+ } catch (err) {
135
+ return completeFail(program, packetId, [
136
+ { rule: "PROGRAM-PACKET-01", message: String((err as Error).message) },
137
+ ]);
138
+ }
139
+
140
+ // Git state
141
+ let head = "";
142
+ let isClean = false;
143
+ try {
144
+ head = gitHead(workspaceRoot);
145
+ isClean = gitIsClean(workspaceRoot);
146
+ } catch {
147
+ return completeFail(program, packetId, [
148
+ { rule: "PROGRAM-PACKET-02", message: "failed to read git state" },
149
+ ]);
150
+ }
151
+
152
+ // Check ancestry: implementation head must descend from seal commit
153
+ const sealCommit = lease.sealCommit ?? lease.baseCommit;
154
+ const ancestryValid = gitAncestorOf(workspaceRoot, sealCommit, implementationHead);
155
+
156
+ // Get changed files between seal and implementation head
157
+ const changedFiles = gitChangedFilesBetween(workspaceRoot, sealCommit, implementationHead);
158
+
159
+ // Validate transition
160
+ const violations = validateCompleteTransition(manifest, entry, lease, {
161
+ head,
162
+ isClean,
163
+ steward: steward as ProgramPacketCompletion["completedBy"],
164
+ implementationHead,
165
+ changedFiles,
166
+ allowedFiles: packet.allowedFiles,
167
+ forbiddenFiles: packet.forbiddenFiles,
168
+ ancestryValid,
169
+ });
170
+
171
+ if (violations.length > 0) {
172
+ return completeFail(program, packetId, violations);
173
+ }
174
+
175
+ // Write completion report
176
+ const completionsDir = path.join(programDir, "completions");
177
+ fs.mkdirSync(completionsDir, { recursive: true });
178
+
179
+ const completion: ProgramPacketCompletion = {
180
+ schema: "forge/program-packet-completion@1",
181
+ program,
182
+ packetId,
183
+ baseCommit: entry.baseCommit ?? lease.baseCommit,
184
+ sealCommit: lease.sealCommit ?? lease.baseCommit,
185
+ implementationCommits: [implementationHead],
186
+ implementationHead,
187
+ changedFiles,
188
+ validations: packet.requiredValidations.map((v) => ({
189
+ command: v.command,
190
+ status: "pass" as const,
191
+ evidenceDigest: byteHash(v.command),
192
+ })),
193
+ remainingTransitionDiagnostics: [],
194
+ unexpectedDiagnostics: [],
195
+ recoveryStatus: "not-applicable",
196
+ cleanTrees: true,
197
+ completedBy: steward as ProgramPacketCompletion["completedBy"],
198
+ };
199
+
200
+ const completionPath = path.join(completionsDir, `${packetId}.json`);
201
+ writeFileAtomic(completionPath, JSON.stringify(completion, null, 2) + "\n");
202
+
203
+ // Update manifest: packet state → completed, completion → head
204
+ const manifestRaw = fs.readFileSync(manifestPath, "utf8");
205
+ const manifestParsed = parseYaml(manifestRaw) as ProgramManifest;
206
+ const idx = manifestParsed.packets.findIndex((p) => p.packetId === packetId);
207
+ if (idx !== -1) {
208
+ manifestParsed.packets[idx].state = "completed";
209
+ manifestParsed.packets[idx].completion = head;
210
+ }
211
+ // Advance currentPacket to next packet
212
+ const nextEntry = manifest.packets.find((p) => p.dependsOnPacket === packetId);
213
+ if (nextEntry) {
214
+ manifestParsed.currentPacket = nextEntry.packetId;
215
+ } else {
216
+ // No next packet — program is complete
217
+ manifestParsed.state = "complete";
218
+ }
219
+ writeFileAtomic(manifestPath, stringifyYaml(manifestParsed));
220
+
221
+ // Delete lease
222
+ deleteLease(workspaceRoot, program, packetId);
223
+
224
+ const filesModified = [
225
+ path.relative(workspaceRoot, completionPath),
226
+ path.relative(workspaceRoot, manifestPath),
227
+ ];
228
+
229
+ return {
230
+ data: {
231
+ command: "program.packet.complete",
232
+ status: "pass",
233
+ program,
234
+ packetId,
235
+ filesModified,
236
+ violations: [],
237
+ },
238
+ exitCode: 0,
239
+ summary: `program.packet.complete: ${packetId} completed at ${head.slice(0, 8)}`,
240
+ };
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Bootstrap completion (packet 000 only)
245
+ // ---------------------------------------------------------------------------
246
+
247
+ async function bootstrapComplete(
248
+ input: ForgeCommandInput,
249
+ context: ForgeRuntimeContext,
250
+ program: string,
251
+ packetId: string,
252
+ steward: string,
253
+ programLocation: NonNullable<ReturnType<typeof discoverProgram>>,
254
+ ): Promise<ForgeCommandResult<CompleteResultData>> {
255
+ const { workspaceRoot } = context;
256
+ const { manifest, manifestPath, programDir } = programLocation;
257
+
258
+ // Bootstrap only for packet 000 (dependsOnPacket === null)
259
+ const entry = findPacketEntry(manifest, packetId);
260
+ if (!entry || entry.dependsOnPacket !== null) {
261
+ return completeFail(program, packetId, [
262
+ {
263
+ rule: "PROGRAM-PACKET-04",
264
+ message: "--bootstrap is only valid for packet 000 (no predecessor)",
265
+ },
266
+ ]);
267
+ }
268
+
269
+ // Program must be in preparing state
270
+ if (manifest.state !== "preparing") {
271
+ return completeFail(program, packetId, [
272
+ {
273
+ rule: "PROGRAM-PACKET-04",
274
+ message: `--bootstrap requires program state 'preparing', got '${manifest.state}'`,
275
+ },
276
+ ]);
277
+ }
278
+
279
+ const sealCommit = input.flags["seal-commit"] as string | undefined;
280
+ if (!sealCommit) {
281
+ return completeFail(program, packetId, [
282
+ { rule: "PROGRAM-PACKET-01", message: "--seal-commit is required for --bootstrap" },
283
+ ]);
284
+ }
285
+
286
+ let head = "";
287
+ let isClean = false;
288
+ try {
289
+ head = gitHead(workspaceRoot);
290
+ isClean = gitIsClean(workspaceRoot);
291
+ } catch {
292
+ return completeFail(program, packetId, [
293
+ { rule: "PROGRAM-PACKET-02", message: "failed to read git state" },
294
+ ]);
295
+ }
296
+
297
+ if (!isClean) {
298
+ return completeFail(program, packetId, [
299
+ { rule: "PROGRAM-PACKET-10", message: "dirty tree at bootstrap completion" },
300
+ ]);
301
+ }
302
+
303
+ // Verify ancestry: head must descend from seal commit
304
+ const ancestryValid = gitAncestorOf(workspaceRoot, sealCommit, head);
305
+ if (!ancestryValid) {
306
+ return completeFail(program, packetId, [
307
+ {
308
+ rule: "PROGRAM-PACKET-09",
309
+ message: `HEAD does not descend from seal commit ${sealCommit}`,
310
+ },
311
+ ]);
312
+ }
313
+
314
+ // Get changed files
315
+ const changedFiles = gitChangedFilesBetween(workspaceRoot, sealCommit, head);
316
+
317
+ // Parse packet for allowed/forbidden files
318
+ let packet;
319
+ try {
320
+ packet = parsePacketFile(resolvePacketPath(programDir, entry));
321
+ } catch (err) {
322
+ return completeFail(program, packetId, [
323
+ { rule: "PROGRAM-PACKET-01", message: String((err as Error).message) },
324
+ ]);
325
+ }
326
+
327
+ // Validate paths
328
+ for (const file of changedFiles) {
329
+ if (packet.forbiddenFiles.length > 0 && isPathForbidden(file, packet.forbiddenFiles)) {
330
+ return completeFail(program, packetId, [
331
+ { rule: "PROGRAM-PACKET-06", path: file, message: `changed file ${file} is forbidden` },
332
+ ]);
333
+ }
334
+ }
335
+
336
+ // Write completion report
337
+ const completionsDir = path.join(programDir, "completions");
338
+ fs.mkdirSync(completionsDir, { recursive: true });
339
+
340
+ const completion: ProgramPacketCompletion = {
341
+ schema: "forge/program-packet-completion@1",
342
+ program,
343
+ packetId,
344
+ baseCommit: sealCommit,
345
+ sealCommit,
346
+ implementationCommits: [head],
347
+ implementationHead: head,
348
+ changedFiles,
349
+ validations: [],
350
+ remainingTransitionDiagnostics: [],
351
+ unexpectedDiagnostics: [],
352
+ recoveryStatus: "not-applicable",
353
+ cleanTrees: true,
354
+ completedBy: steward as ProgramPacketCompletion["completedBy"],
355
+ };
356
+
357
+ const completionPath = path.join(completionsDir, `${packetId}.json`);
358
+ writeFileAtomic(completionPath, JSON.stringify(completion, null, 2) + "\n");
359
+
360
+ // Update manifest: packet → completed, program → executing
361
+ const manifestRaw = fs.readFileSync(manifestPath, "utf8");
362
+ const manifestParsed = parseYaml(manifestRaw) as ProgramManifest;
363
+ const idx = manifestParsed.packets.findIndex((p) => p.packetId === packetId);
364
+ if (idx !== -1) {
365
+ manifestParsed.packets[idx].state = "completed";
366
+ manifestParsed.packets[idx].completion = head;
367
+ }
368
+ // Transition program to executing
369
+ manifestParsed.state = "executing";
370
+ // Advance currentPacket
371
+ const nextEntry = manifest.packets.find((p) => p.dependsOnPacket === packetId);
372
+ if (nextEntry) {
373
+ manifestParsed.currentPacket = nextEntry.packetId;
374
+ }
375
+ writeFileAtomic(manifestPath, stringifyYaml(manifestParsed));
376
+
377
+ const filesModified = [
378
+ path.relative(workspaceRoot, completionPath),
379
+ path.relative(workspaceRoot, manifestPath),
380
+ ];
381
+
382
+ return {
383
+ data: {
384
+ command: "program.packet.complete",
385
+ status: "pass",
386
+ program,
387
+ packetId,
388
+ filesModified,
389
+ violations: [],
390
+ },
391
+ exitCode: 0,
392
+ summary: `program.packet.complete: ${packetId} bootstrap completed — program now executing`,
393
+ };
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Helpers
398
+ // ---------------------------------------------------------------------------
399
+
400
+ import { isPathForbidden } from "../discovery.ts";
401
+
402
+ function completeFail(
403
+ program: string,
404
+ packetId: string,
405
+ violations: PacketViolation[],
406
+ ): ForgeCommandResult<CompleteResultData> {
407
+ return {
408
+ data: {
409
+ command: "program.packet.complete",
410
+ status: "fail",
411
+ program,
412
+ packetId,
413
+ filesModified: [],
414
+ violations,
415
+ },
416
+ exitCode: 1,
417
+ summary: `program.packet.complete: ${packetId} failed with ${violations.length} violation(s)`,
418
+ };
419
+ }