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.
- package/bin/engine/phases-args.test.mjs +2 -1
- package/bin/engine/phases.mjs +349 -90
- package/bin/engine/runlog.mjs +25 -2
- package/bin/engine-cli.mjs +4 -1
- package/bin/engine.mjs +72 -19
- package/bin/hooks/interactive-engine.mjs +135 -209
- package/bin/hooks/pre-tool-use.mjs +16 -4
- package/bin/labels.json +10 -0
- package/bin/opencode-adapter.mjs +1 -1
- package/bin/orchestrate-preflight.mjs +1 -1
- package/bin/runner.mjs +258 -23
- package/bin/watch-utils.mjs +1 -0
- package/commands/autopilot.md +6 -6
- package/commands/cleanup.md +8 -5
- package/commands/orchestrate/phase-1-resolve.md +1 -1
- package/commands/orchestrate/phase-3-dependency.md +14 -19
- package/commands/orchestrate/phase-4-execution.md +80 -53
- package/commands/orchestrate/phase-6-report.md +6 -0
- package/commands/orchestrate/safety.md +1 -1
- package/commands/pipeline-health/phase-2-metrics.md +39 -0
- package/commands/pipeline-health/phase-5-report.md +1 -0
- package/commands/pipeline-resume.md +1 -1
- package/commands/pipeline-status.md +2 -2
- package/commands/recover-orphans.md +5 -5
- package/commands/review-pr-agents/api.md +1 -1
- package/commands/review-pr-agents/auth.md +1 -1
- package/commands/review-pr-agents/infra.md +3 -3
- package/commands/review-pr-agents/protocols.md +15 -6
- package/commands/review-pr-agents/scraper.md +1 -1
- package/commands/review-pr-agents/security.md +5 -5
- package/commands/review-pr-agents.md +12 -12
- package/commands/review-pr-staging.md +226 -8
- package/commands/review-pr.md +394 -53
- package/commands/work-on/build/architect.md +36 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build/validate.md +88 -4
- package/commands/work-on/build.md +106 -7
- package/commands/work-on/decompose.md +2 -2
- package/commands/work-on/investigate.md +14 -4
- package/commands/work-on/remediate.md +137 -25
- package/commands/work-on/review.md +25 -9
- package/commands/work-on.md +15 -15
- package/docs/CONFIG.md +29 -0
- package/docs/RELEASE-NOTES.md +45 -0
- package/docs/spec/forge-protocol-v1.md +22 -1
- package/docs/spec/label-state-machine.md +6 -1
- package/docs/spec/review-protocol.md +20 -5
- package/package.json +2 -1
- package/packages/protocol/src/phases.js +21 -0
- package/scripts/check-blast-radius.sh +164 -0
- package/scripts/check-blast-radius.test.sh +183 -0
- package/scripts/check-contract-scope.sh +137 -0
- package/scripts/check-contract-scope.test.sh +83 -0
- package/scripts/check-spec-bash.sh +60 -3
- package/scripts/check-spec-bash.test.sh +56 -0
- package/scripts/check-spec-markers.sh +29 -0
- package/scripts/check-test-wiring.sh +57 -0
- package/scripts/check-test-wiring.test.sh +70 -0
- package/scripts/classify-finding.sh +72 -8
- package/scripts/classify-finding.test.sh +77 -8
- package/scripts/close-dossier-no-main-commit.test.sh +12 -12
- package/scripts/diff-size.test.sh +3 -2
- package/scripts/doctor-pipeline-state.sh +35 -0
- package/scripts/forge-annotation.sh +3 -0
- package/scripts/forge-root.test.sh +103 -13
- package/scripts/forge-run.sh +2 -2
- package/scripts/recovery-claim-live.test.sh +10 -7
- package/scripts/remediate-basesync.test.sh +103 -0
- package/scripts/review-delta.sh +206 -0
- package/scripts/review-delta.test.sh +213 -0
- package/scripts/review-pr-domain-map.test.sh +87 -0
- package/scripts/review-pr-gate2.test.sh +84 -0
- package/scripts/review-pr-staging-scope.test.sh +99 -0
- package/scripts/run-shell-tests.sh +115 -0
- package/scripts/select-fix-targets.sh +17 -5
- 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
|
-
* (
|
|
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
|
|
37
|
-
*
|
|
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
|
-
*
|
|
40
|
-
* FORGE:CONTEXT:COMPLETE
|
|
41
|
-
* FORGE:ARCHITECT:COMPLETE
|
|
42
|
-
* FORGE:BUILDER:COMPLETE
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
|
|
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
|
|
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,
|
|
132
|
+
let appendEvent, deriveState, readLog, makeProjector, reconcileState, detectTrustedOutcome;
|
|
217
133
|
try {
|
|
218
|
-
({ appendEvent, deriveState, readLog
|
|
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
|
-
|
|
241
|
-
let
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
state
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
lane,
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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`
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
415
|
-
|
|
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"],
|
|
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
|
-
|
|
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",
|
package/bin/opencode-adapter.mjs
CHANGED
|
@@ -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
|
|
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",
|