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.
- package/README.md +300 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +404 -0
- package/dist/core/domain/types.d.ts +190 -0
- package/dist/core/domain/types.js +1 -0
- package/dist/core/errors/forgium-errors.d.ts +53 -0
- package/dist/core/errors/forgium-errors.js +58 -0
- package/dist/core/execution/execution-adapter.d.ts +35 -0
- package/dist/core/execution/execution-adapter.js +25 -0
- package/dist/core/execution/pi-rpc-execution-adapter.d.ts +13 -0
- package/dist/core/execution/pi-rpc-execution-adapter.js +119 -0
- package/dist/core/execution/spec-flow-execution-adapter.d.ts +14 -0
- package/dist/core/execution/spec-flow-execution-adapter.js +233 -0
- package/dist/core/index.d.ts +11 -0
- package/dist/core/index.js +11 -0
- package/dist/core/repository/filesystem-forgium-repository.d.ts +68 -0
- package/dist/core/repository/filesystem-forgium-repository.js +656 -0
- package/dist/core/repository/paths.d.ts +10 -0
- package/dist/core/repository/paths.js +14 -0
- package/dist/core/repository/repo-discovery.d.ts +1 -0
- package/dist/core/repository/repo-discovery.js +17 -0
- package/dist/core/schemas/draft.schema.d.ts +44 -0
- package/dist/core/schemas/draft.schema.js +11 -0
- package/dist/core/schemas/inbox.schema.d.ts +23 -0
- package/dist/core/schemas/inbox.schema.js +9 -0
- package/dist/core/schemas/manifest.schema.d.ts +116 -0
- package/dist/core/schemas/manifest.schema.js +20 -0
- package/dist/core/schemas/receipt.schema.d.ts +355 -0
- package/dist/core/schemas/receipt.schema.js +54 -0
- package/dist/core/services/id.d.ts +4 -0
- package/dist/core/services/id.js +9 -0
- package/dist/core/services/spec-flow-commands.d.ts +6 -0
- package/dist/core/services/spec-flow-commands.js +7 -0
- package/dist/core/transitions/transition-rules.d.ts +3 -0
- package/dist/core/transitions/transition-rules.js +10 -0
- package/package.json +34 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { EditorUnavailableError, ExecutionAdapterRegistry, FilesystemForgiumRepository, ForgiumError, InvalidDraftError, PiRpcExecutionAdapter, RunOptionsInvalidError, SpecFlowExecutionAdapter, findRepositoryRoot } from "../core/index.js";
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program
|
|
9
|
+
.name("forgium")
|
|
10
|
+
.description("Repository-native workflow engine for software development agents")
|
|
11
|
+
.version("0.1.0")
|
|
12
|
+
.option("--root <path>", "repository root override")
|
|
13
|
+
.option("--json", "emit JSON output");
|
|
14
|
+
program.command("init")
|
|
15
|
+
.description("Initialize Forgium structures in the repository")
|
|
16
|
+
.action(async () => {
|
|
17
|
+
const repo = await repoForCommand();
|
|
18
|
+
await repo.init();
|
|
19
|
+
output({ initialized: true, root: repo.root }, `Initialized Forgium in ${repo.root}`);
|
|
20
|
+
});
|
|
21
|
+
program.command("capture")
|
|
22
|
+
.description("Capture a raw Inbox item")
|
|
23
|
+
.argument("[text...]", "capture text")
|
|
24
|
+
.option("--source <source>", "producer source", "cli")
|
|
25
|
+
.action(async (parts, opts) => {
|
|
26
|
+
const repo = await repoForCommand();
|
|
27
|
+
const argText = parts.join(" ").trim();
|
|
28
|
+
const text = argText || (process.stdin.isTTY ? "" : (await readStdin()).trim());
|
|
29
|
+
const item = await repo.capture({ text, source: opts.source });
|
|
30
|
+
output(item, `Captured ${item.id}\n${relative(repo.root, item.path)}`);
|
|
31
|
+
});
|
|
32
|
+
program.command("inbox")
|
|
33
|
+
.description("List Inbox items")
|
|
34
|
+
.action(async () => {
|
|
35
|
+
const repo = await repoForCommand();
|
|
36
|
+
const items = await repo.listInbox();
|
|
37
|
+
output(items, items.length ? items.map((i) => `${i.id}\t${i.status}\t${i.created}\t${i.title}`).join("\n") : "Inbox is empty");
|
|
38
|
+
});
|
|
39
|
+
program.command("triage")
|
|
40
|
+
.description("Resolve Drafts and triage captured Inbox items")
|
|
41
|
+
.option("--non-interactive", "never prompt or approve captured Inbox items")
|
|
42
|
+
.option("--edit", "open each Draft in the configured editor")
|
|
43
|
+
.option("--dry-run", "show the next action without writing")
|
|
44
|
+
.action(async (opts) => {
|
|
45
|
+
const repo = await repoForCommand();
|
|
46
|
+
const result = await triage(repo, opts);
|
|
47
|
+
output(result, renderTriage(result));
|
|
48
|
+
});
|
|
49
|
+
program.command("run")
|
|
50
|
+
.description("Run the bounded repository workflow loop")
|
|
51
|
+
.option("--max-features <n>", "maximum number of Features", (value) => value)
|
|
52
|
+
.option("--until-empty", "continue until no eligible work remains")
|
|
53
|
+
.option("--engine <engine>", "execution engine (pi or pi-spec-flow)")
|
|
54
|
+
.option("--non-interactive", "never prompt or approve captured Inbox items")
|
|
55
|
+
.option("--edit", "open each Draft in the configured editor")
|
|
56
|
+
.option("--dry-run", "show planned actions without writing")
|
|
57
|
+
.action(async (opts) => {
|
|
58
|
+
const repo = await repoForCommand();
|
|
59
|
+
let interrupted = false;
|
|
60
|
+
const onInterrupt = () => { interrupted = true; };
|
|
61
|
+
process.once("SIGINT", onInterrupt);
|
|
62
|
+
process.once("SIGTERM", onInterrupt);
|
|
63
|
+
try {
|
|
64
|
+
const result = await runLoop(repo, opts, () => interrupted);
|
|
65
|
+
output(result, renderRun(result));
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
process.removeListener("SIGINT", onInterrupt);
|
|
69
|
+
process.removeListener("SIGTERM", onInterrupt);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
program.command("status")
|
|
73
|
+
.description("Show repository workflow status")
|
|
74
|
+
.action(async () => {
|
|
75
|
+
const repo = await repoForCommand();
|
|
76
|
+
const status = await repo.getStatus();
|
|
77
|
+
output(status, renderStatus(status));
|
|
78
|
+
});
|
|
79
|
+
program.command("validate")
|
|
80
|
+
.description("Validate Forgium repository structures and schemas")
|
|
81
|
+
.action(async () => {
|
|
82
|
+
const repo = await repoForCommand();
|
|
83
|
+
const report = await repo.validate();
|
|
84
|
+
output(report, report.valid ? "Forgium repository is valid" : report.issues.map((i) => `${i.severity.toUpperCase()} ${i.code}: ${i.message}`).join("\n"));
|
|
85
|
+
if (!report.valid)
|
|
86
|
+
process.exitCode = 2;
|
|
87
|
+
});
|
|
88
|
+
const feature = program.command("feature").description("Feature lifecycle utilities");
|
|
89
|
+
feature.command("create")
|
|
90
|
+
.description("Create a ready Feature manifest")
|
|
91
|
+
.requiredOption("--title <title>", "Feature title")
|
|
92
|
+
.requiredOption("--goal <goal>", "Feature goal")
|
|
93
|
+
.requiredOption("--acceptance <criteria...>", "Acceptance criteria; repeat values after the flag")
|
|
94
|
+
.option("--constraint <constraints...>", "Constraints")
|
|
95
|
+
.option("--slug <slug>", "Feature slug")
|
|
96
|
+
.action(async (opts) => {
|
|
97
|
+
const repo = await repoForCommand();
|
|
98
|
+
const created = await repo.createFeature({ title: opts.title, goal: opts.goal, acceptance: opts.acceptance, constraints: opts.constraint, slug: opts.slug });
|
|
99
|
+
output(created, `Created ${created.id}\n${relative(repo.root, created.path)}`);
|
|
100
|
+
});
|
|
101
|
+
feature.command("list")
|
|
102
|
+
.description("List Features")
|
|
103
|
+
.option("--state <state>", "state filter")
|
|
104
|
+
.action(async (opts) => {
|
|
105
|
+
const repo = await repoForCommand();
|
|
106
|
+
const features = await repo.listFeatures(opts.state);
|
|
107
|
+
output(features, features.length ? features.map((f) => `${f.id}\t${f.state}\t${f.manifest.created}\t${f.manifest.title}`).join("\n") : "No Features found");
|
|
108
|
+
});
|
|
109
|
+
feature.command("start <id>").action(async (id) => transition(id, "startFeature", "Started"));
|
|
110
|
+
feature.command("submit <id>").description("Move doing → review").action(async (id) => transition(id, "submitForReview", "Submitted for review"));
|
|
111
|
+
feature.command("complete <id>").description("Move review → done").action(async (id) => transition(id, "completeFeature", "Completed"));
|
|
112
|
+
feature.command("block <id>").requiredOption("--reason <reason>").description("Move doing/review → blocked").action(async (id, opts) => {
|
|
113
|
+
const repo = await repoForCommand();
|
|
114
|
+
const result = await repo.blockFeature(id, opts.reason);
|
|
115
|
+
output(result, `Blocked ${result.id}\n${relative(repo.root, result.path)}`);
|
|
116
|
+
});
|
|
117
|
+
feature.command("unblock <id>").description("Move blocked → ready").action(async (id) => transition(id, "unblockFeature", "Unblocked"));
|
|
118
|
+
feature.command("profile <id>").description("Inspect Feature execution profile").action(async (id) => {
|
|
119
|
+
const repo = await repoForCommand();
|
|
120
|
+
const profile = await repo.inspectExecutionMode(id);
|
|
121
|
+
output(profile, JSON.stringify(profile, null, 2));
|
|
122
|
+
});
|
|
123
|
+
feature.command("verify <id>").description("Run configured verification checks").action(async (id) => {
|
|
124
|
+
const repo = await repoForCommand();
|
|
125
|
+
const receipt = await repo.verifyFeature(id);
|
|
126
|
+
output(receipt, `Verification ${receipt.outcome}\nReceipt ${receipt.id}`);
|
|
127
|
+
});
|
|
128
|
+
program.command("work")
|
|
129
|
+
.description("Select the next ready Feature and prepare it for execution")
|
|
130
|
+
.action(async () => {
|
|
131
|
+
const repo = await repoForCommand();
|
|
132
|
+
const next = await repo.getNextReady();
|
|
133
|
+
if (!next)
|
|
134
|
+
return output({ status: "idle" }, "No ready Features");
|
|
135
|
+
const doing = await repo.startFeature(next.id);
|
|
136
|
+
const profile = await repo.inspectExecutionMode(doing.id);
|
|
137
|
+
output({ status: "started", feature: doing, profile }, `Started ${doing.id}\nProfile: ${profile.kind}${renderProfileHint(profile)}`);
|
|
138
|
+
});
|
|
139
|
+
program.command("review")
|
|
140
|
+
.description("Mark a review Feature done, or send it back to doing/blocked")
|
|
141
|
+
.argument("<id>")
|
|
142
|
+
.option("--fail", "send review back to doing")
|
|
143
|
+
.option("--block <reason>", "block the Feature")
|
|
144
|
+
.action(async (id, opts) => {
|
|
145
|
+
const repo = await repoForCommand();
|
|
146
|
+
const result = opts.block
|
|
147
|
+
? await repo.reviewFeature(id, "blocked", opts.block)
|
|
148
|
+
: opts.fail
|
|
149
|
+
? await repo.reviewFeature(id, "changes_requested", "Review requested changes.")
|
|
150
|
+
: await repo.reviewFeature(id, "approved", "Review approved via Forgium CLI.");
|
|
151
|
+
output(result, `${opts.block ? "Blocked" : opts.fail ? "Returned to doing" : "Completed"} ${result.id}`);
|
|
152
|
+
});
|
|
153
|
+
program.parseAsync(process.argv).catch((error) => {
|
|
154
|
+
if (error instanceof ForgiumError) {
|
|
155
|
+
const json = program.opts().json;
|
|
156
|
+
if (json)
|
|
157
|
+
console.error(JSON.stringify({ error: { code: error.code, message: error.message } }, null, 2));
|
|
158
|
+
else
|
|
159
|
+
console.error(`${error.code}: ${error.message}`);
|
|
160
|
+
process.exit(error.exitCode);
|
|
161
|
+
}
|
|
162
|
+
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
163
|
+
process.exit(1);
|
|
164
|
+
});
|
|
165
|
+
async function repoForCommand() {
|
|
166
|
+
const opts = program.opts();
|
|
167
|
+
const root = opts.root ? path.resolve(opts.root) : await findRepositoryRoot(process.cwd()).catch(() => process.cwd());
|
|
168
|
+
return new FilesystemForgiumRepository(root);
|
|
169
|
+
}
|
|
170
|
+
async function transition(id, method, label) {
|
|
171
|
+
const repo = await repoForCommand();
|
|
172
|
+
const result = await repo[method](id);
|
|
173
|
+
output(result, `${label} ${result.id}\n${relative(repo.root, result.path)}`);
|
|
174
|
+
}
|
|
175
|
+
function output(data, human) { if (program.opts().json)
|
|
176
|
+
console.log(JSON.stringify(data, null, 2));
|
|
177
|
+
else
|
|
178
|
+
console.log(human); }
|
|
179
|
+
function relative(root, p) { return path.relative(root, p) || "."; }
|
|
180
|
+
function renderStatus(status) {
|
|
181
|
+
return `Inbox\n captured: ${status.inbox.captured ?? 0}\n drafted: ${status.inbox.drafted ?? 0}\n promoted: ${status.inbox.promoted ?? 0}\n merged: ${status.inbox.merged ?? 0}\n deferred: ${status.inbox.deferred ?? 0}\n\nDrafts\n total: ${status.drafts}\n\nFeatures\n ready: ${status.features.ready}\n doing: ${status.features.doing}\n review: ${status.features.review}\n blocked: ${status.features.blocked}\n done: ${status.features.done}`;
|
|
182
|
+
}
|
|
183
|
+
async function triage(repo, options) {
|
|
184
|
+
const result = { root: repo.root, actions: [], stopReason: "no_actionable_work" };
|
|
185
|
+
const drafts = await repo.listDrafts();
|
|
186
|
+
const inbox = await repo.listInbox();
|
|
187
|
+
for (const draft of drafts) {
|
|
188
|
+
if (options.dryRun) {
|
|
189
|
+
result.actions.push({ kind: "draft", id: draft.id, action: "needs_input" });
|
|
190
|
+
result.stopReason = "dry_run";
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (options.nonInteractive) {
|
|
194
|
+
try {
|
|
195
|
+
await repo.promoteDraft(draft.id);
|
|
196
|
+
result.actions.push({ kind: "draft", id: draft.id, action: "promoted" });
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (!(error instanceof InvalidDraftError))
|
|
200
|
+
throw error;
|
|
201
|
+
result.actions.push({ kind: "draft", id: draft.id, action: "needs_input" });
|
|
202
|
+
result.stopReason = "human_input_required";
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
let autoEdit = options.edit === true;
|
|
207
|
+
let resolved = false;
|
|
208
|
+
while (!resolved) {
|
|
209
|
+
if (autoEdit) {
|
|
210
|
+
await openEditor(draft);
|
|
211
|
+
result.actions.push({ kind: "draft", id: draft.id, action: "edited" });
|
|
212
|
+
autoEdit = false;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const action = await ask(`Draft ${draft.id}: [e]ditar [p]romover [s]altar [q]salir `);
|
|
216
|
+
if (action === "e") {
|
|
217
|
+
await openEditor(draft);
|
|
218
|
+
result.actions.push({ kind: "draft", id: draft.id, action: "edited" });
|
|
219
|
+
}
|
|
220
|
+
else if (action === "p") {
|
|
221
|
+
try {
|
|
222
|
+
await repo.promoteDraft(draft.id);
|
|
223
|
+
result.actions.push({ kind: "draft", id: draft.id, action: "promoted" });
|
|
224
|
+
resolved = true;
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (!(error instanceof InvalidDraftError))
|
|
228
|
+
throw error;
|
|
229
|
+
result.actions.push({ kind: "draft", id: draft.id, action: "needs_input" });
|
|
230
|
+
resolved = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else if (action === "q") {
|
|
234
|
+
result.stopReason = "user_quit";
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
result.actions.push({ kind: "draft", id: draft.id, action: "skipped" });
|
|
239
|
+
resolved = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const item of inbox.filter((candidate) => candidate.status === "captured")) {
|
|
244
|
+
if (options.dryRun) {
|
|
245
|
+
result.actions.push({ kind: "inbox", id: item.id, action: "needs_approval" });
|
|
246
|
+
result.stopReason = "dry_run";
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (options.nonInteractive) {
|
|
250
|
+
result.actions.push({ kind: "inbox", id: item.id, action: "needs_approval" });
|
|
251
|
+
result.stopReason = "human_input_required";
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const action = await ask(`Inbox ${item.id} (${item.title}): [a]probar [d]iferir [m]ezclar [s]altar [q]salir `);
|
|
255
|
+
if (action === "a") {
|
|
256
|
+
const draft = await repo.createDraftFromInbox(item.id);
|
|
257
|
+
result.actions.push({ kind: "inbox", id: item.id, action: "drafted" });
|
|
258
|
+
if (options.edit) {
|
|
259
|
+
await openEditor(draft);
|
|
260
|
+
result.actions.push({ kind: "draft", id: draft.id, action: "edited" });
|
|
261
|
+
}
|
|
262
|
+
else if ((await ask(`Draft ${draft.id}: [e]ditar [c]ontinuar `)) === "e") {
|
|
263
|
+
await openEditor(draft);
|
|
264
|
+
result.actions.push({ kind: "draft", id: draft.id, action: "edited" });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
else if (action === "d") {
|
|
268
|
+
await repo.deferInbox(item.id);
|
|
269
|
+
result.actions.push({ kind: "inbox", id: item.id, action: "deferred" });
|
|
270
|
+
}
|
|
271
|
+
else if (action === "m") {
|
|
272
|
+
const featureId = await ask("Feature destino: ");
|
|
273
|
+
await repo.mergeInbox(item.id, featureId);
|
|
274
|
+
result.actions.push({ kind: "inbox", id: item.id, action: `merged:${featureId}` });
|
|
275
|
+
}
|
|
276
|
+
else if (action === "q") {
|
|
277
|
+
result.stopReason = "user_quit";
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
result.actions.push({ kind: "inbox", id: item.id, action: "skipped" });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (result.stopReason === "no_actionable_work" && result.actions.some((action) => action.action === "needs_input" || action.action === "needs_approval")) {
|
|
285
|
+
result.stopReason = "human_input_required";
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
async function runLoop(repo, options, isInterrupted = () => false) {
|
|
290
|
+
const maxFeatures = parseRunBudget(options);
|
|
291
|
+
if (options.engine !== undefined && !["pi", "pi-spec-flow"].includes(options.engine))
|
|
292
|
+
throw new RunOptionsInvalidError(`Unknown execution engine: ${options.engine}`);
|
|
293
|
+
const result = { root: repo.root, actions: [], features: [], stopReason: "no_actionable_work" };
|
|
294
|
+
const report = await repo.validate();
|
|
295
|
+
if (isInterrupted()) {
|
|
296
|
+
result.stopReason = "interrupted";
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
if (!report.valid) {
|
|
300
|
+
result.stopReason = "preflight_failed";
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
const triageResult = await triage(repo, {
|
|
304
|
+
nonInteractive: options.nonInteractive,
|
|
305
|
+
edit: options.edit,
|
|
306
|
+
dryRun: options.dryRun
|
|
307
|
+
});
|
|
308
|
+
result.actions.push(...triageResult.actions);
|
|
309
|
+
if (isInterrupted()) {
|
|
310
|
+
result.stopReason = "interrupted";
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
const ready = await repo.listFeatures("ready");
|
|
314
|
+
if (options.dryRun) {
|
|
315
|
+
result.features.push(...ready.slice(0, maxFeatures).map((feature) => ({ id: feature.id, state: feature.state, action: "planned" })));
|
|
316
|
+
result.stopReason = "dry_run";
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
if (!ready.length) {
|
|
320
|
+
result.stopReason = triageResult.stopReason === "human_input_required" ? "human_input_required" : triageResult.stopReason === "user_quit" ? "user_quit" : "no_actionable_work";
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
const registry = options.engine === "pi"
|
|
324
|
+
? new ExecutionAdapterRegistry([new PiRpcExecutionAdapter()])
|
|
325
|
+
: options.engine === "pi-spec-flow"
|
|
326
|
+
? new ExecutionAdapterRegistry([new SpecFlowExecutionAdapter()])
|
|
327
|
+
: new ExecutionAdapterRegistry();
|
|
328
|
+
const candidates = ready.slice(0, maxFeatures);
|
|
329
|
+
for (const feature of candidates) {
|
|
330
|
+
const execution = await repo.executeFeature(feature.id, registry);
|
|
331
|
+
result.features.push({ id: feature.id, state: execution.feature.state, action: execution.outcome });
|
|
332
|
+
if (execution.outcome === "needs_human")
|
|
333
|
+
result.stopReason = options.engine === "pi-spec-flow" ? "human_input_required" : "engine_unavailable";
|
|
334
|
+
else if (execution.outcome === "completed")
|
|
335
|
+
result.stopReason = "review_required";
|
|
336
|
+
else if (execution.outcome === "blocked")
|
|
337
|
+
result.stopReason = "feature_blocked";
|
|
338
|
+
else if (execution.outcome === "verification_failed")
|
|
339
|
+
result.stopReason = "verification_failed";
|
|
340
|
+
else
|
|
341
|
+
result.stopReason = "interrupted";
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
function parseRunBudget(options) {
|
|
347
|
+
if (options.untilEmpty && options.maxFeatures !== undefined)
|
|
348
|
+
throw new RunOptionsInvalidError("--until-empty cannot be combined with --max-features.");
|
|
349
|
+
if (options.maxFeatures === undefined)
|
|
350
|
+
return options.untilEmpty ? Number.MAX_SAFE_INTEGER : 1;
|
|
351
|
+
if (!/^[1-9]\d*$/.test(options.maxFeatures))
|
|
352
|
+
throw new RunOptionsInvalidError("--max-features must be a positive integer.");
|
|
353
|
+
const value = Number(options.maxFeatures);
|
|
354
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
355
|
+
throw new RunOptionsInvalidError("--max-features must be a positive integer.");
|
|
356
|
+
return value;
|
|
357
|
+
}
|
|
358
|
+
async function ask(question) {
|
|
359
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
360
|
+
try {
|
|
361
|
+
return (await readline.question(question)).trim().toLowerCase();
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
readline.close();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async function openEditor(draft) {
|
|
368
|
+
const configured = process.env.VISUAL ?? process.env.EDITOR;
|
|
369
|
+
if (!configured)
|
|
370
|
+
throw new EditorUnavailableError();
|
|
371
|
+
const command = splitCommand(configured);
|
|
372
|
+
if (!command[0])
|
|
373
|
+
throw new EditorUnavailableError();
|
|
374
|
+
await runProcess(command[0], [...command.slice(1), path.join(draft.path, "draft.md")]);
|
|
375
|
+
}
|
|
376
|
+
function splitCommand(value) {
|
|
377
|
+
return value.match(/(?:[^\s"]+|"[^"]*")+/g)?.map((part) => part.replace(/^"|"$/g, "")) ?? [];
|
|
378
|
+
}
|
|
379
|
+
function runProcess(command, args) {
|
|
380
|
+
return new Promise((resolve, reject) => {
|
|
381
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
382
|
+
child.once("error", reject);
|
|
383
|
+
child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`Editor exited with code ${code}`)));
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
function renderTriage(result) {
|
|
387
|
+
return `Forgium triage\n Actions: ${result.actions.length}\n Stop: ${result.stopReason}`;
|
|
388
|
+
}
|
|
389
|
+
function renderRun(result) {
|
|
390
|
+
return `Forgium run\n Actions: ${result.actions.length}\n Features: ${result.features.length}\n Stop: ${result.stopReason}`;
|
|
391
|
+
}
|
|
392
|
+
function renderProfileHint(profile) {
|
|
393
|
+
if (profile.kind === "direct")
|
|
394
|
+
return "\nDirect execution should be handled by the Forgium/Pi runtime.";
|
|
395
|
+
if (profile.kind === "spec-needs-plan")
|
|
396
|
+
return `\nPlan tickets with: ${profile.commands.init}\nThen implement with: ${profile.commands.implement}`;
|
|
397
|
+
return `\nContinue spec-flow with: ${profile.commands.implement}\nNext ticket: ${profile.commands.next}`;
|
|
398
|
+
}
|
|
399
|
+
async function readStdin() {
|
|
400
|
+
const chunks = [];
|
|
401
|
+
for await (const chunk of process.stdin)
|
|
402
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
403
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
404
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
export type FeatureState = "ready" | "doing" | "review" | "blocked" | "done";
|
|
2
|
+
export declare const FEATURE_STATES: FeatureState[];
|
|
3
|
+
export interface InboxItem {
|
|
4
|
+
id: string;
|
|
5
|
+
source: string;
|
|
6
|
+
created: string;
|
|
7
|
+
status: "captured" | "drafted" | "promoted" | "merged" | "deferred";
|
|
8
|
+
title: string;
|
|
9
|
+
body?: string;
|
|
10
|
+
path: string;
|
|
11
|
+
draftRef?: string;
|
|
12
|
+
featureRef?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface DraftFrontmatter {
|
|
15
|
+
schemaVersion: 1;
|
|
16
|
+
id: string;
|
|
17
|
+
created: string;
|
|
18
|
+
source: {
|
|
19
|
+
type: string;
|
|
20
|
+
ref: string;
|
|
21
|
+
};
|
|
22
|
+
title: string;
|
|
23
|
+
goal: string;
|
|
24
|
+
acceptance: string[];
|
|
25
|
+
constraints?: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface Draft {
|
|
28
|
+
id: string;
|
|
29
|
+
slug: string;
|
|
30
|
+
path: string;
|
|
31
|
+
frontmatter: DraftFrontmatter;
|
|
32
|
+
body: string;
|
|
33
|
+
}
|
|
34
|
+
export interface FeatureManifest {
|
|
35
|
+
schemaVersion: 1;
|
|
36
|
+
id: string;
|
|
37
|
+
title: string;
|
|
38
|
+
created: string;
|
|
39
|
+
source?: {
|
|
40
|
+
type: string;
|
|
41
|
+
ref: string;
|
|
42
|
+
};
|
|
43
|
+
goal: string;
|
|
44
|
+
acceptance: string[];
|
|
45
|
+
constraints?: string[];
|
|
46
|
+
verification?: VerificationPolicy;
|
|
47
|
+
}
|
|
48
|
+
export interface VerificationCommand {
|
|
49
|
+
name: string;
|
|
50
|
+
run: string;
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
}
|
|
53
|
+
export interface RequiredEvidence {
|
|
54
|
+
criterion: string;
|
|
55
|
+
kind: string;
|
|
56
|
+
}
|
|
57
|
+
export interface VerificationPolicy {
|
|
58
|
+
commands: VerificationCommand[];
|
|
59
|
+
requiredEvidence?: RequiredEvidence[];
|
|
60
|
+
review?: "required";
|
|
61
|
+
}
|
|
62
|
+
export type ReceiptKind = "execution" | "verification" | "review" | "handoff";
|
|
63
|
+
export type VerificationOutcome = "passed" | "failed" | "manual_required" | "not_configured" | "cancelled";
|
|
64
|
+
export type ReviewDecision = "approved" | "changes_requested" | "blocked";
|
|
65
|
+
export interface ReceiptActor {
|
|
66
|
+
type: string;
|
|
67
|
+
name: string;
|
|
68
|
+
version?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface ReceiptFeatureRef {
|
|
71
|
+
id: string;
|
|
72
|
+
manifestPath: string;
|
|
73
|
+
}
|
|
74
|
+
export interface ReceiptBase {
|
|
75
|
+
schemaVersion: 1;
|
|
76
|
+
id: string;
|
|
77
|
+
kind: ReceiptKind;
|
|
78
|
+
created: string;
|
|
79
|
+
feature: ReceiptFeatureRef;
|
|
80
|
+
runId: string;
|
|
81
|
+
actor: ReceiptActor;
|
|
82
|
+
outcome: string;
|
|
83
|
+
summary: string;
|
|
84
|
+
}
|
|
85
|
+
export interface VerificationCheck {
|
|
86
|
+
name: string;
|
|
87
|
+
command: string;
|
|
88
|
+
cwd: string;
|
|
89
|
+
exitCode: number | null;
|
|
90
|
+
durationMs: number;
|
|
91
|
+
status: "passed" | "failed" | "cancelled";
|
|
92
|
+
outputSummary: string;
|
|
93
|
+
}
|
|
94
|
+
export interface ReceiptEvidence {
|
|
95
|
+
criterion: string;
|
|
96
|
+
kind: string;
|
|
97
|
+
status: "passed" | "failed" | "pending";
|
|
98
|
+
ref?: string;
|
|
99
|
+
}
|
|
100
|
+
export interface VerificationReceipt extends ReceiptBase {
|
|
101
|
+
kind: "verification";
|
|
102
|
+
outcome: VerificationOutcome;
|
|
103
|
+
checks: VerificationCheck[];
|
|
104
|
+
evidence?: ReceiptEvidence[];
|
|
105
|
+
}
|
|
106
|
+
export interface ReviewReceipt extends ReceiptBase {
|
|
107
|
+
kind: "review";
|
|
108
|
+
outcome: ReviewDecision;
|
|
109
|
+
decision: ReviewDecision;
|
|
110
|
+
findings?: string[];
|
|
111
|
+
}
|
|
112
|
+
export interface HandoffReceipt extends ReceiptBase {
|
|
113
|
+
kind: "handoff";
|
|
114
|
+
outcome: "failed" | "blocked" | "needs_human" | "cancelled";
|
|
115
|
+
reason: string;
|
|
116
|
+
relatedReceipt?: string;
|
|
117
|
+
}
|
|
118
|
+
export interface ExecutionReceipt extends ReceiptBase {
|
|
119
|
+
kind: "execution";
|
|
120
|
+
outcome: string;
|
|
121
|
+
engine?: string;
|
|
122
|
+
artifacts?: string[];
|
|
123
|
+
}
|
|
124
|
+
export type Receipt = VerificationReceipt | ReviewReceipt | HandoffReceipt | ExecutionReceipt;
|
|
125
|
+
export interface FeatureArtifacts {
|
|
126
|
+
spec?: string;
|
|
127
|
+
ticketsDirectory?: string;
|
|
128
|
+
notes?: string;
|
|
129
|
+
research?: string;
|
|
130
|
+
receiptsDirectory?: string;
|
|
131
|
+
}
|
|
132
|
+
export interface Feature {
|
|
133
|
+
id: string;
|
|
134
|
+
slug: string;
|
|
135
|
+
state: FeatureState;
|
|
136
|
+
path: string;
|
|
137
|
+
manifest: FeatureManifest;
|
|
138
|
+
artifacts: FeatureArtifacts;
|
|
139
|
+
}
|
|
140
|
+
export type ExecutionProfile = {
|
|
141
|
+
kind: "direct";
|
|
142
|
+
} | {
|
|
143
|
+
kind: "spec-flow";
|
|
144
|
+
specPath: string;
|
|
145
|
+
ticketsPath: string;
|
|
146
|
+
commands: {
|
|
147
|
+
implement: string;
|
|
148
|
+
next: string;
|
|
149
|
+
};
|
|
150
|
+
} | {
|
|
151
|
+
kind: "spec-needs-plan";
|
|
152
|
+
specPath: string;
|
|
153
|
+
commands: {
|
|
154
|
+
init: string;
|
|
155
|
+
implement: string;
|
|
156
|
+
next: string;
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
export interface RepositoryStatus {
|
|
160
|
+
inbox: Record<string, number>;
|
|
161
|
+
drafts: number;
|
|
162
|
+
features: Record<FeatureState, number>;
|
|
163
|
+
}
|
|
164
|
+
export interface ValidationIssue {
|
|
165
|
+
severity: "error" | "warning";
|
|
166
|
+
code: string;
|
|
167
|
+
message: string;
|
|
168
|
+
path?: string;
|
|
169
|
+
}
|
|
170
|
+
export interface ValidationReport {
|
|
171
|
+
valid: boolean;
|
|
172
|
+
issues: ValidationIssue[];
|
|
173
|
+
}
|
|
174
|
+
export interface CaptureInput {
|
|
175
|
+
text: string;
|
|
176
|
+
source?: string;
|
|
177
|
+
}
|
|
178
|
+
export interface CreateFeatureInput {
|
|
179
|
+
id?: string;
|
|
180
|
+
slug?: string;
|
|
181
|
+
title: string;
|
|
182
|
+
goal: string;
|
|
183
|
+
acceptance: string[];
|
|
184
|
+
constraints?: string[];
|
|
185
|
+
verification?: VerificationPolicy;
|
|
186
|
+
source?: {
|
|
187
|
+
type: string;
|
|
188
|
+
ref: string;
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const FEATURE_STATES = ["ready", "doing", "review", "blocked", "done"];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export declare class ForgiumError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
readonly exitCode: number;
|
|
4
|
+
constructor(message: string, code: string, exitCode?: number);
|
|
5
|
+
}
|
|
6
|
+
export declare class ForgiumRepositoryNotFoundError extends ForgiumError {
|
|
7
|
+
constructor();
|
|
8
|
+
}
|
|
9
|
+
export declare class ForgiumNotInitializedError extends ForgiumError {
|
|
10
|
+
constructor();
|
|
11
|
+
}
|
|
12
|
+
export declare class InvalidInboxItemError extends ForgiumError {
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
15
|
+
export declare class InvalidDraftError extends ForgiumError {
|
|
16
|
+
constructor(message: string);
|
|
17
|
+
}
|
|
18
|
+
export declare class InvalidManifestError extends ForgiumError {
|
|
19
|
+
constructor(message: string);
|
|
20
|
+
}
|
|
21
|
+
export declare class DraftNotFoundError extends ForgiumError {
|
|
22
|
+
constructor(id: string);
|
|
23
|
+
}
|
|
24
|
+
export declare class DraftAlreadyExistsError extends ForgiumError {
|
|
25
|
+
constructor(id: string);
|
|
26
|
+
}
|
|
27
|
+
export declare class EditorUnavailableError extends ForgiumError {
|
|
28
|
+
constructor();
|
|
29
|
+
}
|
|
30
|
+
export declare class ReceiptAlreadyExistsError extends ForgiumError {
|
|
31
|
+
constructor(id: string);
|
|
32
|
+
}
|
|
33
|
+
export declare class InvalidReceiptError extends ForgiumError {
|
|
34
|
+
constructor(message: string);
|
|
35
|
+
}
|
|
36
|
+
export declare class ReviewReceiptRequiredError extends ForgiumError {
|
|
37
|
+
constructor(id: string);
|
|
38
|
+
}
|
|
39
|
+
export declare class RunOptionsInvalidError extends ForgiumError {
|
|
40
|
+
constructor(message: string);
|
|
41
|
+
}
|
|
42
|
+
export declare class FeatureNotFoundError extends ForgiumError {
|
|
43
|
+
constructor(id: string);
|
|
44
|
+
}
|
|
45
|
+
export declare class FeatureAlreadyExistsError extends ForgiumError {
|
|
46
|
+
constructor(id: string);
|
|
47
|
+
}
|
|
48
|
+
export declare class FeatureStateConflictError extends ForgiumError {
|
|
49
|
+
constructor(message: string);
|
|
50
|
+
}
|
|
51
|
+
export declare class InvalidStateTransitionError extends ForgiumError {
|
|
52
|
+
constructor(from: string, to: string);
|
|
53
|
+
}
|