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.
Files changed (97) hide show
  1. package/README.md +10 -33
  2. package/bin/cinema.mjs +23 -5
  3. package/bin/engine/invariants.mjs +361 -0
  4. package/bin/engine-cli.mjs +137 -11
  5. package/bin/env-detect.mjs +249 -0
  6. package/bin/forge-utils.mjs +364 -4
  7. package/bin/forgedock.mjs +1064 -135
  8. package/bin/hooks/pre-tool-use.mjs +182 -0
  9. package/bin/hooks/session-start.mjs +68 -3
  10. package/bin/init-detect.mjs +101 -12
  11. package/bin/journey.mjs +917 -16
  12. package/bin/labels.json +10 -0
  13. package/bin/recall.mjs +557 -0
  14. package/bin/registry.mjs +91 -3
  15. package/bin/runner.mjs +38 -2
  16. package/bin/tui.mjs +49 -3
  17. package/commands/adopt.md +1 -1
  18. package/commands/analytics.md +8 -8
  19. package/commands/audit.md +1 -1
  20. package/commands/autopilot.md +958 -77
  21. package/commands/ci-audit.md +1 -1
  22. package/commands/cleanup.md +139 -55
  23. package/commands/compat-audit.md +1 -1
  24. package/commands/deploy-info.md +1 -1
  25. package/commands/deploy-pr.md +1 -1
  26. package/commands/diagnose.md +1 -1
  27. package/commands/explain.md +1 -1
  28. package/commands/fix-ci.md +1 -1
  29. package/commands/forgedock-init.md +1 -1
  30. package/commands/incident-response.md +3 -3
  31. package/commands/issue.md +1 -1
  32. package/commands/optimize.md +586 -1
  33. package/commands/orchestrate/config.md +13 -3
  34. package/commands/orchestrate/phase-1-resolve.md +36 -26
  35. package/commands/orchestrate/phase-2-triage.md +1 -1
  36. package/commands/orchestrate/phase-2.5-synthesis.md +1 -1
  37. package/commands/orchestrate/phase-3-dependency.md +587 -48
  38. package/commands/orchestrate/phase-4-execution.md +1031 -57
  39. package/commands/orchestrate/phase-5-cleanup.md +1 -1
  40. package/commands/orchestrate/phase-6-report.md +248 -11
  41. package/commands/orchestrate/safety.md +2 -2
  42. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  43. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  44. package/commands/qa-sweep.md +1 -1
  45. package/commands/quality-gate.md +418 -1
  46. package/commands/recover-orphans.md +5 -5
  47. package/commands/replay.md +1 -1
  48. package/commands/review-pr-agents/api.md +2 -2
  49. package/commands/review-pr-agents/auth.md +2 -2
  50. package/commands/review-pr-agents/billing.md +2 -2
  51. package/commands/review-pr-agents/concurrency.md +2 -2
  52. package/commands/review-pr-agents/database.md +2 -2
  53. package/commands/review-pr-agents/frontend.md +2 -2
  54. package/commands/review-pr-agents/infra.md +2 -2
  55. package/commands/review-pr-agents/protocols.md +1 -1
  56. package/commands/review-pr-agents/scraper.md +2 -2
  57. package/commands/review-pr-agents/security.md +2 -2
  58. package/commands/review-pr-staging.md +41 -3
  59. package/commands/review-pr.md +462 -19
  60. package/commands/rollback.md +1 -1
  61. package/commands/scope.md +1 -1
  62. package/commands/security-audit.md +1 -1
  63. package/commands/spec-doctor.md +429 -0
  64. package/commands/test-gate.md +1 -1
  65. package/commands/validate.md +53 -1
  66. package/commands/work-on/build/architect.md +78 -1
  67. package/commands/work-on/build/context.md +360 -22
  68. package/commands/work-on/build/implement.md +3 -1
  69. package/commands/work-on/build/validate.md +29 -1
  70. package/commands/work-on/build.md +75 -6
  71. package/commands/work-on/close.md +624 -10
  72. package/commands/work-on/decompose.md +1 -1
  73. package/commands/work-on/investigate.md +235 -5
  74. package/commands/work-on/remediate.md +331 -0
  75. package/commands/work-on/review.md +1 -1
  76. package/commands/work-on-monolithic.md +1 -1
  77. package/commands/work-on.md +205 -66
  78. package/docs/CI.md +2 -2
  79. package/docs/CODEX.md +1 -1
  80. package/docs/CONFIG.md +186 -0
  81. package/docs/FORGE-PROTOCOL.md +101 -0
  82. package/docs/site/command-reference.md +31 -1
  83. package/docs/site/getting-started.md +9 -34
  84. package/docs/site/index.md +1 -1
  85. package/docs/spec/forge-protocol-v1.md +97 -5
  86. package/docs/spec/label-state-machine.md +6 -1
  87. package/package.json +3 -2
  88. package/packages/protocol/src/cli.js +374 -84
  89. package/packages/protocol/src/types.js +75 -1
  90. package/scripts/build-knowledge-index.mjs +1761 -0
  91. package/scripts/calibration.mjs +996 -0
  92. package/scripts/check-command-docs-drift.sh +4 -2
  93. package/scripts/danger-zones.mjs +789 -0
  94. package/scripts/doctor-pipeline-state.sh +88 -2
  95. package/scripts/select-fix-targets.sh +234 -0
  96. package/scripts/transition-label.sh +245 -6
  97. 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 # project-scoped install: checks your environment, installs commands for this repo, detects your repo, and hands you a reviewed forge.yaml
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
- **Project-scoped is the default.** `npx forgedock` installs commands scoped to the current project directory. Use `--global` only when you want the commands available across all your projects on this machine.
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
- **Install modes:**
255
+ **Explicit install command:**
257
256
 
258
257
  ```bash
259
- npx forgedock install # project-scoped: <cwd>/.claude/commands/ (default)
260
- npx forgedock install --global # global: ~/.claude/commands/ (opt-in)
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 (auto-detects mode)
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 (auto-detects mode)
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>Migrating from a global install</strong></summary>
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
- **Switch to project-scoped** (recommended for multi-repo setups):
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
- Run `npx forgedock doctor` after switching to confirm everything is healthy.
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
- /** Ember-bordered fix card (wraps tui.box; border color applied when able). */
204
- export function fixCard(lines, mode) {
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 ember = fg([255, 107, 53], mode);
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) => `${ember}${m}\x1b[0m`))
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
+ */
@@ -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
- /** Real gh/git accessors. */
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
- const ACTIVE_LABELS = [
152
- "workflow:investigating",
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 ACTIVE_LABELS) {
287
+ for (const label of ACTIVE_WORKFLOW_LABELS) {
162
288
  try {
163
289
  const out = await io.gh([
164
290
  "issue", "list",