forgium 0.1.0

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 (36) hide show
  1. package/README.md +300 -0
  2. package/dist/cli/index.d.ts +2 -0
  3. package/dist/cli/index.js +404 -0
  4. package/dist/core/domain/types.d.ts +190 -0
  5. package/dist/core/domain/types.js +1 -0
  6. package/dist/core/errors/forgium-errors.d.ts +53 -0
  7. package/dist/core/errors/forgium-errors.js +58 -0
  8. package/dist/core/execution/execution-adapter.d.ts +35 -0
  9. package/dist/core/execution/execution-adapter.js +25 -0
  10. package/dist/core/execution/pi-rpc-execution-adapter.d.ts +13 -0
  11. package/dist/core/execution/pi-rpc-execution-adapter.js +119 -0
  12. package/dist/core/execution/spec-flow-execution-adapter.d.ts +14 -0
  13. package/dist/core/execution/spec-flow-execution-adapter.js +233 -0
  14. package/dist/core/index.d.ts +11 -0
  15. package/dist/core/index.js +11 -0
  16. package/dist/core/repository/filesystem-forgium-repository.d.ts +68 -0
  17. package/dist/core/repository/filesystem-forgium-repository.js +656 -0
  18. package/dist/core/repository/paths.d.ts +10 -0
  19. package/dist/core/repository/paths.js +14 -0
  20. package/dist/core/repository/repo-discovery.d.ts +1 -0
  21. package/dist/core/repository/repo-discovery.js +17 -0
  22. package/dist/core/schemas/draft.schema.d.ts +44 -0
  23. package/dist/core/schemas/draft.schema.js +11 -0
  24. package/dist/core/schemas/inbox.schema.d.ts +23 -0
  25. package/dist/core/schemas/inbox.schema.js +9 -0
  26. package/dist/core/schemas/manifest.schema.d.ts +116 -0
  27. package/dist/core/schemas/manifest.schema.js +20 -0
  28. package/dist/core/schemas/receipt.schema.d.ts +355 -0
  29. package/dist/core/schemas/receipt.schema.js +54 -0
  30. package/dist/core/services/id.d.ts +4 -0
  31. package/dist/core/services/id.js +9 -0
  32. package/dist/core/services/spec-flow-commands.d.ts +6 -0
  33. package/dist/core/services/spec-flow-commands.js +7 -0
  34. package/dist/core/transitions/transition-rules.d.ts +3 -0
  35. package/dist/core/transitions/transition-rules.js +10 -0
  36. package/package.json +34 -0
@@ -0,0 +1,656 @@
1
+ import fs from "node:fs/promises";
2
+ import fssync from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { exec as execCallback } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import YAML from "yaml";
8
+ import { FEATURE_STATES } from "../domain/types.js";
9
+ import { DraftAlreadyExistsError, DraftNotFoundError, FeatureAlreadyExistsError, FeatureNotFoundError, FeatureStateConflictError, ForgiumNotInitializedError, InvalidDraftError, InvalidInboxItemError, InvalidManifestError, InvalidReceiptError, InvalidStateTransitionError, ReceiptAlreadyExistsError, ReviewReceiptRequiredError } from "../errors/forgium-errors.js";
10
+ import { DraftSchema } from "../schemas/draft.schema.js";
11
+ import { InboxFrontmatterSchema } from "../schemas/inbox.schema.js";
12
+ import { ManifestSchema } from "../schemas/manifest.schema.js";
13
+ import { ReceiptSchema } from "../schemas/receipt.schema.js";
14
+ import { isoNow, shortId, slugify, timestampForFile } from "../services/id.js";
15
+ import { specFlowCommandsForSpec } from "../services/spec-flow-commands.js";
16
+ import { canTransition } from "../transitions/transition-rules.js";
17
+ import { forgiumPaths } from "./paths.js";
18
+ const exec = promisify(execCallback);
19
+ const DEFAULT_CHECK_TIMEOUT_MS = 120_000;
20
+ const MAX_OUTPUT_SUMMARY_LENGTH = 2_000;
21
+ export class FilesystemForgiumRepository {
22
+ root;
23
+ paths;
24
+ constructor(root) {
25
+ this.root = root;
26
+ this.paths = forgiumPaths(root);
27
+ }
28
+ async init() {
29
+ for (const dir of this.paths.requiredDirs)
30
+ await fs.mkdir(dir, { recursive: true });
31
+ await this.ensureGitignoreRuntime();
32
+ }
33
+ async capture(input) {
34
+ await this.assertInitialized();
35
+ const text = input.text.trim();
36
+ if (!text)
37
+ throw new InvalidInboxItemError("Capture text cannot be empty.");
38
+ const lines = text.split(/\r?\n/);
39
+ const title = lines[0].replace(/^#\s+/, "").trim();
40
+ if (!title)
41
+ throw new InvalidInboxItemError("Capture title cannot be empty.");
42
+ const body = lines.slice(1).join("\n").trim();
43
+ const stamp = timestampForFile();
44
+ const idSuffix = shortId();
45
+ const id = `inbox-${stamp}-${idSuffix}`;
46
+ const created = isoNow();
47
+ const source = input.source ?? "cli";
48
+ const filename = `${stamp}-${idSuffix}.md`;
49
+ const filePath = path.join(this.paths.inbox, filename);
50
+ const content = this.renderInbox({ id, source, created, status: "captured", title, body, path: filePath });
51
+ await this.writeFileAtomic(filePath, content);
52
+ return { id, source, created, status: "captured", title, body: body || undefined, path: filePath };
53
+ }
54
+ async listInbox() {
55
+ await this.assertInitialized();
56
+ const entries = await safeReaddir(this.paths.inbox);
57
+ const items = [];
58
+ for (const entry of entries.filter((e) => e.endsWith(".md")).sort()) {
59
+ items.push(await this.readInboxFile(path.join(this.paths.inbox, entry)));
60
+ }
61
+ return items.sort((a, b) => a.created.localeCompare(b.created) || a.id.localeCompare(b.id));
62
+ }
63
+ async listDrafts() {
64
+ await this.assertInitialized();
65
+ const drafts = [];
66
+ for (const entry of (await safeReaddir(this.paths.featureStateDir("draft"))).sort()) {
67
+ const full = this.paths.featureDir("draft", entry);
68
+ if ((await fs.stat(full)).isDirectory())
69
+ drafts.push(await this.readDraft(full));
70
+ }
71
+ return drafts.sort((a, b) => a.frontmatter.created.localeCompare(b.frontmatter.created) || a.id.localeCompare(b.id));
72
+ }
73
+ async getDraft(id) {
74
+ for (const draft of await this.listDrafts())
75
+ if (draft.id === id || draft.slug === id)
76
+ return draft;
77
+ return null;
78
+ }
79
+ async createDraftFromInbox(id) {
80
+ await this.assertInitialized();
81
+ const item = (await this.listInbox()).find((candidate) => candidate.id === id);
82
+ if (!item)
83
+ throw new InvalidInboxItemError(`Inbox item not found: ${id}`);
84
+ if (item.status !== "captured")
85
+ throw new InvalidInboxItemError(`Inbox item is not captured: ${id}`);
86
+ const slug = slugify(item.title);
87
+ const draftId = `draft-${slug}`;
88
+ const draftPath = this.paths.featureDir("draft", slug);
89
+ if (await exists(draftPath))
90
+ throw new DraftAlreadyExistsError(draftId);
91
+ const frontmatter = {
92
+ schemaVersion: 1,
93
+ id: draftId,
94
+ created: isoNow(),
95
+ source: { type: "inbox", ref: item.id },
96
+ title: item.title,
97
+ goal: "",
98
+ acceptance: [],
99
+ constraints: []
100
+ };
101
+ DraftSchema.parse(frontmatter);
102
+ const draftContent = this.renderDraft(frontmatter, item.body ? `${item.title}\n\n${item.body}` : item.title);
103
+ await fs.mkdir(draftPath, { recursive: false });
104
+ try {
105
+ await this.writeFileAtomic(path.join(draftPath, "draft.md"), draftContent);
106
+ await this.writeInboxItem({ ...item, status: "drafted", draftRef: draftId });
107
+ }
108
+ catch (error) {
109
+ await fs.rm(draftPath, { recursive: true, force: true });
110
+ throw error;
111
+ }
112
+ return this.readDraft(draftPath);
113
+ }
114
+ async deferInbox(id) {
115
+ const item = await this.requireInbox(id);
116
+ if (item.status !== "captured")
117
+ throw new InvalidInboxItemError(`Inbox item is not captured: ${id}`);
118
+ const deferred = { ...item, status: "deferred" };
119
+ await this.writeInboxItem(deferred);
120
+ return deferred;
121
+ }
122
+ async mergeInbox(id, featureId) {
123
+ const item = await this.requireInbox(id);
124
+ if (item.status !== "captured")
125
+ throw new InvalidInboxItemError(`Inbox item is not captured: ${id}`);
126
+ if (!(await this.getFeature(featureId)))
127
+ throw new FeatureNotFoundError(featureId);
128
+ const merged = { ...item, status: "merged", featureRef: featureId };
129
+ await this.writeInboxItem(merged);
130
+ return merged;
131
+ }
132
+ async promoteDraft(id) {
133
+ await this.assertInitialized();
134
+ const draft = await this.requireDraft(id);
135
+ const frontmatter = this.parsePromotableDraft(draft);
136
+ const featureId = `feature-${draft.slug}`;
137
+ const sourceInbox = (await this.listInbox()).find((item) => item.id === frontmatter.source.ref);
138
+ if (!sourceInbox || sourceInbox.status !== "drafted" || sourceInbox.draftRef !== draft.id) {
139
+ throw new InvalidDraftError(`Draft source is not linked to a drafted Inbox item: ${draft.id}`);
140
+ }
141
+ if (await this.featureIdExists(featureId) || await exists(this.paths.featureDir("ready", draft.slug))) {
142
+ throw new FeatureAlreadyExistsError(featureId);
143
+ }
144
+ const manifest = {
145
+ schemaVersion: 1,
146
+ id: featureId,
147
+ title: frontmatter.title.trim(),
148
+ created: frontmatter.created,
149
+ source: frontmatter.source,
150
+ goal: frontmatter.goal.trim(),
151
+ acceptance: frontmatter.acceptance.map((criterion) => criterion.trim()).filter(Boolean),
152
+ constraints: frontmatter.constraints?.map((constraint) => constraint.trim()).filter(Boolean)
153
+ };
154
+ ManifestSchema.parse(manifest);
155
+ const destination = this.paths.featureDir("ready", draft.slug);
156
+ const manifestPath = path.join(draft.path, "manifest.yaml");
157
+ try {
158
+ await this.writeFileAtomic(manifestPath, YAML.stringify(manifest));
159
+ await fs.rename(draft.path, destination);
160
+ }
161
+ catch (error) {
162
+ await fs.rm(manifestPath, { force: true });
163
+ throw error;
164
+ }
165
+ try {
166
+ await this.writeInboxItem({ ...sourceInbox, status: "promoted", featureRef: featureId });
167
+ }
168
+ catch (error) {
169
+ await fs.rename(destination, draft.path).catch(() => undefined);
170
+ await fs.rm(manifestPath, { force: true });
171
+ throw error;
172
+ }
173
+ return this.readFeature(destination, "ready");
174
+ }
175
+ async createFeature(input) {
176
+ await this.assertInitialized();
177
+ const slug = input.slug ? slugify(input.slug) : slugify(input.title);
178
+ const id = input.id ?? `feature-${slug}`;
179
+ const featurePath = this.paths.featureDir("ready", slug);
180
+ if (await exists(featurePath))
181
+ throw new FeatureAlreadyExistsError(id);
182
+ const manifest = {
183
+ schemaVersion: 1,
184
+ id,
185
+ title: input.title,
186
+ created: isoNow(),
187
+ source: input.source,
188
+ goal: input.goal,
189
+ acceptance: input.acceptance,
190
+ constraints: input.constraints,
191
+ verification: input.verification
192
+ };
193
+ const parsed = ManifestSchema.safeParse(manifest);
194
+ if (!parsed.success)
195
+ throw new InvalidManifestError(parsed.error.message);
196
+ await fs.mkdir(featurePath, { recursive: false });
197
+ await this.writeFileAtomic(path.join(featurePath, "manifest.yaml"), YAML.stringify(manifest));
198
+ return this.readFeature(featurePath, "ready");
199
+ }
200
+ async listReceipts(featureId) {
201
+ const feature = await this.requireFeature(featureId);
202
+ const receipts = [];
203
+ for (const entry of (await safeReaddir(path.join(feature.path, "receipts"))).filter((name) => name.endsWith(".yaml")).sort()) {
204
+ const receiptPath = path.join(feature.path, "receipts", entry);
205
+ try {
206
+ const receipt = ReceiptSchema.parse(YAML.parse(await fs.readFile(receiptPath, "utf8")));
207
+ this.validateReceiptReferences(feature.path, receipt);
208
+ receipts.push(receipt);
209
+ }
210
+ catch (error) {
211
+ throw new InvalidReceiptError(`Invalid receipt ${receiptPath}: ${String(error.message)}`);
212
+ }
213
+ }
214
+ return receipts.sort((a, b) => a.created.localeCompare(b.created) || a.id.localeCompare(b.id));
215
+ }
216
+ async verifyFeature(id, options = {}) {
217
+ const feature = await this.requireFeature(id);
218
+ if (feature.state !== "doing")
219
+ throw new InvalidStateTransitionError(feature.state, "review");
220
+ const runId = options.runId ?? `run-${timestampForFile()}-${shortId()}`;
221
+ const policy = feature.manifest.verification;
222
+ const checks = [];
223
+ let evidence;
224
+ let outcome = "not_configured";
225
+ if (policy) {
226
+ for (const command of policy.commands) {
227
+ const started = Date.now();
228
+ try {
229
+ const result = await exec(command.run, { cwd: this.root, timeout: command.timeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS, maxBuffer: 64 * 1024 });
230
+ checks.push({ name: command.name, command: command.run, cwd: ".", exitCode: 0, durationMs: Date.now() - started, status: "passed", outputSummary: redactOutput(`${result.stdout}${result.stderr}`) });
231
+ }
232
+ catch (error) {
233
+ const commandError = error;
234
+ checks.push({ name: command.name, command: command.run, cwd: ".", exitCode: typeof commandError.code === "number" ? commandError.code : null, durationMs: Date.now() - started, status: commandError.killed ? "cancelled" : "failed", outputSummary: redactOutput(`${commandError.stdout ?? ""}${commandError.stderr ?? ""}`) });
235
+ }
236
+ }
237
+ evidence = policy.requiredEvidence?.map((required) => options.evidence?.find((candidate) => candidate.criterion === required.criterion && candidate.kind === required.kind) ?? { ...required, status: "pending" });
238
+ if (checks.some((check) => check.status === "cancelled"))
239
+ outcome = "cancelled";
240
+ else if (checks.some((check) => check.status === "failed"))
241
+ outcome = "failed";
242
+ else if (evidence?.some((item) => item.status !== "passed"))
243
+ outcome = "manual_required";
244
+ else
245
+ outcome = "passed";
246
+ }
247
+ const receipt = this.createVerificationReceipt(feature, runId, outcome, checks, evidence);
248
+ await this.persistReceipt(feature, receipt);
249
+ if (outcome === "failed" || outcome === "cancelled") {
250
+ await this.persistReceipt(feature, this.createHandoffReceipt(feature, runId, outcome === "cancelled" ? "cancelled" : "failed", receipt.id, receipt.summary));
251
+ }
252
+ else {
253
+ await this.transition(id, "review");
254
+ }
255
+ return receipt;
256
+ }
257
+ async reviewFeature(id, decision, summary, actorName = "forgium") {
258
+ const feature = await this.requireFeature(id);
259
+ if (feature.state !== "review")
260
+ throw new InvalidStateTransitionError(feature.state, "review");
261
+ await this.persistReceipt(feature, this.createReviewReceipt(feature, decision, summary, actorName));
262
+ return this.transition(id, decision === "approved" ? "done" : decision === "changes_requested" ? "doing" : "blocked");
263
+ }
264
+ async executeFeature(id, registry) {
265
+ const ready = await this.requireFeature(id);
266
+ if (ready.state !== "ready")
267
+ throw new InvalidStateTransitionError(ready.state, "doing");
268
+ const profile = await this.inspectExecutionMode(id);
269
+ const runId = `run-${timestampForFile()}-${shortId()}`;
270
+ const request = { root: this.root, feature: ready, profile, runId, permissions: "repository" };
271
+ const adapter = await registry.resolve(request);
272
+ if (!adapter)
273
+ return { outcome: "needs_human", feature: ready, summary: "No execution adapter is available for this Feature." };
274
+ const doing = await this.startFeature(id);
275
+ const activeProfile = await this.inspectExecutionMode(id);
276
+ const result = await adapter.execute({ ...request, feature: doing, profile: activeProfile });
277
+ await this.persistReceipt(doing, this.createExecutionReceipt(doing, runId, adapter.id, result));
278
+ if (result.outcome === "completed") {
279
+ const verification = await this.verifyFeature(id, { runId });
280
+ return { outcome: verification.outcome === "failed" ? "verification_failed" : result.outcome, feature: (await this.requireFeature(id)), summary: result.summary, adapterId: adapter.id };
281
+ }
282
+ if (result.outcome === "blocked" || result.outcome === "needs_human") {
283
+ const blocked = await this.transition(id, "blocked");
284
+ await this.persistReceipt(blocked, this.createHandoffReceipt(blocked, runId, result.outcome, undefined, result.reason ?? result.summary));
285
+ return { outcome: result.outcome, feature: blocked, summary: result.summary, adapterId: adapter.id };
286
+ }
287
+ if (result.outcome === "verification_failed") {
288
+ await this.persistReceipt(doing, this.createHandoffReceipt(doing, runId, "failed", undefined, result.reason ?? result.summary));
289
+ }
290
+ else {
291
+ await this.persistReceipt(doing, this.createHandoffReceipt(doing, runId, "cancelled", undefined, result.reason ?? result.summary));
292
+ }
293
+ return { outcome: result.outcome, feature: await this.requireFeature(id), summary: result.summary, adapterId: adapter.id };
294
+ }
295
+ async listFeatures(state) {
296
+ await this.assertInitialized();
297
+ const states = state ? [state] : FEATURE_STATES;
298
+ const features = [];
299
+ for (const s of states) {
300
+ for (const entry of (await safeReaddir(this.paths.featureStateDir(s))).sort()) {
301
+ const full = this.paths.featureDir(s, entry);
302
+ if ((await fs.stat(full)).isDirectory())
303
+ features.push(await this.readFeature(full, s));
304
+ }
305
+ }
306
+ return features.sort((a, b) => a.manifest.created.localeCompare(b.manifest.created) || a.id.localeCompare(b.id));
307
+ }
308
+ async getFeature(id) {
309
+ for (const feature of await this.listFeatures())
310
+ if (feature.id === id || feature.slug === id)
311
+ return feature;
312
+ return null;
313
+ }
314
+ async getNextReady() { return (await this.listFeatures("ready"))[0] ?? null; }
315
+ async startFeature(id) { return this.transition(id, "doing", true); }
316
+ async submitForReview(id) { return this.transition(id, "review"); }
317
+ async completeFeature(id) {
318
+ const feature = await this.requireFeature(id);
319
+ if (!(await this.hasApprovedReviewReceipt(feature)))
320
+ throw new ReviewReceiptRequiredError(id);
321
+ return this.transition(id, "done");
322
+ }
323
+ async returnToDoing(id) { return this.transition(id, "doing"); }
324
+ async unblockFeature(id) { return this.transition(id, "ready"); }
325
+ async blockFeature(id, reason) {
326
+ const feature = await this.transition(id, "blocked");
327
+ await fs.appendFile(path.join(feature.path, "notes.md"), `\n## Blocked ${new Date().toISOString()}\n\n${reason}\n`);
328
+ return feature;
329
+ }
330
+ async inspectExecutionMode(id) {
331
+ const feature = await this.requireFeature(id);
332
+ const specPath = path.join(feature.path, "spec.md");
333
+ const ticketsPath = path.join(feature.path, "tickets");
334
+ const hasSpec = await exists(specPath);
335
+ const hasTickets = await exists(ticketsPath);
336
+ if (hasSpec && hasTickets) {
337
+ const commands = specFlowCommandsForSpec(specPath);
338
+ return { kind: "spec-flow", specPath, ticketsPath, commands: { implement: commands.implement, next: commands.next } };
339
+ }
340
+ if (hasSpec)
341
+ return { kind: "spec-needs-plan", specPath, commands: specFlowCommandsForSpec(specPath) };
342
+ return { kind: "direct" };
343
+ }
344
+ async getStatus() {
345
+ await this.assertInitialized();
346
+ const inbox = { captured: 0, drafted: 0, promoted: 0, merged: 0, deferred: 0 };
347
+ for (const item of await this.listInbox())
348
+ inbox[item.status] = (inbox[item.status] ?? 0) + 1;
349
+ const features = Object.fromEntries(FEATURE_STATES.map((s) => [s, 0]));
350
+ for (const feature of await this.listFeatures())
351
+ features[feature.state]++;
352
+ return { inbox, drafts: await this.countDirectories(this.paths.featureStateDir("draft")), features };
353
+ }
354
+ async validate() {
355
+ const issues = [];
356
+ for (const dir of this.paths.requiredDirs)
357
+ if (!(await exists(dir)))
358
+ issues.push({ severity: "error", code: "MISSING_DIRECTORY", message: `Missing directory: ${path.relative(this.root, dir)}`, path: dir });
359
+ if (issues.length)
360
+ return { valid: false, issues };
361
+ for (const file of (await safeReaddir(this.paths.inbox)).filter((e) => e.endsWith(".md"))) {
362
+ try {
363
+ await this.readInboxFile(path.join(this.paths.inbox, file));
364
+ }
365
+ catch (error) {
366
+ issues.push({ severity: "error", code: "INVALID_INBOX_ITEM", message: String(error.message), path: path.join(this.paths.inbox, file) });
367
+ }
368
+ }
369
+ for (const state of FEATURE_STATES) {
370
+ for (const entry of await safeReaddir(this.paths.featureStateDir(state))) {
371
+ const full = this.paths.featureDir(state, entry);
372
+ if (!(await fs.stat(full)).isDirectory())
373
+ continue;
374
+ try {
375
+ await this.readFeature(full, state);
376
+ }
377
+ catch (error) {
378
+ issues.push({ severity: "error", code: "INVALID_FEATURE", message: String(error.message), path: full });
379
+ }
380
+ try {
381
+ await this.validateReceiptFiles(full);
382
+ }
383
+ catch (error) {
384
+ issues.push({ severity: "error", code: "INVALID_RECEIPT", message: String(error.message), path: full });
385
+ }
386
+ }
387
+ }
388
+ for (const entry of await safeReaddir(this.paths.featureStateDir("draft"))) {
389
+ const full = this.paths.featureDir("draft", entry);
390
+ if (!(await fs.stat(full)).isDirectory())
391
+ continue;
392
+ try {
393
+ await this.readDraft(full);
394
+ }
395
+ catch (error) {
396
+ issues.push({ severity: "error", code: "INVALID_DRAFT", message: String(error.message), path: full });
397
+ }
398
+ }
399
+ return { valid: issues.filter((i) => i.severity === "error").length === 0, issues };
400
+ }
401
+ async transition(id, to, createLease = false) {
402
+ const feature = await this.requireFeature(id);
403
+ if (!canTransition(feature.state, to))
404
+ throw new InvalidStateTransitionError(feature.state, to);
405
+ ManifestSchema.parse(feature.manifest);
406
+ const dest = this.paths.featureDir(to, feature.slug);
407
+ if (await exists(dest))
408
+ throw new FeatureStateConflictError(`Destination already exists: ${dest}`);
409
+ await fs.rename(feature.path, dest);
410
+ if (createLease)
411
+ await this.createLease(feature.id);
412
+ return this.readFeature(dest, to);
413
+ }
414
+ async createLease(featureId) {
415
+ const runsDir = path.join(this.paths.runtime, "runs");
416
+ await fs.mkdir(runsDir, { recursive: true });
417
+ const runId = `run-${timestampForFile()}-${shortId()}`;
418
+ await this.writeFileAtomic(path.join(runsDir, `${runId}.json`), JSON.stringify({ runId, featureId, startedAt: isoNow(), host: os.hostname(), pid: process.pid }, null, 2));
419
+ }
420
+ async requireFeature(id) { const feature = await this.getFeature(id); if (!feature)
421
+ throw new FeatureNotFoundError(id); return feature; }
422
+ async requireInbox(id) {
423
+ const item = (await this.listInbox()).find((candidate) => candidate.id === id);
424
+ if (!item)
425
+ throw new InvalidInboxItemError(`Inbox item not found: ${id}`);
426
+ return item;
427
+ }
428
+ async requireDraft(id) { const draft = await this.getDraft(id); if (!draft)
429
+ throw new DraftNotFoundError(id); return draft; }
430
+ async assertInitialized() {
431
+ for (const dir of this.paths.requiredDirs)
432
+ if (!(await exists(dir)))
433
+ throw new ForgiumNotInitializedError();
434
+ }
435
+ createVerificationReceipt(feature, runId, outcome, checks, evidence) {
436
+ return {
437
+ ...this.receiptBase(feature, "verification", outcome, `Verification outcome: ${outcome}.`, runId, "verifier", "forgium"),
438
+ kind: "verification",
439
+ outcome,
440
+ checks,
441
+ evidence
442
+ };
443
+ }
444
+ createReviewReceipt(feature, decision, summary, actorName) {
445
+ return {
446
+ ...this.receiptBase(feature, "review", decision, summary, `review-${timestampForFile()}-${shortId()}`, "reviewer", actorName),
447
+ kind: "review",
448
+ outcome: decision,
449
+ decision
450
+ };
451
+ }
452
+ createExecutionReceipt(feature, runId, engine, result) {
453
+ return {
454
+ ...this.receiptBase(feature, "execution", result.outcome, result.summary, runId, "executor", engine),
455
+ kind: "execution",
456
+ outcome: result.outcome,
457
+ engine,
458
+ artifacts: result.artifacts
459
+ };
460
+ }
461
+ createHandoffReceipt(feature, runId, outcome, relatedReceipt, reason) {
462
+ return {
463
+ ...this.receiptBase(feature, "handoff", outcome, reason, runId, "system", "forgium"),
464
+ kind: "handoff",
465
+ outcome,
466
+ reason,
467
+ relatedReceipt
468
+ };
469
+ }
470
+ receiptBase(feature, kind, outcome, summary, runId, actorType, actorName) {
471
+ return {
472
+ schemaVersion: 1,
473
+ id: `receipt-${timestampForFile()}-${shortId()}-${kind}`,
474
+ kind,
475
+ created: isoNow(),
476
+ feature: { id: feature.id, manifestPath: "manifest.yaml" },
477
+ runId,
478
+ actor: { type: actorType, name: actorName, version: "0.1.0" },
479
+ outcome,
480
+ summary: summary.trim() || "No summary provided."
481
+ };
482
+ }
483
+ async persistReceipt(feature, receipt) {
484
+ const parsed = ReceiptSchema.safeParse(receipt);
485
+ if (!parsed.success)
486
+ throw new InvalidReceiptError(parsed.error.message);
487
+ if (receipt.feature.id !== feature.id)
488
+ throw new InvalidReceiptError(`Receipt feature does not match ${feature.id}.`);
489
+ this.validateReceiptReferences(feature.path, receipt);
490
+ const receiptsPath = path.join(feature.path, "receipts");
491
+ const receiptPath = path.join(receiptsPath, `${receipt.id}.yaml`);
492
+ if (await exists(receiptPath))
493
+ throw new ReceiptAlreadyExistsError(receipt.id);
494
+ await this.writeFileAtomic(receiptPath, YAML.stringify(receipt));
495
+ }
496
+ async validateReceiptFiles(featurePath) {
497
+ for (const entry of (await safeReaddir(path.join(featurePath, "receipts"))).filter((name) => name.endsWith(".yaml"))) {
498
+ const receiptPath = path.join(featurePath, "receipts", entry);
499
+ let receipt;
500
+ try {
501
+ receipt = ReceiptSchema.parse(YAML.parse(await fs.readFile(receiptPath, "utf8")));
502
+ }
503
+ catch (error) {
504
+ throw new InvalidReceiptError(`${receiptPath}: ${String(error.message)}`);
505
+ }
506
+ this.validateReceiptReferences(featurePath, receipt);
507
+ }
508
+ }
509
+ validateReceiptReferences(featurePath, receipt) {
510
+ const manifestPath = path.resolve(featurePath, receipt.feature.manifestPath);
511
+ if (!isWithin(featurePath, manifestPath) || !fssync.existsSync(manifestPath))
512
+ throw new InvalidReceiptError(`Missing manifest reference: ${receipt.feature.manifestPath}`);
513
+ const refs = [];
514
+ if (receipt.kind === "verification")
515
+ refs.push(...(receipt.evidence ?? []).flatMap((item) => item.ref ? [item.ref] : []));
516
+ if (receipt.kind === "execution")
517
+ refs.push(...(receipt.artifacts ?? []));
518
+ for (const ref of refs) {
519
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(ref))
520
+ continue;
521
+ const resolved = path.resolve(featurePath, ref);
522
+ if (!isWithin(featurePath, resolved) || !fssync.existsSync(resolved))
523
+ throw new InvalidReceiptError(`Invalid local receipt reference: ${ref}`);
524
+ }
525
+ }
526
+ async hasApprovedReviewReceipt(feature) {
527
+ const receipts = await this.listReceipts(feature.id);
528
+ return receipts.some((receipt) => receipt.kind === "review" && receipt.decision === "approved");
529
+ }
530
+ async readFeature(featurePath, state) {
531
+ const manifestPath = path.join(featurePath, "manifest.yaml");
532
+ const manifest = ManifestSchema.parse(YAML.parse(await fs.readFile(manifestPath, "utf8")));
533
+ return {
534
+ id: manifest.id,
535
+ slug: path.basename(featurePath),
536
+ state,
537
+ path: featurePath,
538
+ manifest,
539
+ artifacts: {
540
+ spec: (await exists(path.join(featurePath, "spec.md"))) ? path.join(featurePath, "spec.md") : undefined,
541
+ ticketsDirectory: (await exists(path.join(featurePath, "tickets"))) ? path.join(featurePath, "tickets") : undefined,
542
+ notes: (await exists(path.join(featurePath, "notes.md"))) ? path.join(featurePath, "notes.md") : undefined,
543
+ research: (await exists(path.join(featurePath, "research.md"))) ? path.join(featurePath, "research.md") : undefined,
544
+ receiptsDirectory: (await exists(path.join(featurePath, "receipts"))) ? path.join(featurePath, "receipts") : undefined
545
+ }
546
+ };
547
+ }
548
+ async readDraft(draftPath) {
549
+ const draftFile = path.join(draftPath, "draft.md");
550
+ const raw = await fs.readFile(draftFile, "utf8");
551
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
552
+ if (!match)
553
+ throw new InvalidDraftError(`Missing frontmatter in ${draftFile}`);
554
+ let frontmatter;
555
+ try {
556
+ frontmatter = DraftSchema.parse(YAML.parse(match[1]));
557
+ }
558
+ catch (error) {
559
+ throw new InvalidDraftError(`Invalid Draft frontmatter in ${draftFile}: ${String(error.message)}`);
560
+ }
561
+ return { id: frontmatter.id, slug: path.basename(draftPath), path: draftPath, frontmatter, body: match[2].trim() };
562
+ }
563
+ async readInboxFile(filePath) {
564
+ const raw = await fs.readFile(filePath, "utf8");
565
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
566
+ if (!match)
567
+ throw new InvalidInboxItemError(`Missing frontmatter in ${filePath}`);
568
+ const frontmatter = InboxFrontmatterSchema.parse(YAML.parse(match[1]));
569
+ const body = match[2].trim();
570
+ const titleMatch = body.match(/^#\s+(.+)$/m);
571
+ if (!titleMatch)
572
+ throw new InvalidInboxItemError(`Inbox item must contain one H1 title: ${filePath}`);
573
+ const rest = body.replace(/^#\s+.+\n?/, "").trim();
574
+ return { ...frontmatter, title: titleMatch[1].trim(), body: rest || undefined, path: filePath };
575
+ }
576
+ async writeInboxItem(item) {
577
+ await this.writeFileAtomic(item.path, this.renderInbox(item));
578
+ }
579
+ renderDraft(frontmatter, originalText) {
580
+ return `---\n${YAML.stringify(frontmatter)}---\n\n# Contexto original\n\n${originalText.trim()}\n\n## Notas de definición\n\n<!-- Decisiones, enlaces de investigación y detalles no contractuales. -->\n`;
581
+ }
582
+ parsePromotableDraft(draft) {
583
+ const parsed = DraftSchema.safeParse(draft.frontmatter);
584
+ if (!parsed.success)
585
+ throw new InvalidDraftError(`Invalid Draft ${draft.id}: ${parsed.error.message}`);
586
+ const frontmatter = parsed.data;
587
+ if (!frontmatter.title.trim() || !frontmatter.goal.trim() || !frontmatter.acceptance.some((criterion) => criterion.trim())) {
588
+ throw new InvalidDraftError(`Draft is incomplete and cannot be promoted: ${draft.id}`);
589
+ }
590
+ return frontmatter;
591
+ }
592
+ async featureIdExists(id) {
593
+ for (const feature of await this.listFeatures())
594
+ if (feature.id === id)
595
+ return true;
596
+ return false;
597
+ }
598
+ async countDirectories(directory) {
599
+ let count = 0;
600
+ for (const entry of await safeReaddir(directory)) {
601
+ if ((await fs.stat(path.join(directory, entry))).isDirectory())
602
+ count++;
603
+ }
604
+ return count;
605
+ }
606
+ renderInbox(item) {
607
+ const fm = { id: item.id, source: item.source, created: item.created, status: item.status };
608
+ if (item.draftRef)
609
+ fm.draftRef = item.draftRef;
610
+ if (item.featureRef)
611
+ fm.featureRef = item.featureRef;
612
+ return `---\n${YAML.stringify(fm)}---\n\n# ${item.title}\n${item.body ? `\n${item.body}\n` : ""}`;
613
+ }
614
+ async ensureGitignoreRuntime() {
615
+ const gitignore = path.join(this.root, ".gitignore");
616
+ const line = ".forgium/runtime/";
617
+ const current = await fs.readFile(gitignore, "utf8").catch(() => "");
618
+ if (!current.split(/\r?\n/).includes(line))
619
+ await fs.appendFile(gitignore, `${current.endsWith("\n") || current.length === 0 ? "" : "\n"}${line}\n`);
620
+ }
621
+ async writeFileAtomic(filePath, content) {
622
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
623
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
624
+ try {
625
+ await fs.writeFile(tmp, content, "utf8");
626
+ await fs.rename(tmp, filePath);
627
+ }
628
+ catch (error) {
629
+ await fs.rm(tmp, { force: true });
630
+ throw error;
631
+ }
632
+ }
633
+ }
634
+ async function exists(filePath) { try {
635
+ await fs.access(filePath);
636
+ return true;
637
+ }
638
+ catch {
639
+ return false;
640
+ } }
641
+ async function safeReaddir(dir) { try {
642
+ return await fs.readdir(dir);
643
+ }
644
+ catch {
645
+ return [];
646
+ } }
647
+ function isWithin(parent, child) {
648
+ const relative = path.relative(path.resolve(parent), child);
649
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
650
+ }
651
+ function redactOutput(output) {
652
+ const redacted = output
653
+ .replace(/(authorization\s*:\s*|bearer\s+|token\s*=\s*|password\s*=\s*)[^\s,;]+/gi, "$1[REDACTED]")
654
+ .trim();
655
+ return redacted.length > MAX_OUTPUT_SUMMARY_LENGTH ? `${redacted.slice(0, MAX_OUTPUT_SUMMARY_LENGTH)}…` : redacted;
656
+ }
@@ -0,0 +1,10 @@
1
+ export declare function forgiumPaths(root: string): {
2
+ root: string;
3
+ product: string;
4
+ inbox: string;
5
+ features: string;
6
+ runtime: string;
7
+ featureStateDir: (state: string) => string;
8
+ featureDir: (state: string, slug: string) => string;
9
+ requiredDirs: string[];
10
+ };
@@ -0,0 +1,14 @@
1
+ import path from "node:path";
2
+ import { FEATURE_STATES } from "../domain/types.js";
3
+ export function forgiumPaths(root) {
4
+ const product = path.join(root, "product");
5
+ const inbox = path.join(product, "inbox");
6
+ const features = path.join(root, "features");
7
+ return {
8
+ root, product, inbox, features,
9
+ runtime: path.join(root, ".forgium", "runtime"),
10
+ featureStateDir: (state) => path.join(features, state),
11
+ featureDir: (state, slug) => path.join(features, state, slug),
12
+ requiredDirs: [inbox, path.join(features, "draft"), ...FEATURE_STATES.map((s) => path.join(features, s))]
13
+ };
14
+ }