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.
- package/README.md +3 -1
- package/bin/engine/invariants.mjs +361 -0
- package/bin/hooks/pre-tool-use.mjs +182 -0
- package/bin/journey.mjs +77 -7
- package/bin/labels.json +10 -0
- package/bin/recall.mjs +540 -0
- package/commands/autopilot.md +959 -78
- package/commands/cleanup.md +52 -3
- package/commands/optimize.md +585 -0
- package/commands/orchestrate/phase-1-resolve.md +35 -25
- package/commands/orchestrate/phase-3-dependency.md +465 -34
- package/commands/orchestrate/phase-4-execution.md +448 -29
- package/commands/orchestrate/phase-6-report.md +198 -9
- package/commands/pipeline-health/phase-2-metrics.md +305 -1
- package/commands/pipeline-health/phase-4-proposals.md +127 -0
- package/commands/quality-gate.md +417 -0
- package/commands/review-pr.md +391 -13
- package/commands/spec-doctor.md +429 -0
- package/commands/validate.md +52 -0
- package/commands/work-on/build/architect.md +77 -0
- package/commands/work-on/build/context.md +357 -22
- package/commands/work-on/build/implement.md +2 -0
- package/commands/work-on/build/validate.md +28 -0
- package/commands/work-on/build.md +44 -0
- package/commands/work-on/close.md +621 -8
- package/commands/work-on/investigate.md +221 -0
- package/commands/work-on.md +15 -14
- package/docs/FORGE-PROTOCOL.md +101 -0
- package/docs/design/awesome-list-submissions.md +105 -0
- package/docs/design/f5bot-monitoring-setup.md +43 -0
- package/docs/design/growth-strategy-2026.md +579 -0
- package/docs/spec/forge-protocol-v1.md +97 -5
- package/docs/spec/label-state-machine.md +6 -1
- package/package.json +2 -2
- package/packages/protocol/src/cli.js +374 -84
- package/packages/protocol/src/types.js +75 -1
- package/scripts/build-knowledge-index.mjs +1761 -0
- package/scripts/calibration.mjs +996 -0
- package/scripts/danger-zones.mjs +782 -0
- package/scripts/doctor-pipeline-state.sh +88 -2
- package/scripts/refresh-bot-token.sh +97 -0
- package/scripts/select-fix-targets.sh +234 -0
- 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/
|
|
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
|
// ---------------------------------------------------------------------------
|