@warpgogol/forge 2.7.0 → 2.8.1

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 (268) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +23 -13
  3. package/README.uk.md +19 -12
  4. package/dist/src/config/forge-config.d.ts +1 -1
  5. package/dist/src/config/forge-config.d.ts.map +1 -1
  6. package/dist/src/config/forge-config.js +2 -2
  7. package/dist/src/config/forge-config.js.map +1 -1
  8. package/dist/src/onboarding/agents-generate.d.ts +2 -1
  9. package/dist/src/onboarding/agents-generate.d.ts.map +1 -1
  10. package/dist/src/onboarding/agents-generate.js +24 -4
  11. package/dist/src/onboarding/agents-generate.js.map +1 -1
  12. package/dist/src/profiles/profile-schema.d.ts +2 -0
  13. package/dist/src/profiles/profile-schema.d.ts.map +1 -1
  14. package/dist/src/profiles/profile-schema.js +1 -0
  15. package/dist/src/profiles/profile-schema.js.map +1 -1
  16. package/dist/src/profiles/stack-profile.d.ts +1 -0
  17. package/dist/src/profiles/stack-profile.d.ts.map +1 -1
  18. package/dist/src/profiles/stack-profile.js +2 -0
  19. package/dist/src/profiles/stack-profile.js.map +1 -1
  20. package/os/adr/adr.module.ts +158 -0
  21. package/os/adr/frontmatter-io.ts +80 -0
  22. package/os/adr/handlers/archive.ts +244 -0
  23. package/os/adr/handlers/implement-stamp.ts +331 -0
  24. package/os/adr/handlers/list-create.ts +207 -0
  25. package/os/adr/handlers/validate.test.ts +232 -0
  26. package/os/adr/handlers/validate.ts +429 -0
  27. package/os/adr/index.ts +33 -0
  28. package/os/adr/types.ts +141 -0
  29. package/os/audit/audit.module.ts +48 -0
  30. package/os/audit/frontmatter-io.ts +84 -0
  31. package/os/audit/handlers/archive.ts +239 -0
  32. package/os/audit/index.ts +23 -0
  33. package/os/audit/types.ts +29 -0
  34. package/os/compass/compass.module.ts +186 -0
  35. package/os/compass/handlers/compass-audit-handler.ts +382 -0
  36. package/os/compass/handlers/compass-change-summary-handler.ts +272 -0
  37. package/os/compass/handlers/compass-inventory-handler.ts +294 -0
  38. package/os/compass/handlers/compass-inventory.ts +521 -0
  39. package/os/compass/handlers/git-revision.ts +137 -0
  40. package/os/compass/handlers/resolve-scan-root-workpiece.test.ts +65 -0
  41. package/os/compass/handlers/resolve-scan-root.ts +79 -0
  42. package/os/compass/index.ts +33 -0
  43. package/os/core/core.module.ts +948 -0
  44. package/os/core/handlers/assets-check.ts +122 -0
  45. package/os/core/handlers/assets-helpers.ts +174 -0
  46. package/os/core/handlers/assets-list.ts +99 -0
  47. package/os/core/handlers/build.ts +155 -0
  48. package/os/core/handlers/determinism-check.ts +354 -0
  49. package/os/core/handlers/dev.ts +127 -0
  50. package/os/core/handlers/invariant-engine.test.ts +681 -0
  51. package/os/core/handlers/knowledge-compact.ts +248 -0
  52. package/os/core/handlers/lifecycle-handlers.test.ts +524 -0
  53. package/os/core/handlers/note-frontmatter-validate.test.ts +93 -0
  54. package/os/core/handlers/note-link-validate.test.ts +117 -0
  55. package/os/core/handlers/note-orphan-detect.test.ts +94 -0
  56. package/os/core/handlers/package-health.test.ts +226 -0
  57. package/os/core/handlers/package-health.ts +231 -0
  58. package/os/core/handlers/pinned-check.ts +200 -0
  59. package/os/core/handlers/pinned-init.ts +333 -0
  60. package/os/core/handlers/pinned-types.ts +50 -0
  61. package/os/core/handlers/pinned-validate.ts +301 -0
  62. package/os/core/handlers/profile-resolve.ts +92 -0
  63. package/os/core/handlers/release-prepare.ts +294 -0
  64. package/os/core/handlers/release-publish.ts +211 -0
  65. package/os/core/handlers/validate.ts +262 -0
  66. package/os/core/handlers/workspace-deps.ts +55 -0
  67. package/os/core/index.ts +13 -0
  68. package/os/exploration/exploration.module.ts +90 -0
  69. package/os/exploration/frontmatter-io.ts +77 -0
  70. package/os/exploration/handlers/archive.ts +159 -0
  71. package/os/exploration/handlers/list.ts +77 -0
  72. package/os/exploration/handlers/show.ts +107 -0
  73. package/os/exploration/index.ts +25 -0
  74. package/os/exploration/types.ts +65 -0
  75. package/os/mission/handlers/archive.test.ts +391 -0
  76. package/os/mission/handlers/archive.ts +453 -0
  77. package/os/mission/index.ts +1 -0
  78. package/os/mission/mission.module.ts +48 -0
  79. package/os/mission/types.ts +41 -0
  80. package/os/naming/index.ts +14 -0
  81. package/os/naming/naming-convention.test.ts +163 -0
  82. package/os/naming/naming-convention.ts +378 -0
  83. package/os/naming/naming.module.ts +36 -0
  84. package/os/notes/index.ts +10 -0
  85. package/os/notes/notes.module.ts +109 -0
  86. package/os/plan/frontmatter-io.ts +82 -0
  87. package/os/plan/handlers/archive.ts +238 -0
  88. package/os/plan/index.ts +23 -0
  89. package/os/plan/plan.module.ts +48 -0
  90. package/os/plan/types.ts +29 -0
  91. package/os/program/discovery.ts +294 -0
  92. package/os/program/handlers/complete.ts +419 -0
  93. package/os/program/handlers/lease.ts +433 -0
  94. package/os/program/handlers/seal.ts +332 -0
  95. package/os/program/handlers/validate.ts +213 -0
  96. package/os/program/lease.ts +156 -0
  97. package/os/program/program.module.ts +212 -0
  98. package/os/program/schemas.ts +218 -0
  99. package/os/program/state.ts +264 -0
  100. package/os/rfc/acceptance.ts +344 -0
  101. package/os/rfc/decision-log.ts +310 -0
  102. package/os/rfc/dna-trace.ts +280 -0
  103. package/os/rfc/frontmatter-io.test.ts +132 -0
  104. package/os/rfc/frontmatter-io.ts +130 -0
  105. package/os/rfc/handlers/archive.ts +300 -0
  106. package/os/rfc/handlers/check.ts +238 -0
  107. package/os/rfc/handlers/implement-stamp.ts +515 -0
  108. package/os/rfc/handlers/index-graph.ts +237 -0
  109. package/os/rfc/handlers/lifecycle.test.ts +127 -0
  110. package/os/rfc/handlers/lifecycle.ts +249 -0
  111. package/os/rfc/handlers/list-create.ts +296 -0
  112. package/os/rfc/handlers/pipeline-status.ts +173 -0
  113. package/os/rfc/handlers/shared.ts +121 -0
  114. package/os/rfc/handlers/supersede-propose.ts +241 -0
  115. package/os/rfc/handlers/validate-rules.test.ts +891 -0
  116. package/os/rfc/handlers/validate-rules.ts +984 -0
  117. package/os/rfc/handlers/validate.ts +166 -0
  118. package/os/rfc/handlers.ts +19 -0
  119. package/os/rfc/index.ts +97 -0
  120. package/os/rfc/rfc.module.ts +416 -0
  121. package/os/rfc/types.ts +596 -0
  122. package/os/rfc/verification-evidence.ts +244 -0
  123. package/os/session/atif-parser.ts +166 -0
  124. package/os/session/frontmatter-io.ts +163 -0
  125. package/os/session/handlers/archive.ts +288 -0
  126. package/os/session/handlers/list.ts +174 -0
  127. package/os/session/handlers/save.ts +348 -0
  128. package/os/session/handlers/validate.ts +250 -0
  129. package/os/session/index.ts +50 -0
  130. package/os/session/session.module.ts +126 -0
  131. package/os/session/types.ts +203 -0
  132. package/os/spec/live-spec-list-show-validate.test.ts +257 -0
  133. package/os/spec/live-spec-list.ts +78 -0
  134. package/os/spec/live-spec-merge.test.ts +261 -0
  135. package/os/spec/live-spec-merge.ts +413 -0
  136. package/os/spec/live-spec-show.ts +117 -0
  137. package/os/spec/live-spec-types.ts +93 -0
  138. package/os/spec/live-spec-validate.ts +170 -0
  139. package/os/spec/spec-materialize.ts +390 -0
  140. package/os/spec/spec-schema.ts +174 -0
  141. package/os/spec/spec-status.ts +284 -0
  142. package/os/spec/spec-validate.ts +513 -0
  143. package/os/spec/spec.module.ts +135 -0
  144. package/os/werkstatt/handlers/lock.ts +168 -0
  145. package/os/werkstatt/handlers/schema.ts +47 -0
  146. package/os/werkstatt/handlers/werkstatt-lock-recover.ts +175 -0
  147. package/os/werkstatt/handlers/werkstatt-lock-status.ts +73 -0
  148. package/os/werkstatt/handlers/werkstatt-operation-validate.ts +104 -0
  149. package/os/werkstatt/index.ts +39 -0
  150. package/os/werkstatt/werkstatt.module.ts +62 -0
  151. package/os/workflow/handlers.ts +304 -0
  152. package/os/workflow/index.ts +28 -0
  153. package/os/workflow/types.ts +99 -0
  154. package/os/workflow/workflow.module.ts +54 -0
  155. package/package.json +3 -3
  156. package/profiles/godot-csharp.yaml +5 -4
  157. package/profiles/knowledge-typescript-turborepo.yaml +110 -0
  158. package/profiles/phaser-turborepo.yaml +5 -5
  159. package/profiles/root-agents-godot.md +133 -0
  160. package/skills/_shared/fo-session-summary.md +132 -16
  161. package/skills/fo/fo-add-tests/pbt-guide.md +13 -13
  162. package/skills/fo/fo-doc-audit/SKILL.md +6 -6
  163. package/skills/fo/fo-handoff/SKILL.md +5 -0
  164. package/skills/fo/fo-idea/SKILL.md +1 -1
  165. package/skills/fo/fo-review/SKILL.md +1 -1
  166. package/skills/fo/fo-session-retro/SKILL.md +8 -0
  167. package/skills/fo/fo-session-save/SKILL.md +8 -0
  168. package/src/cli-output.ts +59 -0
  169. package/src/config/forge-config.ts +524 -0
  170. package/src/forge-module.ts +39 -0
  171. package/src/index.ts +172 -0
  172. package/src/knowledge/budgets.ts +202 -0
  173. package/src/knowledge/compact.ts +405 -0
  174. package/src/knowledge/index.ts +53 -0
  175. package/src/knowledge/parse.ts +227 -0
  176. package/src/knowledge/promote.ts +156 -0
  177. package/src/knowledge/schema.ts +112 -0
  178. package/src/knowledge/serialize.ts +72 -0
  179. package/src/migration-adapters/git-utils.ts +71 -0
  180. package/src/migration-adapters/ignored-files.ts +221 -0
  181. package/src/migration-adapters/index.ts +21 -0
  182. package/src/migration-adapters/node-typescript-pnpm/index.ts +189 -0
  183. package/src/migration-adapters/phaser-pnpm/index.ts +189 -0
  184. package/src/migration-adapters/registry.ts +61 -0
  185. package/src/migration-adapters/types.ts +68 -0
  186. package/src/onboarding/agents-generate.ts +443 -0
  187. package/src/onboarding/create.ts +427 -0
  188. package/src/onboarding/doctor.ts +1297 -0
  189. package/src/onboarding/init.ts +337 -0
  190. package/src/onboarding/invariant-engine.ts +448 -0
  191. package/src/onboarding/memory-scaffold.ts +189 -0
  192. package/src/onboarding/nested-agents-generate.ts +93 -0
  193. package/src/onboarding/nested-agents-templates.ts +233 -0
  194. package/src/onboarding/profile-validate.ts +139 -0
  195. package/src/onboarding/scaffold-project.ts +299 -0
  196. package/src/onboarding/scaffold.ts +143 -0
  197. package/src/onboarding/upgrade.ts +558 -0
  198. package/src/onboarding/workspace-discovery.ts +167 -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 +51 -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 +217 -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/fs-atomic.ts +93 -0
  256. package/src/utils/fs-idempotent.ts +41 -0
  257. package/src/utils/fs-trash-sync.ts +37 -0
  258. package/src/utils/fs-trash.ts +24 -0
  259. package/src/utils/fs.ts +74 -0
  260. package/src/utils/generated-marker.ts +166 -0
  261. package/src/utils/hash.ts +19 -0
  262. package/src/utils/index.ts +29 -0
  263. package/src/utils/string-utils.ts +19 -0
  264. package/src/validators/note-frontmatter-validate.ts +142 -0
  265. package/src/validators/note-link-validate.ts +145 -0
  266. package/src/validators/note-orphan-detect.ts +146 -0
  267. package/src/validators/port-validate.ts +97 -0
  268. package/src/validators/skill-validate.ts +831 -0
@@ -0,0 +1,433 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Command handler for program.packet.lease — manages the exclusive
4
+ local executor lease for a sealed packet. Supports start, heartbeat, release,
5
+ and recover actions (RFC-0856).</purpose>
6
+ <non-goals>
7
+ <item>Do not store raw lease tokens — only the hash is persisted.</item>
8
+ <item>Do not commit — lease files are untracked.</item>
9
+ <item>Do not import from @warpgogol/* — forge must remain dependency-free.</item>
10
+ </non-goals>
11
+ </MODULE_CONTRACT>
12
+ <CHANGE_SUMMARY>
13
+ <item>RFC-0856: initial lease command handler.</item>
14
+ </CHANGE_SUMMARY>
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
20
+ import type {
21
+ ForgeCommandInput,
22
+ ForgeCommandResult,
23
+ ForgeRuntimeContext,
24
+ } from "../../../src/types.ts";
25
+ import { writeFileAtomic } from "../../../src/utils/fs-atomic.ts";
26
+ import {
27
+ discoverProgram,
28
+ findPacketEntry,
29
+ resolvePacketPath,
30
+ parsePacketFile,
31
+ gitHead,
32
+ } from "../discovery.ts";
33
+ import {
34
+ generateLeaseToken,
35
+ hashLeaseToken,
36
+ readLease,
37
+ writeLease,
38
+ deleteLease,
39
+ findActiveLeases,
40
+ isLeaseStale,
41
+ updateHeartbeat,
42
+ } from "../lease.ts";
43
+ import type { ProgramPacketLease } from "../schemas.ts";
44
+ import { validateLeaseStartTransition, type PacketViolation } from "../state.ts";
45
+ import type { ProgramManifest, RecoveryRecord } from "../schemas.ts";
46
+
47
+ export interface LeaseResultData {
48
+ command: string;
49
+ status: "pass" | "fail";
50
+ program: string;
51
+ packetId: string;
52
+ action: string;
53
+ leaseToken?: string;
54
+ violations: PacketViolation[];
55
+ }
56
+
57
+ const DEFAULT_TIMEOUT_SECONDS = 3600; // 1 hour
58
+
59
+ export async function runLease(
60
+ input: ForgeCommandInput,
61
+ context: ForgeRuntimeContext,
62
+ ): Promise<ForgeCommandResult<LeaseResultData>> {
63
+ const program = input.flags.program as string | undefined;
64
+ const packetId = input.flags.packet as string | undefined;
65
+ const action = input.flags.action as string | undefined;
66
+
67
+ if (!program || !packetId || !action) {
68
+ return leaseFail(program ?? "<missing>", packetId ?? "<missing>", action ?? "<missing>", [
69
+ { rule: "PROGRAM-PACKET-01", message: "--program, --packet, and --action are required" },
70
+ ]);
71
+ }
72
+
73
+ switch (action) {
74
+ case "start":
75
+ return leaseStart(input, context, program, packetId);
76
+ case "heartbeat":
77
+ return leaseHeartbeat(context, program, packetId, input);
78
+ case "release":
79
+ return leaseRelease(context, program, packetId, input);
80
+ case "recover":
81
+ return leaseRecover(input, context, program, packetId);
82
+ default:
83
+ return leaseFail(program, packetId, action, [
84
+ { rule: "PROGRAM-PACKET-01", message: `unknown action: ${action}` },
85
+ ]);
86
+ }
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // start
91
+ // ---------------------------------------------------------------------------
92
+
93
+ async function leaseStart(
94
+ input: ForgeCommandInput,
95
+ context: ForgeRuntimeContext,
96
+ program: string,
97
+ packetId: string,
98
+ ): Promise<ForgeCommandResult<LeaseResultData>> {
99
+ const executor = input.flags.executor as string | undefined;
100
+ if (!executor) {
101
+ return leaseFail(program, packetId, "start", [
102
+ { rule: "PROGRAM-PACKET-01", message: "--executor is required for --action=start" },
103
+ ]);
104
+ }
105
+
106
+ const phase = (input.flags.phase as string | undefined) ?? "execution";
107
+
108
+ const { workspaceRoot } = context;
109
+
110
+ // Discover program
111
+ let programLocation;
112
+ try {
113
+ programLocation = discoverProgram(workspaceRoot, program);
114
+ } catch (err) {
115
+ return leaseFail(program, packetId, "start", [
116
+ { rule: "PROGRAM-PACKET-01", message: String((err as Error).message) },
117
+ ]);
118
+ }
119
+ if (!programLocation) {
120
+ return leaseFail(program, packetId, "start", [
121
+ { rule: "PROGRAM-PACKET-01", message: `program ${program} not found` },
122
+ ]);
123
+ }
124
+
125
+ const { manifest, programDir } = programLocation;
126
+ const entry = findPacketEntry(manifest, packetId);
127
+ if (!entry) {
128
+ return leaseFail(program, packetId, "start", [
129
+ { rule: "PROGRAM-PACKET-01", message: `packet ${packetId} not found` },
130
+ ]);
131
+ }
132
+
133
+ let packet;
134
+ try {
135
+ packet = parsePacketFile(resolvePacketPath(programDir, entry));
136
+ } catch (err) {
137
+ return leaseFail(program, packetId, "start", [
138
+ { rule: "PROGRAM-PACKET-01", message: String((err as Error).message) },
139
+ ]);
140
+ }
141
+
142
+ // Check for existing leases
143
+ const activeLeases = findActiveLeases(workspaceRoot, program);
144
+ const hasActiveLease = activeLeases.some(
145
+ (l) => l.packetId === packetId && !isLeaseStale(l.lease),
146
+ );
147
+
148
+ let head = "";
149
+ try {
150
+ head = gitHead(workspaceRoot);
151
+ } catch {
152
+ return leaseFail(program, packetId, "start", [
153
+ { rule: "PROGRAM-PACKET-02", message: "failed to read git HEAD" },
154
+ ]);
155
+ }
156
+
157
+ const violations = validateLeaseStartTransition(manifest, entry, packet, {
158
+ head,
159
+ steward: packet.steward,
160
+ actor: executor as ProgramPacketLease["actor"],
161
+ hasActiveLease,
162
+ });
163
+
164
+ if (violations.length > 0) {
165
+ return leaseFail(program, packetId, "start", violations);
166
+ }
167
+
168
+ // Generate lease
169
+ const rawToken = generateLeaseToken();
170
+ const tokenHash = hashLeaseToken(rawToken);
171
+ const now = new Date().toISOString();
172
+
173
+ const lease: ProgramPacketLease = {
174
+ schema: "forge/program-packet-lease@1",
175
+ program,
176
+ packetId,
177
+ phase: phase as ProgramPacketLease["phase"],
178
+ actor: executor as ProgramPacketLease["actor"],
179
+ baseCommit: head,
180
+ sealCommit: entry.sealCommit ?? null,
181
+ tokenHash,
182
+ startedAt: now,
183
+ heartbeatAt: now,
184
+ timeoutSeconds: DEFAULT_TIMEOUT_SECONDS,
185
+ };
186
+
187
+ writeLease(workspaceRoot, program, lease);
188
+
189
+ // Return raw token ONLY in data.leaseToken — never in logs.
190
+ return {
191
+ data: {
192
+ command: "program.packet.lease",
193
+ status: "pass",
194
+ program,
195
+ packetId,
196
+ action: "start",
197
+ leaseToken: rawToken,
198
+ violations: [],
199
+ },
200
+ exitCode: 0,
201
+ summary: `program.packet.lease: ${packetId} lease started for ${executor}`,
202
+ };
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // heartbeat
207
+ // ---------------------------------------------------------------------------
208
+
209
+ async function leaseHeartbeat(
210
+ context: ForgeRuntimeContext,
211
+ program: string,
212
+ packetId: string,
213
+ input: ForgeCommandInput,
214
+ ): Promise<ForgeCommandResult<LeaseResultData>> {
215
+ const leaseToken = input.flags["lease-token"] as string | undefined;
216
+ if (!leaseToken) {
217
+ return leaseFail(program, packetId, "heartbeat", [
218
+ { rule: "PROGRAM-PACKET-01", message: "--lease-token is required for --action=heartbeat" },
219
+ ]);
220
+ }
221
+
222
+ const { workspaceRoot } = context;
223
+ const existing = readLease(workspaceRoot, program, packetId);
224
+ if (!existing) {
225
+ return leaseFail(program, packetId, "heartbeat", [
226
+ { rule: "PROGRAM-PACKET-05", message: "no lease found" },
227
+ ]);
228
+ }
229
+
230
+ const tokenHash = hashLeaseToken(leaseToken);
231
+ if (tokenHash !== existing.tokenHash) {
232
+ return leaseFail(program, packetId, "heartbeat", [
233
+ { rule: "PROGRAM-PACKET-11", message: "token mismatch" },
234
+ ]);
235
+ }
236
+
237
+ updateHeartbeat(workspaceRoot, program, packetId);
238
+
239
+ return {
240
+ data: {
241
+ command: "program.packet.lease",
242
+ status: "pass",
243
+ program,
244
+ packetId,
245
+ action: "heartbeat",
246
+ violations: [],
247
+ },
248
+ exitCode: 0,
249
+ summary: `program.packet.lease: ${packetId} heartbeat updated`,
250
+ };
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // release
255
+ // ---------------------------------------------------------------------------
256
+
257
+ async function leaseRelease(
258
+ context: ForgeRuntimeContext,
259
+ program: string,
260
+ packetId: string,
261
+ input: ForgeCommandInput,
262
+ ): Promise<ForgeCommandResult<LeaseResultData>> {
263
+ const leaseToken = input.flags["lease-token"] as string | undefined;
264
+ if (!leaseToken) {
265
+ return leaseFail(program, packetId, "release", [
266
+ { rule: "PROGRAM-PACKET-01", message: "--lease-token is required for --action=release" },
267
+ ]);
268
+ }
269
+
270
+ const { workspaceRoot } = context;
271
+ const existing = readLease(workspaceRoot, program, packetId);
272
+ if (!existing) {
273
+ return leaseFail(program, packetId, "release", [
274
+ { rule: "PROGRAM-PACKET-05", message: "no lease found" },
275
+ ]);
276
+ }
277
+
278
+ const tokenHash = hashLeaseToken(leaseToken);
279
+ if (tokenHash !== existing.tokenHash) {
280
+ return leaseFail(program, packetId, "release", [
281
+ { rule: "PROGRAM-PACKET-11", message: "token mismatch" },
282
+ ]);
283
+ }
284
+
285
+ deleteLease(workspaceRoot, program, packetId);
286
+
287
+ return {
288
+ data: {
289
+ command: "program.packet.lease",
290
+ status: "pass",
291
+ program,
292
+ packetId,
293
+ action: "release",
294
+ violations: [],
295
+ },
296
+ exitCode: 0,
297
+ summary: `program.packet.lease: ${packetId} lease released`,
298
+ };
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // recover
303
+ // ---------------------------------------------------------------------------
304
+
305
+ async function leaseRecover(
306
+ input: ForgeCommandInput,
307
+ context: ForgeRuntimeContext,
308
+ program: string,
309
+ packetId: string,
310
+ ): Promise<ForgeCommandResult<LeaseResultData>> {
311
+ const steward = input.flags.steward as string | undefined;
312
+ const reason = input.flags.reason as string | undefined;
313
+ if (!steward || !reason) {
314
+ return leaseFail(program, packetId, "recover", [
315
+ {
316
+ rule: "PROGRAM-PACKET-01",
317
+ message: "--steward and --reason are required for --action=recover",
318
+ },
319
+ ]);
320
+ }
321
+
322
+ const { workspaceRoot } = context;
323
+ const existing = readLease(workspaceRoot, program, packetId);
324
+ if (!existing) {
325
+ return leaseFail(program, packetId, "recover", [
326
+ { rule: "PROGRAM-PACKET-05", message: "no lease to recover" },
327
+ ]);
328
+ }
329
+
330
+ // Only recover stale leases
331
+ if (!isLeaseStale(existing)) {
332
+ return leaseFail(program, packetId, "recover", [
333
+ { rule: "PROGRAM-PACKET-05", message: "lease is not stale — cannot recover active lease" },
334
+ ]);
335
+ }
336
+
337
+ // Write recovery record
338
+ const recoveryDir = path.join(
339
+ workspaceRoot,
340
+ "docs",
341
+ "plans",
342
+ programLocationDir(workspaceRoot, program),
343
+ "recoveries",
344
+ );
345
+ fs.mkdirSync(recoveryDir, { recursive: true });
346
+
347
+ const head = gitHead(workspaceRoot);
348
+ const recovery: RecoveryRecord = {
349
+ schema: "forge/program-packet-recovery@1",
350
+ program,
351
+ packetId,
352
+ previousLeaseDigest: existing.tokenHash,
353
+ reason,
354
+ actor: steward as RecoveryRecord["actor"],
355
+ observedHead: head,
356
+ target: "blocked",
357
+ recoveredAt: new Date().toISOString(),
358
+ };
359
+
360
+ const recoveryPath = path.join(recoveryDir, `${packetId}-${Date.now()}.json`);
361
+ writeFileAtomic(recoveryPath, JSON.stringify(recovery, null, 2) + "\n");
362
+
363
+ // Delete the stale lease
364
+ deleteLease(workspaceRoot, program, packetId);
365
+
366
+ // Update manifest: packet state → blocked
367
+ const manifestPath = path.join(
368
+ workspaceRoot,
369
+ "docs",
370
+ "plans",
371
+ programLocationDir(workspaceRoot, program),
372
+ "program.yaml",
373
+ );
374
+ const manifestRaw = fs.readFileSync(manifestPath, "utf8");
375
+ const manifestParsed = parseYaml(manifestRaw) as ProgramManifest;
376
+ const idx = manifestParsed.packets.findIndex((p) => p.packetId === packetId);
377
+ if (idx !== -1) {
378
+ manifestParsed.packets[idx].state = "blocked";
379
+ writeFileAtomic(manifestPath, stringifyYaml(manifestParsed));
380
+ }
381
+
382
+ return {
383
+ data: {
384
+ command: "program.packet.lease",
385
+ status: "pass",
386
+ program,
387
+ packetId,
388
+ action: "recover",
389
+ violations: [],
390
+ },
391
+ exitCode: 0,
392
+ summary: `program.packet.lease: ${packetId} recovered (stale lease cleared, recovery record written)`,
393
+ };
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Helpers
398
+ // ---------------------------------------------------------------------------
399
+
400
+ function programLocationDir(workspaceRoot: string, program: string): string {
401
+ // Find the directory name under docs/plans/ that contains this program
402
+ const plansDir = path.join(workspaceRoot, "docs", "plans");
403
+ if (!fs.existsSync(plansDir)) return program;
404
+ for (const entry of fs.readdirSync(plansDir, { withFileTypes: true })) {
405
+ if (!entry.isDirectory()) continue;
406
+ const manifestPath = path.join(plansDir, entry.name, "program.yaml");
407
+ if (!fs.existsSync(manifestPath)) continue;
408
+ const raw = fs.readFileSync(manifestPath, "utf8");
409
+ const parsed = parseYaml(raw) as Record<string, unknown>;
410
+ if (parsed.program === program) return entry.name;
411
+ }
412
+ return program;
413
+ }
414
+
415
+ function leaseFail(
416
+ program: string,
417
+ packetId: string,
418
+ action: string,
419
+ violations: PacketViolation[],
420
+ ): ForgeCommandResult<LeaseResultData> {
421
+ return {
422
+ data: {
423
+ command: "program.packet.lease",
424
+ status: "fail",
425
+ program,
426
+ packetId,
427
+ action,
428
+ violations,
429
+ },
430
+ exitCode: 1,
431
+ summary: `program.packet.lease: ${packetId} ${action} failed with ${violations.length} violation(s)`,
432
+ };
433
+ }