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.
Files changed (184) hide show
  1. package/README.md +255 -211
  2. package/bin/batch-runner.mjs +339 -0
  3. package/bin/cinema.mjs +320 -0
  4. package/bin/demo.mjs +92 -7
  5. package/bin/engine/phases.mjs +168 -0
  6. package/bin/engine/projector.mjs +32 -0
  7. package/bin/engine/reconcile.mjs +11 -0
  8. package/bin/engine/runlog.mjs +79 -0
  9. package/bin/engine/state.mjs +32 -0
  10. package/bin/engine-cli.mjs +222 -0
  11. package/bin/engine.mjs +138 -0
  12. package/bin/forgedock.mjs +896 -1540
  13. package/bin/hooks/interactive-engine.mjs +509 -0
  14. package/bin/hooks/pre-tool-use.mjs +498 -0
  15. package/bin/hooks/subagent-stop-enforce.mjs +197 -0
  16. package/bin/journey.mjs +841 -0
  17. package/bin/registry.mjs +139 -0
  18. package/bin/report.mjs +447 -0
  19. package/bin/runner.mjs +136 -20
  20. package/bin/settings-hook.mjs +346 -0
  21. package/bin/tui.mjs +6 -3
  22. package/commands/adopt.md +4 -1
  23. package/commands/analytics.md +4 -1
  24. package/commands/audit-agents.md +110 -27
  25. package/commands/audit.md +20 -1
  26. package/commands/autopilot.md +84 -12
  27. package/commands/changelog.md +2 -1
  28. package/commands/ci-audit.md +2 -1
  29. package/commands/cleanup.md +161 -1
  30. package/commands/compat-audit.md +2 -1
  31. package/commands/deploy-info.md +63 -4
  32. package/commands/design-bench.md +6 -1
  33. package/commands/design-render-critique-loop.md +2 -1
  34. package/commands/design.md +1 -0
  35. package/commands/diagnose.md +2 -1
  36. package/commands/explain.md +2 -1
  37. package/commands/forge-stats.md +122 -2
  38. package/commands/forgedock-init.md +1 -1
  39. package/commands/geo-audit.md +3 -0
  40. package/commands/incident-response.md +2 -1
  41. package/commands/issue.md +24 -3
  42. package/commands/milestone.md +77 -12
  43. package/commands/optimize.md +2 -1
  44. package/commands/orchestrate/config.md +67 -0
  45. package/commands/orchestrate/phase-1-resolve.md +159 -0
  46. package/commands/orchestrate/phase-2-triage.md +126 -0
  47. package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
  48. package/commands/orchestrate/phase-3-dependency.md +591 -0
  49. package/commands/orchestrate/phase-4-execution.md +962 -0
  50. package/commands/orchestrate/phase-5-cleanup.md +47 -0
  51. package/commands/orchestrate/phase-6-report.md +198 -0
  52. package/commands/orchestrate/safety.md +62 -0
  53. package/commands/orchestrate.md +25 -2027
  54. package/commands/pipeline-health/config.md +34 -0
  55. package/commands/pipeline-health/phase-1-context.md +180 -0
  56. package/commands/pipeline-health/phase-2-metrics.md +1729 -0
  57. package/commands/pipeline-health/phase-3-analyze.md +204 -0
  58. package/commands/pipeline-health/phase-4-proposals.md +117 -0
  59. package/commands/pipeline-health/phase-5-report.md +284 -0
  60. package/commands/pipeline-health/phase-6-summary.md +52 -0
  61. package/commands/pipeline-health.md +25 -2401
  62. package/commands/pipeline-resume.md +24 -1
  63. package/commands/pipeline-status.md +65 -0
  64. package/commands/qa-sweep.md +4 -1
  65. package/commands/quality-gate.md +326 -42
  66. package/commands/replay.md +2 -1
  67. package/commands/review-pr-agents/api.md +135 -0
  68. package/commands/review-pr-agents/auth.md +137 -0
  69. package/commands/review-pr-agents/billing.md +101 -0
  70. package/commands/review-pr-agents/concurrency.md +145 -0
  71. package/commands/review-pr-agents/database.md +103 -0
  72. package/commands/review-pr-agents/frontend.md +89 -0
  73. package/commands/review-pr-agents/infra.md +385 -0
  74. package/commands/review-pr-agents/protocols.md +185 -0
  75. package/commands/review-pr-agents/scraper.md +185 -0
  76. package/commands/review-pr-agents/security.md +406 -0
  77. package/commands/review-pr-agents.md +45 -1722
  78. package/commands/review-pr-staging.md +196 -40
  79. package/commands/review-pr.md +612 -56
  80. package/commands/rollback.md +2 -1
  81. package/commands/scope.md +2 -1
  82. package/commands/security-audit.md +2 -1
  83. package/commands/signal-planner.md +623 -0
  84. package/commands/sync-ecosystem.md +1 -0
  85. package/commands/test-gate.md +17 -1
  86. package/commands/validate.md +2 -1
  87. package/commands/work-on/build/architect.md +28 -0
  88. package/commands/work-on/build/context.md +2 -0
  89. package/commands/work-on/build/implement.md +19 -5
  90. package/commands/work-on/build/validate.md +62 -6
  91. package/commands/work-on/build.md +187 -30
  92. package/commands/work-on/close.md +132 -5
  93. package/commands/work-on/decompose.md +19 -1
  94. package/commands/work-on/investigate.md +117 -2
  95. package/commands/work-on/review.md +48 -4
  96. package/commands/work-on-monolithic.md +8 -2
  97. package/commands/work-on.md +247 -7
  98. package/docs/BRAND.md +16 -5
  99. package/docs/CI.md +2 -2
  100. package/docs/CODEX.md +36 -1
  101. package/docs/CONFIG.md +44 -42
  102. package/docs/FORGE-PROTOCOL.md +15 -1064
  103. package/docs/anthropic-devrel-outreach.md +2 -2
  104. package/docs/articles/command-decomposition-token-savings.md +214 -0
  105. package/docs/articles/model-release-playbook.md +131 -0
  106. package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
  107. package/docs/claude-breakpoints.json +75 -5
  108. package/docs/design/abc-benchmark.md +1 -1
  109. package/docs/design/design-spec-schema.md +1 -1
  110. package/docs/design/divergent-generation.md +1 -1
  111. package/docs/design/eval-gate-runbook.md +178 -0
  112. package/docs/design/show-hn-draft.md +26 -1
  113. package/docs/design/token-cost-baseline-1298.md +59 -0
  114. package/docs/eval/README.md +117 -0
  115. package/docs/eval/corpus/seed-v1.json +6 -0
  116. package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
  117. package/docs/site/annotations-explained.md +24 -0
  118. package/docs/site/command-learning-path.md +51 -14
  119. package/docs/site/command-reference.md +339 -113
  120. package/docs/site/for-companies.md +95 -0
  121. package/docs/site/forge-annotation-protocol.md +12 -256
  122. package/docs/site/getting-started.md +58 -12
  123. package/docs/site/github-is-the-memory.md +113 -0
  124. package/docs/site/how-it-works.md +3 -13
  125. package/docs/site/index.md +10 -2
  126. package/docs/spec/eval-run-result.md +147 -0
  127. package/docs/spec/forge-protocol-v1.md +441 -18
  128. package/docs/spec/label-state-machine.md +55 -0
  129. package/docs/spec/review-protocol.md +158 -0
  130. package/docs/spec-graph-schema.md +28 -0
  131. package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
  132. package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
  133. package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
  134. package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
  135. package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
  136. package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
  137. package/examples/forgedock-demo/README.md +41 -22
  138. package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
  139. package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
  140. package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
  141. package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
  142. package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
  143. package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
  144. package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
  145. package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
  146. package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
  147. package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
  148. package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
  149. package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
  150. package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
  151. package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
  152. package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
  153. package/examples/forgedock-demo/scripts/smoke.js +18 -1
  154. package/examples/forgedock-demo/src/db.js +36 -11
  155. package/examples/forgedock-demo/src/routes/notes.js +106 -9
  156. package/examples/forgedock-demo/src/server.js +4 -1
  157. package/package.json +12 -4
  158. package/packages/protocol/LICENSE +21 -0
  159. package/packages/protocol/src/cli.js +135 -0
  160. package/packages/protocol/src/emit.js +146 -0
  161. package/packages/protocol/src/index.js +43 -0
  162. package/packages/protocol/src/parse.js +229 -0
  163. package/packages/protocol/src/types.js +171 -0
  164. package/packages/protocol/src/validate.js +122 -0
  165. package/scripts/bench-topology-cost.mjs +372 -0
  166. package/scripts/check-command-docs-drift.sh +198 -0
  167. package/scripts/check-protocol-restatements.sh +176 -0
  168. package/scripts/check-registry/README.md +72 -0
  169. package/scripts/check-registry/manifest.json +6 -0
  170. package/scripts/code-index.sh +454 -0
  171. package/scripts/conformance-check.mjs +168 -0
  172. package/scripts/doctor-pipeline-state.sh +484 -0
  173. package/scripts/eval-gate-baseline.json +17 -0
  174. package/scripts/eval-gate-scorecard.mjs +242 -0
  175. package/scripts/eval-scorecard-aggregator.mjs +252 -0
  176. package/scripts/flaky-quarantine.sh +241 -0
  177. package/scripts/forge-annotation.sh +567 -0
  178. package/scripts/issue-dedup.sh +236 -0
  179. package/scripts/validate-annotation-node.mjs +233 -0
  180. package/scripts/verify-env-vars.sh +18 -2
  181. package/scripts/verify-host-headers.sh +4 -4
  182. package/scripts/verify-route-registration.sh +2 -2
  183. package/scripts/worktree-lifecycle.sh +202 -0
  184. package/templates/devdocs/agent/using-forgedock.md +14 -0
@@ -0,0 +1,498 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "module";
3
+ import { existsSync } from "fs";
4
+ import { dirname, join, resolve as resolvePath } from "path";
5
+ import { fileURLToPath, pathToFileURL } from "url";
6
+ const _require = createRequire(import.meta.url);
7
+
8
+ /** Absolute path to the ForgeDock installation root (parent of bin/). */
9
+ const FORGE_HOME = resolvePath(dirname(fileURLToPath(import.meta.url)), "..", "..");
10
+
11
+ /**
12
+ * bin/hooks/pre-tool-use.mjs — ForgeDock PreToolUse hook.
13
+ *
14
+ * Deterministic enforcement layer (issues #1250, #1323): intercepts tool
15
+ * calls before they execute and hard-blocks pipeline violations.
16
+ *
17
+ * === Hook protocol ===
18
+ *
19
+ * Claude Code sends a JSON payload to stdin and reads the exit code:
20
+ * exit 0 — allow the tool call
21
+ * exit 2 — block the tool call (error message on stderr is shown to agent)
22
+ *
23
+ * Input payload (stdin JSON):
24
+ * {
25
+ * "hook_event_name": "PreToolUse",
26
+ * "session_id": "...",
27
+ * "tool_name": "Bash",
28
+ * "tool_input": { "command": "gh pr create ..." }
29
+ * }
30
+ *
31
+ * === Enforced rules ===
32
+ *
33
+ * 1. PR branch target validation
34
+ * Intercepts `gh pr create` and validates `--base` against the
35
+ * pipeline's allowed targets (staging, milestone/<slug>).
36
+ * Hard-blocks PRs targeting main.
37
+ *
38
+ * 2. Label transition validation
39
+ * Intercepts `gh issue edit --add-label` and validates the transition
40
+ * against the workflow label state machine.
41
+ * Blocks invalid transitions (e.g. jumping from investigating to merged).
42
+ *
43
+ * Both rules only apply inside a ForgeDock-managed directory (a directory
44
+ * with a `forge.yaml` or `.forgedock` marker) — see `isForgeDockManagedCwd()`
45
+ * (issue #1591). The hook installs into the user's global
46
+ * `~/.claude/settings.json`, so without this guard every Bash call in every
47
+ * repo on the machine would be subject to these ForgeDock-specific rules,
48
+ * including unrelated repos where `main` is a legitimate PR target.
49
+ *
50
+ * === Fail-open contract ===
51
+ *
52
+ * Any uncaught error or parse failure exits 0 (allow) — this hook must
53
+ * NEVER prevent a legitimate tool call due to a hook bug.
54
+ *
55
+ * === Wiring ===
56
+ *
57
+ * Installed into ~/.claude/settings.json under hooks.PreToolUse by
58
+ * `forgedock install` (via bin/settings-hook.mjs). The install entry carries
59
+ * `matcher: "Bash"` so Claude Code's harness only spawns this script for
60
+ * Bash tool calls, not every tool call.
61
+ * Removed by `forgedock uninstall`.
62
+ */
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Label state machine
66
+ // Valid successors for each workflow label. Only forward transitions allowed.
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Allowed label transitions: from → [to, ...]
71
+ * A label not in this map can be added freely (not a workflow: label).
72
+ */
73
+ const LABEL_TRANSITIONS = {
74
+ "workflow:investigating": ["workflow:ready-to-build", "workflow:invalid", "workflow:decomposed"],
75
+ "workflow:ready-to-build": ["workflow:building"],
76
+ "workflow:building": ["workflow:in-review", "workflow:ready-to-build"], // retry allowed
77
+ "workflow:in-review": ["workflow:merged", "workflow:building"], // review → re-build allowed
78
+ "workflow:merged": [], // terminal — no successors
79
+ "workflow:invalid": [], // terminal
80
+ "workflow:decomposed": [], // terminal
81
+ };
82
+
83
+ /** Labels that are never allowed as a PR --base target. */
84
+ const FORBIDDEN_PR_BASES = ["main", "master"];
85
+
86
+ /** Labels that are always valid (non-workflow labels are not constrained). */
87
+ const WORKFLOW_LABEL_PREFIX = "workflow:";
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Main — fail-open wrapper
91
+ // ---------------------------------------------------------------------------
92
+
93
+ try {
94
+ await main();
95
+ } catch {
96
+ // Fail open — never block a tool call due to a hook error.
97
+ process.exit(0);
98
+ }
99
+
100
+ async function main() {
101
+ const raw = await readStdin();
102
+ if (!raw.trim()) { process.exit(0); return; }
103
+
104
+ let payload;
105
+ try {
106
+ payload = JSON.parse(raw);
107
+ } catch {
108
+ process.exit(0); return;
109
+ }
110
+
111
+ if (payload.hook_event_name !== "PreToolUse") { process.exit(0); return; }
112
+
113
+ const toolName = payload.tool_name || "";
114
+ const toolInput = payload.tool_input || {};
115
+
116
+ // Only intercept Bash tool calls.
117
+ if (toolName !== "Bash") { process.exit(0); return; }
118
+
119
+ // Only enforce inside a ForgeDock-managed project — this hook installs
120
+ // globally (~/.claude/settings.json), so without this guard both rules
121
+ // below would fire in every unrelated repo on the machine (issue #1591).
122
+ if (!(await isForgeDockManagedCwd())) { process.exit(0); return; }
123
+
124
+ const command = String(toolInput.command || "");
125
+
126
+ // --- Rule 1: PR branch target validation ---
127
+ const prViolation = checkPrTarget(command);
128
+ if (prViolation) {
129
+ process.stderr.write(prViolation);
130
+ process.exit(2);
131
+ return;
132
+ }
133
+
134
+ // --- Rule 2: Label transition validation ---
135
+ const labelViolation = checkLabelTransition(command);
136
+ if (labelViolation) {
137
+ process.stderr.write(labelViolation);
138
+ process.exit(2);
139
+ return;
140
+ }
141
+
142
+ process.exit(0);
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Project-scope guard (issue #1591)
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Determine whether the current working directory is a ForgeDock-managed
151
+ * project — i.e. whether pipeline enforcement rules should apply here at
152
+ * all. This hook installs into the user's global `~/.claude/settings.json`,
153
+ * so it fires for every Bash call in every repo unless explicitly scoped.
154
+ *
155
+ * Reuses `bin/registry.mjs::resolveState`, the same primitive
156
+ * `bin/hooks/session-start.mjs` already uses to decide whether to inject
157
+ * ForgeDock context — a directory is managed iff it has a `forge.yaml` or
158
+ * `.forgedock` marker (and hasn't been explicitly opted out).
159
+ *
160
+ * Fail-open: if `registry.mjs` cannot be dynamically imported (broken
161
+ * install, missing file), falls back to a direct `forge.yaml`/`.forgedock`
162
+ * existence check on cwd. If even that throws, returns false (no
163
+ * enforcement) rather than letting an error propagate — consistent with
164
+ * this hook's overall fail-open contract.
165
+ *
166
+ * @returns {Promise<boolean>}
167
+ */
168
+ async function isForgeDockManagedCwd() {
169
+ const cwd = process.cwd();
170
+ try {
171
+ const { resolveState } = await import(
172
+ pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
173
+ );
174
+ return resolveState(cwd) === "managed-active";
175
+ } catch {
176
+ try {
177
+ return existsSync(join(cwd, "forge.yaml")) || existsSync(join(cwd, ".forgedock"));
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Rule 1: PR branch target validation
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /**
189
+ * Check whether a gh pr create command targets a forbidden base branch.
190
+ *
191
+ * @param {string} command
192
+ * @returns {string|null} Error message to show, or null if allowed.
193
+ */
194
+ function checkPrTarget(command) {
195
+ // Only check gh pr create commands.
196
+ if (!/gh\s+pr\s+create/.test(command)) return null;
197
+
198
+ const base = extractFlag(command, "--base") || extractFlag(command, "-B");
199
+ if (!base) return null; // no --base flag — gh will use the default
200
+
201
+ if (FORBIDDEN_PR_BASES.includes(base.toLowerCase())) {
202
+ return [
203
+ `[ForgeDock] BLOCKED: PR targets "${base}" — pipeline rule violation.`,
204
+ ``,
205
+ `PRs MUST target "staging" (fast lane) or "milestone/<slug>" (feature lane).`,
206
+ `A PR to "${base}" is a hard pipeline violation. Fix the --base flag.`,
207
+ ``,
208
+ `Allowed targets: staging | milestone/<slug>`,
209
+ ].join("\n");
210
+ }
211
+
212
+ // Warn if the base doesn't match expected patterns, but don't hard-block
213
+ // (the project may have custom branch names).
214
+ return null;
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Rule 2: Label transition validation
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * Check whether a gh issue edit --add-label command represents a valid
223
+ * label state-machine transition.
224
+ *
225
+ * Reads the current workflow labels from GitHub via `gh issue view` and
226
+ * validates that the new label is a legal successor in the state machine.
227
+ * Falls back to allow (exit 0) on any gh CLI error so the hook is fail-open.
228
+ *
229
+ * @param {string} command
230
+ * @returns {string|null} Error message to show, or null if allowed.
231
+ */
232
+ function checkLabelTransition(command) {
233
+ // Only check gh issue edit commands that add labels.
234
+ if (!/gh\s+issue\s+edit/.test(command)) return null;
235
+ if (!command.includes("--add-label")) return null;
236
+
237
+ const newLabel = extractFlag(command, "--add-label");
238
+ if (!newLabel) return null;
239
+ if (!newLabel.startsWith(WORKFLOW_LABEL_PREFIX)) return null; // non-workflow label, skip
240
+
241
+ // The new label must be a known workflow label.
242
+ if (!Object.prototype.hasOwnProperty.call(LABEL_TRANSITIONS, newLabel) &&
243
+ !Object.values(LABEL_TRANSITIONS).some((arr) => arr.includes(newLabel))) {
244
+ return null; // unknown workflow label — don't block
245
+ }
246
+
247
+ // Extract the issue number from the command.
248
+ // Supports: `gh issue edit 123`, `gh issue edit #123`, `-R repo` or positional
249
+ const issueNumM = command.match(/gh\s+issue\s+edit\s+(?:#?(\d+)|(\d+))/);
250
+ if (!issueNumM) return null; // can't determine issue — fail-open
251
+ const issueNum = issueNumM[1] || issueNumM[2];
252
+
253
+ // Extract repo (-R flag) if present.
254
+ const repoFlag = extractFlag(command, "-R") || extractFlag(command, "--repo");
255
+
256
+ // Read current labels from GitHub synchronously.
257
+ let currentWorkflowLabel = null;
258
+ try {
259
+ const { execFileSync: exec } = _require("child_process");
260
+ const args = ["issue", "view", issueNum, "--json", "labels"];
261
+ if (repoFlag) { args.push("-R", repoFlag); }
262
+ const out = exec("gh", args, {
263
+ encoding: "utf-8",
264
+ stdio: ["pipe", "pipe", "pipe"],
265
+ timeout: 8000,
266
+ });
267
+ const parsed = JSON.parse(out);
268
+ const labels = Array.isArray(parsed.labels)
269
+ ? parsed.labels.map((l) => (typeof l === "string" ? l : l.name))
270
+ : [];
271
+ // Find the current workflow label (the most specific one, last wins).
272
+ for (const lbl of labels) {
273
+ if (lbl.startsWith(WORKFLOW_LABEL_PREFIX)) {
274
+ currentWorkflowLabel = lbl;
275
+ }
276
+ }
277
+ } catch {
278
+ return null; // gh CLI error — fail-open
279
+ }
280
+
281
+ if (!currentWorkflowLabel) {
282
+ // No current workflow label — allow any workflow label to be added.
283
+ return null;
284
+ }
285
+
286
+ // Terminal labels cannot be transitioned away from.
287
+ if (isTerminalLabel(currentWorkflowLabel)) {
288
+ return [
289
+ `[ForgeDock] BLOCKED: Label transition violation.`,
290
+ ``,
291
+ `Issue is in terminal state "${currentWorkflowLabel}" — no further transitions allowed.`,
292
+ `Attempted to add: "${newLabel}"`,
293
+ ``,
294
+ `Terminal states (${Object.keys(LABEL_TRANSITIONS).filter((k) => LABEL_TRANSITIONS[k].length === 0).join(", ")}) are final.`,
295
+ ].join("\n");
296
+ }
297
+
298
+ // Validate the transition against the state machine.
299
+ const allowed = LABEL_TRANSITIONS[currentWorkflowLabel] || null;
300
+ if (allowed === null) {
301
+ return null; // current state not in map — unknown, fail-open
302
+ }
303
+ if (!allowed.includes(newLabel)) {
304
+ return [
305
+ `[ForgeDock] BLOCKED: Invalid label transition.`,
306
+ ``,
307
+ `Current workflow state: "${currentWorkflowLabel}"`,
308
+ `Attempted transition: → "${newLabel}"`,
309
+ `Allowed next states : ${allowed.length > 0 ? allowed.map((s) => `"${s}"`).join(", ") : "(none — terminal)"}`,
310
+ ``,
311
+ `Fix: check the pipeline phase and apply the correct next workflow label.`,
312
+ ].join("\n");
313
+ }
314
+
315
+ return null; // valid transition — allow
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Helpers
320
+ // ---------------------------------------------------------------------------
321
+
322
+ /**
323
+ * @typedef {Object} CommandToken
324
+ * @property {string} value The token text, with surrounding quotes removed.
325
+ * @property {boolean} quoted True if ANY character of this token originated
326
+ * inside single or double quotes.
327
+ */
328
+
329
+ /**
330
+ * Split a shell command string into argv-like tokens, honoring single and
331
+ * double quotes so that quoted argument values (e.g. `--title "..."`) are
332
+ * never split apart or scanned as separate tokens.
333
+ *
334
+ * Each token carries a `quoted` flag recording whether any of its characters
335
+ * came from inside quotes. This flag is retained for diagnostics, but
336
+ * `extractFlag` does NOT use it to decide whether a token can be a flag —
337
+ * see the note on `extractFlag` (issue #1591) for why "was any character
338
+ * quoted" is the wrong discriminator.
339
+ *
340
+ * This is intentionally NOT a full POSIX shell parser — it doesn't handle
341
+ * escapes, `$()`, backticks, or command chaining. It only needs to be
342
+ * accurate enough to distinguish "a flag in argument position" from
343
+ * "flag-shaped text embedded inside a different argument's value", which is
344
+ * all `extractFlag` needs (issue #1519).
345
+ *
346
+ * @param {string} command
347
+ * @returns {CommandToken[]}
348
+ */
349
+ function tokenizeCommand(command) {
350
+ const tokens = [];
351
+ let current = "";
352
+ let quoted = false;
353
+ let inSingle = false;
354
+ let inDouble = false;
355
+
356
+ for (let i = 0; i < command.length; i++) {
357
+ const ch = command[i];
358
+
359
+ if (inSingle) {
360
+ if (ch === "'") inSingle = false;
361
+ else current += ch;
362
+ continue;
363
+ }
364
+
365
+ if (inDouble) {
366
+ if (ch === '"') inDouble = false;
367
+ else current += ch;
368
+ continue;
369
+ }
370
+
371
+ if (ch === "'") {
372
+ inSingle = true;
373
+ quoted = true;
374
+ continue;
375
+ }
376
+
377
+ if (ch === '"') {
378
+ inDouble = true;
379
+ quoted = true;
380
+ continue;
381
+ }
382
+
383
+ if (/\s/.test(ch)) {
384
+ if (current.length > 0) {
385
+ tokens.push({ value: current, quoted });
386
+ }
387
+ current = "";
388
+ quoted = false;
389
+ continue;
390
+ }
391
+
392
+ current += ch;
393
+ }
394
+
395
+ if (current.length > 0) tokens.push({ value: current, quoted });
396
+ return tokens;
397
+ }
398
+
399
+ /**
400
+ * Extract the value of a CLI flag from a command string.
401
+ * Handles `--flag value`, `--flag=value`, and — for single-dash, single-letter
402
+ * short flags only (e.g. `-B`, `-R`) — the attached form `-Bvalue`.
403
+ *
404
+ * Tokenizes the command first (see `tokenizeCommand`) and matches the flag
405
+ * against actual argv tokens — NOT against substrings anywhere in the raw
406
+ * command string. This prevents flag-shaped text inside a quoted argument
407
+ * value (e.g. `--title "Fix -B main thread bug"`) from being misread as a
408
+ * real flag (issue #1519).
409
+ *
410
+ * The attached form is valid getopt-style syntax that real CLIs (including
411
+ * `gh`) accept for single-dash short flags — e.g. `gh pr create -Bmain` is
412
+ * equivalent to `gh pr create -B main`. Without this branch, a forbidden PR
413
+ * base written as `-Bmain` would produce no match and bypass the hard block
414
+ * (issue #1550). The attached-form check is scoped to 2-character single-dash
415
+ * flags so long flags (`--base`) are never affected.
416
+ *
417
+ * DECOY RULE: the equals form (`-B=value`/`--base=value`) and the attached
418
+ * form (`-Bvalue`) are skipped on any token that contains EMBEDDED
419
+ * WHITESPACE — not on any token that was merely quoted.
420
+ *
421
+ * A shell token can only contain whitespace if quoting was used to glue
422
+ * multiple words into a single argument (e.g. `--title "-Bstaging is
423
+ * fine"` — the value is a whole sentence, not a flag). That is the actual
424
+ * signature of the issue #1519 decoy: flag-shaped text embedded inside an
425
+ * unrelated multi-word `--title`/`--body` value. Skipping on embedded
426
+ * whitespace preserves that protection.
427
+ *
428
+ * A single-word token being quoted, on the other hand, changes nothing about
429
+ * the resulting argv entry — `"-Bmain"`, `'-Bmain'`, and `-Bmain` are all
430
+ * identical once the shell hands the argument to `gh`, as are `--base="main"`
431
+ * and `--base=main`. Treating "was quoted at all" as reason enough to skip
432
+ * (the previous behaviour) let these single-word quoted forms sail through
433
+ * untouched — a real, commonly-written bypass (issue #1591), not a decoy.
434
+ * So quoting alone must never exempt a token from these checks; only
435
+ * embedded whitespace does. The exact form (`token === flag`) stays
436
+ * quote-insensitive as before: a fully-quoted `"-B"` in flag position is
437
+ * unambiguously the flag, and its value is the next token regardless of
438
+ * quoting.
439
+ *
440
+ * @param {string} command
441
+ * @param {string} flag e.g. "--base" or "-B"
442
+ * @returns {string|null}
443
+ */
444
+ function extractFlag(command, flag) {
445
+ const tokens = tokenizeCommand(command);
446
+ const eqPrefix = `${flag}=`;
447
+ const isShortFlag = flag.length === 2 && flag[0] === "-" && flag[1] !== "-";
448
+
449
+ for (let i = 0; i < tokens.length; i++) {
450
+ const { value: token } = tokens[i];
451
+
452
+ // --flag value form (value is the next token). Quote-insensitive: an exact
453
+ // token match is unambiguous even when the flag itself was quoted.
454
+ if (token === flag) {
455
+ return i + 1 < tokens.length ? tokens[i + 1].value : null;
456
+ }
457
+
458
+ // Prefix-based forms below must not fire on a token that required
459
+ // quoting to glue multiple words into one argument — that shape is the
460
+ // real #1519 decoy signature (e.g. a --title value that happens to
461
+ // start with flag-looking text). A token can only contain whitespace if
462
+ // it came from inside quotes, so embedded whitespace — not quoting
463
+ // itself — is the correct discriminator (issue #1591).
464
+ if (/\s/.test(token)) continue;
465
+
466
+ // --flag=value form (also covers the equals form of a short flag, e.g. -B=value)
467
+ if (token.startsWith(eqPrefix)) {
468
+ return token.slice(eqPrefix.length);
469
+ }
470
+
471
+ // -Bvalue attached form (single-dash short flags only, e.g. -Bmain)
472
+ if (isShortFlag && token.startsWith(flag) && token.length > flag.length) {
473
+ return token.slice(flag.length);
474
+ }
475
+ }
476
+
477
+ return null;
478
+ }
479
+
480
+ function isTerminalLabel(label) {
481
+ const successors = LABEL_TRANSITIONS[label];
482
+ return successors !== undefined && successors.length === 0;
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // stdin reader
487
+ // ---------------------------------------------------------------------------
488
+
489
+ async function readStdin() {
490
+ return new Promise((resolve) => {
491
+ let buf = "";
492
+ process.stdin.setEncoding("utf-8");
493
+ process.stdin.on("data", (chunk) => { buf += chunk; });
494
+ process.stdin.on("end", () => resolve(buf));
495
+ process.stdin.on("error", () => resolve(""));
496
+ setTimeout(() => resolve(buf), 1000);
497
+ });
498
+ }
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bin/hooks/subagent-stop-enforce.mjs — ForgeDock SubagentStop enforcement hook.
4
+ *
5
+ * Deterministic annotation enforcement (issue #1250): called by Claude Code
6
+ * when a sub-agent (Skill invocation) completes. Checks the transcript for
7
+ * the expected FORGE: annotation and blocks silent completion if the
8
+ * annotation is missing.
9
+ *
10
+ * === Hook protocol ===
11
+ *
12
+ * Claude Code sends a JSON payload to stdin:
13
+ * {
14
+ * "hook_event_name": "SubagentStop",
15
+ * "session_id": "...",
16
+ * "transcript_path": "...", // path to the agent's JSONL transcript
17
+ * "stop_hook_active": false
18
+ * }
19
+ *
20
+ * Exit codes:
21
+ * exit 0 — allow completion
22
+ * exit 2 — block completion; stdout message injected as additionalContext
23
+ * (requires Claude Code v2.1.163+ for additionalContext injection)
24
+ *
25
+ * === Annotation detection ===
26
+ *
27
+ * The hook scans the transcript for FORGE: annotation markers written via
28
+ * `gh issue comment` or `gh api` calls in the most recent pipeline phase.
29
+ * Each pipeline phase is expected to post a specific FORGE: marker:
30
+ *
31
+ * FORGE:INVESTIGATOR — investigate phase
32
+ * FORGE:CONTRACT — context/contract phase
33
+ * FORGE:ARCHITECT — architect phase
34
+ * FORGE:BUILDER — build phase (also FORGE:BUILDER:COMPLETE)
35
+ * FORGE:REVIEWER — review phase
36
+ *
37
+ * If the transcript shows a pipeline phase Skill was invoked but no matching
38
+ * FORGE: annotation appears in the tool results, completion is blocked with
39
+ * corrective context.
40
+ *
41
+ * === Fail-open contract ===
42
+ *
43
+ * Any uncaught error or parse failure exits 0 (allow). This hook MUST NOT
44
+ * block legitimate completions due to a hook bug or missing transcript.
45
+ * Transcript read errors, missing files, and unknown phase patterns all
46
+ * exit 0 silently.
47
+ *
48
+ * === Wiring ===
49
+ *
50
+ * NOT currently installed (forge#1527): `detectPhase()`'s trigger condition
51
+ * (a literal `FORGE:PHASE_START` marker in the transcript) is never emitted
52
+ * anywhere in the pipeline — no command/skill writes that string into a
53
+ * Claude Code transcript, so this hook always fell through to "not a
54
+ * pipeline phase subagent" and exited 0 with zero enforcement effect. As of
55
+ * forge#1527, `forgedock install`/`update` no longer installs this hook and
56
+ * actively removes any prior installation via `removeSubagentStopEnforceHook`.
57
+ *
58
+ * This file and its install/remove primitives (`installSubagentStopEnforceHook`
59
+ * / `removeSubagentStopEnforceHook` in bin/settings-hook.mjs) are kept intact
60
+ * for a future fix that wires a real transcript-visible trigger (e.g. the
61
+ * skill-header text each work-on subcommand already renders into the
62
+ * transcript) and corrects the `context` → `FORGE:CONTRACT` mapping bug in
63
+ * `PHASE_ANNOTATIONS` below (should require `FORGE:CONTEXT` instead).
64
+ * Identified by the string "subagent-stop-enforce.mjs" in SUBAGENT_ENFORCE_MARKER.
65
+ */
66
+
67
+ import { readFileSync } from "fs";
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Phase annotation map — which FORGE: marker each pipeline phase must post
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Map from phase-detection pattern to required FORGE: annotation marker.
75
+ * The phase-detection pattern matches the Skill name or the FORGE:PHASE_START
76
+ * marker that indicates which pipeline phase was active.
77
+ *
78
+ * Key: regex pattern matching the phase label in the transcript
79
+ * Value: FORGE: annotation string that must appear in a tool result
80
+ */
81
+ const PHASE_ANNOTATIONS = {
82
+ "FORGE:PHASE_START.*investigate": "FORGE:INVESTIGATOR",
83
+ "FORGE:PHASE_START.*context": "FORGE:CONTRACT",
84
+ "FORGE:PHASE_START.*architect": "FORGE:ARCHITECT",
85
+ "FORGE:PHASE_START.*build": "FORGE:BUILDER",
86
+ "FORGE:PHASE_START.*review": "FORGE:REVIEWER",
87
+ };
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Main — fail-open wrapper
91
+ // ---------------------------------------------------------------------------
92
+
93
+ try {
94
+ await main();
95
+ } catch {
96
+ // Fail open — never block a sub-agent completion due to a hook error.
97
+ process.exit(0);
98
+ }
99
+
100
+ async function main() {
101
+ const raw = await readStdin();
102
+ if (!raw.trim()) { process.exit(0); return; }
103
+
104
+ let payload;
105
+ try {
106
+ payload = JSON.parse(raw);
107
+ } catch {
108
+ process.exit(0); return;
109
+ }
110
+
111
+ if (payload.hook_event_name !== "SubagentStop") { process.exit(0); return; }
112
+
113
+ const transcriptPath = payload.transcript_path;
114
+ if (!transcriptPath) { process.exit(0); return; }
115
+
116
+ // Read the transcript file.
117
+ let transcriptText;
118
+ try {
119
+ transcriptText = readFileSync(transcriptPath, "utf-8");
120
+ } catch {
121
+ // Transcript unreadable — fail open.
122
+ process.exit(0); return;
123
+ }
124
+
125
+ // Determine which pipeline phase was active in this transcript.
126
+ const detectedPhase = detectPhase(transcriptText);
127
+ if (!detectedPhase) {
128
+ // Not a pipeline phase subagent — allow completion.
129
+ process.exit(0); return;
130
+ }
131
+
132
+ const { phaseName, requiredAnnotation } = detectedPhase;
133
+
134
+ // Check whether the required FORGE: annotation appears in the transcript.
135
+ if (transcriptText.includes(requiredAnnotation)) {
136
+ // Annotation present — allow completion.
137
+ process.exit(0); return;
138
+ }
139
+
140
+ // Annotation missing — block completion with corrective context.
141
+ const message = [
142
+ `[ForgeDock] BLOCKED: Pipeline phase "${phaseName}" completed without posting its FORGE: annotation.`,
143
+ ``,
144
+ `Required annotation: <!-- ${requiredAnnotation} --> (or <!-- ${requiredAnnotation}:... -->)`,
145
+ ``,
146
+ `Before completing this phase, post the annotation to the GitHub issue using:`,
147
+ ` gh issue comment <issue-number> --body "<!-- ${requiredAnnotation} -->\\n..."`,
148
+ ``,
149
+ `FORGE: annotations are the authoritative pipeline record. Without them, the`,
150
+ `next phase cannot locate the output of this phase after a context reset.`,
151
+ ``,
152
+ `Post the annotation and then signal completion again.`,
153
+ ].join("\n");
154
+
155
+ process.stdout.write(message);
156
+ process.exit(2);
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Phase detection
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /**
164
+ * Scan the transcript for a FORGE:PHASE_START marker to identify the active
165
+ * pipeline phase. Returns the phase name and required annotation, or null if
166
+ * this is not a pipeline phase transcript.
167
+ *
168
+ * @param {string} transcript - Full transcript text.
169
+ * @returns {{ phaseName: string, requiredAnnotation: string } | null}
170
+ */
171
+ function detectPhase(transcript) {
172
+ for (const [pattern, annotation] of Object.entries(PHASE_ANNOTATIONS)) {
173
+ const re = new RegExp(pattern, "i");
174
+ if (re.test(transcript)) {
175
+ // Extract phase name from pattern (text after last dot or slash).
176
+ const phaseMatch = pattern.match(/\*\*?([a-z]+)\s*$/i);
177
+ const phaseName = phaseMatch ? phaseMatch[1] : pattern;
178
+ return { phaseName, requiredAnnotation: annotation };
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // stdin reader
186
+ // ---------------------------------------------------------------------------
187
+
188
+ async function readStdin() {
189
+ return new Promise((resolve) => {
190
+ let buf = "";
191
+ process.stdin.setEncoding("utf-8");
192
+ process.stdin.on("data", (chunk) => { buf += chunk; });
193
+ process.stdin.on("end", () => resolve(buf));
194
+ process.stdin.on("error", () => resolve(""));
195
+ setTimeout(() => resolve(buf), 1000);
196
+ });
197
+ }