forgedock 1.0.22 → 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.
- package/README.md +255 -211
- package/bin/batch-runner.mjs +339 -0
- package/bin/cinema.mjs +320 -0
- package/bin/demo.mjs +92 -7
- package/bin/engine/phases.mjs +168 -0
- package/bin/engine/projector.mjs +32 -0
- package/bin/engine/reconcile.mjs +11 -0
- package/bin/engine/runlog.mjs +79 -0
- package/bin/engine/state.mjs +32 -0
- package/bin/engine-cli.mjs +222 -0
- package/bin/engine.mjs +138 -0
- package/bin/forgedock.mjs +896 -1540
- package/bin/hooks/interactive-engine.mjs +509 -0
- package/bin/hooks/pre-tool-use.mjs +498 -0
- package/bin/hooks/subagent-stop-enforce.mjs +197 -0
- package/bin/journey.mjs +841 -0
- package/bin/registry.mjs +139 -0
- package/bin/report.mjs +447 -0
- package/bin/runner.mjs +136 -20
- package/bin/settings-hook.mjs +346 -0
- package/bin/tui.mjs +6 -3
- package/commands/adopt.md +4 -1
- package/commands/analytics.md +4 -1
- package/commands/audit-agents.md +110 -27
- package/commands/audit.md +20 -1
- package/commands/autopilot.md +84 -12
- package/commands/changelog.md +2 -1
- package/commands/ci-audit.md +2 -1
- package/commands/cleanup.md +161 -1
- package/commands/compat-audit.md +2 -1
- package/commands/deploy-info.md +63 -4
- package/commands/design-bench.md +6 -1
- package/commands/design-render-critique-loop.md +2 -1
- package/commands/design.md +1 -0
- package/commands/diagnose.md +2 -1
- package/commands/explain.md +2 -1
- package/commands/forge-stats.md +122 -2
- package/commands/forgedock-init.md +1 -1
- package/commands/geo-audit.md +3 -0
- package/commands/incident-response.md +2 -1
- package/commands/issue.md +24 -3
- package/commands/milestone.md +77 -12
- package/commands/optimize.md +2 -1
- package/commands/orchestrate/config.md +67 -0
- package/commands/orchestrate/phase-1-resolve.md +159 -0
- package/commands/orchestrate/phase-2-triage.md +126 -0
- package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
- package/commands/orchestrate/phase-3-dependency.md +591 -0
- package/commands/orchestrate/phase-4-execution.md +962 -0
- package/commands/orchestrate/phase-5-cleanup.md +47 -0
- package/commands/orchestrate/phase-6-report.md +198 -0
- package/commands/orchestrate/safety.md +62 -0
- package/commands/orchestrate.md +25 -2027
- package/commands/pipeline-health/config.md +34 -0
- package/commands/pipeline-health/phase-1-context.md +180 -0
- package/commands/pipeline-health/phase-2-metrics.md +1729 -0
- package/commands/pipeline-health/phase-3-analyze.md +204 -0
- package/commands/pipeline-health/phase-4-proposals.md +117 -0
- package/commands/pipeline-health/phase-5-report.md +284 -0
- package/commands/pipeline-health/phase-6-summary.md +52 -0
- package/commands/pipeline-health.md +25 -2401
- package/commands/pipeline-resume.md +24 -1
- package/commands/pipeline-status.md +65 -0
- package/commands/qa-sweep.md +4 -1
- package/commands/quality-gate.md +326 -42
- package/commands/replay.md +2 -1
- package/commands/review-pr-agents/api.md +135 -0
- package/commands/review-pr-agents/auth.md +137 -0
- package/commands/review-pr-agents/billing.md +101 -0
- package/commands/review-pr-agents/concurrency.md +145 -0
- package/commands/review-pr-agents/database.md +103 -0
- package/commands/review-pr-agents/frontend.md +89 -0
- package/commands/review-pr-agents/infra.md +385 -0
- package/commands/review-pr-agents/protocols.md +185 -0
- package/commands/review-pr-agents/scraper.md +185 -0
- package/commands/review-pr-agents/security.md +406 -0
- package/commands/review-pr-agents.md +45 -1722
- package/commands/review-pr-staging.md +196 -40
- package/commands/review-pr.md +612 -56
- package/commands/rollback.md +2 -1
- package/commands/scope.md +2 -1
- package/commands/security-audit.md +2 -1
- package/commands/signal-planner.md +623 -0
- package/commands/sync-ecosystem.md +1 -0
- package/commands/test-gate.md +17 -1
- package/commands/validate.md +2 -1
- package/commands/work-on/build/architect.md +28 -0
- package/commands/work-on/build/context.md +2 -0
- package/commands/work-on/build/implement.md +19 -5
- package/commands/work-on/build/validate.md +62 -6
- package/commands/work-on/build.md +187 -30
- package/commands/work-on/close.md +132 -5
- package/commands/work-on/decompose.md +19 -1
- package/commands/work-on/investigate.md +117 -2
- package/commands/work-on/review.md +48 -4
- package/commands/work-on-monolithic.md +8 -2
- package/commands/work-on.md +247 -7
- package/docs/BRAND.md +16 -5
- package/docs/CI.md +2 -2
- package/docs/CODEX.md +36 -1
- package/docs/CONFIG.md +44 -42
- package/docs/FORGE-PROTOCOL.md +15 -1064
- package/docs/anthropic-devrel-outreach.md +2 -2
- package/docs/articles/command-decomposition-token-savings.md +214 -0
- package/docs/articles/model-release-playbook.md +131 -0
- package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
- package/docs/claude-breakpoints.json +75 -5
- package/docs/design/abc-benchmark.md +1 -1
- package/docs/design/design-spec-schema.md +1 -1
- package/docs/design/divergent-generation.md +1 -1
- package/docs/design/eval-gate-runbook.md +178 -0
- package/docs/design/show-hn-draft.md +26 -1
- package/docs/design/token-cost-baseline-1298.md +59 -0
- package/docs/eval/README.md +117 -0
- package/docs/eval/corpus/seed-v1.json +6 -0
- package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
- package/docs/site/annotations-explained.md +24 -0
- package/docs/site/command-learning-path.md +51 -14
- package/docs/site/command-reference.md +339 -113
- package/docs/site/for-companies.md +95 -0
- package/docs/site/forge-annotation-protocol.md +12 -256
- package/docs/site/getting-started.md +58 -12
- package/docs/site/github-is-the-memory.md +113 -0
- package/docs/site/how-it-works.md +3 -13
- package/docs/site/index.md +10 -2
- package/docs/spec/eval-run-result.md +147 -0
- package/docs/spec/forge-protocol-v1.md +441 -18
- package/docs/spec/label-state-machine.md +55 -0
- package/docs/spec/review-protocol.md +158 -0
- package/docs/spec-graph-schema.md +28 -0
- package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
- package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
- package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
- package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
- package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
- package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
- package/examples/forgedock-demo/README.md +41 -22
- package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
- package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
- package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
- package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
- package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
- package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
- package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
- package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
- package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
- package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
- package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
- package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
- package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
- package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
- package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
- package/examples/forgedock-demo/scripts/smoke.js +18 -1
- package/examples/forgedock-demo/src/db.js +36 -11
- package/examples/forgedock-demo/src/routes/notes.js +106 -9
- package/examples/forgedock-demo/src/server.js +4 -1
- package/package.json +12 -4
- package/packages/protocol/LICENSE +21 -0
- package/packages/protocol/src/cli.js +135 -0
- package/packages/protocol/src/emit.js +146 -0
- package/packages/protocol/src/index.js +43 -0
- package/packages/protocol/src/parse.js +229 -0
- package/packages/protocol/src/types.js +171 -0
- package/packages/protocol/src/validate.js +122 -0
- package/scripts/bench-topology-cost.mjs +372 -0
- package/scripts/check-command-docs-drift.sh +198 -0
- package/scripts/check-protocol-restatements.sh +176 -0
- package/scripts/check-registry/README.md +72 -0
- package/scripts/check-registry/manifest.json +6 -0
- package/scripts/code-index.sh +454 -0
- package/scripts/conformance-check.mjs +168 -0
- package/scripts/doctor-pipeline-state.sh +484 -0
- package/scripts/eval-gate-baseline.json +17 -0
- package/scripts/eval-gate-scorecard.mjs +242 -0
- package/scripts/eval-scorecard-aggregator.mjs +252 -0
- package/scripts/flaky-quarantine.sh +241 -0
- package/scripts/forge-annotation.sh +567 -0
- package/scripts/issue-dedup.sh +236 -0
- package/scripts/validate-annotation-node.mjs +233 -0
- package/scripts/verify-env-vars.sh +18 -2
- package/scripts/verify-host-headers.sh +4 -4
- package/scripts/verify-route-registration.sh +2 -2
- package/scripts/worktree-lifecycle.sh +202 -0
- package/templates/devdocs/agent/using-forgedock.md +14 -0
|
@@ -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
|
+
}
|