forgedock 1.1.1 → 1.1.2

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 (171) hide show
  1. package/README.md +53 -8
  2. package/bin/batch-runner.mjs +339 -0
  3. package/bin/demo.mjs +92 -7
  4. package/bin/engine/phases.mjs +20 -0
  5. package/bin/engine/state.mjs +8 -3
  6. package/bin/engine-cli.mjs +177 -5
  7. package/bin/forgedock.mjs +409 -26
  8. package/bin/hooks/interactive-engine.mjs +509 -0
  9. package/bin/hooks/pre-tool-use.mjs +498 -0
  10. package/bin/hooks/subagent-stop-enforce.mjs +197 -0
  11. package/bin/journey.mjs +72 -14
  12. package/bin/registry.mjs +139 -0
  13. package/bin/report.mjs +447 -0
  14. package/bin/runner.mjs +68 -19
  15. package/bin/settings-hook.mjs +235 -1
  16. package/commands/adopt.md +4 -1
  17. package/commands/analytics.md +4 -1
  18. package/commands/audit-agents.md +110 -27
  19. package/commands/audit.md +20 -1
  20. package/commands/autopilot.md +80 -8
  21. package/commands/changelog.md +2 -1
  22. package/commands/ci-audit.md +2 -1
  23. package/commands/cleanup.md +161 -1
  24. package/commands/compat-audit.md +2 -1
  25. package/commands/deploy-info.md +58 -1
  26. package/commands/design-bench.md +5 -1
  27. package/commands/design-render-critique-loop.md +1 -1
  28. package/commands/diagnose.md +2 -1
  29. package/commands/explain.md +2 -1
  30. package/commands/forge-stats.md +121 -2
  31. package/commands/forgedock-init.md +1 -1
  32. package/commands/geo-audit.md +3 -0
  33. package/commands/incident-response.md +2 -1
  34. package/commands/issue.md +23 -2
  35. package/commands/milestone.md +77 -12
  36. package/commands/optimize.md +2 -1
  37. package/commands/orchestrate/config.md +67 -0
  38. package/commands/orchestrate/phase-1-resolve.md +159 -0
  39. package/commands/orchestrate/phase-2-triage.md +126 -0
  40. package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
  41. package/commands/orchestrate/phase-3-dependency.md +591 -0
  42. package/commands/orchestrate/phase-4-execution.md +962 -0
  43. package/commands/orchestrate/phase-5-cleanup.md +47 -0
  44. package/commands/orchestrate/phase-6-report.md +198 -0
  45. package/commands/orchestrate/safety.md +62 -0
  46. package/commands/orchestrate.md +25 -2045
  47. package/commands/pipeline-health/config.md +34 -0
  48. package/commands/pipeline-health/phase-1-context.md +180 -0
  49. package/commands/pipeline-health/phase-2-metrics.md +1729 -0
  50. package/commands/pipeline-health/phase-3-analyze.md +204 -0
  51. package/commands/pipeline-health/phase-4-proposals.md +117 -0
  52. package/commands/pipeline-health/phase-5-report.md +284 -0
  53. package/commands/pipeline-health/phase-6-summary.md +52 -0
  54. package/commands/pipeline-health.md +25 -2401
  55. package/commands/pipeline-resume.md +24 -1
  56. package/commands/pipeline-status.md +65 -0
  57. package/commands/qa-sweep.md +4 -1
  58. package/commands/quality-gate.md +248 -10
  59. package/commands/replay.md +2 -1
  60. package/commands/review-pr-agents/api.md +135 -0
  61. package/commands/review-pr-agents/auth.md +137 -0
  62. package/commands/review-pr-agents/billing.md +101 -0
  63. package/commands/review-pr-agents/concurrency.md +145 -0
  64. package/commands/review-pr-agents/database.md +103 -0
  65. package/commands/review-pr-agents/frontend.md +89 -0
  66. package/commands/review-pr-agents/infra.md +385 -0
  67. package/commands/review-pr-agents/protocols.md +185 -0
  68. package/commands/review-pr-agents/scraper.md +185 -0
  69. package/commands/review-pr-agents/security.md +406 -0
  70. package/commands/review-pr-agents.md +45 -1722
  71. package/commands/review-pr-staging.md +196 -40
  72. package/commands/review-pr.md +591 -52
  73. package/commands/rollback.md +2 -1
  74. package/commands/scope.md +2 -1
  75. package/commands/security-audit.md +2 -1
  76. package/commands/signal-planner.md +623 -0
  77. package/commands/sync-ecosystem.md +1 -0
  78. package/commands/test-gate.md +17 -1
  79. package/commands/validate.md +2 -1
  80. package/commands/work-on/build/architect.md +28 -0
  81. package/commands/work-on/build/context.md +2 -0
  82. package/commands/work-on/build/implement.md +3 -1
  83. package/commands/work-on/build/validate.md +26 -7
  84. package/commands/work-on/build.md +163 -24
  85. package/commands/work-on/close.md +130 -3
  86. package/commands/work-on/decompose.md +19 -1
  87. package/commands/work-on/investigate.md +117 -2
  88. package/commands/work-on/review.md +24 -4
  89. package/commands/work-on-monolithic.md +7 -2
  90. package/commands/work-on.md +202 -4
  91. package/docs/BRAND.md +14 -7
  92. package/docs/CI.md +2 -2
  93. package/docs/CODEX.md +36 -1
  94. package/docs/CONFIG.md +5 -8
  95. package/docs/FORGE-PROTOCOL.md +15 -1064
  96. package/docs/anthropic-devrel-outreach.md +2 -2
  97. package/docs/articles/command-decomposition-token-savings.md +214 -0
  98. package/docs/articles/model-release-playbook.md +131 -0
  99. package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
  100. package/docs/claude-breakpoints.json +75 -5
  101. package/docs/design/abc-benchmark.md +1 -1
  102. package/docs/design/design-spec-schema.md +1 -1
  103. package/docs/design/divergent-generation.md +1 -1
  104. package/docs/design/eval-gate-runbook.md +178 -0
  105. package/docs/design/show-hn-draft.md +26 -1
  106. package/docs/design/token-cost-baseline-1298.md +59 -0
  107. package/docs/eval/README.md +117 -0
  108. package/docs/eval/corpus/seed-v1.json +6 -0
  109. package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
  110. package/docs/site/annotations-explained.md +24 -0
  111. package/docs/site/command-learning-path.md +51 -14
  112. package/docs/site/command-reference.md +339 -113
  113. package/docs/site/for-companies.md +95 -0
  114. package/docs/site/forge-annotation-protocol.md +12 -256
  115. package/docs/site/getting-started.md +43 -4
  116. package/docs/site/github-is-the-memory.md +113 -0
  117. package/docs/site/how-it-works.md +3 -13
  118. package/docs/site/index.md +9 -1
  119. package/docs/spec/eval-run-result.md +147 -0
  120. package/docs/spec/forge-protocol-v1.md +441 -18
  121. package/docs/spec/label-state-machine.md +55 -0
  122. package/docs/spec/review-protocol.md +158 -0
  123. package/docs/spec-graph-schema.md +28 -0
  124. package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
  125. package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
  126. package/examples/forgedock-demo/README.md +41 -22
  127. package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
  128. package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
  129. package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
  130. package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
  131. package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
  132. package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
  133. package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
  134. package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
  135. package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
  136. package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
  137. package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
  138. package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
  139. package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
  140. package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
  141. package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
  142. package/examples/forgedock-demo/scripts/smoke.js +18 -1
  143. package/examples/forgedock-demo/src/db.js +36 -11
  144. package/examples/forgedock-demo/src/routes/notes.js +106 -9
  145. package/examples/forgedock-demo/src/server.js +4 -1
  146. package/package.json +3 -1
  147. package/packages/protocol/LICENSE +21 -0
  148. package/packages/protocol/src/cli.js +135 -0
  149. package/packages/protocol/src/emit.js +146 -0
  150. package/packages/protocol/src/index.js +43 -0
  151. package/packages/protocol/src/parse.js +229 -0
  152. package/packages/protocol/src/types.js +171 -0
  153. package/packages/protocol/src/validate.js +122 -0
  154. package/scripts/bench-topology-cost.mjs +372 -0
  155. package/scripts/check-command-docs-drift.sh +198 -0
  156. package/scripts/check-protocol-restatements.sh +176 -0
  157. package/scripts/check-registry/README.md +72 -0
  158. package/scripts/check-registry/manifest.json +6 -0
  159. package/scripts/code-index.sh +454 -0
  160. package/scripts/conformance-check.mjs +168 -0
  161. package/scripts/doctor-pipeline-state.sh +484 -0
  162. package/scripts/eval-gate-baseline.json +17 -0
  163. package/scripts/eval-gate-scorecard.mjs +242 -0
  164. package/scripts/eval-scorecard-aggregator.mjs +252 -0
  165. package/scripts/flaky-quarantine.sh +241 -0
  166. package/scripts/forge-annotation.sh +567 -0
  167. package/scripts/issue-dedup.sh +236 -0
  168. package/scripts/validate-annotation-node.mjs +233 -0
  169. package/scripts/worktree-lifecycle.sh +3 -3
  170. package/templates/devdocs/agent/using-forgedock.md +14 -0
  171. package/docs/site/.vitepress/config.mjs +0 -85
@@ -0,0 +1,509 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bin/hooks/interactive-engine.mjs — ForgeDock SubagentStop hook.
4
+ *
5
+ * Interactive engine adapter (issue #1323): bridges the interactive
6
+ * /work-on path to the durable engine core so that interactive Claude Code
7
+ * sessions write the same run-log + FORGE:STATE as headless runner sessions
8
+ * and are resumable across compaction/context-window resets.
9
+ *
10
+ * === How it works ===
11
+ *
12
+ * Claude Code calls this hook when a subagent (Skill invocation) completes.
13
+ * The hook receives a JSON payload on stdin:
14
+ *
15
+ * {
16
+ * "hook_event_name": "SubagentStop",
17
+ * "session_id": "...",
18
+ * "transcript_path": "...", // path to the agent's JSONL transcript
19
+ * "stop_hook_active": false
20
+ * }
21
+ *
22
+ * The hook:
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).
26
+ * 2. Determines the issue number from the FORGE:STATE block on the issue
27
+ * body (GitHub is the authoritative store).
28
+ * 3. Appends the appropriate PHASE_COMMIT event to the local run-log.
29
+ * 4. Writes the updated FORGE:STATE back to the GitHub issue body.
30
+ *
31
+ * If no /work-on phase is detected (the subagent was something else), the
32
+ * hook exits 0 silently — fail-open.
33
+ *
34
+ * === Phase detection ===
35
+ *
36
+ * The hook looks for FORGE annotation markers in the transcript's tool
37
+ * results (gh issue comment / gh api calls):
38
+ *
39
+ * INVESTIGATION:COMPLETE → phase "investigate" committed
40
+ * FORGE:CONTEXT → phase "context" committed
41
+ * FORGE:ARCHITECT → phase "architect" committed
42
+ * FORGE:BUILDER:COMPLETE → phase "build" committed
43
+ * FORGE:REVIEWER → phase "review" committed
44
+ * workflow:merged label → phase "close" committed (terminal)
45
+ *
46
+ * === Fail-open contract ===
47
+ *
48
+ * Any uncaught error exits 0 (never blocks a Claude Code session). Errors
49
+ * are written to stderr only — they appear in Claude Code's diagnostic
50
+ * output but do not affect the user's workflow.
51
+ *
52
+ * === Wiring ===
53
+ *
54
+ * Installed into ~/.claude/settings.json under hooks.SubagentStop by
55
+ * `forgedock install` (via bin/settings-hook.mjs).
56
+ * Removed by `forgedock uninstall`.
57
+ */
58
+
59
+ import { fileURLToPath, pathToFileURL } from "url";
60
+ import { dirname, join, resolve } from "path";
61
+ import { existsSync, readFileSync } from "fs";
62
+ import { execFileSync } from "child_process";
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Bootstrap
66
+ // ---------------------------------------------------------------------------
67
+
68
+ const __filename = fileURLToPath(import.meta.url);
69
+ const __dirname = dirname(__filename);
70
+ /** Absolute path to the ForgeDock installation root (parent of bin/). */
71
+ const FORGE_HOME = resolve(__dirname, "..", "..");
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Phase marker table
75
+ // Maps FORGE annotation markers (found in transcript tool results) to phase IDs.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /** @type {Array<{marker: string, phase: string, terminal?: boolean, terminalReason?: string}>} */
79
+ const PHASE_MARKERS = [
80
+ { marker: "INVESTIGATION:COMPLETE", phase: "investigate" },
81
+ { marker: "INVESTIGATION:INVALID", phase: "investigate", terminal: true, terminalReason: "invalid" },
82
+ { marker: "DECOMPOSE:YES", phase: "investigate", terminal: true, terminalReason: "decomposed" },
83
+ { marker: "FORGE:CONTEXT", phase: "context" },
84
+ { marker: "FORGE:ARCHITECT", phase: "architect" },
85
+ { marker: "FORGE:BUILDER:COMPLETE", phase: "build" },
86
+ // review phase: PR merged is detected from gh label/state
87
+ { marker: "FORGE:REVIEWER:MERGED", phase: "review" },
88
+ // close phase: issue closed with workflow:merged
89
+ { marker: "workflow:merged", phase: "close", terminal: true, terminalReason: "merged" },
90
+ ];
91
+
92
+ /**
93
+ * Match a single string of text against the PHASE_MARKERS table and return
94
+ * the phase (and terminal reason) implied by the first marker found, in
95
+ * table order — a terminal marker stops the scan. This is the same
96
+ * marker-matching rule `detectPhase()` applies per-block below, factored
97
+ * out as a standalone export so it can be tested directly against a bare
98
+ * string without needing a full transcript-entries array. Purely additive:
99
+ * `detectPhase()`'s existing multi-entry accumulation algorithm is
100
+ * untouched and does not call this function. See #1592.
101
+ *
102
+ * @param {string} text
103
+ * @returns {{ phaseId: string|null, terminalReason: string|null }}
104
+ */
105
+ export function detectPhaseFromText(text) {
106
+ const str = typeof text === "string" ? text : "";
107
+ let phaseId = null;
108
+ let terminalReason = null;
109
+ for (const { marker, phase, terminal, terminalReason: tr } of PHASE_MARKERS) {
110
+ if (str.includes(marker)) {
111
+ phaseId = phase;
112
+ if (terminal) { terminalReason = tr; break; }
113
+ }
114
+ }
115
+ return { phaseId, terminalReason };
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Main — fail-open wrapper
120
+ // ---------------------------------------------------------------------------
121
+
122
+ // Only auto-run when this file is executed directly as the Claude Code
123
+ // SubagentStop hook — not when it's `import`ed (e.g. by tests, to reuse
124
+ // parseTranscript/detectPhase/detectLane). Without this guard, importing the
125
+ // module for testing would trigger main()'s real gh/git side effects and
126
+ // kill the test process via process.exit(0) (issue #1580).
127
+ const isDirectExecution =
128
+ !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
129
+
130
+ if (isDirectExecution) {
131
+ try {
132
+ await main();
133
+ } catch (err) {
134
+ process.stderr.write(`[ForgeDock:interactive-engine] ERROR: ${err.message}\n`);
135
+ }
136
+ process.exit(0);
137
+ }
138
+
139
+ async function main() {
140
+ // Read the hook payload from stdin.
141
+ const raw = await readStdin();
142
+ if (!raw.trim()) return;
143
+
144
+ let payload;
145
+ try {
146
+ payload = JSON.parse(raw);
147
+ } catch {
148
+ return; // not a JSON hook payload — ignore
149
+ }
150
+
151
+ if (payload.hook_event_name !== "SubagentStop") return;
152
+
153
+ const transcriptPath = payload.transcript_path;
154
+ if (!transcriptPath || !existsSync(transcriptPath)) return;
155
+
156
+ // Parse the transcript to find the skill invocation and annotations.
157
+ const transcript = parseTranscript(transcriptPath);
158
+ if (!transcript) return;
159
+
160
+ const { issueNumber, phaseId, terminalReason, outputs, skillInvoked, annotationMissing } = detectPhase(transcript);
161
+
162
+ // --- Annotation enforcement (#1250) ---
163
+ // If a /work-on skill was invoked but the expected FORGE annotation is
164
+ // missing, block the subagent from completing silently and inject
165
+ // corrective context so the agent knows what to do.
166
+ if (skillInvoked && annotationMissing && phaseId) {
167
+ const PHASE_ANNOTATION_MAP = {
168
+ investigate: "INVESTIGATION:COMPLETE (or INVESTIGATION:INVALID / DECOMPOSE:YES)",
169
+ context: "FORGE:CONTEXT",
170
+ architect: "FORGE:ARCHITECT",
171
+ build: "FORGE:BUILDER:COMPLETE",
172
+ review: "FORGE:REVIEWER:MERGED",
173
+ close: "workflow:merged label",
174
+ };
175
+ const expected = PHASE_ANNOTATION_MAP[phaseId] || `the ${phaseId} phase annotation`;
176
+ // Output additionalContext JSON (v2.1.163+ SubagentStop format).
177
+ // Claude Code reads this and injects it as context for the agent.
178
+ const feedback = {
179
+ decision: "block",
180
+ reason: `[ForgeDock] Phase "${phaseId}" completed without posting its FORGE annotation.`,
181
+ additionalContext: [
182
+ `The ${phaseId} phase must post annotation: ${expected}`,
183
+ `Post this annotation now via gh issue comment, then re-complete this phase.`,
184
+ `This is a pipeline enforcement check — annotation-free completions are not tracked`,
185
+ `and cannot be resumed across compaction events.`,
186
+ ].join("\n"),
187
+ };
188
+ process.stdout.write(JSON.stringify(feedback) + "\n");
189
+ process.exit(2);
190
+ return;
191
+ }
192
+
193
+ if (!issueNumber || !phaseId) return; // not a /work-on sub-phase
194
+
195
+ // Resolve the run-log directory.
196
+ const runLogDir = resolveRunLogDir();
197
+ if (!runLogDir) return;
198
+
199
+ // Import engine modules dynamically (fail-open if missing).
200
+ let appendEvent, deriveState, readLog, rewriteLog, makeProjector, reconcileState, freshState;
201
+ try {
202
+ ({ appendEvent, deriveState, readLog, rewriteLog } = await import(
203
+ pathToFileURL(join(FORGE_HOME, "bin", "engine", "runlog.mjs")).href
204
+ ));
205
+ ({ makeProjector } = await import(
206
+ pathToFileURL(join(FORGE_HOME, "bin", "engine", "projector.mjs")).href
207
+ ));
208
+ ({ reconcileState } = await import(
209
+ pathToFileURL(join(FORGE_HOME, "bin", "engine", "reconcile.mjs")).href
210
+ ));
211
+ } catch (importErr) {
212
+ process.stderr.write(`[ForgeDock:interactive-engine] engine modules unavailable: ${importErr.message}\n`);
213
+ return;
214
+ }
215
+
216
+ // Build a minimal io adapter using the gh CLI.
217
+ const io = makeCliIo();
218
+
219
+ // Load or reconcile state.
220
+ const projector = makeProjector(io);
221
+ const local = readLog(runLogDir, issueNumber).length
222
+ ? deriveState(readLog(runLogDir, issueNumber))
223
+ : null;
224
+ const remote = await projector.readState(issueNumber);
225
+ let { state } = reconcileState(local, remote);
226
+
227
+ if (!state) {
228
+ // Fresh run — bootstrap.
229
+ const lane = detectLane(transcript) || "staging";
230
+ state = {
231
+ v: 0,
232
+ run: `r_${issueNumber}_${lane}_interactive`,
233
+ issue: issueNumber,
234
+ lane,
235
+ committed: [],
236
+ phase: null,
237
+ branch: null,
238
+ pr: null,
239
+ terminal: false,
240
+ terminalReason: null,
241
+ lease: null,
242
+ };
243
+ appendEvent(runLogDir, issueNumber, {
244
+ event: "RUN_START",
245
+ issue: issueNumber,
246
+ run: state.run,
247
+ lane,
248
+ source: "interactive",
249
+ });
250
+ }
251
+
252
+ // Skip if phase already committed (idempotent).
253
+ if (state.committed.includes(phaseId)) return;
254
+
255
+ // Append the PHASE_COMMIT event.
256
+ appendEvent(runLogDir, issueNumber, {
257
+ event: "PHASE_COMMIT",
258
+ phase: phaseId,
259
+ outputs: outputs || {},
260
+ source: "interactive",
261
+ });
262
+ state = deriveState(readLog(runLogDir, issueNumber));
263
+
264
+ if (terminalReason) {
265
+ appendEvent(runLogDir, issueNumber, {
266
+ event: "RUN_TERMINAL",
267
+ reason: terminalReason,
268
+ source: "interactive",
269
+ });
270
+ state = deriveState(readLog(runLogDir, issueNumber));
271
+ state = { ...state, terminal: true, terminalReason, lease: null };
272
+ }
273
+
274
+ // Mirror to GitHub FORGE:STATE.
275
+ try {
276
+ await projector.writeState(issueNumber, state);
277
+ } catch (writeErr) {
278
+ process.stderr.write(`[ForgeDock:interactive-engine] FORGE:STATE write failed: ${writeErr.message}\n`);
279
+ // Non-fatal: run-log is the crash-safe local record; GitHub mirror is best-effort.
280
+ }
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Transcript parsing
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Read and parse a JSONL transcript file.
289
+ * Returns an array of transcript entries, or null on error.
290
+ */
291
+ export function parseTranscript(transcriptPath) {
292
+ try {
293
+ const raw = readFileSync(transcriptPath, "utf-8");
294
+ const lines = raw.split("\n").filter((l) => l.trim());
295
+ return lines.map((l) => {
296
+ try { return JSON.parse(l); } catch { return null; }
297
+ }).filter(Boolean);
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Detect which /work-on phase completed and the issue number from transcript entries.
305
+ *
306
+ * Strategy:
307
+ * - Scan tool_use entries for Skill invocations to find the skill name (→ phase)
308
+ * - Scan tool_result entries for gh CLI output containing FORGE annotation markers
309
+ * - Extract issue number from Skill args or from the skill name context
310
+ *
311
+ * @param {object[]} entries
312
+ * @returns {{ issueNumber: number|null, phaseId: string|null, terminalReason: string|null, outputs: object, skillInvoked: boolean, annotationMissing: boolean }}
313
+ */
314
+ export function detectPhase(entries) {
315
+ let skillName = null;
316
+ let issueNumber = null;
317
+ const foundMarkers = new Set();
318
+ const outputs = {};
319
+ let skillInvoked = false;
320
+
321
+ for (const entry of entries) {
322
+ // Real Claude Code transcript entries nest role/content under
323
+ // `entry.message` (e.g. {"type":"assistant","message":{"role":"assistant",
324
+ // "content":[{"type":"tool_use",...}]}}) — the block-level types
325
+ // (tool_use/tool_result/text) live inside message.content[], never at
326
+ // the entry's own top level. Fall back to a flat/legacy shape
327
+ // (entry.role/entry.content directly) if entry.message is absent, so
328
+ // any pre-normalized or synthetic transcript still works (issue #1580).
329
+ const message = entry && typeof entry === "object" && entry.message ? entry.message : entry;
330
+ const role = message?.role;
331
+ const contentBlocks = Array.isArray(message?.content) ? message.content : [];
332
+
333
+ for (const block of contentBlocks) {
334
+ if (!block || typeof block !== "object") continue;
335
+
336
+ // Tool use blocks — find Skill invocations.
337
+ if (block.type === "tool_use" && block.name === "Skill") {
338
+ const input = block.input || {};
339
+ if (input.skill) { skillName = input.skill; skillInvoked = true; }
340
+ // Extract issue number from args (e.g. "1323" or "#1323").
341
+ if (input.args) {
342
+ const m = String(input.args).match(/\b(\d{3,6})\b/);
343
+ if (m) issueNumber = parseInt(m[1], 10);
344
+ }
345
+ }
346
+
347
+ // Tool result blocks — scan for FORGE markers in gh output.
348
+ if (block.type === "tool_result") {
349
+ const content = Array.isArray(block.content)
350
+ ? block.content.map((c) => (typeof c === "string" ? c : c?.text || "")).join("\n")
351
+ : String(block.content || "");
352
+ for (const { marker } of PHASE_MARKERS) {
353
+ if (content.includes(marker)) foundMarkers.add(marker);
354
+ }
355
+ // Extract PR number if present.
356
+ const prM = content.match(/"number"\s*:\s*(\d+)/);
357
+ if (prM) outputs.pr = parseInt(prM[1], 10);
358
+ // Extract branch from git push output.
359
+ const branchM = content.match(/(?:refs\/heads\/|branch[:\s]+)([\w\-./]+)/i);
360
+ if (branchM) outputs.branch = branchM[1];
361
+ }
362
+
363
+ // Also scan assistant message text blocks for FORGE markers.
364
+ if (role === "assistant") {
365
+ const text = block.text || block.content || "";
366
+ if (typeof text === "string") {
367
+ for (const { marker } of PHASE_MARKERS) {
368
+ if (text.includes(marker)) foundMarkers.add(marker);
369
+ }
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ // Match markers to phase, most-specific first (terminal markers take priority).
376
+ let phaseId = null;
377
+ let terminalReason = null;
378
+
379
+ for (const { marker, phase, terminal, terminalReason: tr } of PHASE_MARKERS) {
380
+ if (foundMarkers.has(marker)) {
381
+ phaseId = phase;
382
+ if (terminal) terminalReason = tr || null;
383
+ // Keep first match unless a more specific (terminal) marker overrides.
384
+ if (terminal) break;
385
+ }
386
+ }
387
+
388
+ // Fallback: derive phase from skill name if no markers found.
389
+ // annotationMissing is true when a skill was invoked but no FORGE markers
390
+ // were found in the transcript — the agent ran but didn't post its annotation.
391
+ const markersFound = foundMarkers.size > 0;
392
+ const phaseFromSkillFallback = !phaseId && skillName ? phaseFromSkill(skillName) : null;
393
+ if (!phaseId && phaseFromSkillFallback) {
394
+ phaseId = phaseFromSkillFallback;
395
+ }
396
+
397
+ const annotationMissing = skillInvoked && !markersFound;
398
+
399
+ return { issueNumber, phaseId, terminalReason, outputs, skillInvoked, annotationMissing };
400
+ }
401
+
402
+ /**
403
+ * Map a Skill name to a phase ID.
404
+ *
405
+ * Real Skill() invocations use colon-separated names (e.g. "work-on:build:context",
406
+ * matching the registered skill catalog and commands/work-on/build.md's exception-path
407
+ * invocations). Some spec prose still uses slash-separated names (e.g. "work-on/build"),
408
+ * so the input is normalized to colons before lookup — this keeps the fallback working
409
+ * regardless of which convention a given caller followed (issue #1525).
410
+ * @param {string} skill
411
+ * @returns {string|null}
412
+ */
413
+ export function phaseFromSkill(skill) {
414
+ const normalized = String(skill || "").replace(/\//g, ":");
415
+ const map = {
416
+ "work-on:investigate": "investigate",
417
+ "work-on:build:context": "context",
418
+ "work-on:build:architect": "architect",
419
+ "work-on:build": "build",
420
+ "work-on:review": "review",
421
+ "work-on:close": "close",
422
+ };
423
+ return map[normalized] || null;
424
+ }
425
+
426
+ /**
427
+ * Detect the pipeline lane from transcript tool results.
428
+ * Looks for branch names or milestone labels that imply feature vs staging lane.
429
+ */
430
+ export function detectLane(entries) {
431
+ for (const entry of entries) {
432
+ // Same nested-schema normalization as detectPhase() — tool_result blocks
433
+ // live under entry.message.content[], not at entry's own top level
434
+ // (sibling of the #1580 bug: this function had the identical mistake).
435
+ const message = entry && typeof entry === "object" && entry.message ? entry.message : entry;
436
+ const contentBlocks = Array.isArray(message?.content) ? message.content : [];
437
+
438
+ for (const block of contentBlocks) {
439
+ if (!block || typeof block !== "object" || block.type !== "tool_result") continue;
440
+ const content = Array.isArray(block.content)
441
+ ? block.content.map((c) => (typeof c === "string" ? c : c?.text || "")).join("\n")
442
+ : String(block.content || "");
443
+ if (/milestone\//.test(content)) return "feature";
444
+ if (/staging/.test(content)) return "staging";
445
+ }
446
+ }
447
+ return null;
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Run-log directory resolution
452
+ // ---------------------------------------------------------------------------
453
+
454
+ /**
455
+ * Resolve the directory where run-log JSONL files are stored.
456
+ * Uses .forgedock/run-logs/ in the current working directory,
457
+ * or FORGE_RUN_LOG_DIR env override for testing.
458
+ */
459
+ function resolveRunLogDir() {
460
+ if (process.env.FORGE_RUN_LOG_DIR) return process.env.FORGE_RUN_LOG_DIR;
461
+ const cwd = process.cwd();
462
+ // Prefer .forgedock/ if it exists (managed project).
463
+ const managed = join(cwd, ".forgedock", "run-logs");
464
+ // Fall back to a temp-like XDG path.
465
+ return managed;
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // CLI io adapter
470
+ // ---------------------------------------------------------------------------
471
+
472
+ /**
473
+ * Build an io object that delegates gh/git calls to the CLI.
474
+ * Used by makeProjector to read/write FORGE:STATE on GitHub.
475
+ */
476
+ function makeCliIo() {
477
+ function runCli(cmd, args) {
478
+ try {
479
+ return execFileSync(cmd, args, {
480
+ encoding: "utf-8",
481
+ stdio: ["pipe", "pipe", "pipe"],
482
+ timeout: 15000,
483
+ });
484
+ } catch (e) {
485
+ throw new Error(`${cmd} ${args.join(" ")}: ${e.stderr || e.message}`);
486
+ }
487
+ }
488
+
489
+ return {
490
+ gh: async (args) => runCli("gh", args),
491
+ git: async (args) => runCli("git", args),
492
+ };
493
+ }
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // stdin reader
497
+ // ---------------------------------------------------------------------------
498
+
499
+ async function readStdin() {
500
+ return new Promise((resolve) => {
501
+ let buf = "";
502
+ process.stdin.setEncoding("utf-8");
503
+ process.stdin.on("data", (chunk) => { buf += chunk; });
504
+ process.stdin.on("end", () => resolve(buf));
505
+ process.stdin.on("error", () => resolve(""));
506
+ // Timeout: if stdin has no data after 2s, resolve empty.
507
+ setTimeout(() => resolve(buf), 2000);
508
+ });
509
+ }