forgedock 1.1.6 → 1.1.8

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 (43) hide show
  1. package/README.md +3 -1
  2. package/bin/engine/invariants.mjs +361 -0
  3. package/bin/hooks/pre-tool-use.mjs +182 -0
  4. package/bin/journey.mjs +77 -7
  5. package/bin/labels.json +10 -0
  6. package/bin/recall.mjs +540 -0
  7. package/commands/autopilot.md +959 -78
  8. package/commands/cleanup.md +52 -3
  9. package/commands/optimize.md +585 -0
  10. package/commands/orchestrate/phase-1-resolve.md +35 -25
  11. package/commands/orchestrate/phase-3-dependency.md +465 -34
  12. package/commands/orchestrate/phase-4-execution.md +448 -29
  13. package/commands/orchestrate/phase-6-report.md +198 -9
  14. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  15. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  16. package/commands/quality-gate.md +417 -0
  17. package/commands/review-pr.md +391 -13
  18. package/commands/spec-doctor.md +429 -0
  19. package/commands/validate.md +52 -0
  20. package/commands/work-on/build/architect.md +77 -0
  21. package/commands/work-on/build/context.md +357 -22
  22. package/commands/work-on/build/implement.md +2 -0
  23. package/commands/work-on/build/validate.md +28 -0
  24. package/commands/work-on/build.md +44 -0
  25. package/commands/work-on/close.md +621 -8
  26. package/commands/work-on/investigate.md +221 -0
  27. package/commands/work-on.md +15 -14
  28. package/docs/FORGE-PROTOCOL.md +101 -0
  29. package/docs/design/awesome-list-submissions.md +105 -0
  30. package/docs/design/f5bot-monitoring-setup.md +43 -0
  31. package/docs/design/growth-strategy-2026.md +579 -0
  32. package/docs/spec/forge-protocol-v1.md +97 -5
  33. package/docs/spec/label-state-machine.md +6 -1
  34. package/package.json +2 -2
  35. package/packages/protocol/src/cli.js +374 -84
  36. package/packages/protocol/src/types.js +75 -1
  37. package/scripts/build-knowledge-index.mjs +1761 -0
  38. package/scripts/calibration.mjs +996 -0
  39. package/scripts/danger-zones.mjs +782 -0
  40. package/scripts/doctor-pipeline-state.sh +88 -2
  41. package/scripts/refresh-bot-token.sh +97 -0
  42. package/scripts/select-fix-targets.sh +234 -0
  43. package/scripts/transition-label.sh +157 -6
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="https://avatars.githubusercontent.com/in/3731547?s=200&u=b38eba537e011502c010d4b682d641f802591845&v=4" alt="ForgeDock" width="80" />
3
+ <img src="https://avatars.githubusercontent.com/in/4051319?s=200&v=4" alt="ForgeDock" width="80" />
4
4
 
5
5
  <h1>ForgeDock</h1>
6
6
 
@@ -227,6 +227,8 @@ npx forgedock --global # global install: installs into ~/.claude/commands/ — a
227
227
 
228
228
  **Project-scoped is the default.** `npx forgedock` installs commands scoped to the current project directory. Use `--global` only when you want the commands available across all your projects on this machine.
229
229
 
230
+ > **Want engine-mode dispatch?** `npx forgedock` is transient — the `forgedock` binary isn't persisted in PATH after install. `/orchestrate` and `/autopilot` use agent dispatch mode by default, which is fully functional. To enable engine-mode dispatch (`forgedock run-issue`) with its durable phase table and fail-closed review gate, run `npm install -g forgedock` instead.
231
+
230
232
  One command does everything: it checks your environment, installs the slash commands into Claude Code, detects your repo (owner, branches, paths), and hands you a single annotated `forge.yaml` to review — press Enter to accept. Run `npx forgedock init` any time afterward to re-generate the config only.
231
233
 
232
234
  Installing also registers a SessionStart hook, so every Claude Code session
@@ -0,0 +1,361 @@
1
+ /**
2
+ * bin/engine/invariants.mjs — ForgeDock pipeline invariant evaluator.
3
+ *
4
+ * Evaluates three classes of invariants declared in forge-invariants.yaml:
5
+ *
6
+ * pretooluse — preconditions checked in pre-tool-use.mjs before tool
7
+ * execution (e.g. branch-must-exist-on-remote)
8
+ * runlog — temporal ordering rules over the append-only run-log
9
+ * (e.g. review-precedes-merge)
10
+ * close — terminal-state assertions checked before trajectory post
11
+ * (e.g. issue-closed-at-terminal)
12
+ *
13
+ * === Design constraints ===
14
+ *
15
+ * • No circular imports: only imports node:fs, node:child_process, node:path,
16
+ * node:url. Does NOT import engine.mjs or phases.mjs.
17
+ * • Fail-open: every exported function returns a result object on error
18
+ * rather than throwing. A missing forge-invariants.yaml or yq returns [].
19
+ * • Single-pass temporal evaluation: O(n) over the run-log event list.
20
+ * No GitHub API calls.
21
+ * • Named violations: every failed result includes { ok: false, id, violated }
22
+ * so callers can surface the proposition by name.
23
+ *
24
+ * === Usage ===
25
+ *
26
+ * // Load all declared invariants:
27
+ * const invariants = loadInvariants(forgeInvariantsYamlPath);
28
+ *
29
+ * // Evaluate temporal rules over a run-log event stream:
30
+ * const results = evaluateTemporalRules(invariants, events);
31
+ *
32
+ * // Check a hook precondition (branch must exist):
33
+ * const result = await checkPrecondition(invariants, "branch_must_exist_on_remote", { branch });
34
+ *
35
+ * // Assert close-time invariants before posting trajectory:
36
+ * const results = assertCloseInvariants(invariants, events);
37
+ */
38
+
39
+ import { existsSync } from "node:fs";
40
+ import { execFileSync } from "node:child_process";
41
+ import { resolve } from "node:path";
42
+ import { fileURLToPath } from "node:url";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // YAML loader — uses yq (pipeline dependency) for proper array support.
46
+ // forge-utils.mjs's minimal parser does not handle YAML sequences.
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Load invariant declarations from forge-invariants.yaml.
51
+ *
52
+ * Returns an empty array if:
53
+ * - forge-invariants.yaml is absent (fresh install before file ships)
54
+ * - yq is not installed (graceful degradation)
55
+ * - any parse error occurs
56
+ *
57
+ * @param {string} [yamlPath] Absolute path to forge-invariants.yaml.
58
+ * Defaults to {repo root}/forge-invariants.yaml resolved relative to
59
+ * this file's location.
60
+ * @returns {Invariant[]}
61
+ */
62
+ export function loadInvariants(yamlPath) {
63
+ // Default: resolve relative to bin/engine/ → repo root / forge-invariants.yaml
64
+ const DEFAULT_PATH = resolve(
65
+ fileURLToPath(import.meta.url),
66
+ "..", "..", "..", "forge-invariants.yaml",
67
+ );
68
+ const target = yamlPath || DEFAULT_PATH;
69
+
70
+ if (!existsSync(target)) return []; // file absent — fail-open, not an error
71
+
72
+ try {
73
+ // Use yq to convert YAML → JSON (handles arrays; yq is a pipeline dep).
74
+ const json = execFileSync("yq", ["-o", "json", ".", target], {
75
+ encoding: "utf-8",
76
+ stdio: ["pipe", "pipe", "pipe"],
77
+ timeout: 5000,
78
+ });
79
+ const parsed = JSON.parse(json);
80
+ const items = parsed?.invariants;
81
+ if (!Array.isArray(items)) return [];
82
+ return items.filter(
83
+ (i) => i && typeof i.id === "string" && typeof i.scope === "string",
84
+ );
85
+ } catch {
86
+ // yq not installed, parse error, or any other failure — fail-open.
87
+ return [];
88
+ }
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Temporal rule evaluation (single-pass, O(n), no I/O)
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Evaluate all runlog-scope invariants against an event list.
97
+ *
98
+ * @param {Invariant[]} invariants Full invariant list from loadInvariants().
99
+ * @param {object[]} events Event list from readLog() / deriveState().
100
+ * @returns {InvariantResult[]}
101
+ */
102
+ export function evaluateTemporalRules(invariants, events) {
103
+ const rules = invariants.filter((i) => i.scope === "runlog");
104
+ if (!rules.length || !events.length) return rules.map((r) => ({ ok: true, id: r.id }));
105
+
106
+ // Single-pass: collect per-event-type positions.
107
+ const phaseCommits = {}; // phase id → first commit seq
108
+ let firstTerminal = null;
109
+ let terminalCount = 0;
110
+ let terminalReason = null;
111
+
112
+ for (const e of events) {
113
+ if (e.event === "PHASE_COMMIT" && e.phase) {
114
+ if (!(e.phase in phaseCommits)) phaseCommits[e.phase] = e.seq;
115
+ } else if (e.event === "RUN_TERMINAL") {
116
+ terminalCount++;
117
+ if (firstTerminal === null) { firstTerminal = e.seq; terminalReason = e.reason; }
118
+ }
119
+ }
120
+
121
+ return rules.map((r) => {
122
+ try {
123
+ return evaluateOneRule(r, { phaseCommits, firstTerminal, terminalCount, terminalReason });
124
+ } catch {
125
+ return { ok: true, id: r.id }; // evaluator error → fail-open
126
+ }
127
+ });
128
+ }
129
+
130
+ function evaluateOneRule(rule, { phaseCommits, firstTerminal, terminalCount, terminalReason }) {
131
+ switch (rule.id) {
132
+ case "review_precedes_merge": {
133
+ // Only check when the run ended with 'merged'.
134
+ if (terminalReason !== "merged") return { ok: true, id: rule.id };
135
+ const reviewSeq = phaseCommits["review"] ?? null;
136
+ if (reviewSeq === null || (firstTerminal !== null && reviewSeq > firstTerminal)) {
137
+ return {
138
+ ok: false, id: rule.id,
139
+ violated: rule.proposition,
140
+ detail: `RUN_TERMINAL(merged) at seq ${firstTerminal} but no PHASE_COMMIT(review) precedes it`,
141
+ };
142
+ }
143
+ return { ok: true, id: rule.id };
144
+ }
145
+
146
+ case "gate_event_precedes_review": {
147
+ const buildSeq = phaseCommits["build"] ?? null;
148
+ const reviewSeq = phaseCommits["review"] ?? null;
149
+ if (reviewSeq !== null && (buildSeq === null || buildSeq > reviewSeq)) {
150
+ return {
151
+ ok: false, id: rule.id,
152
+ violated: rule.proposition,
153
+ detail: `PHASE_COMMIT(review) at seq ${reviewSeq} but PHASE_COMMIT(build) ${buildSeq === null ? "absent" : `at seq ${buildSeq} (after review)`}`,
154
+ };
155
+ }
156
+ return { ok: true, id: rule.id };
157
+ }
158
+
159
+ case "terminal_state_once": {
160
+ if (terminalCount > 1) {
161
+ return {
162
+ ok: false, id: rule.id,
163
+ violated: rule.proposition,
164
+ detail: `RUN_TERMINAL appears ${terminalCount} times in run-log (expected ≤1)`,
165
+ };
166
+ }
167
+ return { ok: true, id: rule.id };
168
+ }
169
+
170
+ default:
171
+ // Unknown runlog invariant ID — fail-open (future invariants not yet in evaluator).
172
+ return { ok: true, id: rule.id };
173
+ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Hook precondition checks (async — may shell out for git ls-remote)
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Evaluate a named pretooluse-scope precondition.
182
+ *
183
+ * @param {Invariant[]} invariants Full invariant list from loadInvariants().
184
+ * @param {string} id Invariant ID to evaluate.
185
+ * @param {object} context Evaluation context (e.g. { branch, repoPath }).
186
+ * @returns {Promise<InvariantResult>}
187
+ */
188
+ export async function checkPrecondition(invariants, id, context) {
189
+ const inv = invariants.find((i) => i.scope === "pretooluse" && i.id === id);
190
+ if (!inv) return { ok: true, id }; // not declared — fail-open
191
+
192
+ try {
193
+ return await evaluateOnePrecondition(inv, context);
194
+ } catch {
195
+ return { ok: true, id }; // evaluator error → fail-open
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Evaluate all pretooluse-scope preconditions matching a named context.
201
+ * Returns all results so the caller can surface the first failure.
202
+ *
203
+ * @param {Invariant[]} invariants
204
+ * @param {object} context
205
+ * @returns {Promise<InvariantResult[]>}
206
+ */
207
+ export async function checkAllPreconditions(invariants, context) {
208
+ const rules = invariants.filter((i) => i.scope === "pretooluse");
209
+ const results = [];
210
+ for (const r of rules) {
211
+ try {
212
+ results.push(await evaluateOnePrecondition(r, context));
213
+ } catch {
214
+ results.push({ ok: true, id: r.id }); // fail-open
215
+ }
216
+ }
217
+ return results;
218
+ }
219
+
220
+ async function evaluateOnePrecondition(inv, context) {
221
+ switch (inv.id) {
222
+ case "branch_must_exist_on_remote": {
223
+ const branch = context?.branch;
224
+ if (!branch) return { ok: true, id: inv.id }; // no branch in context — skip
225
+
226
+ // git ls-remote --exit-code: exits 0 if ref found, 2 if not found.
227
+ // Use a cached local-only check first (avoids network round-trip when
228
+ // the branch is already fetched).
229
+ try {
230
+ execFileSync("git", ["rev-parse", "--verify", `origin/${branch}`], {
231
+ encoding: "utf-8",
232
+ stdio: ["pipe", "pipe", "pipe"],
233
+ timeout: 5000,
234
+ });
235
+ return { ok: true, id: inv.id }; // local origin ref exists
236
+ } catch {
237
+ // Not in local refs — fall through to remote check.
238
+ }
239
+
240
+ try {
241
+ const url = getRemoteUrl();
242
+ if (!url) return { ok: true, id: inv.id }; // no remote configured — fail-open
243
+
244
+ execFileSync("git", ["ls-remote", "--exit-code", "origin", branch], {
245
+ encoding: "utf-8",
246
+ stdio: ["pipe", "pipe", "pipe"],
247
+ timeout: 10000,
248
+ });
249
+ return { ok: true, id: inv.id }; // remote ref found
250
+ } catch (e) {
251
+ const exitCode = e?.status ?? null;
252
+ if (exitCode === 2) {
253
+ // Exit code 2 from ls-remote means ref not found.
254
+ return {
255
+ ok: false, id: inv.id,
256
+ violated: inv.proposition,
257
+ detail: `Branch '${branch}' not found on origin — checkout would fail`,
258
+ };
259
+ }
260
+ // Other errors (network, auth, etc.) → fail-open.
261
+ return { ok: true, id: inv.id };
262
+ }
263
+ }
264
+
265
+ default:
266
+ return { ok: true, id: inv.id }; // unknown precondition — fail-open
267
+ }
268
+ }
269
+
270
+ function getRemoteUrl() {
271
+ try {
272
+ return execFileSync("git", ["remote", "get-url", "origin"], {
273
+ encoding: "utf-8",
274
+ stdio: ["pipe", "pipe", "pipe"],
275
+ timeout: 3000,
276
+ }).trim() || null;
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Close-time assertions (synchronous — run-log only, no I/O)
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /**
287
+ * Assert close-scope invariants before the trajectory annotation is posted.
288
+ * These run in the close phase after the PR is merged and issue is closed.
289
+ *
290
+ * @param {Invariant[]} invariants Full invariant list from loadInvariants().
291
+ * @param {object[]} events Event list from readLog().
292
+ * @returns {InvariantResult[]}
293
+ */
294
+ export function assertCloseInvariants(invariants, events) {
295
+ const assertions = invariants.filter((i) => i.scope === "close");
296
+ if (!assertions.length) return [];
297
+
298
+ return assertions.map((a) => {
299
+ try {
300
+ return evaluateOneCloseAssertion(a, events);
301
+ } catch {
302
+ return { ok: true, id: a.id }; // evaluator error → fail-open
303
+ }
304
+ });
305
+ }
306
+
307
+ function evaluateOneCloseAssertion(assertion, events) {
308
+ switch (assertion.id) {
309
+ case "run_log_terminal_at_close": {
310
+ const hasTerminal = events.some((e) => e.event === "RUN_TERMINAL");
311
+ if (!hasTerminal) {
312
+ return {
313
+ ok: false, id: assertion.id,
314
+ violated: assertion.proposition,
315
+ detail: "RUN_TERMINAL event absent from run-log — pipeline may have exited abnormally",
316
+ };
317
+ }
318
+ return { ok: true, id: assertion.id };
319
+ }
320
+
321
+ case "issue_closed_at_terminal":
322
+ // This assertion is evaluated externally (requires a gh CLI call in the
323
+ // close phase). The evaluator returns ok=true here so callers that do
324
+ // not pass GitHub state still pass without error. The close.md phase
325
+ // block implements the actual gh issue view check.
326
+ return { ok: true, id: assertion.id };
327
+
328
+ default:
329
+ return { ok: true, id: assertion.id }; // unknown close assertion — fail-open
330
+ }
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Convenience: format a violation for human output
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /**
338
+ * Format a failed InvariantResult into a human-readable block.
339
+ * Safe to call on ok:true results (returns null).
340
+ *
341
+ * @param {InvariantResult} result
342
+ * @returns {string|null}
343
+ */
344
+ export function formatViolation(result) {
345
+ if (result.ok) return null;
346
+ return [
347
+ `[ForgeDock] INVARIANT VIOLATION: ${result.id}`,
348
+ ``,
349
+ `Violated proposition: "${result.violated}"`,
350
+ result.detail ? `Detail: ${result.detail}` : "",
351
+ ].filter((l) => l !== undefined).join("\n");
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // JSDoc types
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * @typedef {{ id: string, scope: string, proposition: string, enforcement: string, description?: string }} Invariant
360
+ * @typedef {{ ok: boolean, id: string, violated?: string, detail?: string }} InvariantResult
361
+ */
@@ -8,6 +8,41 @@ const _require = createRequire(import.meta.url);
8
8
  /** Absolute path to the ForgeDock installation root (parent of bin/). */
9
9
  const FORGE_HOME = resolvePath(dirname(fileURLToPath(import.meta.url)), "..", "..");
10
10
 
11
+ // Lazy-loaded invariants module and YAML declarations (loaded once on first use).
12
+ // Using a lazy import avoids top-level await and keeps the module synchronous
13
+ // for the simple path — invariant loading only happens when a branch checkout
14
+ // command is detected.
15
+ // Sentinel states for _invariants:
16
+ // null — import not yet attempted
17
+ // false — a prior import attempt failed (do NOT retry; stay fail-open)
18
+ // object — successfully loaded module namespace (cached)
19
+ let _invariants = null;
20
+ let _invariantDecls = null;
21
+
22
+ async function getInvariants() {
23
+ // A prior import attempt already failed — stay fail-open, do not retry.
24
+ if (_invariants === false) return { module: null, decls: [] };
25
+ // Successful load cached from a previous call.
26
+ if (_invariants !== null) return { module: _invariants, decls: _invariantDecls };
27
+ // Not yet attempted — try importing now.
28
+ try {
29
+ _invariants = await import(
30
+ pathToFileURL(join(FORGE_HOME, "bin", "engine", "invariants.mjs")).href
31
+ );
32
+ _invariantDecls = _invariants.loadInvariants(
33
+ join(FORGE_HOME, "forge-invariants.yaml")
34
+ );
35
+ } catch {
36
+ // invariants.mjs unavailable (fresh install, partial update) — fail-open.
37
+ // Mark the failure with a distinct sentinel so we don't re-attempt the
38
+ // import on every subsequent call.
39
+ _invariants = false;
40
+ _invariantDecls = [];
41
+ return { module: null, decls: [] };
42
+ }
43
+ return { module: _invariants, decls: _invariantDecls };
44
+ }
45
+
11
46
  /**
12
47
  * bin/hooks/pre-tool-use.mjs — ForgeDock PreToolUse hook.
13
48
  *
@@ -40,6 +75,23 @@ const FORGE_HOME = resolvePath(dirname(fileURLToPath(import.meta.url)), "..", ".
40
75
  * against the workflow label state machine.
41
76
  * Blocks invalid transitions (e.g. jumping from investigating to merged).
42
77
  *
78
+ * 3. Declared precondition checks (from forge-invariants.yaml)
79
+ * Intercepts git checkout / git worktree add / git switch commands and
80
+ * evaluates pretooluse-scope invariants declared in forge-invariants.yaml.
81
+ * Currently: branch_must_exist_on_remote — blocks checkouts of branches
82
+ * that don't exist on origin (catches hallucinated branch names before
83
+ * they cause a mid-build git failure).
84
+ *
85
+ * 4. Gist visibility guard (issue #1729)
86
+ * Intercepts `gh gist create --public` and hard-blocks it.
87
+ * Memory-bearing pipeline gists (FORGE:KNOWLEDGE_GIST, FORGE:MILESTONE_INDEX,
88
+ * FORGE:PRIOR_GIST, FORGE:MEMORY_INDEX) MUST be secret — publishing them to
89
+ * a world-readable Gist exposes root causes, file paths, and security findings.
90
+ * Override: set FORGE_ALLOW_PUBLIC_GIST=1 in the shell environment before
91
+ * starting Claude Code (operator-set only — agents cannot bypass this by
92
+ * setting env vars via Bash tool calls, as this hook reads process.env which
93
+ * is set at process start, not at tool-call time).
94
+ *
43
95
  * Both rules only apply inside a ForgeDock-managed directory (a directory
44
96
  * with a `forge.yaml` or `.forgedock` marker) — see `isForgeDockManagedCwd()`
45
97
  * (issue #1591). The hook installs into the user's global
@@ -139,9 +191,87 @@ async function main() {
139
191
  return;
140
192
  }
141
193
 
194
+ // --- Rule 3: Declared precondition checks (forge-invariants.yaml) ---
195
+ const preconditionViolation = await checkDeclaredPreconditions(command);
196
+ if (preconditionViolation) {
197
+ process.stderr.write(preconditionViolation);
198
+ process.exit(2);
199
+ return;
200
+ }
201
+
202
+ // --- Rule 4: Gist visibility guard ---
203
+ const gistViolation = checkGistVisibility(command);
204
+ if (gistViolation) {
205
+ process.stderr.write(gistViolation);
206
+ process.exit(2);
207
+ return;
208
+ }
209
+
142
210
  process.exit(0);
143
211
  }
144
212
 
213
+ // ---------------------------------------------------------------------------
214
+ // Rule 3: Declared precondition checks (forge-invariants.yaml)
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /**
218
+ * Check declared pretooluse-scope invariants from forge-invariants.yaml.
219
+ *
220
+ * Currently evaluates the branch_must_exist_on_remote precondition for:
221
+ * - git checkout <branch>
222
+ * - git worktree add <path> [-b <branch>] <base>
223
+ * - git switch <branch>
224
+ *
225
+ * Wrapped in a try/catch in the caller (main()) — fail-open on any error.
226
+ *
227
+ * @param {string} command
228
+ * @returns {Promise<string|null>} Error message or null if allowed.
229
+ */
230
+ async function checkDeclaredPreconditions(command) {
231
+ // Only intercept git commands that reference a branch.
232
+ const isCheckout = /git\s+(?:checkout|switch)/.test(command) &&
233
+ !/git\s+checkout\s+-[bf]/.test(command); // -b creates new branch (no check needed)
234
+ const isWorktreeAdd = /git\s+worktree\s+add/.test(command);
235
+
236
+ if (!isCheckout && !isWorktreeAdd) return null;
237
+
238
+ // Extract branch name from command.
239
+ let branch = null;
240
+ if (isCheckout) {
241
+ // git checkout <branch> or git switch <branch>
242
+ // Avoid matching flags: skip tokens starting with -
243
+ const m = command.match(/git\s+(?:checkout|switch)\s+(?:--\s+)?([^\s-][^\s]*)/);
244
+ if (m) branch = m[1];
245
+ } else if (isWorktreeAdd) {
246
+ // git worktree add <path> <base> — base is typically origin/<branch> or just <branch>
247
+ // Also handle: git worktree add <path> -b <new-branch> <base>
248
+ const tokens = tokenizeCommand(command).map((t) => t.value);
249
+ // Find -b flag if present (new branch — no remote check needed)
250
+ const bIdx = tokens.indexOf("-b");
251
+ if (bIdx !== -1) return null; // creating new branch — no remote existence check
252
+ // Otherwise the last non-option argument after 'add' and path is the base
253
+ const addIdx = tokens.findIndex((t) => t === "add");
254
+ if (addIdx !== -1 && addIdx + 2 < tokens.length) {
255
+ const base = tokens[addIdx + 2];
256
+ // base may be 'origin/<branch>' — strip the remote prefix
257
+ branch = base.replace(/^origin\//, "");
258
+ }
259
+ }
260
+
261
+ if (!branch || branch.startsWith("-") || branch === "HEAD") return null;
262
+
263
+ // Load invariants (cached after first load).
264
+ const { module, decls } = await getInvariants();
265
+ if (!module || !decls?.length) return null;
266
+
267
+ // Evaluate the branch_must_exist precondition.
268
+ const result = await module.checkPrecondition(decls, "branch_must_exist_on_remote", { branch });
269
+ if (!result.ok) {
270
+ return module.formatViolation(result) + "\n";
271
+ }
272
+ return null;
273
+ }
274
+
145
275
  // ---------------------------------------------------------------------------
146
276
  // Project-scope guard (issue #1591)
147
277
  // ---------------------------------------------------------------------------
@@ -326,6 +456,58 @@ function checkLabelTransition(command) {
326
456
  return null; // valid transition — allow
327
457
  }
328
458
 
459
+ // ---------------------------------------------------------------------------
460
+ // Rule 4: Gist visibility guard (issue #1729)
461
+ // ---------------------------------------------------------------------------
462
+
463
+ /**
464
+ * Check whether a gh gist create command uses the --public flag.
465
+ *
466
+ * Pipeline gists (FORGE:KNOWLEDGE_GIST, FORGE:MILESTONE_INDEX, FORGE:PRIOR_GIST,
467
+ * FORGE:MEMORY_INDEX) MUST be secret. Passing --public to gh gist create publishes
468
+ * investigation findings — root causes, file paths, security details — to a
469
+ * world-readable URL. gh gist create is secret by default; this rule blocks any
470
+ * explicit --public override so a future spec edit cannot reintroduce a public gist.
471
+ *
472
+ * Override: FORGE_ALLOW_PUBLIC_GIST=1 in the operator's shell environment.
473
+ * This env var must be set BEFORE starting Claude Code — it is read from process.env
474
+ * at hook startup, not from the tool payload, so agents cannot bypass it by setting
475
+ * the variable via a Bash tool call in the same session.
476
+ *
477
+ * @param {string} command
478
+ * @returns {string|null} Error message to show, or null if allowed.
479
+ */
480
+ function checkGistVisibility(command) {
481
+ // Only check gh gist create commands.
482
+ if (!/gh\s+gist\s+create/.test(command)) return null;
483
+
484
+ // Allow operator override via env var.
485
+ if (process.env.FORGE_ALLOW_PUBLIC_GIST === "1") return null;
486
+
487
+ // Check for --public flag (space form, equals form, and standalone --public).
488
+ // extractFlag handles --public=value; for the bare boolean flag --public we
489
+ // additionally check whether "--public" appears as a standalone token.
490
+ const tokens = tokenizeCommand(command);
491
+ const hasPublicFlag = tokens.some(({ value }) => value === "--public") ||
492
+ extractFlag(command, "--public") !== null;
493
+
494
+ if (hasPublicFlag) {
495
+ return [
496
+ `[ForgeDock] BLOCKED: gh gist create --public is a pipeline violation.`,
497
+ ``,
498
+ `Pipeline gists (FORGE:KNOWLEDGE_GIST, FORGE:MILESTONE_INDEX, FORGE:PRIOR_GIST,`,
499
+ `FORGE:MEMORY_INDEX) MUST be created secret. Passing --public publishes`,
500
+ `investigation findings — root causes, file paths, security details — to a`,
501
+ `world-readable URL. Remove the --public flag (gh gist create is secret by default).`,
502
+ ``,
503
+ `Exception: set FORGE_ALLOW_PUBLIC_GIST=1 in your shell environment BEFORE`,
504
+ `starting Claude Code if you intentionally need a public gist.`,
505
+ ].join("\n");
506
+ }
507
+
508
+ return null;
509
+ }
510
+
329
511
  // ---------------------------------------------------------------------------
330
512
  // Git helpers
331
513
  // ---------------------------------------------------------------------------