forgedock 1.1.7 → 1.1.9
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 +10 -33
- package/bin/cinema.mjs +23 -5
- package/bin/engine/invariants.mjs +361 -0
- package/bin/engine-cli.mjs +137 -11
- package/bin/env-detect.mjs +249 -0
- package/bin/forge-utils.mjs +364 -4
- package/bin/forgedock.mjs +1064 -135
- package/bin/hooks/pre-tool-use.mjs +182 -0
- package/bin/hooks/session-start.mjs +68 -3
- package/bin/init-detect.mjs +101 -12
- package/bin/journey.mjs +917 -16
- package/bin/labels.json +10 -0
- package/bin/recall.mjs +557 -0
- package/bin/registry.mjs +91 -3
- package/bin/runner.mjs +38 -2
- package/bin/tui.mjs +49 -3
- package/commands/adopt.md +1 -1
- package/commands/analytics.md +8 -8
- package/commands/audit.md +1 -1
- package/commands/autopilot.md +958 -77
- package/commands/ci-audit.md +1 -1
- package/commands/cleanup.md +139 -55
- package/commands/compat-audit.md +1 -1
- package/commands/deploy-info.md +1 -1
- package/commands/deploy-pr.md +1 -1
- package/commands/diagnose.md +1 -1
- package/commands/explain.md +1 -1
- package/commands/fix-ci.md +1 -1
- package/commands/forgedock-init.md +1 -1
- package/commands/incident-response.md +3 -3
- package/commands/issue.md +1 -1
- package/commands/optimize.md +586 -1
- package/commands/orchestrate/config.md +13 -3
- package/commands/orchestrate/phase-1-resolve.md +36 -26
- package/commands/orchestrate/phase-2-triage.md +1 -1
- package/commands/orchestrate/phase-2.5-synthesis.md +1 -1
- package/commands/orchestrate/phase-3-dependency.md +587 -48
- package/commands/orchestrate/phase-4-execution.md +1031 -57
- package/commands/orchestrate/phase-5-cleanup.md +1 -1
- package/commands/orchestrate/phase-6-report.md +248 -11
- package/commands/orchestrate/safety.md +2 -2
- package/commands/pipeline-health/phase-2-metrics.md +305 -1
- package/commands/pipeline-health/phase-4-proposals.md +127 -0
- package/commands/qa-sweep.md +1 -1
- package/commands/quality-gate.md +418 -1
- package/commands/recover-orphans.md +5 -5
- package/commands/replay.md +1 -1
- package/commands/review-pr-agents/api.md +2 -2
- package/commands/review-pr-agents/auth.md +2 -2
- package/commands/review-pr-agents/billing.md +2 -2
- package/commands/review-pr-agents/concurrency.md +2 -2
- package/commands/review-pr-agents/database.md +2 -2
- package/commands/review-pr-agents/frontend.md +2 -2
- package/commands/review-pr-agents/infra.md +2 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-agents/scraper.md +2 -2
- package/commands/review-pr-agents/security.md +2 -2
- package/commands/review-pr-staging.md +41 -3
- package/commands/review-pr.md +462 -19
- package/commands/rollback.md +1 -1
- package/commands/scope.md +1 -1
- package/commands/security-audit.md +1 -1
- package/commands/spec-doctor.md +429 -0
- package/commands/test-gate.md +1 -1
- package/commands/validate.md +53 -1
- package/commands/work-on/build/architect.md +78 -1
- package/commands/work-on/build/context.md +360 -22
- package/commands/work-on/build/implement.md +3 -1
- package/commands/work-on/build/validate.md +29 -1
- package/commands/work-on/build.md +75 -6
- package/commands/work-on/close.md +624 -10
- package/commands/work-on/decompose.md +1 -1
- package/commands/work-on/investigate.md +235 -5
- package/commands/work-on/remediate.md +331 -0
- package/commands/work-on/review.md +1 -1
- package/commands/work-on-monolithic.md +1 -1
- package/commands/work-on.md +205 -66
- package/docs/CI.md +2 -2
- package/docs/CODEX.md +1 -1
- package/docs/CONFIG.md +186 -0
- package/docs/FORGE-PROTOCOL.md +101 -0
- package/docs/site/command-reference.md +31 -1
- package/docs/site/getting-started.md +9 -34
- package/docs/site/index.md +1 -1
- package/docs/spec/forge-protocol-v1.md +97 -5
- package/docs/spec/label-state-machine.md +6 -1
- package/package.json +3 -2
- package/packages/protocol/src/cli.js +374 -84
- package/packages/protocol/src/types.js +75 -1
- package/scripts/build-knowledge-index.mjs +1761 -0
- package/scripts/calibration.mjs +996 -0
- package/scripts/check-command-docs-drift.sh +4 -2
- package/scripts/danger-zones.mjs +789 -0
- package/scripts/doctor-pipeline-state.sh +88 -2
- package/scripts/select-fix-targets.sh +234 -0
- package/scripts/transition-label.sh +245 -6
- package/scripts/refresh-bot-token.sh +0 -97
package/README.md
CHANGED
|
@@ -221,11 +221,10 @@ More ship today (web-property analytics, browser QA sweeps, self-benchmarking)
|
|
|
221
221
|
**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) · [GitHub CLI](https://cli.github.com/) (authenticated) · Node.js ≥ 18.
|
|
222
222
|
|
|
223
223
|
```bash
|
|
224
|
-
npx forgedock
|
|
225
|
-
npx forgedock --global # global install: installs into ~/.claude/commands/ — available in every Claude Code session on this machine
|
|
224
|
+
npx forgedock # checks your environment, installs commands into ~/.claude/commands/ (available in every Claude Code session on this machine), detects your repo, and hands you a reviewed forge.yaml
|
|
226
225
|
```
|
|
227
226
|
|
|
228
|
-
**
|
|
227
|
+
**Install is always global**, to `~/.claude/commands/`. `--global` is still accepted on the command line for backward compatibility but has no effect — there's no other install location to opt out of.
|
|
229
228
|
|
|
230
229
|
> **Want engine-mode dispatch?** `npx forgedock` is transient — the `forgedock` binary isn't persisted in PATH after install. `/orchestrate` and `/autopilot` use agent dispatch mode by default, which is fully functional. To enable engine-mode dispatch (`forgedock run-issue`) with its durable phase table and fail-closed review gate, run `npm install -g forgedock` instead.
|
|
231
230
|
|
|
@@ -253,15 +252,13 @@ Commands then appear as `/forgedock:work-on`, etc. You still run `npx forgedock
|
|
|
253
252
|
|
|
254
253
|
**Headless / CI:** the pipeline also runs outside Claude Code. `npx forgedock run work-on <issue> --dry-run` previews the assembled prompt and tool plan; with an `ANTHROPIC_API_KEY`, `npx forgedock run` drives the same command specs through a hardened tool-use loop, and `npx forgedock run-issue <issue>` executes them on the durable engine (event-sourced run log, leases, crash-safe resume).
|
|
255
254
|
|
|
256
|
-
**
|
|
255
|
+
**Explicit install command:**
|
|
257
256
|
|
|
258
257
|
```bash
|
|
259
|
-
npx forgedock install #
|
|
260
|
-
npx forgedock install --global # global
|
|
258
|
+
npx forgedock install # installs into ~/.claude/commands/
|
|
259
|
+
npx forgedock install --global # same thing — --global is accepted but is a no-op
|
|
261
260
|
```
|
|
262
261
|
|
|
263
|
-
`uninstall`, `update`, and `doctor` auto-detect which mode is installed — no need to re-specify `--global` after a global install. To explicitly target global: `npx forgedock doctor --global`.
|
|
264
|
-
|
|
265
262
|
**Maintenance:**
|
|
266
263
|
|
|
267
264
|
```bash
|
|
@@ -269,9 +266,9 @@ npx forgedock update # relink commands + refresh the SessionStart hook
|
|
|
269
266
|
npx forgedock enable # turn ForgeDock on for this directory
|
|
270
267
|
npx forgedock disable # turn ForgeDock off for this directory
|
|
271
268
|
npx forgedock status # show ForgeDock's state for this directory
|
|
272
|
-
npx forgedock doctor # installation health check with fix hints
|
|
269
|
+
npx forgedock doctor # installation health check with fix hints
|
|
273
270
|
npx forgedock report # 30-day pipeline impact receipts (--md for Markdown, --json for scripting)
|
|
274
|
-
npx forgedock uninstall # remove commands, the hook, and tracked copies
|
|
271
|
+
npx forgedock uninstall # remove commands, the hook, and tracked copies
|
|
275
272
|
npx forgedock help # show everything
|
|
276
273
|
```
|
|
277
274
|
|
|
@@ -280,31 +277,11 @@ npx forgedock help # show everything
|
|
|
280
277
|
</details>
|
|
281
278
|
|
|
282
279
|
<details>
|
|
283
|
-
<summary><strong>
|
|
284
|
-
|
|
285
|
-
If you installed ForgeDock before project-scoped became the default, your commands live in `~/.claude/commands/`. You have two paths forward:
|
|
286
|
-
|
|
287
|
-
**Keep the global install** — add `--global` going forward and nothing changes:
|
|
288
|
-
|
|
289
|
-
```bash
|
|
290
|
-
npx forgedock --global # install / update globally as before
|
|
291
|
-
```
|
|
280
|
+
<summary><strong>A note on install location</strong></summary>
|
|
292
281
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
```bash
|
|
296
|
-
# 1. Check your current install mode
|
|
297
|
-
npx forgedock status
|
|
298
|
-
|
|
299
|
-
# 2. Remove the global install
|
|
300
|
-
npx forgedock uninstall --global
|
|
301
|
-
|
|
302
|
-
# 3. Install project-scoped in each repo you use ForgeDock in
|
|
303
|
-
cd /path/to/your/project
|
|
304
|
-
npx forgedock
|
|
305
|
-
```
|
|
282
|
+
ForgeDock briefly experimented with a project-scoped-by-default install mode. It was backed out after causing a "split-brain" bug (`detect` assumed project-scoped while the installer still wrote globally — [#1589](https://github.com/RapierCraftStudios/ForgeDock/issues/1589)), so every version you'd realistically install today only ever writes to `~/.claude/commands/`. `--global` is still accepted as a flag for old scripts/muscle memory, but it changes nothing.
|
|
306
283
|
|
|
307
|
-
|
|
284
|
+
If you're carrying an older `--global` habit in scripts or CI, it's harmless to leave it — `npx forgedock --global` and `npx forgedock` do exactly the same thing.
|
|
308
285
|
|
|
309
286
|
</details>
|
|
310
287
|
|
package/bin/cinema.mjs
CHANGED
|
@@ -200,14 +200,32 @@ function badgeText(badge, mode) {
|
|
|
200
200
|
return `${fg(rgb, mode)}[${label}]\x1b[0m`;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
/**
|
|
204
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Severity-banded fix card (wraps tui.box; border color/weight applied when
|
|
205
|
+
* able).
|
|
206
|
+
*
|
|
207
|
+
* - "error" (default): ember/red bordered box — current look, unchanged.
|
|
208
|
+
* - "warning": amber bordered box — lighter than error, still boxed.
|
|
209
|
+
* - "info": no border — plain, dimmed lines only.
|
|
210
|
+
*
|
|
211
|
+
* @param {string[]} lines
|
|
212
|
+
* @param {string} mode
|
|
213
|
+
* @param {'error'|'warning'|'info'} [severity]
|
|
214
|
+
*/
|
|
215
|
+
export function fixCard(lines, mode, severity = "error") {
|
|
216
|
+
if (severity === "info") {
|
|
217
|
+
const padded = lines.map((l) => ` ${l} `);
|
|
218
|
+
if (mode === "none") return padded.join("\n");
|
|
219
|
+
return padded.map((l) => `\x1b[2m${l}\x1b[22m`).join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
205
222
|
const boxed = box(lines.map((l) => ` ${l} `), { title: "fix" });
|
|
206
223
|
if (mode === "none") return boxed;
|
|
207
|
-
const
|
|
224
|
+
const rgb = severity === "warning" ? [232, 192, 96] : [255, 107, 53];
|
|
225
|
+
const color = fg(rgb, mode);
|
|
208
226
|
return boxed
|
|
209
227
|
.split("\n")
|
|
210
|
-
.map((l) => l.replace(/[╭╮╰╯─│]/gu, (m) => `${
|
|
228
|
+
.map((l) => l.replace(/[╭╮╰╯─│]/gu, (m) => `${color}${m}\x1b[0m`))
|
|
211
229
|
.join("\n");
|
|
212
230
|
}
|
|
213
231
|
|
|
@@ -253,7 +271,7 @@ export async function revealRows(
|
|
|
253
271
|
}
|
|
254
272
|
writer.write(formatRow(row.label, res, mode) + "\n");
|
|
255
273
|
if (!res.ok && res.fix && res.fix.length > 0) {
|
|
256
|
-
writer.write(fixCard(res.fix, mode) + "\n");
|
|
274
|
+
writer.write(fixCard(res.fix, mode, res.severity || "error") + "\n");
|
|
257
275
|
}
|
|
258
276
|
results.push(res);
|
|
259
277
|
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bin/engine/invariants.mjs — ForgeDock pipeline invariant evaluator.
|
|
3
|
+
*
|
|
4
|
+
* Evaluates three classes of invariants declared in forge-invariants.yaml:
|
|
5
|
+
*
|
|
6
|
+
* pretooluse — preconditions checked in pre-tool-use.mjs before tool
|
|
7
|
+
* execution (e.g. branch-must-exist-on-remote)
|
|
8
|
+
* runlog — temporal ordering rules over the append-only run-log
|
|
9
|
+
* (e.g. review-precedes-merge)
|
|
10
|
+
* close — terminal-state assertions checked before trajectory post
|
|
11
|
+
* (e.g. issue-closed-at-terminal)
|
|
12
|
+
*
|
|
13
|
+
* === Design constraints ===
|
|
14
|
+
*
|
|
15
|
+
* • No circular imports: only imports node:fs, node:child_process, node:path,
|
|
16
|
+
* node:url. Does NOT import engine.mjs or phases.mjs.
|
|
17
|
+
* • Fail-open: every exported function returns a result object on error
|
|
18
|
+
* rather than throwing. A missing forge-invariants.yaml or yq returns [].
|
|
19
|
+
* • Single-pass temporal evaluation: O(n) over the run-log event list.
|
|
20
|
+
* No GitHub API calls.
|
|
21
|
+
* • Named violations: every failed result includes { ok: false, id, violated }
|
|
22
|
+
* so callers can surface the proposition by name.
|
|
23
|
+
*
|
|
24
|
+
* === Usage ===
|
|
25
|
+
*
|
|
26
|
+
* // Load all declared invariants:
|
|
27
|
+
* const invariants = loadInvariants(forgeInvariantsYamlPath);
|
|
28
|
+
*
|
|
29
|
+
* // Evaluate temporal rules over a run-log event stream:
|
|
30
|
+
* const results = evaluateTemporalRules(invariants, events);
|
|
31
|
+
*
|
|
32
|
+
* // Check a hook precondition (branch must exist):
|
|
33
|
+
* const result = await checkPrecondition(invariants, "branch_must_exist_on_remote", { branch });
|
|
34
|
+
*
|
|
35
|
+
* // Assert close-time invariants before posting trajectory:
|
|
36
|
+
* const results = assertCloseInvariants(invariants, events);
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { existsSync } from "node:fs";
|
|
40
|
+
import { execFileSync } from "node:child_process";
|
|
41
|
+
import { resolve } from "node:path";
|
|
42
|
+
import { fileURLToPath } from "node:url";
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// YAML loader — uses yq (pipeline dependency) for proper array support.
|
|
46
|
+
// forge-utils.mjs's minimal parser does not handle YAML sequences.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Load invariant declarations from forge-invariants.yaml.
|
|
51
|
+
*
|
|
52
|
+
* Returns an empty array if:
|
|
53
|
+
* - forge-invariants.yaml is absent (fresh install before file ships)
|
|
54
|
+
* - yq is not installed (graceful degradation)
|
|
55
|
+
* - any parse error occurs
|
|
56
|
+
*
|
|
57
|
+
* @param {string} [yamlPath] Absolute path to forge-invariants.yaml.
|
|
58
|
+
* Defaults to {repo root}/forge-invariants.yaml resolved relative to
|
|
59
|
+
* this file's location.
|
|
60
|
+
* @returns {Invariant[]}
|
|
61
|
+
*/
|
|
62
|
+
export function loadInvariants(yamlPath) {
|
|
63
|
+
// Default: resolve relative to bin/engine/ → repo root / forge-invariants.yaml
|
|
64
|
+
const DEFAULT_PATH = resolve(
|
|
65
|
+
fileURLToPath(import.meta.url),
|
|
66
|
+
"..", "..", "..", "forge-invariants.yaml",
|
|
67
|
+
);
|
|
68
|
+
const target = yamlPath || DEFAULT_PATH;
|
|
69
|
+
|
|
70
|
+
if (!existsSync(target)) return []; // file absent — fail-open, not an error
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Use yq to convert YAML → JSON (handles arrays; yq is a pipeline dep).
|
|
74
|
+
const json = execFileSync("yq", ["-o", "json", ".", target], {
|
|
75
|
+
encoding: "utf-8",
|
|
76
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
77
|
+
timeout: 5000,
|
|
78
|
+
});
|
|
79
|
+
const parsed = JSON.parse(json);
|
|
80
|
+
const items = parsed?.invariants;
|
|
81
|
+
if (!Array.isArray(items)) return [];
|
|
82
|
+
return items.filter(
|
|
83
|
+
(i) => i && typeof i.id === "string" && typeof i.scope === "string",
|
|
84
|
+
);
|
|
85
|
+
} catch {
|
|
86
|
+
// yq not installed, parse error, or any other failure — fail-open.
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Temporal rule evaluation (single-pass, O(n), no I/O)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Evaluate all runlog-scope invariants against an event list.
|
|
97
|
+
*
|
|
98
|
+
* @param {Invariant[]} invariants Full invariant list from loadInvariants().
|
|
99
|
+
* @param {object[]} events Event list from readLog() / deriveState().
|
|
100
|
+
* @returns {InvariantResult[]}
|
|
101
|
+
*/
|
|
102
|
+
export function evaluateTemporalRules(invariants, events) {
|
|
103
|
+
const rules = invariants.filter((i) => i.scope === "runlog");
|
|
104
|
+
if (!rules.length || !events.length) return rules.map((r) => ({ ok: true, id: r.id }));
|
|
105
|
+
|
|
106
|
+
// Single-pass: collect per-event-type positions.
|
|
107
|
+
const phaseCommits = {}; // phase id → first commit seq
|
|
108
|
+
let firstTerminal = null;
|
|
109
|
+
let terminalCount = 0;
|
|
110
|
+
let terminalReason = null;
|
|
111
|
+
|
|
112
|
+
for (const e of events) {
|
|
113
|
+
if (e.event === "PHASE_COMMIT" && e.phase) {
|
|
114
|
+
if (!(e.phase in phaseCommits)) phaseCommits[e.phase] = e.seq;
|
|
115
|
+
} else if (e.event === "RUN_TERMINAL") {
|
|
116
|
+
terminalCount++;
|
|
117
|
+
if (firstTerminal === null) { firstTerminal = e.seq; terminalReason = e.reason; }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return rules.map((r) => {
|
|
122
|
+
try {
|
|
123
|
+
return evaluateOneRule(r, { phaseCommits, firstTerminal, terminalCount, terminalReason });
|
|
124
|
+
} catch {
|
|
125
|
+
return { ok: true, id: r.id }; // evaluator error → fail-open
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function evaluateOneRule(rule, { phaseCommits, firstTerminal, terminalCount, terminalReason }) {
|
|
131
|
+
switch (rule.id) {
|
|
132
|
+
case "review_precedes_merge": {
|
|
133
|
+
// Only check when the run ended with 'merged'.
|
|
134
|
+
if (terminalReason !== "merged") return { ok: true, id: rule.id };
|
|
135
|
+
const reviewSeq = phaseCommits["review"] ?? null;
|
|
136
|
+
if (reviewSeq === null || (firstTerminal !== null && reviewSeq > firstTerminal)) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false, id: rule.id,
|
|
139
|
+
violated: rule.proposition,
|
|
140
|
+
detail: `RUN_TERMINAL(merged) at seq ${firstTerminal} but no PHASE_COMMIT(review) precedes it`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, id: rule.id };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
case "gate_event_precedes_review": {
|
|
147
|
+
const buildSeq = phaseCommits["build"] ?? null;
|
|
148
|
+
const reviewSeq = phaseCommits["review"] ?? null;
|
|
149
|
+
if (reviewSeq !== null && (buildSeq === null || buildSeq > reviewSeq)) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false, id: rule.id,
|
|
152
|
+
violated: rule.proposition,
|
|
153
|
+
detail: `PHASE_COMMIT(review) at seq ${reviewSeq} but PHASE_COMMIT(build) ${buildSeq === null ? "absent" : `at seq ${buildSeq} (after review)`}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, id: rule.id };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case "terminal_state_once": {
|
|
160
|
+
if (terminalCount > 1) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false, id: rule.id,
|
|
163
|
+
violated: rule.proposition,
|
|
164
|
+
detail: `RUN_TERMINAL appears ${terminalCount} times in run-log (expected ≤1)`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, id: rule.id };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
default:
|
|
171
|
+
// Unknown runlog invariant ID — fail-open (future invariants not yet in evaluator).
|
|
172
|
+
return { ok: true, id: rule.id };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Hook precondition checks (async — may shell out for git ls-remote)
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Evaluate a named pretooluse-scope precondition.
|
|
182
|
+
*
|
|
183
|
+
* @param {Invariant[]} invariants Full invariant list from loadInvariants().
|
|
184
|
+
* @param {string} id Invariant ID to evaluate.
|
|
185
|
+
* @param {object} context Evaluation context (e.g. { branch, repoPath }).
|
|
186
|
+
* @returns {Promise<InvariantResult>}
|
|
187
|
+
*/
|
|
188
|
+
export async function checkPrecondition(invariants, id, context) {
|
|
189
|
+
const inv = invariants.find((i) => i.scope === "pretooluse" && i.id === id);
|
|
190
|
+
if (!inv) return { ok: true, id }; // not declared — fail-open
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
return await evaluateOnePrecondition(inv, context);
|
|
194
|
+
} catch {
|
|
195
|
+
return { ok: true, id }; // evaluator error → fail-open
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Evaluate all pretooluse-scope preconditions matching a named context.
|
|
201
|
+
* Returns all results so the caller can surface the first failure.
|
|
202
|
+
*
|
|
203
|
+
* @param {Invariant[]} invariants
|
|
204
|
+
* @param {object} context
|
|
205
|
+
* @returns {Promise<InvariantResult[]>}
|
|
206
|
+
*/
|
|
207
|
+
export async function checkAllPreconditions(invariants, context) {
|
|
208
|
+
const rules = invariants.filter((i) => i.scope === "pretooluse");
|
|
209
|
+
const results = [];
|
|
210
|
+
for (const r of rules) {
|
|
211
|
+
try {
|
|
212
|
+
results.push(await evaluateOnePrecondition(r, context));
|
|
213
|
+
} catch {
|
|
214
|
+
results.push({ ok: true, id: r.id }); // fail-open
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return results;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function evaluateOnePrecondition(inv, context) {
|
|
221
|
+
switch (inv.id) {
|
|
222
|
+
case "branch_must_exist_on_remote": {
|
|
223
|
+
const branch = context?.branch;
|
|
224
|
+
if (!branch) return { ok: true, id: inv.id }; // no branch in context — skip
|
|
225
|
+
|
|
226
|
+
// git ls-remote --exit-code: exits 0 if ref found, 2 if not found.
|
|
227
|
+
// Use a cached local-only check first (avoids network round-trip when
|
|
228
|
+
// the branch is already fetched).
|
|
229
|
+
try {
|
|
230
|
+
execFileSync("git", ["rev-parse", "--verify", `origin/${branch}`], {
|
|
231
|
+
encoding: "utf-8",
|
|
232
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
233
|
+
timeout: 5000,
|
|
234
|
+
});
|
|
235
|
+
return { ok: true, id: inv.id }; // local origin ref exists
|
|
236
|
+
} catch {
|
|
237
|
+
// Not in local refs — fall through to remote check.
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const url = getRemoteUrl();
|
|
242
|
+
if (!url) return { ok: true, id: inv.id }; // no remote configured — fail-open
|
|
243
|
+
|
|
244
|
+
execFileSync("git", ["ls-remote", "--exit-code", "origin", branch], {
|
|
245
|
+
encoding: "utf-8",
|
|
246
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
247
|
+
timeout: 10000,
|
|
248
|
+
});
|
|
249
|
+
return { ok: true, id: inv.id }; // remote ref found
|
|
250
|
+
} catch (e) {
|
|
251
|
+
const exitCode = e?.status ?? null;
|
|
252
|
+
if (exitCode === 2) {
|
|
253
|
+
// Exit code 2 from ls-remote means ref not found.
|
|
254
|
+
return {
|
|
255
|
+
ok: false, id: inv.id,
|
|
256
|
+
violated: inv.proposition,
|
|
257
|
+
detail: `Branch '${branch}' not found on origin — checkout would fail`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
// Other errors (network, auth, etc.) → fail-open.
|
|
261
|
+
return { ok: true, id: inv.id };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
default:
|
|
266
|
+
return { ok: true, id: inv.id }; // unknown precondition — fail-open
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getRemoteUrl() {
|
|
271
|
+
try {
|
|
272
|
+
return execFileSync("git", ["remote", "get-url", "origin"], {
|
|
273
|
+
encoding: "utf-8",
|
|
274
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
275
|
+
timeout: 3000,
|
|
276
|
+
}).trim() || null;
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Close-time assertions (synchronous — run-log only, no I/O)
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Assert close-scope invariants before the trajectory annotation is posted.
|
|
288
|
+
* These run in the close phase after the PR is merged and issue is closed.
|
|
289
|
+
*
|
|
290
|
+
* @param {Invariant[]} invariants Full invariant list from loadInvariants().
|
|
291
|
+
* @param {object[]} events Event list from readLog().
|
|
292
|
+
* @returns {InvariantResult[]}
|
|
293
|
+
*/
|
|
294
|
+
export function assertCloseInvariants(invariants, events) {
|
|
295
|
+
const assertions = invariants.filter((i) => i.scope === "close");
|
|
296
|
+
if (!assertions.length) return [];
|
|
297
|
+
|
|
298
|
+
return assertions.map((a) => {
|
|
299
|
+
try {
|
|
300
|
+
return evaluateOneCloseAssertion(a, events);
|
|
301
|
+
} catch {
|
|
302
|
+
return { ok: true, id: a.id }; // evaluator error → fail-open
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function evaluateOneCloseAssertion(assertion, events) {
|
|
308
|
+
switch (assertion.id) {
|
|
309
|
+
case "run_log_terminal_at_close": {
|
|
310
|
+
const hasTerminal = events.some((e) => e.event === "RUN_TERMINAL");
|
|
311
|
+
if (!hasTerminal) {
|
|
312
|
+
return {
|
|
313
|
+
ok: false, id: assertion.id,
|
|
314
|
+
violated: assertion.proposition,
|
|
315
|
+
detail: "RUN_TERMINAL event absent from run-log — pipeline may have exited abnormally",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return { ok: true, id: assertion.id };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
case "issue_closed_at_terminal":
|
|
322
|
+
// This assertion is evaluated externally (requires a gh CLI call in the
|
|
323
|
+
// close phase). The evaluator returns ok=true here so callers that do
|
|
324
|
+
// not pass GitHub state still pass without error. The close.md phase
|
|
325
|
+
// block implements the actual gh issue view check.
|
|
326
|
+
return { ok: true, id: assertion.id };
|
|
327
|
+
|
|
328
|
+
default:
|
|
329
|
+
return { ok: true, id: assertion.id }; // unknown close assertion — fail-open
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Convenience: format a violation for human output
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Format a failed InvariantResult into a human-readable block.
|
|
339
|
+
* Safe to call on ok:true results (returns null).
|
|
340
|
+
*
|
|
341
|
+
* @param {InvariantResult} result
|
|
342
|
+
* @returns {string|null}
|
|
343
|
+
*/
|
|
344
|
+
export function formatViolation(result) {
|
|
345
|
+
if (result.ok) return null;
|
|
346
|
+
return [
|
|
347
|
+
`[ForgeDock] INVARIANT VIOLATION: ${result.id}`,
|
|
348
|
+
``,
|
|
349
|
+
`Violated proposition: "${result.violated}"`,
|
|
350
|
+
result.detail ? `Detail: ${result.detail}` : "",
|
|
351
|
+
].filter((l) => l !== undefined).join("\n");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// JSDoc types
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* @typedef {{ id: string, scope: string, proposition: string, enforcement: string, description?: string }} Invariant
|
|
360
|
+
* @typedef {{ ok: boolean, id: string, violated?: string, detail?: string }} InvariantResult
|
|
361
|
+
*/
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -6,17 +6,48 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
|
+
import { readdirSync, statSync } from "node:fs";
|
|
9
10
|
import { homedir } from "node:os";
|
|
10
11
|
import { join } from "node:path";
|
|
11
12
|
import { runIssue } from "./engine.mjs";
|
|
12
13
|
import { makeProjector } from "./engine/projector.mjs";
|
|
14
|
+
import { readLog, deriveState } from "./engine/runlog.mjs";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Workflow labels that mark an issue as "in the pipeline" (not yet terminal).
|
|
18
|
+
* Shared by resume-stalled's candidate enumeration and countEngineActivity()
|
|
19
|
+
* (the re-entry dashboard's in-flight/stalled counter, #1945) so both surfaces
|
|
20
|
+
* agree on what "in-flight" means — a single source of truth rather than two
|
|
21
|
+
* lists that can silently drift apart.
|
|
22
|
+
*/
|
|
23
|
+
export const ACTIVE_WORKFLOW_LABELS = [
|
|
24
|
+
"workflow:investigating",
|
|
25
|
+
"workflow:ready-to-build",
|
|
26
|
+
"workflow:building",
|
|
27
|
+
"workflow:in-review",
|
|
28
|
+
];
|
|
13
29
|
|
|
14
30
|
const pexec = promisify(execFile);
|
|
15
31
|
|
|
16
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Real gh/git accessors.
|
|
34
|
+
*
|
|
35
|
+
* `timeout: 10000` matches the convention already used for the sibling
|
|
36
|
+
* `gh pr list` call in the interactive dashboard (bin/forgedock.mjs's
|
|
37
|
+
* gatherDashboardData()). Every io.gh()/io.git() call site issues a single,
|
|
38
|
+
* normally-fast metadata command (gh issue view, gh api .../comments,
|
|
39
|
+
* gh pr list/view, git rev-list --count) — none are legitimately
|
|
40
|
+
* long-running — so one shared timeout is appropriate across all callers
|
|
41
|
+
* (the interactive status dashboard, run-issue, and resume-stalled). A
|
|
42
|
+
* hung/black-holed gh process (flaky network, corporate proxy, stuck auth
|
|
43
|
+
* prompt) would otherwise hang commands like `npx forgedock status`
|
|
44
|
+
* indefinitely (#1962). Callers already wrap every io.gh()/io.git() call in
|
|
45
|
+
* try/catch and degrade to null/"unknown" on any rejection, so a timeout
|
|
46
|
+
* fails closed gracefully with no additional error handling required.
|
|
47
|
+
*/
|
|
17
48
|
export function makeIo() {
|
|
18
49
|
const run = (bin) => async (args) => {
|
|
19
|
-
const { stdout } = await pexec(bin, args, { maxBuffer: 100 * 1024 * 1024 });
|
|
50
|
+
const { stdout } = await pexec(bin, args, { maxBuffer: 100 * 1024 * 1024, timeout: 10000 });
|
|
20
51
|
return stdout;
|
|
21
52
|
};
|
|
22
53
|
return { gh: run("gh"), git: run("git") };
|
|
@@ -88,6 +119,106 @@ export async function scanStalls(issues, io, now) {
|
|
|
88
119
|
return stalled;
|
|
89
120
|
}
|
|
90
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Read-only in-flight/stalled counter for the `status`/`install` re-entry
|
|
124
|
+
* dashboard (#1945). Reuses the exact same enumeration + lease-expiry logic
|
|
125
|
+
* as `resumeStalledFromCli` (workflow-label search via `gh issue list`, then
|
|
126
|
+
* `scanStalls` against the GitHub-authoritative FORGE:STATE via the
|
|
127
|
+
* projector) — GitHub is this codebase's source of truth for run state, not
|
|
128
|
+
* the local `~/.forge/runs` hot-path log, so the dashboard counts the same
|
|
129
|
+
* way the orchestrator does. Never dispatches or mutates anything.
|
|
130
|
+
*
|
|
131
|
+
* Best-effort: a `gh issue list` failure for one label is swallowed (treated
|
|
132
|
+
* as zero matches for that label) — consistent with resumeStalledFromCli's
|
|
133
|
+
* existing tolerance — so one bad label search never blocks the others.
|
|
134
|
+
*
|
|
135
|
+
* @param {{gh: Function}} io
|
|
136
|
+
* @param {string|null} repo - "owner/repo", or null to use gh's cwd-resolved default
|
|
137
|
+
* @param {number} now
|
|
138
|
+
* @returns {Promise<{total: number, inFlight: number, stalled: number}>}
|
|
139
|
+
*/
|
|
140
|
+
export async function countEngineActivity(io, repo, now) {
|
|
141
|
+
const repoFlag = repo ? ["--repo", repo] : [];
|
|
142
|
+
const issueSet = new Set();
|
|
143
|
+
|
|
144
|
+
for (const label of ACTIVE_WORKFLOW_LABELS) {
|
|
145
|
+
try {
|
|
146
|
+
const out = await io.gh([
|
|
147
|
+
"issue", "list",
|
|
148
|
+
...repoFlag,
|
|
149
|
+
"--state", "open",
|
|
150
|
+
"--label", label,
|
|
151
|
+
"--limit", "100",
|
|
152
|
+
"--json", "number",
|
|
153
|
+
]);
|
|
154
|
+
const items = JSON.parse(out);
|
|
155
|
+
for (const { number } of items) issueSet.add(number);
|
|
156
|
+
} catch {
|
|
157
|
+
// gh may return non-zero when no issues match, or fail transiently —
|
|
158
|
+
// treat as empty for this label rather than aborting the whole count.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (issueSet.size === 0) return { total: 0, inFlight: 0, stalled: 0 };
|
|
163
|
+
|
|
164
|
+
const projector = makeProjector(io);
|
|
165
|
+
const candidates = [...issueSet];
|
|
166
|
+
const stalled = await scanStalls(candidates, projector, now);
|
|
167
|
+
return { total: candidates.length, inFlight: candidates.length - stalled.length, stalled: stalled.length };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Best-effort summary of the most recently modified local run-log under
|
|
172
|
+
* `~/.forge/runs` (runDir()). This is a MACHINE-LOCAL view — the crash-safe
|
|
173
|
+
* hot-path log described in engine/runlog.mjs, not the GitHub-authoritative
|
|
174
|
+
* state used by countEngineActivity() above. Appropriate only for a "what did
|
|
175
|
+
* I last run here" dashboard row, not for cross-machine in-flight/stalled
|
|
176
|
+
* counts.
|
|
177
|
+
*
|
|
178
|
+
* Never throws: returns null when the runs dir doesn't exist, holds no
|
|
179
|
+
* `.jsonl` files, or the newest file can't be read/parsed. A single corrupt
|
|
180
|
+
* or unreadable file is skipped in favor of the next-newest one rather than
|
|
181
|
+
* failing the whole lookup (forge#1528 — batch operations here must not abort
|
|
182
|
+
* on one bad item).
|
|
183
|
+
*
|
|
184
|
+
* @param {string} dir - runDir()
|
|
185
|
+
* @returns {{issue: number, terminal: boolean, terminalReason: string|null} | null}
|
|
186
|
+
*/
|
|
187
|
+
export function lastLocalRun(dir) {
|
|
188
|
+
let entries;
|
|
189
|
+
try {
|
|
190
|
+
entries = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Sort newest-first by mtime so a corrupt newest file falls through to the
|
|
196
|
+
// next-newest candidate instead of giving up entirely.
|
|
197
|
+
const withMtime = [];
|
|
198
|
+
for (const f of entries) {
|
|
199
|
+
try {
|
|
200
|
+
withMtime.push({ f, mtime: statSync(join(dir, f)).mtimeMs });
|
|
201
|
+
} catch {
|
|
202
|
+
// unreadable — skip
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
withMtime.sort((a, b) => b.mtime - a.mtime);
|
|
206
|
+
|
|
207
|
+
for (const { f } of withMtime) {
|
|
208
|
+
const issue = parseInt(f.slice(0, -".jsonl".length), 10);
|
|
209
|
+
if (!Number.isInteger(issue)) continue;
|
|
210
|
+
try {
|
|
211
|
+
const events = readLog(dir, issue);
|
|
212
|
+
if (events.length === 0) continue;
|
|
213
|
+
const state = deriveState(events);
|
|
214
|
+
return { issue, terminal: state.terminal, terminalReason: state.terminalReason };
|
|
215
|
+
} catch {
|
|
216
|
+
continue; // corrupt file — try the next-newest
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
91
222
|
/**
|
|
92
223
|
* @param {string[]} argv
|
|
93
224
|
* @param {{io?: {gh: Function}, runIssue?: Function}} [deps]
|
|
@@ -147,18 +278,13 @@ export async function resumeStalledFromCli(argv, deps = {}) {
|
|
|
147
278
|
const projector = makeProjector(io);
|
|
148
279
|
const now = Date.now();
|
|
149
280
|
|
|
150
|
-
// Collect candidate issue numbers from all non-terminal workflow labels
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"workflow:ready-to-build",
|
|
154
|
-
"workflow:building",
|
|
155
|
-
"workflow:in-review",
|
|
156
|
-
];
|
|
157
|
-
|
|
281
|
+
// Collect candidate issue numbers from all non-terminal workflow labels
|
|
282
|
+
// (ACTIVE_WORKFLOW_LABELS — shared with countEngineActivity() so both
|
|
283
|
+
// surfaces agree on what "in-flight" means).
|
|
158
284
|
const repoFlag = repo ? ["--repo", repo] : [];
|
|
159
285
|
const issueSet = new Set();
|
|
160
286
|
|
|
161
|
-
for (const label of
|
|
287
|
+
for (const label of ACTIVE_WORKFLOW_LABELS) {
|
|
162
288
|
try {
|
|
163
289
|
const out = await io.gh([
|
|
164
290
|
"issue", "list",
|