forgedock 1.0.22 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +255 -211
- package/bin/batch-runner.mjs +339 -0
- package/bin/cinema.mjs +320 -0
- package/bin/demo.mjs +92 -7
- package/bin/engine/phases.mjs +168 -0
- package/bin/engine/projector.mjs +32 -0
- package/bin/engine/reconcile.mjs +11 -0
- package/bin/engine/runlog.mjs +79 -0
- package/bin/engine/state.mjs +32 -0
- package/bin/engine-cli.mjs +222 -0
- package/bin/engine.mjs +138 -0
- package/bin/forgedock.mjs +896 -1540
- package/bin/hooks/interactive-engine.mjs +509 -0
- package/bin/hooks/pre-tool-use.mjs +498 -0
- package/bin/hooks/subagent-stop-enforce.mjs +197 -0
- package/bin/journey.mjs +841 -0
- package/bin/registry.mjs +139 -0
- package/bin/report.mjs +447 -0
- package/bin/runner.mjs +136 -20
- package/bin/settings-hook.mjs +346 -0
- package/bin/tui.mjs +6 -3
- package/commands/adopt.md +4 -1
- package/commands/analytics.md +4 -1
- package/commands/audit-agents.md +110 -27
- package/commands/audit.md +20 -1
- package/commands/autopilot.md +84 -12
- package/commands/changelog.md +2 -1
- package/commands/ci-audit.md +2 -1
- package/commands/cleanup.md +161 -1
- package/commands/compat-audit.md +2 -1
- package/commands/deploy-info.md +63 -4
- package/commands/design-bench.md +6 -1
- package/commands/design-render-critique-loop.md +2 -1
- package/commands/design.md +1 -0
- package/commands/diagnose.md +2 -1
- package/commands/explain.md +2 -1
- package/commands/forge-stats.md +122 -2
- package/commands/forgedock-init.md +1 -1
- package/commands/geo-audit.md +3 -0
- package/commands/incident-response.md +2 -1
- package/commands/issue.md +24 -3
- package/commands/milestone.md +77 -12
- package/commands/optimize.md +2 -1
- package/commands/orchestrate/config.md +67 -0
- package/commands/orchestrate/phase-1-resolve.md +159 -0
- package/commands/orchestrate/phase-2-triage.md +126 -0
- package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
- package/commands/orchestrate/phase-3-dependency.md +591 -0
- package/commands/orchestrate/phase-4-execution.md +962 -0
- package/commands/orchestrate/phase-5-cleanup.md +47 -0
- package/commands/orchestrate/phase-6-report.md +198 -0
- package/commands/orchestrate/safety.md +62 -0
- package/commands/orchestrate.md +25 -2027
- package/commands/pipeline-health/config.md +34 -0
- package/commands/pipeline-health/phase-1-context.md +180 -0
- package/commands/pipeline-health/phase-2-metrics.md +1729 -0
- package/commands/pipeline-health/phase-3-analyze.md +204 -0
- package/commands/pipeline-health/phase-4-proposals.md +117 -0
- package/commands/pipeline-health/phase-5-report.md +284 -0
- package/commands/pipeline-health/phase-6-summary.md +52 -0
- package/commands/pipeline-health.md +25 -2401
- package/commands/pipeline-resume.md +24 -1
- package/commands/pipeline-status.md +65 -0
- package/commands/qa-sweep.md +4 -1
- package/commands/quality-gate.md +326 -42
- package/commands/replay.md +2 -1
- package/commands/review-pr-agents/api.md +135 -0
- package/commands/review-pr-agents/auth.md +137 -0
- package/commands/review-pr-agents/billing.md +101 -0
- package/commands/review-pr-agents/concurrency.md +145 -0
- package/commands/review-pr-agents/database.md +103 -0
- package/commands/review-pr-agents/frontend.md +89 -0
- package/commands/review-pr-agents/infra.md +385 -0
- package/commands/review-pr-agents/protocols.md +185 -0
- package/commands/review-pr-agents/scraper.md +185 -0
- package/commands/review-pr-agents/security.md +406 -0
- package/commands/review-pr-agents.md +45 -1722
- package/commands/review-pr-staging.md +196 -40
- package/commands/review-pr.md +612 -56
- package/commands/rollback.md +2 -1
- package/commands/scope.md +2 -1
- package/commands/security-audit.md +2 -1
- package/commands/signal-planner.md +623 -0
- package/commands/sync-ecosystem.md +1 -0
- package/commands/test-gate.md +17 -1
- package/commands/validate.md +2 -1
- package/commands/work-on/build/architect.md +28 -0
- package/commands/work-on/build/context.md +2 -0
- package/commands/work-on/build/implement.md +19 -5
- package/commands/work-on/build/validate.md +62 -6
- package/commands/work-on/build.md +187 -30
- package/commands/work-on/close.md +132 -5
- package/commands/work-on/decompose.md +19 -1
- package/commands/work-on/investigate.md +117 -2
- package/commands/work-on/review.md +48 -4
- package/commands/work-on-monolithic.md +8 -2
- package/commands/work-on.md +247 -7
- package/docs/BRAND.md +16 -5
- package/docs/CI.md +2 -2
- package/docs/CODEX.md +36 -1
- package/docs/CONFIG.md +44 -42
- package/docs/FORGE-PROTOCOL.md +15 -1064
- package/docs/anthropic-devrel-outreach.md +2 -2
- package/docs/articles/command-decomposition-token-savings.md +214 -0
- package/docs/articles/model-release-playbook.md +131 -0
- package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
- package/docs/claude-breakpoints.json +75 -5
- package/docs/design/abc-benchmark.md +1 -1
- package/docs/design/design-spec-schema.md +1 -1
- package/docs/design/divergent-generation.md +1 -1
- package/docs/design/eval-gate-runbook.md +178 -0
- package/docs/design/show-hn-draft.md +26 -1
- package/docs/design/token-cost-baseline-1298.md +59 -0
- package/docs/eval/README.md +117 -0
- package/docs/eval/corpus/seed-v1.json +6 -0
- package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
- package/docs/site/annotations-explained.md +24 -0
- package/docs/site/command-learning-path.md +51 -14
- package/docs/site/command-reference.md +339 -113
- package/docs/site/for-companies.md +95 -0
- package/docs/site/forge-annotation-protocol.md +12 -256
- package/docs/site/getting-started.md +58 -12
- package/docs/site/github-is-the-memory.md +113 -0
- package/docs/site/how-it-works.md +3 -13
- package/docs/site/index.md +10 -2
- package/docs/spec/eval-run-result.md +147 -0
- package/docs/spec/forge-protocol-v1.md +441 -18
- package/docs/spec/label-state-machine.md +55 -0
- package/docs/spec/review-protocol.md +158 -0
- package/docs/spec-graph-schema.md +28 -0
- package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
- package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
- package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
- package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
- package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
- package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
- package/examples/forgedock-demo/README.md +41 -22
- package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
- package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
- package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
- package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
- package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
- package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
- package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
- package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
- package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
- package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
- package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
- package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
- package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
- package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
- package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
- package/examples/forgedock-demo/scripts/smoke.js +18 -1
- package/examples/forgedock-demo/src/db.js +36 -11
- package/examples/forgedock-demo/src/routes/notes.js +106 -9
- package/examples/forgedock-demo/src/server.js +4 -1
- package/package.json +12 -4
- package/packages/protocol/LICENSE +21 -0
- package/packages/protocol/src/cli.js +135 -0
- package/packages/protocol/src/emit.js +146 -0
- package/packages/protocol/src/index.js +43 -0
- package/packages/protocol/src/parse.js +229 -0
- package/packages/protocol/src/types.js +171 -0
- package/packages/protocol/src/validate.js +122 -0
- package/scripts/bench-topology-cost.mjs +372 -0
- package/scripts/check-command-docs-drift.sh +198 -0
- package/scripts/check-protocol-restatements.sh +176 -0
- package/scripts/check-registry/README.md +72 -0
- package/scripts/check-registry/manifest.json +6 -0
- package/scripts/code-index.sh +454 -0
- package/scripts/conformance-check.mjs +168 -0
- package/scripts/doctor-pipeline-state.sh +484 -0
- package/scripts/eval-gate-baseline.json +17 -0
- package/scripts/eval-gate-scorecard.mjs +242 -0
- package/scripts/eval-scorecard-aggregator.mjs +252 -0
- package/scripts/flaky-quarantine.sh +241 -0
- package/scripts/forge-annotation.sh +567 -0
- package/scripts/issue-dedup.sh +236 -0
- package/scripts/validate-annotation-node.mjs +233 -0
- package/scripts/verify-env-vars.sh +18 -2
- package/scripts/verify-host-headers.sh +4 -4
- package/scripts/verify-route-registration.sh +2 -2
- package/scripts/worktree-lifecycle.sh +202 -0
- package/templates/devdocs/agent/using-forgedock.md +14 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless entry point: `forgedock run-issue <issue>` drives one issue through the
|
|
3
|
+
* durable engine; scanStalls finds dead-lease issues for the orchestrator to resume.
|
|
4
|
+
* `forgedock resume-stalled [--dry-run] [--lane <lane>]` enumerates all in-flight
|
|
5
|
+
* issues, calls scanStalls, and re-dispatches each stalled issue via run-issue.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { runIssue } from "./engine.mjs";
|
|
12
|
+
import { makeProjector } from "./engine/projector.mjs";
|
|
13
|
+
|
|
14
|
+
const pexec = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
/** Real gh/git accessors. */
|
|
17
|
+
export function makeIo() {
|
|
18
|
+
const run = (bin) => async (args) => {
|
|
19
|
+
const { stdout } = await pexec(bin, args, { maxBuffer: 100 * 1024 * 1024 });
|
|
20
|
+
return stdout;
|
|
21
|
+
};
|
|
22
|
+
return { gh: run("gh"), git: run("git") };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolves the repo `gh` would target by default (i.e. resolved from the cwd
|
|
29
|
+
* git remote), via `gh repo view`. Used to validate an explicit `--repo` flag
|
|
30
|
+
* against the ambient context — see `assertRepoMatchesCwd`.
|
|
31
|
+
* @param {{gh: Function}} io
|
|
32
|
+
* @returns {Promise<string|null>} "owner/repo", or null if it can't be determined
|
|
33
|
+
*/
|
|
34
|
+
async function resolveDefaultRepo(io) {
|
|
35
|
+
try {
|
|
36
|
+
const out = await io.gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
37
|
+
const repo = String(out).trim();
|
|
38
|
+
return repo || null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Guards against cross-repo state confusion (forge#1593): `--repo` used to be
|
|
46
|
+
* threaded into the `gh issue list` enumeration call but never into the
|
|
47
|
+
* projector's state reads/writes or into re-dispatch, so a mismatched
|
|
48
|
+
* `--repo` silently read/wrote FORGE:STATE in the cwd-resolved repo instead
|
|
49
|
+
* of the requested one. Fully threading `--repo` through every `io.gh`/
|
|
50
|
+
* `io.git` call site the engine makes during a run (phases.mjs, reconcile.mjs,
|
|
51
|
+
* projector.mjs) would be a much larger, riskier change. Instead, this fails
|
|
52
|
+
* closed: if `--repo` is given and doesn't match the repo `gh` would use by
|
|
53
|
+
* default, refuse to run at all rather than silently mixing repos.
|
|
54
|
+
* @param {{gh: Function}} io
|
|
55
|
+
* @param {string|null} repo
|
|
56
|
+
* @returns {Promise<void>}
|
|
57
|
+
*/
|
|
58
|
+
async function assertRepoMatchesCwd(io, repo) {
|
|
59
|
+
if (!repo) return;
|
|
60
|
+
const defaultRepo = await resolveDefaultRepo(io);
|
|
61
|
+
if (defaultRepo === null) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`--repo ${repo} was given, but the current repo could not be determined (\`gh repo view\` failed) to ` +
|
|
64
|
+
`verify it matches. Refusing to run cross-repo without verification.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (defaultRepo !== repo) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`--repo ${repo} does not match the current repo (${defaultRepo}). Cross-repo dispatch is not supported — ` +
|
|
70
|
+
`state reads/writes (issue view/edit) are cwd-scoped and would silently target ${defaultRepo} instead of ` +
|
|
71
|
+
`${repo}. Run this command with cwd set to a checkout of ${repo}, or omit --repo to operate on ${defaultRepo}.`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {number[]} issues
|
|
78
|
+
* @param {{readState:(i:number)=>Promise<{terminal:boolean,lease:?{until:number}}|null>}} io
|
|
79
|
+
* @param {number} now
|
|
80
|
+
* @returns {Promise<number[]>} issues that appear stalled (expired lease, non-terminal)
|
|
81
|
+
*/
|
|
82
|
+
export async function scanStalls(issues, io, now) {
|
|
83
|
+
const stalled = [];
|
|
84
|
+
for (const i of issues) {
|
|
85
|
+
const s = await io.readState(i);
|
|
86
|
+
if (s && !s.terminal && s.lease && s.lease.until < now) stalled.push(i);
|
|
87
|
+
}
|
|
88
|
+
return stalled;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string[]} argv
|
|
93
|
+
* @param {{io?: {gh: Function}, runIssue?: Function}} [deps]
|
|
94
|
+
* Injectable for tests — defaults to real `gh`/`git` (makeIo()) and the real
|
|
95
|
+
* `runIssue` engine driver.
|
|
96
|
+
*/
|
|
97
|
+
export async function runFromCli(argv, deps = {}) {
|
|
98
|
+
const issue = parseInt(argv[0], 10);
|
|
99
|
+
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane>");
|
|
100
|
+
const lane = flag(argv, "--lane");
|
|
101
|
+
if (!lane) throw new Error("--lane is required: e.g. --lane main or --lane staging. No default to prevent accidental production targeting.");
|
|
102
|
+
const repo = flag(argv, "--repo");
|
|
103
|
+
const io = deps.io ?? makeIo();
|
|
104
|
+
await assertRepoMatchesCwd(io, repo);
|
|
105
|
+
const runIssueFn = deps.runIssue ?? runIssue;
|
|
106
|
+
const agentId = `cli_${process.pid}`;
|
|
107
|
+
const res = await runIssueFn({ issue, dir: runDir(), agentId, lane, io,
|
|
108
|
+
runner: (await import("./runner.mjs")).runCommand, now: () => Date.now() });
|
|
109
|
+
console.log(`issue #${issue} → ${res.terminalReason}`);
|
|
110
|
+
return res;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `forgedock resume-stalled [--dry-run] [--lane <lane>] [--repo <owner/repo>]`
|
|
115
|
+
*
|
|
116
|
+
* Enumerates all open issues carrying non-terminal workflow labels, reads each
|
|
117
|
+
* issue's FORGE:STATE block via the projector, identifies those with an expired
|
|
118
|
+
* lease (stalled), and re-dispatches them through the existing run-issue path.
|
|
119
|
+
*
|
|
120
|
+
* Flags:
|
|
121
|
+
* --dry-run Print the stalled list and exit 0 without dispatching anything.
|
|
122
|
+
* --lane Lane to pass to run-issue (required — no default to prevent accidental production targeting).
|
|
123
|
+
* --repo GitHub repo (owner/repo). Must match the repo `gh` resolves by default
|
|
124
|
+
* (the cwd git remote) — cross-repo dispatch is refused (forge#1593),
|
|
125
|
+
* since state reads/writes are cwd-scoped and would otherwise silently
|
|
126
|
+
* target the wrong repo. Omit --repo to operate on the cwd-resolved repo.
|
|
127
|
+
*
|
|
128
|
+
* Per-issue dispatch failures are caught and isolated — one issue's engine error
|
|
129
|
+
* (e.g. NO_API_KEY/NO_SDK or any other uncaught phase error from runIssue) does
|
|
130
|
+
* not abort dispatch of the remaining stalled issues in the batch. Failures are
|
|
131
|
+
* recorded in the returned `failed` array; the caller decides how to surface them.
|
|
132
|
+
*
|
|
133
|
+
* @param {string[]} argv
|
|
134
|
+
* @param {{io?: {gh: Function}, dispatch?: (argv: string[]) => Promise<any>}} [deps]
|
|
135
|
+
* Injectable for tests — defaults to real `gh`/`git` (makeIo()) and the real
|
|
136
|
+
* `runFromCli` dispatcher.
|
|
137
|
+
*/
|
|
138
|
+
export async function resumeStalledFromCli(argv, deps = {}) {
|
|
139
|
+
const dryRun = argv.includes("--dry-run");
|
|
140
|
+
const lane = flag(argv, "--lane");
|
|
141
|
+
if (!lane) throw new Error("--lane is required for resume-stalled: e.g. --lane main or --lane staging.");
|
|
142
|
+
const repo = flag(argv, "--repo");
|
|
143
|
+
|
|
144
|
+
const io = deps.io ?? makeIo();
|
|
145
|
+
await assertRepoMatchesCwd(io, repo);
|
|
146
|
+
const dispatch = deps.dispatch ?? runFromCli;
|
|
147
|
+
const projector = makeProjector(io);
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
|
|
150
|
+
// Collect candidate issue numbers from all non-terminal workflow labels.
|
|
151
|
+
const ACTIVE_LABELS = [
|
|
152
|
+
"workflow:investigating",
|
|
153
|
+
"workflow:ready-to-build",
|
|
154
|
+
"workflow:building",
|
|
155
|
+
"workflow:in-review",
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
const repoFlag = repo ? ["--repo", repo] : [];
|
|
159
|
+
const issueSet = new Set();
|
|
160
|
+
|
|
161
|
+
for (const label of ACTIVE_LABELS) {
|
|
162
|
+
try {
|
|
163
|
+
const out = await io.gh([
|
|
164
|
+
"issue", "list",
|
|
165
|
+
...repoFlag,
|
|
166
|
+
"--state", "open",
|
|
167
|
+
"--label", label,
|
|
168
|
+
"--limit", "100",
|
|
169
|
+
"--json", "number",
|
|
170
|
+
]);
|
|
171
|
+
const items = JSON.parse(out);
|
|
172
|
+
for (const { number } of items) issueSet.add(number);
|
|
173
|
+
} catch {
|
|
174
|
+
// gh may return non-zero when no issues match — treat as empty.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (issueSet.size === 0) {
|
|
179
|
+
console.log("resume-stalled: no in-flight issues found.");
|
|
180
|
+
return { stalled: [], dispatched: [], failed: [] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const candidates = [...issueSet];
|
|
184
|
+
const stalled = await scanStalls(candidates, projector, now);
|
|
185
|
+
|
|
186
|
+
if (stalled.length === 0) {
|
|
187
|
+
console.log("resume-stalled: all in-flight issues have active leases — nothing stalled.");
|
|
188
|
+
return { stalled: [], dispatched: [], failed: [] };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log(`resume-stalled: ${stalled.length} stalled issue(s) found: ${stalled.map((n) => `#${n}`).join(", ")}`);
|
|
192
|
+
|
|
193
|
+
if (dryRun) {
|
|
194
|
+
console.log("resume-stalled: --dry-run — not dispatching.");
|
|
195
|
+
return { stalled, dispatched: [], failed: [] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const dispatched = [];
|
|
199
|
+
const failed = [];
|
|
200
|
+
for (const issue of stalled) {
|
|
201
|
+
console.log(`resume-stalled: dispatching #${issue} …`);
|
|
202
|
+
try {
|
|
203
|
+
await dispatch([String(issue), "--lane", lane, ...(repo ? ["--repo", repo] : [])]);
|
|
204
|
+
dispatched.push(issue);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
const message = err?.message ?? String(err);
|
|
207
|
+
console.error(`resume-stalled: #${issue} failed: ${message}`);
|
|
208
|
+
failed.push({ issue, error: message });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (failed.length > 0) {
|
|
213
|
+
console.log(
|
|
214
|
+
`resume-stalled: ${dispatched.length} dispatched, ${failed.length} failed: ` +
|
|
215
|
+
`${failed.map((f) => `#${f.issue}`).join(", ")}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { stalled, dispatched, failed };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; }
|
package/bin/engine.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable per-phase engine loop. Drives one pipeline phase at a time via an
|
|
3
|
+
* injected runner (runCommand-shaped), determining each phase's outcome from
|
|
4
|
+
* GitHub state (phase.detectOutcome). All effects are injected → fully testable.
|
|
5
|
+
*/
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
|
|
8
|
+
import { pickPhase, TERMINAL_REASONS } from "./engine/phases.mjs";
|
|
9
|
+
import { reconcileState } from "./engine/reconcile.mjs";
|
|
10
|
+
import { makeProjector } from "./engine/projector.mjs";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
13
|
+
|
|
14
|
+
export async function runIssue(opts) {
|
|
15
|
+
const { issue, dir, agentId, lane = "staging", io, runner,
|
|
16
|
+
now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
17
|
+
commandsDir = fileURLToPath(new URL("../commands", import.meta.url)) } = opts;
|
|
18
|
+
const projector = makeProjector(io);
|
|
19
|
+
|
|
20
|
+
// 1. Load + reconcile (GitHub wins).
|
|
21
|
+
const local = readLog(dir, issue).length ? deriveState(readLog(dir, issue)) : null;
|
|
22
|
+
const remote = await projector.readState(issue);
|
|
23
|
+
let { state, action } = reconcileState(local, remote);
|
|
24
|
+
|
|
25
|
+
// I3 (best-effort): GitHub issue-edit is not an atomic CAS, so this is a
|
|
26
|
+
// courtesy check, not a hard mutual-exclusion guarantee — a concurrent
|
|
27
|
+
// start can still race between this read and the next lease write.
|
|
28
|
+
// MUST come before any projector.writeState() — remirror/hydrate branches
|
|
29
|
+
// must not overwrite GitHub state when another agent holds a valid lease.
|
|
30
|
+
if (remote?.lease && remote.lease.until > now() && remote.lease.by !== agentId) {
|
|
31
|
+
return { terminalReason: "deferred", detail: `issue ${issue} leased by ${remote.lease.by}` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!state) {
|
|
35
|
+
state = freshState(issue, lane);
|
|
36
|
+
appendEvent(dir, issue, { event: "RUN_START", issue, run: state.run, lane });
|
|
37
|
+
await projector.writeState(issue, state);
|
|
38
|
+
} else if (action === "remirror") {
|
|
39
|
+
await projector.writeState(issue, state);
|
|
40
|
+
} else if (action === "hydrate") {
|
|
41
|
+
// GitHub is ahead of (or the only source of) local state. Rebuild the
|
|
42
|
+
// local run-log to match the remote compact index so downstream
|
|
43
|
+
// `deriveState(readLog(...))` calls stay consistent — otherwise the
|
|
44
|
+
// post-commit re-derive below would fold over an empty/stale local log
|
|
45
|
+
// and regress committed/issue/v (C2).
|
|
46
|
+
rewriteLog(dir, issue, eventsFromIndex(state));
|
|
47
|
+
state = deriveState(readLog(dir, issue));
|
|
48
|
+
await projector.writeState(issue, state);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. Drive phases until terminal.
|
|
52
|
+
let phase;
|
|
53
|
+
while ((phase = pickPhase(state))) {
|
|
54
|
+
// Every issue's build works on a deterministic branch; set it before build runs.
|
|
55
|
+
if (phase.id === "build" && !state.branch) state.branch = `fix/pipeline-${issue}`;
|
|
56
|
+
|
|
57
|
+
let reconciled;
|
|
58
|
+
try {
|
|
59
|
+
reconciled = phase.reconcile ? await phase.reconcile(state, io) : { satisfied: false };
|
|
60
|
+
} catch {
|
|
61
|
+
// A reconcile probe (gh/git) failing must not crash the run — degrade
|
|
62
|
+
// to "not satisfied" so the phase runs normally instead (C1).
|
|
63
|
+
reconciled = { satisfied: false };
|
|
64
|
+
}
|
|
65
|
+
let outcome;
|
|
66
|
+
if (reconciled.satisfied) {
|
|
67
|
+
outcome = { status: "committed", outputs: reconciled.outputs || {} };
|
|
68
|
+
} else {
|
|
69
|
+
if (reconciled.outputs?.pr) state.pr = reconciled.outputs.pr;
|
|
70
|
+
outcome = await runPhaseWithRetry(phase, state, { io, runner, dir, issue, commandsDir, maxAttempts });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (outcome.status === "blocked") return await terminate(state, "needs-human", outcome.detail);
|
|
74
|
+
|
|
75
|
+
// committed
|
|
76
|
+
appendEvent(dir, issue, { event: "PHASE_COMMIT", phase: phase.id, outputs: outcome.outputs || {} });
|
|
77
|
+
state = deriveState(readLog(dir, issue));
|
|
78
|
+
if (outcome.terminalReason) state.terminalReason = outcome.terminalReason;
|
|
79
|
+
await projector.writeState(issue, { ...state, lease: { by: agentId, until: now() + 600000 } });
|
|
80
|
+
|
|
81
|
+
if (outcome.terminalReason && TERMINAL_REASONS.includes(outcome.terminalReason))
|
|
82
|
+
return await terminate(state, outcome.terminalReason);
|
|
83
|
+
if (phase.isTerminalAfter && phase.isTerminalAfter(state))
|
|
84
|
+
return await terminate(state, state.terminalReason || "merged");
|
|
85
|
+
}
|
|
86
|
+
return await terminate(state, state.terminalReason || "merged");
|
|
87
|
+
|
|
88
|
+
async function terminate(s, reason, detail) {
|
|
89
|
+
appendEvent(dir, issue, { event: "RUN_TERMINAL", reason });
|
|
90
|
+
const final = { ...deriveState(readLog(dir, issue)), terminal: true, terminalReason: reason, lease: null };
|
|
91
|
+
await projector.writeState(issue, final);
|
|
92
|
+
if (reason === "needs-human") await projector.setLabel(issue, "needs-human");
|
|
93
|
+
return { terminalReason: reason, detail };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runPhaseWithRetry(phase, state, ctx) {
|
|
98
|
+
const { io, runner, dir, issue, commandsDir, maxAttempts } = ctx;
|
|
99
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
100
|
+
appendEvent(dir, issue, { event: "PHASE_START", phase: phase.id, attempt });
|
|
101
|
+
try {
|
|
102
|
+
await runner({ commandsDir, commandName: phase.command, args: [String(issue)] });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// A missing API key / SDK is a config error, not a transient phase
|
|
105
|
+
// failure — surface it distinctly instead of burning retries and
|
|
106
|
+
// escalating to needs-human as if the LLM run itself misbehaved.
|
|
107
|
+
if (e.code === "NO_API_KEY" || e.code === "NO_SDK") throw e;
|
|
108
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: e.message });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const outcome = await phase.detectOutcome(state, io);
|
|
112
|
+
if (outcome.status === "committed" || outcome.status === "blocked") return outcome;
|
|
113
|
+
appendEvent(dir, issue, { event: "PHASE_FAILED", phase: phase.id, attempt, reason: outcome.detail });
|
|
114
|
+
}
|
|
115
|
+
// Exhausted transient retries → escalate (spec §7).
|
|
116
|
+
return { status: "blocked", detail: `phase ${phase.id} failed after ${maxAttempts} attempts` };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a run-log event sequence that reproduces a compact FORGE:STATE index,
|
|
121
|
+
* used to reconstruct the local run-log on hydrate (C2).
|
|
122
|
+
*/
|
|
123
|
+
function eventsFromIndex(idx) {
|
|
124
|
+
const events = [{ event: "RUN_START", issue: idx.issue, run: idx.run, lane: idx.lane }];
|
|
125
|
+
for (const phase of idx.committed) {
|
|
126
|
+
const outputs = {};
|
|
127
|
+
if (phase === "build" && idx.branch) outputs.branch = idx.branch;
|
|
128
|
+
if (phase === "review" && idx.pr != null) outputs.pr = idx.pr;
|
|
129
|
+
events.push({ event: "PHASE_COMMIT", phase, outputs });
|
|
130
|
+
}
|
|
131
|
+
if (idx.terminal) events.push({ event: "RUN_TERMINAL", reason: idx.terminalReason });
|
|
132
|
+
return events;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function freshState(issue, lane) {
|
|
136
|
+
return { v: 0, run: `r_${issue}_${lane}`, issue, lane, committed: [], phase: null,
|
|
137
|
+
branch: null, pr: null, terminal: false, terminalReason: null, lease: null };
|
|
138
|
+
}
|