forgedock 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/bin/engine/phases-args.test.mjs +2 -1
  2. package/bin/engine/phases.mjs +349 -90
  3. package/bin/engine/runlog.mjs +25 -2
  4. package/bin/engine-cli.mjs +4 -1
  5. package/bin/engine.mjs +72 -19
  6. package/bin/hooks/interactive-engine.mjs +135 -209
  7. package/bin/hooks/pre-tool-use.mjs +16 -4
  8. package/bin/labels.json +10 -0
  9. package/bin/opencode-adapter.mjs +1 -1
  10. package/bin/orchestrate-preflight.mjs +1 -1
  11. package/bin/runner.mjs +258 -23
  12. package/bin/watch-utils.mjs +1 -0
  13. package/commands/autopilot.md +6 -6
  14. package/commands/cleanup.md +8 -5
  15. package/commands/orchestrate/phase-1-resolve.md +1 -1
  16. package/commands/orchestrate/phase-3-dependency.md +14 -19
  17. package/commands/orchestrate/phase-4-execution.md +80 -53
  18. package/commands/orchestrate/phase-6-report.md +6 -0
  19. package/commands/orchestrate/safety.md +1 -1
  20. package/commands/pipeline-health/phase-2-metrics.md +39 -0
  21. package/commands/pipeline-health/phase-5-report.md +1 -0
  22. package/commands/pipeline-resume.md +1 -1
  23. package/commands/pipeline-status.md +2 -2
  24. package/commands/recover-orphans.md +5 -5
  25. package/commands/review-pr-agents/api.md +1 -1
  26. package/commands/review-pr-agents/auth.md +1 -1
  27. package/commands/review-pr-agents/infra.md +3 -3
  28. package/commands/review-pr-agents/protocols.md +15 -6
  29. package/commands/review-pr-agents/scraper.md +1 -1
  30. package/commands/review-pr-agents/security.md +5 -5
  31. package/commands/review-pr-agents.md +12 -12
  32. package/commands/review-pr-staging.md +226 -8
  33. package/commands/review-pr.md +394 -53
  34. package/commands/work-on/build/architect.md +36 -0
  35. package/commands/work-on/build/implement.md +1 -1
  36. package/commands/work-on/build/validate.md +88 -4
  37. package/commands/work-on/build.md +106 -7
  38. package/commands/work-on/decompose.md +2 -2
  39. package/commands/work-on/investigate.md +14 -4
  40. package/commands/work-on/remediate.md +137 -25
  41. package/commands/work-on/review.md +25 -9
  42. package/commands/work-on.md +15 -15
  43. package/docs/CONFIG.md +29 -0
  44. package/docs/RELEASE-NOTES.md +45 -0
  45. package/docs/spec/forge-protocol-v1.md +22 -1
  46. package/docs/spec/label-state-machine.md +6 -1
  47. package/docs/spec/review-protocol.md +20 -5
  48. package/package.json +2 -1
  49. package/packages/protocol/src/phases.js +21 -0
  50. package/scripts/check-blast-radius.sh +164 -0
  51. package/scripts/check-blast-radius.test.sh +183 -0
  52. package/scripts/check-contract-scope.sh +137 -0
  53. package/scripts/check-contract-scope.test.sh +83 -0
  54. package/scripts/check-spec-bash.sh +60 -3
  55. package/scripts/check-spec-bash.test.sh +56 -0
  56. package/scripts/check-spec-markers.sh +29 -0
  57. package/scripts/check-test-wiring.sh +57 -0
  58. package/scripts/check-test-wiring.test.sh +70 -0
  59. package/scripts/classify-finding.sh +72 -8
  60. package/scripts/classify-finding.test.sh +77 -8
  61. package/scripts/close-dossier-no-main-commit.test.sh +12 -12
  62. package/scripts/diff-size.test.sh +3 -2
  63. package/scripts/doctor-pipeline-state.sh +35 -0
  64. package/scripts/forge-annotation.sh +3 -0
  65. package/scripts/forge-root.test.sh +103 -13
  66. package/scripts/forge-run.sh +2 -2
  67. package/scripts/recovery-claim-live.test.sh +10 -7
  68. package/scripts/remediate-basesync.test.sh +103 -0
  69. package/scripts/review-delta.sh +206 -0
  70. package/scripts/review-delta.test.sh +213 -0
  71. package/scripts/review-pr-domain-map.test.sh +87 -0
  72. package/scripts/review-pr-gate2.test.sh +84 -0
  73. package/scripts/review-pr-staging-scope.test.sh +99 -0
  74. package/scripts/run-shell-tests.sh +115 -0
  75. package/scripts/select-fix-targets.sh +17 -5
  76. package/scripts/transition-label.sh +3 -1
@@ -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,63 +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
- export function detectPhaseFromText(text) {
120
- const str = typeof text === "string" ? text : "";
121
- let phaseId = null;
122
- let terminalReason = null;
123
- for (const { marker, phase, terminal, terminalReason: tr } of PHASE_MARKERS) {
124
- if (str.includes(marker)) {
125
- phaseId = phase;
126
- if (terminal) { terminalReason = tr; break; }
127
- }
128
- }
129
- return { phaseId, terminalReason };
130
- }
79
+ /** Phases whose only failure mode is a missing marker — safe to enforce. */
80
+ const ENFORCED_PHASES = ["investigate", "context", "architect", "build"];
131
81
 
132
82
  // ---------------------------------------------------------------------------
133
83
  // Main — fail-open wrapper
@@ -171,41 +121,7 @@ async function main() {
171
121
  const transcript = parseTranscript(transcriptPath);
172
122
  if (!transcript) return;
173
123
 
174
- const { issueNumber, phaseId, terminalReason, outputs, skillInvoked, annotationMissing } = detectPhase(transcript);
175
-
176
- // --- Annotation enforcement (#1250) ---
177
- // If a /work-on skill was invoked but the expected FORGE annotation is
178
- // missing, block the subagent from completing silently and inject
179
- // corrective context so the agent knows what to do.
180
- if (skillInvoked && annotationMissing && phaseId) {
181
- // forge#2378: sourced from the same registry as PHASE_MARKERS above — no
182
- // second hand-written copy of these marker strings.
183
- const PHASE_ANNOTATION_MAP = {
184
- investigate: `${PHASE_MARKER_REGISTRY.investigate.completionMarker} (or ${PHASE_MARKER_REGISTRY.investigate.invalidMarker} / ${PHASE_MARKER_REGISTRY.investigate.decomposedMarker})`,
185
- context: PHASE_MARKER_REGISTRY.context.completionMarker,
186
- architect: PHASE_MARKER_REGISTRY.architect.completionMarker,
187
- build: PHASE_MARKER_REGISTRY.build.completionMarker,
188
- review: PHASE_MARKER_REGISTRY.review.completionMarker,
189
- close: `${PHASE_MARKER_REGISTRY.close.completionLabel} label`,
190
- };
191
- const expected = PHASE_ANNOTATION_MAP[phaseId] || `the ${phaseId} phase annotation`;
192
- // Output additionalContext JSON (v2.1.163+ SubagentStop format).
193
- // Claude Code reads this and injects it as context for the agent.
194
- const feedback = {
195
- decision: "block",
196
- reason: `[ForgeDock] Phase "${phaseId}" completed without posting its FORGE annotation.`,
197
- additionalContext: [
198
- `The ${phaseId} phase must post annotation: ${expected}`,
199
- `Post this annotation now via gh issue comment, then re-complete this phase.`,
200
- `This is a pipeline enforcement check — annotation-free completions are not tracked`,
201
- `and cannot be resumed across compaction events.`,
202
- ].join("\n"),
203
- };
204
- process.stdout.write(JSON.stringify(feedback) + "\n");
205
- process.exit(2);
206
- return;
207
- }
208
-
124
+ const { issueNumber, phaseId } = detectPhase(transcript);
209
125
  if (!issueNumber || !phaseId) return; // not a /work-on sub-phase
210
126
 
211
127
  // Resolve the run-log directory.
@@ -213,9 +129,9 @@ async function main() {
213
129
  if (!runLogDir) return;
214
130
 
215
131
  // Import engine modules dynamically (fail-open if missing).
216
- let appendEvent, deriveState, readLog, rewriteLog, makeProjector, reconcileState, freshState;
132
+ let appendEvent, deriveState, readLog, makeProjector, reconcileState, detectTrustedOutcome;
217
133
  try {
218
- ({ appendEvent, deriveState, readLog, rewriteLog } = await import(
134
+ ({ appendEvent, deriveState, readLog } = await import(
219
135
  pathToFileURL(join(FORGE_HOME, "bin", "engine", "runlog.mjs")).href
220
136
  ));
221
137
  ({ makeProjector } = await import(
@@ -224,6 +140,9 @@ async function main() {
224
140
  ({ reconcileState } = await import(
225
141
  pathToFileURL(join(FORGE_HOME, "bin", "engine", "reconcile.mjs")).href
226
142
  ));
143
+ ({ detectTrustedOutcome } = await import(
144
+ pathToFileURL(join(FORGE_HOME, "bin", "engine", "phases.mjs")).href
145
+ ));
227
146
  } catch (importErr) {
228
147
  process.stderr.write(`[ForgeDock:interactive-engine] engine modules unavailable: ${importErr.message}\n`);
229
148
  return;
@@ -232,47 +151,105 @@ async function main() {
232
151
  // Build a minimal io adapter using the gh CLI.
233
152
  const io = makeCliIo();
234
153
 
235
- // 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.
236
156
  const projector = makeProjector(io);
237
157
  const local = readLog(runLogDir, issueNumber).length
238
158
  ? deriveState(readLog(runLogDir, issueNumber))
239
159
  : null;
240
- const remote = await projector.readState(issueNumber);
241
- let { state } = reconcileState(local, remote);
242
-
243
- if (!state) {
244
- // Fresh run — bootstrap.
245
- const lane = detectLane(transcript) || "staging";
246
- state = {
247
- v: 0,
248
- run: `r_${issueNumber}_${lane}_interactive`,
249
- issue: issueNumber,
250
- lane,
251
- committed: [],
252
- phase: null,
253
- branch: null,
254
- pr: null,
255
- terminal: false,
256
- terminalReason: null,
257
- lease: null,
258
- };
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) {
259
239
  appendEvent(runLogDir, issueNumber, {
260
240
  event: "RUN_START",
261
241
  issue: issueNumber,
262
242
  run: state.run,
263
- lane,
243
+ lane: state.lane,
264
244
  source: "interactive",
265
245
  });
266
246
  }
267
247
 
268
- // Skip if phase already committed (idempotent).
269
- if (state.committed.includes(phaseId)) return;
270
-
271
- // Append the PHASE_COMMIT event.
248
+ // Append the PHASE_COMMIT event (outputs come from the GitHub-derived outcome).
272
249
  appendEvent(runLogDir, issueNumber, {
273
250
  event: "PHASE_COMMIT",
274
251
  phase: phaseId,
275
- outputs: outputs || {},
252
+ outputs: outcome.outputs || {},
276
253
  source: "interactive",
277
254
  });
278
255
  state = deriveState(readLog(runLogDir, issueNumber));
@@ -317,53 +294,47 @@ export function parseTranscript(transcriptPath) {
317
294
  }
318
295
 
319
296
  /**
320
- * 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).
321
314
  *
322
- * Strategy:
323
- * - Scan tool_use entries for Skill invocations to find the skill name (→ phase)
324
- * - Scan tool_result entries for gh CLI output containing FORGE annotation markers
325
- * - 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`.
326
319
  *
327
- * `outputs` is intentionally always `{}` from this function (forge#2375): earlier
328
- * versions matched a bare `"number"` field and a bare `branch[:space]` token against
329
- * the full text of every tool_result block, with no scoping to a FORGE-authoritative
330
- * source. That regularly matched unrelated JSON (e.g. `gh issue view`'s own `"number"`
331
- * field, matching the issue's own number as if it were a PR) or unrelated prose
332
- * containing the word "branch", producing corrupt PHASE_COMMIT run-log entries
333
- * (observed: `branch:"diff"`, `branch:"writer"`, `branch:"refs"`, `pr:<issue#>`).
334
- * Those values were also consumed as a state fallback by
335
- * bin/engine/runlog.mjs:deriveState() → bin/engine/phases.mjs:resolveBranch(), so a
336
- * corrupt hook-derived value could leak into the engine's own resolution path. The
337
- * engine already resolves branch/PR from GitHub ground truth
338
- * (bin/engine/phases.mjs:parseBranchFromMarkers(), scoped to the FORGE:BUILDER:COMPLETE
339
- * 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.
340
321
  *
341
322
  * @param {object[]} entries
342
- * @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 }}
343
324
  */
344
325
  export function detectPhase(entries) {
345
326
  let skillName = null;
346
327
  let issueNumber = null;
347
- const foundMarkers = new Set();
348
- const outputs = {};
349
328
  let skillInvoked = false;
350
329
 
351
330
  for (const entry of entries) {
352
- // Real Claude Code transcript entries nest role/content under
353
- // `entry.message` (e.g. {"type":"assistant","message":{"role":"assistant",
354
- // "content":[{"type":"tool_use",...}]}}) — the block-level types
355
- // (tool_use/tool_result/text) live inside message.content[], never at
356
- // the entry's own top level. Fall back to a flat/legacy shape
357
- // (entry.role/entry.content directly) if entry.message is absent, so
358
- // 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).
359
333
  const message = entry && typeof entry === "object" && entry.message ? entry.message : entry;
360
- const role = message?.role;
361
334
  const contentBlocks = Array.isArray(message?.content) ? message.content : [];
362
335
 
363
336
  for (const block of contentBlocks) {
364
337
  if (!block || typeof block !== "object") continue;
365
-
366
- // Tool use blocks — find Skill invocations.
367
338
  if (block.type === "tool_use" && block.name === "Skill") {
368
339
  const input = block.input || {};
369
340
  if (input.skill) { skillName = input.skill; skillInvoked = true; }
@@ -373,56 +344,11 @@ export function detectPhase(entries) {
373
344
  if (m) issueNumber = parseInt(m[1], 10);
374
345
  }
375
346
  }
376
-
377
- // Tool result blocks — scan for FORGE markers in gh output.
378
- if (block.type === "tool_result") {
379
- const content = Array.isArray(block.content)
380
- ? block.content.map((c) => (typeof c === "string" ? c : c?.text || "")).join("\n")
381
- : String(block.content || "");
382
- for (const { marker } of PHASE_MARKERS) {
383
- if (content.includes(marker)) foundMarkers.add(marker);
384
- }
385
- // No PR/branch extraction here (forge#2375) — see the JSDoc above
386
- // detectPhase() for why. `outputs` stays empty from this scan.
387
- }
388
-
389
- // Also scan assistant message text blocks for FORGE markers.
390
- if (role === "assistant") {
391
- const text = block.text || block.content || "";
392
- if (typeof text === "string") {
393
- for (const { marker } of PHASE_MARKERS) {
394
- if (text.includes(marker)) foundMarkers.add(marker);
395
- }
396
- }
397
- }
398
- }
399
- }
400
-
401
- // Match markers to phase, most-specific first (terminal markers take priority).
402
- let phaseId = null;
403
- let terminalReason = null;
404
-
405
- for (const { marker, phase, terminal, terminalReason: tr } of PHASE_MARKERS) {
406
- if (foundMarkers.has(marker)) {
407
- phaseId = phase;
408
- if (terminal) terminalReason = tr || null;
409
- // Keep first match unless a more specific (terminal) marker overrides.
410
- if (terminal) break;
411
347
  }
412
348
  }
413
349
 
414
- // Fallback: derive phase from skill name if no markers found.
415
- // annotationMissing is true when a skill was invoked but no FORGE markers
416
- // were found in the transcript — the agent ran but didn't post its annotation.
417
- const markersFound = foundMarkers.size > 0;
418
- const phaseFromSkillFallback = !phaseId && skillName ? phaseFromSkill(skillName) : null;
419
- if (!phaseId && phaseFromSkillFallback) {
420
- phaseId = phaseFromSkillFallback;
421
- }
422
-
423
- const annotationMissing = skillInvoked && !markersFound;
424
-
425
- return { issueNumber, phaseId, terminalReason, outputs, skillInvoked, annotationMissing };
350
+ const phaseId = skillName ? phaseFromSkill(skillName) : null;
351
+ return { issueNumber, phaseId, outputs: {}, skillInvoked };
426
352
  }
427
353
 
428
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
@@ -39,6 +39,11 @@
39
39
  "color": "5319E7",
40
40
  "description": "Pipeline: PR created, under review. Managed by ForgeDock."
41
41
  },
42
+ {
43
+ "name": "workflow:remediating",
44
+ "color": "C5DEF5",
45
+ "description": "Pipeline: autonomous remediation pending (no human action needed). Managed by ForgeDock."
46
+ },
42
47
  {
43
48
  "name": "workflow:awaiting-merge",
44
49
  "color": "FF8C00",
@@ -79,6 +84,11 @@
79
84
  "color": "D93F0B",
80
85
  "description": "Defect or improvement found during automated PR review. Managed by ForgeDock."
81
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
+ },
82
92
  {
83
93
  "name": "review-degraded",
84
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
  }
@@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url";
16
16
  import { classifyInputPattern } from "./engine/resolve.mjs";
17
17
 
18
18
  const VERSION = "opencode-preflight-v1";
19
- const IN_FLIGHT_LABELS = new Set(["workflow:building", "workflow:in-review"]);
19
+ const IN_FLIGHT_LABELS = new Set(["workflow:building", "workflow:in-review", "workflow:remediating"]);
20
20
  const EXCLUDED_LABELS = new Set([
21
21
  "needs-human",
22
22
  "workflow:decomposed",