forgedock 1.12.1 → 1.13.1

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 (64) hide show
  1. package/bin/engine/phases.mjs +54 -12
  2. package/bin/engine/runlog.mjs +12 -0
  3. package/bin/engine-cli.mjs +3 -1
  4. package/bin/engine.mjs +64 -16
  5. package/bin/hooks/interactive-engine.mjs +135 -212
  6. package/bin/hooks/pre-tool-use.mjs +16 -4
  7. package/bin/labels.json +5 -0
  8. package/bin/opencode-adapter.mjs +1 -1
  9. package/bin/runner.mjs +234 -21
  10. package/commands/autopilot.md +6 -6
  11. package/commands/cleanup.md +7 -4
  12. package/commands/orchestrate/phase-1-resolve.md +1 -1
  13. package/commands/orchestrate/phase-3-dependency.md +14 -19
  14. package/commands/orchestrate/phase-4-execution.md +50 -38
  15. package/commands/orchestrate/phase-6-report.md +6 -0
  16. package/commands/orchestrate/safety.md +1 -1
  17. package/commands/pipeline-health/phase-2-metrics.md +39 -0
  18. package/commands/pipeline-health/phase-5-report.md +1 -0
  19. package/commands/pipeline-resume.md +1 -1
  20. package/commands/pipeline-status.md +2 -2
  21. package/commands/review-pr-agents/api.md +1 -1
  22. package/commands/review-pr-agents/auth.md +1 -1
  23. package/commands/review-pr-agents/infra.md +9 -3
  24. package/commands/review-pr-agents/protocols.md +19 -5
  25. package/commands/review-pr-agents/scraper.md +4 -1
  26. package/commands/review-pr-agents/security.md +17 -5
  27. package/commands/review-pr-agents.md +1 -1
  28. package/commands/review-pr-staging.md +302 -8
  29. package/commands/review-pr.md +254 -34
  30. package/commands/work-on/build/architect.md +36 -0
  31. package/commands/work-on/build/validate.md +88 -4
  32. package/commands/work-on/build.md +95 -3
  33. package/commands/work-on/remediate.md +115 -13
  34. package/commands/work-on.md +4 -4
  35. package/docs/CONFIG.md +29 -0
  36. package/docs/RELEASE-NOTES.md +45 -0
  37. package/docs/spec/forge-protocol-v1.md +22 -1
  38. package/docs/spec/review-protocol.md +24 -4
  39. package/package.json +2 -1
  40. package/scripts/check-blast-radius.sh +164 -0
  41. package/scripts/check-blast-radius.test.sh +183 -0
  42. package/scripts/check-contract-scope.sh +137 -0
  43. package/scripts/check-contract-scope.test.sh +83 -0
  44. package/scripts/check-spec-bash.sh +60 -3
  45. package/scripts/check-spec-bash.test.sh +56 -0
  46. package/scripts/check-spec-markers.sh +29 -0
  47. package/scripts/check-test-wiring.sh +57 -0
  48. package/scripts/check-test-wiring.test.sh +70 -0
  49. package/scripts/classify-finding.sh +72 -8
  50. package/scripts/classify-finding.test.sh +77 -8
  51. package/scripts/close-dossier-no-main-commit.test.sh +12 -12
  52. package/scripts/diff-size.test.sh +3 -2
  53. package/scripts/doctor-pipeline-state.sh +35 -0
  54. package/scripts/forge-annotation.sh +3 -0
  55. package/scripts/forge-root.test.sh +70 -1
  56. package/scripts/forge-run.sh +2 -2
  57. package/scripts/recovery-claim-live.test.sh +10 -7
  58. package/scripts/remediate-basesync.test.sh +103 -0
  59. package/scripts/review-delta.sh +206 -0
  60. package/scripts/review-delta.test.sh +213 -0
  61. package/scripts/review-pr-gate2.test.sh +97 -0
  62. package/scripts/review-pr-staging-scope.test.sh +99 -0
  63. package/scripts/run-shell-tests.sh +115 -0
  64. package/scripts/select-fix-targets.sh +17 -5
@@ -21,8 +21,7 @@
21
21
  *
22
22
  * The hook:
23
23
  * 1. Reads the transcript to identify which /work-on sub-phase just ran
24
- * (by scanning the last Skill invocation and the FORGE annotations
25
- * written to GitHub).
24
+ * (the last Skill invocation) and the issue number.
26
25
  * 2. Determines the issue number from the FORGE:STATE block on the issue
27
26
  * body (GitHub is the authoritative store).
28
27
  * 3. Appends the appropriate PHASE_COMMIT event to the local run-log.
@@ -31,17 +30,23 @@
31
30
  * If no /work-on phase is detected (the subagent was something else), the
32
31
  * hook exits 0 silently — fail-open.
33
32
  *
34
- * === Phase detection ===
33
+ * === Phase detection (forge#3570) ===
35
34
  *
36
- * The hook looks for FORGE annotation markers in the transcript's tool
37
- * results (gh issue comment / gh api calls):
35
+ * The transcript is used ONLY to identify which /work-on skill ran and for
36
+ * which issue (the `Skill` tool_use input). It is never scanned for FORGE
37
+ * markers: transcript text carries no comment authorship and can quote
38
+ * untrusted commenters or spec files. Whether the phase committed (and whether
39
+ * the run is terminal) is confirmed from GitHub through the engine's trusted,
40
+ * anchored detector (`detectTrustedOutcome` in bin/engine/phases.mjs):
38
41
  *
39
- * INVESTIGATION:COMPLETE → phase "investigate" committed
40
- * FORGE:CONTEXT:COMPLETE → phase "context" committed
41
- * FORGE:ARCHITECT:COMPLETE → phase "architect" committed
42
- * FORGE:BUILDER:COMPLETE → phase "build" committed
43
- * FORGE:REVIEWER → phase "review" committed
44
- * workflow:merged label → phase "close" committed (terminal)
42
+ * investigate → trusted FORGE:INVESTIGATOR report (COMPLETE / INVALID / decomposed)
43
+ * context → trusted, anchored FORGE:CONTEXT:COMPLETE
44
+ * architect → trusted, anchored FORGE:ARCHITECT:COMPLETE
45
+ * build → trusted, anchored FORGE:BUILDER:COMPLETE (+ commits ahead)
46
+ * review/close → their engine detectOutcome (PR state / workflow:merged label)
47
+ *
48
+ * Events are appended only on `status: "committed"`; any GitHub error fails
49
+ * open with no events and no enforcement.
45
50
  *
46
51
  * === Fail-open contract ===
47
52
  *
@@ -71,66 +76,8 @@ const __dirname = dirname(__filename);
71
76
  /** Absolute path to the ForgeDock installation root (parent of bin/). */
72
77
  const FORGE_HOME = resolve(__dirname, "..", "..");
73
78
 
74
- // ---------------------------------------------------------------------------
75
- // Phase marker table
76
- // Maps FORGE annotation markers (found in transcript tool results) to phase IDs.
77
- // ---------------------------------------------------------------------------
78
-
79
- // forge#2378: marker strings are single-sourced from packages/protocol's phase
80
- // registry (bin/engine/phases.mjs imports the identical registry) — do NOT
81
- // reintroduce inline "FORGE:..."/"INVESTIGATION:..."/"workflow:merged" literals
82
- // here. This table previously hand-duplicated those strings and drifted out of
83
- // sync with the engine's stricter :COMPLETE gate (forge#2375/PR#2395) — sourcing
84
- // both consumers from one registry makes that class of drift structurally
85
- // impossible going forward.
86
- //
87
- // Require the :COMPLETE suffix — a bare "FORGE:CONTEXT"/"FORGE:ARCHITECT"
88
- // substring also matches a partial/interrupted annotation (e.g.
89
- // FORGE:CONTEXT:PARTIAL), which is not actually committed. bin/engine/phases.mjs's
90
- // architect detectOutcome() requires the identical :COMPLETE marker to consider
91
- // the phase committed.
92
- /** @type {Array<{marker: string, phase: string, terminal?: boolean, terminalReason?: string}>} */
93
- const PHASE_MARKERS = [
94
- { marker: PHASE_MARKER_REGISTRY.investigate.completionMarker, phase: "investigate" },
95
- { marker: PHASE_MARKER_REGISTRY.investigate.invalidMarker, phase: "investigate", terminal: true, terminalReason: "invalid" },
96
- { marker: PHASE_MARKER_REGISTRY.investigate.decomposedMarker, phase: "investigate", terminal: true, terminalReason: "decomposed" },
97
- { marker: PHASE_MARKER_REGISTRY.context.completionMarker, phase: "context" },
98
- { marker: PHASE_MARKER_REGISTRY.architect.completionMarker, phase: "architect" },
99
- { marker: PHASE_MARKER_REGISTRY.build.completionMarker, phase: "build" },
100
- // review phase: PR merged is detected from gh label/state
101
- { marker: PHASE_MARKER_REGISTRY.review.completionMarker, phase: "review" },
102
- // close phase: issue closed with workflow:merged
103
- { marker: PHASE_MARKER_REGISTRY.close.completionLabel, phase: "close", terminal: true, terminalReason: "merged" },
104
- ];
105
-
106
- /**
107
- * Match a single string of text against the PHASE_MARKERS table and return
108
- * the phase (and terminal reason) implied by the first marker found, in
109
- * table order — a terminal marker stops the scan. This is the same
110
- * marker-matching rule `detectPhase()` applies per-block below, factored
111
- * out as a standalone export so it can be tested directly against a bare
112
- * string without needing a full transcript-entries array. Purely additive:
113
- * `detectPhase()`'s existing multi-entry accumulation algorithm is
114
- * untouched and does not call this function. See #1592.
115
- *
116
- * @param {string} text
117
- * @returns {{ phaseId: string|null, terminalReason: string|null }}
118
- */
119
- // forge#3542: this scans a session TRANSCRIPT (tool results / assistant text), which carries no
120
- // comment authorship, so it is outside the headless engine's trusted + anchored marker model
121
- // (bin/engine/phases.mjs). Advisory phase detection only.
122
- export function detectPhaseFromText(text) {
123
- const str = typeof text === "string" ? text : "";
124
- let phaseId = null;
125
- let terminalReason = null;
126
- for (const { marker, phase, terminal, terminalReason: tr } of PHASE_MARKERS) {
127
- if (str.includes(marker)) {
128
- phaseId = phase;
129
- if (terminal) { terminalReason = tr; break; }
130
- }
131
- }
132
- return { phaseId, terminalReason };
133
- }
79
+ /** Phases whose only failure mode is a missing marker — safe to enforce. */
80
+ const ENFORCED_PHASES = ["investigate", "context", "architect", "build"];
134
81
 
135
82
  // ---------------------------------------------------------------------------
136
83
  // Main — fail-open wrapper
@@ -174,41 +121,7 @@ async function main() {
174
121
  const transcript = parseTranscript(transcriptPath);
175
122
  if (!transcript) return;
176
123
 
177
- const { issueNumber, phaseId, terminalReason, outputs, skillInvoked, annotationMissing } = detectPhase(transcript);
178
-
179
- // --- Annotation enforcement (#1250) ---
180
- // If a /work-on skill was invoked but the expected FORGE annotation is
181
- // missing, block the subagent from completing silently and inject
182
- // corrective context so the agent knows what to do.
183
- if (skillInvoked && annotationMissing && phaseId) {
184
- // forge#2378: sourced from the same registry as PHASE_MARKERS above — no
185
- // second hand-written copy of these marker strings.
186
- const PHASE_ANNOTATION_MAP = {
187
- investigate: `${PHASE_MARKER_REGISTRY.investigate.completionMarker} (or ${PHASE_MARKER_REGISTRY.investigate.invalidMarker} / ${PHASE_MARKER_REGISTRY.investigate.decomposedMarker})`,
188
- context: PHASE_MARKER_REGISTRY.context.completionMarker,
189
- architect: PHASE_MARKER_REGISTRY.architect.completionMarker,
190
- build: PHASE_MARKER_REGISTRY.build.completionMarker,
191
- review: PHASE_MARKER_REGISTRY.review.completionMarker,
192
- close: `${PHASE_MARKER_REGISTRY.close.completionLabel} label`,
193
- };
194
- const expected = PHASE_ANNOTATION_MAP[phaseId] || `the ${phaseId} phase annotation`;
195
- // Output additionalContext JSON (v2.1.163+ SubagentStop format).
196
- // Claude Code reads this and injects it as context for the agent.
197
- const feedback = {
198
- decision: "block",
199
- reason: `[ForgeDock] Phase "${phaseId}" completed without posting its FORGE annotation.`,
200
- additionalContext: [
201
- `The ${phaseId} phase must post annotation: ${expected}`,
202
- `Post this annotation now via gh issue comment, then re-complete this phase.`,
203
- `This is a pipeline enforcement check — annotation-free completions are not tracked`,
204
- `and cannot be resumed across compaction events.`,
205
- ].join("\n"),
206
- };
207
- process.stdout.write(JSON.stringify(feedback) + "\n");
208
- process.exit(2);
209
- return;
210
- }
211
-
124
+ const { issueNumber, phaseId } = detectPhase(transcript);
212
125
  if (!issueNumber || !phaseId) return; // not a /work-on sub-phase
213
126
 
214
127
  // Resolve the run-log directory.
@@ -216,9 +129,9 @@ async function main() {
216
129
  if (!runLogDir) return;
217
130
 
218
131
  // Import engine modules dynamically (fail-open if missing).
219
- let appendEvent, deriveState, readLog, rewriteLog, makeProjector, reconcileState, freshState;
132
+ let appendEvent, deriveState, readLog, makeProjector, reconcileState, detectTrustedOutcome;
220
133
  try {
221
- ({ appendEvent, deriveState, readLog, rewriteLog } = await import(
134
+ ({ appendEvent, deriveState, readLog } = await import(
222
135
  pathToFileURL(join(FORGE_HOME, "bin", "engine", "runlog.mjs")).href
223
136
  ));
224
137
  ({ makeProjector } = await import(
@@ -227,6 +140,9 @@ async function main() {
227
140
  ({ reconcileState } = await import(
228
141
  pathToFileURL(join(FORGE_HOME, "bin", "engine", "reconcile.mjs")).href
229
142
  ));
143
+ ({ detectTrustedOutcome } = await import(
144
+ pathToFileURL(join(FORGE_HOME, "bin", "engine", "phases.mjs")).href
145
+ ));
230
146
  } catch (importErr) {
231
147
  process.stderr.write(`[ForgeDock:interactive-engine] engine modules unavailable: ${importErr.message}\n`);
232
148
  return;
@@ -235,47 +151,105 @@ async function main() {
235
151
  // Build a minimal io adapter using the gh CLI.
236
152
  const io = makeCliIo();
237
153
 
238
- // Load or reconcile state.
154
+ // Load or reconcile state. Everything up to the trusted outcome check is
155
+ // read-only: no event is written unless GitHub confirms the phase committed.
239
156
  const projector = makeProjector(io);
240
157
  const local = readLog(runLogDir, issueNumber).length
241
158
  ? deriveState(readLog(runLogDir, issueNumber))
242
159
  : null;
243
- const remote = await projector.readState(issueNumber);
244
- let { state } = reconcileState(local, remote);
245
-
246
- if (!state) {
247
- // Fresh run — bootstrap.
248
- const lane = detectLane(transcript) || "staging";
249
- state = {
250
- v: 0,
251
- run: `r_${issueNumber}_${lane}_interactive`,
252
- issue: issueNumber,
253
- lane,
254
- committed: [],
255
- phase: null,
256
- branch: null,
257
- pr: null,
258
- terminal: false,
259
- terminalReason: null,
260
- lease: null,
261
- };
160
+ let state, bootstrapped = false;
161
+ let outcome;
162
+ try {
163
+ const remote = await projector.readState(issueNumber);
164
+ ({ state } = reconcileState(local, remote));
165
+
166
+ if (!state) {
167
+ // Fresh run — bootstrap in memory only.
168
+ // forge#3594: the lane feeds git refs (origin/<lane>..branch), so it must be a real
169
+ // base branch. Never derive it from transcript text (pseudo-lane "feature" is not a ref).
170
+ const lane = (await refExists("staging", io)) ? "staging" : null;
171
+ if (!lane) return; // no verifiable base: fail open (no events, no enforcement)
172
+ state = {
173
+ v: 0,
174
+ run: `r_${issueNumber}_${lane}_interactive`,
175
+ issue: issueNumber,
176
+ lane,
177
+ committed: [],
178
+ phase: null,
179
+ branch: null,
180
+ pr: null,
181
+ terminal: false,
182
+ terminalReason: null,
183
+ lease: null,
184
+ };
185
+ bootstrapped = true;
186
+ }
187
+
188
+ // Skip if phase already committed (idempotent) — before the GitHub call.
189
+ if (state.committed.includes(phaseId)) return;
190
+
191
+ // forge#3570: ground truth from trusted, anchored GitHub comments — never transcript text.
192
+ outcome = await detectTrustedOutcome(phaseId, state, io);
193
+ } catch (checkErr) {
194
+ // Fail open: no events, no enforcement.
195
+ process.stderr.write(`[ForgeDock:interactive-engine] trusted outcome check failed: ${checkErr.message}\n`);
196
+ return;
197
+ }
198
+
199
+ if (!outcome || outcome.status !== "committed") {
200
+ // --- Annotation enforcement (#1250) ---
201
+ // Skill ran but no trusted, anchored annotation exists on GitHub: block the
202
+ // subagent and inject corrective context. Only for phases whose failure is
203
+ // exactly "missing marker".
204
+ // forge#3594: never re-block a stop that is already a hook continuation, and only
205
+ // block when the failure is exactly a missing marker (not a git/lane/GitHub error).
206
+ if (ENFORCED_PHASES.includes(phaseId) && !payload.stop_hook_active && isMissingMarkerOutcome(outcome)) {
207
+ const PHASE_ANNOTATION_MAP = {
208
+ investigate: `${PHASE_MARKER_REGISTRY.investigate.completionMarker} (or ${PHASE_MARKER_REGISTRY.investigate.invalidMarker} / ${PHASE_MARKER_REGISTRY.investigate.decomposedMarker})`,
209
+ context: PHASE_MARKER_REGISTRY.context.completionMarker,
210
+ architect: PHASE_MARKER_REGISTRY.architect.completionMarker,
211
+ build: PHASE_MARKER_REGISTRY.build.completionMarker,
212
+ };
213
+ const expected = PHASE_ANNOTATION_MAP[phaseId] || `the ${phaseId} phase annotation`;
214
+ // Output additionalContext JSON (v2.1.163+ SubagentStop format).
215
+ const feedback = {
216
+ decision: "block",
217
+ reason: `[ForgeDock] Phase "${phaseId}" completed without posting its FORGE annotation.`,
218
+ additionalContext: [
219
+ `The ${phaseId} phase must post annotation: ${expected}`,
220
+ `Post this annotation now via gh issue comment, then re-complete this phase.`,
221
+ `This is a pipeline enforcement check — annotation-free completions are not tracked`,
222
+ `and cannot be resumed across compaction events.`,
223
+ ].join("\n"),
224
+ };
225
+ process.stdout.write(JSON.stringify(feedback) + "\n");
226
+ process.exit(2);
227
+ }
228
+ return;
229
+ }
230
+
231
+ const terminalReason = outcome.terminalReason || null;
232
+
233
+ // forge#3595: the idempotency check above ran before the GitHub round-trip; re-read the
234
+ // run log immediately before appending so a concurrent hook for the same phase is not duplicated.
235
+ const freshLog = readLog(runLogDir, issueNumber);
236
+ if (freshLog.length && deriveState(freshLog).committed.includes(phaseId)) return;
237
+
238
+ if (bootstrapped) {
262
239
  appendEvent(runLogDir, issueNumber, {
263
240
  event: "RUN_START",
264
241
  issue: issueNumber,
265
242
  run: state.run,
266
- lane,
243
+ lane: state.lane,
267
244
  source: "interactive",
268
245
  });
269
246
  }
270
247
 
271
- // Skip if phase already committed (idempotent).
272
- if (state.committed.includes(phaseId)) return;
273
-
274
- // Append the PHASE_COMMIT event.
248
+ // Append the PHASE_COMMIT event (outputs come from the GitHub-derived outcome).
275
249
  appendEvent(runLogDir, issueNumber, {
276
250
  event: "PHASE_COMMIT",
277
251
  phase: phaseId,
278
- outputs: outputs || {},
252
+ outputs: outcome.outputs || {},
279
253
  source: "interactive",
280
254
  });
281
255
  state = deriveState(readLog(runLogDir, issueNumber));
@@ -320,53 +294,47 @@ export function parseTranscript(transcriptPath) {
320
294
  }
321
295
 
322
296
  /**
323
- * Detect which /work-on phase completed and the issue number from transcript entries.
297
+ * True only when an outcome failed because the phase's FORGE marker is missing
298
+ * (investigate/context/architect "no <marker> marker", build "builder complete=false").
299
+ * Anything else (unresolvable lane/branch, git or GitHub error) must not block (forge#3594).
300
+ */
301
+ export function isMissingMarkerOutcome(outcome) {
302
+ if (!outcome || outcome.status !== "failed") return false;
303
+ const d = String(outcome.detail || "");
304
+ return /^no .+ marker$/.test(d) || /^builder complete=false\b/.test(d);
305
+ }
306
+
307
+ async function refExists(lane, io) {
308
+ try { await io.git(["rev-parse", "--verify", "--quiet", `origin/${lane}`]); return true; } catch { return false; }
309
+ }
310
+
311
+ /**
312
+ * Identify the /work-on phase that ran and the issue number from the `Skill`
313
+ * tool_use input ONLY (forge#3570).
324
314
  *
325
- * Strategy:
326
- * - Scan tool_use entries for Skill invocations to find the skill name (→ phase)
327
- * - Scan tool_result entries for gh CLI output containing FORGE annotation markers
328
- * - Extract issue number from Skill args or from the skill name context
315
+ * Transcript text (tool results, assistant prose) is deliberately never scanned
316
+ * for FORGE markers: it carries no comment authorship and routinely quotes
317
+ * untrusted commenters or spec files, so it must not decide committed/terminal
318
+ * state. The caller confirms the outcome from GitHub via `detectTrustedOutcome`.
329
319
  *
330
- * `outputs` is intentionally always `{}` from this function (forge#2375): earlier
331
- * versions matched a bare `"number"` field and a bare `branch[:space]` token against
332
- * the full text of every tool_result block, with no scoping to a FORGE-authoritative
333
- * source. That regularly matched unrelated JSON (e.g. `gh issue view`'s own `"number"`
334
- * field, matching the issue's own number as if it were a PR) or unrelated prose
335
- * containing the word "branch", producing corrupt PHASE_COMMIT run-log entries
336
- * (observed: `branch:"diff"`, `branch:"writer"`, `branch:"refs"`, `pr:<issue#>`).
337
- * Those values were also consumed as a state fallback by
338
- * bin/engine/runlog.mjs:deriveState() → bin/engine/phases.mjs:resolveBranch(), so a
339
- * corrupt hook-derived value could leak into the engine's own resolution path. The
340
- * engine already resolves branch/PR from GitHub ground truth
341
- * (bin/engine/phases.mjs:parseBranchFromMarkers(), scoped to the FORGE:BUILDER:COMPLETE
342
- * comment — forge#2184) on next pickup, so no replacement extraction is needed here.
320
+ * `outputs` stays `{}` (forge#2375): branch/PR are never derived from transcript text.
343
321
  *
344
322
  * @param {object[]} entries
345
- * @returns {{ issueNumber: number|null, phaseId: string|null, terminalReason: string|null, outputs: object, skillInvoked: boolean, annotationMissing: boolean }}
323
+ * @returns {{ issueNumber: number|null, phaseId: string|null, outputs: object, skillInvoked: boolean }}
346
324
  */
347
325
  export function detectPhase(entries) {
348
326
  let skillName = null;
349
327
  let issueNumber = null;
350
- const foundMarkers = new Set();
351
- const outputs = {};
352
328
  let skillInvoked = false;
353
329
 
354
330
  for (const entry of entries) {
355
- // Real Claude Code transcript entries nest role/content under
356
- // `entry.message` (e.g. {"type":"assistant","message":{"role":"assistant",
357
- // "content":[{"type":"tool_use",...}]}}) — the block-level types
358
- // (tool_use/tool_result/text) live inside message.content[], never at
359
- // the entry's own top level. Fall back to a flat/legacy shape
360
- // (entry.role/entry.content directly) if entry.message is absent, so
361
- // any pre-normalized or synthetic transcript still works (issue #1580).
331
+ // Real Claude Code transcript entries nest role/content under `entry.message`;
332
+ // fall back to a flat/legacy shape (issue #1580).
362
333
  const message = entry && typeof entry === "object" && entry.message ? entry.message : entry;
363
- const role = message?.role;
364
334
  const contentBlocks = Array.isArray(message?.content) ? message.content : [];
365
335
 
366
336
  for (const block of contentBlocks) {
367
337
  if (!block || typeof block !== "object") continue;
368
-
369
- // Tool use blocks — find Skill invocations.
370
338
  if (block.type === "tool_use" && block.name === "Skill") {
371
339
  const input = block.input || {};
372
340
  if (input.skill) { skillName = input.skill; skillInvoked = true; }
@@ -376,56 +344,11 @@ export function detectPhase(entries) {
376
344
  if (m) issueNumber = parseInt(m[1], 10);
377
345
  }
378
346
  }
379
-
380
- // Tool result blocks — scan for FORGE markers in gh output.
381
- if (block.type === "tool_result") {
382
- const content = Array.isArray(block.content)
383
- ? block.content.map((c) => (typeof c === "string" ? c : c?.text || "")).join("\n")
384
- : String(block.content || "");
385
- for (const { marker } of PHASE_MARKERS) {
386
- if (content.includes(marker)) foundMarkers.add(marker);
387
- }
388
- // No PR/branch extraction here (forge#2375) — see the JSDoc above
389
- // detectPhase() for why. `outputs` stays empty from this scan.
390
- }
391
-
392
- // Also scan assistant message text blocks for FORGE markers.
393
- if (role === "assistant") {
394
- const text = block.text || block.content || "";
395
- if (typeof text === "string") {
396
- for (const { marker } of PHASE_MARKERS) {
397
- if (text.includes(marker)) foundMarkers.add(marker);
398
- }
399
- }
400
- }
401
- }
402
- }
403
-
404
- // Match markers to phase, most-specific first (terminal markers take priority).
405
- let phaseId = null;
406
- let terminalReason = null;
407
-
408
- for (const { marker, phase, terminal, terminalReason: tr } of PHASE_MARKERS) {
409
- if (foundMarkers.has(marker)) {
410
- phaseId = phase;
411
- if (terminal) terminalReason = tr || null;
412
- // Keep first match unless a more specific (terminal) marker overrides.
413
- if (terminal) break;
414
347
  }
415
348
  }
416
349
 
417
- // Fallback: derive phase from skill name if no markers found.
418
- // annotationMissing is true when a skill was invoked but no FORGE markers
419
- // were found in the transcript — the agent ran but didn't post its annotation.
420
- const markersFound = foundMarkers.size > 0;
421
- const phaseFromSkillFallback = !phaseId && skillName ? phaseFromSkill(skillName) : null;
422
- if (!phaseId && phaseFromSkillFallback) {
423
- phaseId = phaseFromSkillFallback;
424
- }
425
-
426
- const annotationMissing = skillInvoked && !markersFound;
427
-
428
- return { issueNumber, phaseId, terminalReason, outputs, skillInvoked, annotationMissing };
350
+ const phaseId = skillName ? phaseFromSkill(skillName) : null;
351
+ return { issueNumber, phaseId, outputs: {}, skillInvoked };
429
352
  }
430
353
 
431
354
  /**
@@ -182,12 +182,18 @@ const LABEL_TRANSITIONS = {
182
182
  "workflow:investigating": ["workflow:ready-to-build", "workflow:invalid", "workflow:decomposed"],
183
183
  "workflow:ready-to-build": ["workflow:building", "workflow:invalid"],
184
184
  "workflow:building": ["workflow:in-review", "workflow:ready-to-build", "workflow:invalid"], // retry allowed; evidenced reversal allowed (#2326)
185
- "workflow:in-review": ["workflow:merged", "workflow:building", "workflow:invalid"], // review → re-build allowed; evidenced reversal allowed (#2326)
185
+ "workflow:in-review": ["workflow:merged", "workflow:building", "workflow:invalid", "workflow:remediating", "workflow:awaiting-merge"], // review → re-build allowed; evidenced reversal allowed (#2326); remediation handoff (#3541) and merge-queue handoff (#1810)
186
+ "workflow:remediating": ["workflow:in-review", "workflow:awaiting-merge", "workflow:merged", "workflow:invalid"], // #3541/#3565: remediation → re-review/merge; evidenced reversal allowed
187
+ "workflow:awaiting-merge": ["workflow:merged", "workflow:in-review", "workflow:remediating", "workflow:invalid"], // #1810/#3565: merge queue; evidenced reversal allowed
186
188
  "workflow:merged": [], // terminal — no successors
187
189
  "workflow:invalid": [], // terminal
188
190
  "workflow:decomposed": [], // terminal
189
191
  };
190
192
 
193
+ // Every state in scripts/transition-label.sh VALID_STATES must be a key above
194
+ // (a missing key makes the guard fail open for that state — #3541/#3565).
195
+ // A drift test in bin/tests/pre-tool-use.test.mjs enforces this.
196
+
191
197
  /**
192
198
  * States from which a transition to `workflow:invalid` requires posted
193
199
  * evidence (a reversal comment), rather than being freely allowed.
@@ -199,6 +205,8 @@ const EVIDENCE_REQUIRED_FOR_INVALID_FROM = new Set([
199
205
  "workflow:ready-to-build",
200
206
  "workflow:building",
201
207
  "workflow:in-review",
208
+ "workflow:remediating",
209
+ "workflow:awaiting-merge",
202
210
  ]);
203
211
 
204
212
  /**
@@ -869,7 +877,10 @@ function checkLabelTransition(command) {
869
877
  // Validate the transition against the state machine.
870
878
  const allowed = LABEL_TRANSITIONS[currentWorkflowLabel] || null;
871
879
  if (allowed === null) {
872
- return null; // current state not in map — unknown, fail-open
880
+ // Current state not in map — unknown. Other transitions stay fail-open,
881
+ // but workflow:invalid must still clear the evidence gate (#3565) so a
882
+ // state added without a hook update cannot bypass #2326.
883
+ return checkInvalidReversalEvidence(newLabel, currentWorkflowLabel, comments, true);
873
884
  }
874
885
  if (!allowed.includes(newLabel)) {
875
886
  return [
@@ -910,10 +921,11 @@ function checkLabelTransition(command) {
910
921
  * @param {string} newLabel
911
922
  * @param {string|null} currentWorkflowLabel
912
923
  * @param {Array|null} comments
924
+ * @param {boolean} [unknownSource=false] Current state is not in LABEL_TRANSITIONS; gate anyway (#3565).
913
925
  * @returns {string|null} Error message to show, or null if allowed.
914
926
  */
915
- function checkInvalidReversalEvidence(newLabel, currentWorkflowLabel, comments) {
916
- if (newLabel === "workflow:invalid" && EVIDENCE_REQUIRED_FOR_INVALID_FROM.has(currentWorkflowLabel)) {
927
+ function checkInvalidReversalEvidence(newLabel, currentWorkflowLabel, comments, unknownSource = false) {
928
+ if (newLabel === "workflow:invalid" && (unknownSource || EVIDENCE_REQUIRED_FOR_INVALID_FROM.has(currentWorkflowLabel))) {
917
929
  if (!hasInvalidReversalEvidence(comments)) {
918
930
  return [
919
931
  `[ForgeDock] BLOCKED: workflow:invalid requires reversal evidence.`,
package/bin/labels.json CHANGED
@@ -84,6 +84,11 @@
84
84
  "color": "D93F0B",
85
85
  "description": "Defect or improvement found during automated PR review. Managed by ForgeDock."
86
86
  },
87
+ {
88
+ "name": "pre-existing",
89
+ "color": "BFD4F2",
90
+ "description": "Defect noticed during PR review that the PR did not introduce. Managed by ForgeDock."
91
+ },
87
92
  {
88
93
  "name": "review-degraded",
89
94
  "color": "E4E669",
@@ -200,7 +200,7 @@ function openCodeReviewDispatchContract() {
200
200
  "Before applying the workflow's Claude-specific Task/Agent availability check, treat an OpenCode runtime marker (`FORGE_RUNTIME=opencode`, `OPENCODE_SESSION_ID`, `OPENCODE_PID`, or `OPENCODE`) as native capability context and set `DISPATCH_TOOL=task`.",
201
201
  "Do not enter the `Neither tool is available` branch solely because Claude's literal `Task` and `Agent` names are absent. Every native task call must use `{ description: \"...\", prompt: \"...\", subagent_type: \"general\"|\"explore\", background }`; use `general` for implementation/review and `explore` for read-only discovery.",
202
202
  "Native tasks are foreground by default. Review, quality, remediation, and any other load-bearing child must await its completed task result and propagate its structured `*_RESULT` block before the parent continues. A progress message is not a result. `background: true` is reserved for the Phase 4 OpenCode ready-issue dispatcher, which persists the returned child-session id before handling its completion event.",
203
- "For a `work-on/remediate` task, preserve the remediation state transition: after FIXABLE classification, replace `needs-human` with `workflow:in-review`; restore `needs-human` only for a fresh block or an UNFIXABLE policy escalation. Never retain both labels during automated remediation or re-review.",
203
+ "For a `work-on/remediate` task, preserve the remediation state transition: after FIXABLE classification, replace `needs-human` or `workflow:remediating` with `workflow:in-review`; restore `needs-human` only for a fresh block or an UNFIXABLE policy escalation. Never retain either label alongside `workflow:in-review` during automated remediation or re-review.",
204
204
  "If lowercase native `task` is genuinely absent from the current tool registry, post `FORGE:REVIEW_BLOCKED` and stop; never replace the required isolated review with inline work or another controller.",
205
205
  ].join("\n");
206
206
  }