forgedock 1.1.2 → 1.1.4
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/bin/engine/phases.mjs +8 -6
- package/bin/forgedock.mjs +90 -4
- package/bin/journey.mjs +70 -1
- package/bin/runner.mjs +3 -2
- package/commands/autopilot.md +510 -425
- package/commands/deploy-pr.md +485 -0
- package/commands/fix-ci.md +577 -0
- package/commands/recover-orphans.md +576 -0
- package/docs/CI.md +14 -0
- package/docs/eval/README.md +7 -4
- package/docs/site/command-reference.md +49 -0
- package/package.json +1 -1
- package/packages/protocol/src/parse.js +27 -2
package/bin/engine/phases.mjs
CHANGED
|
@@ -51,9 +51,10 @@ export const PHASES = [
|
|
|
51
51
|
command: "work-on/build/context",
|
|
52
52
|
entryCondition: (s) => s.committed.includes("investigate"),
|
|
53
53
|
async reconcile(state, io) {
|
|
54
|
-
// Idempotent resume: FORGE:CONTEXT
|
|
54
|
+
// Idempotent resume: FORGE:CONTEXT:COMPLETE present → skip the LLM re-run.
|
|
55
|
+
// Bare FORGE:CONTEXT matches a partial/interrupted annotation — require :COMPLETE.
|
|
55
56
|
const m = await issueMarkers(state.issue, io);
|
|
56
|
-
return has(m, "FORGE:CONTEXT") ? { satisfied: true } : { satisfied: false };
|
|
57
|
+
return has(m, "FORGE:CONTEXT:COMPLETE") ? { satisfied: true } : { satisfied: false };
|
|
57
58
|
},
|
|
58
59
|
async detectOutcome(state, io) {
|
|
59
60
|
const m = await issueMarkers(state.issue, io);
|
|
@@ -67,15 +68,16 @@ export const PHASES = [
|
|
|
67
68
|
command: "work-on/build/architect",
|
|
68
69
|
entryCondition: (s) => s.committed.includes("context"),
|
|
69
70
|
async reconcile(state, io) {
|
|
70
|
-
// Idempotent resume: FORGE:ARCHITECT
|
|
71
|
+
// Idempotent resume: FORGE:ARCHITECT:COMPLETE present → skip the LLM re-run.
|
|
72
|
+
// Bare FORGE:ARCHITECT matches a partial/interrupted annotation — require :COMPLETE.
|
|
71
73
|
const m = await issueMarkers(state.issue, io);
|
|
72
|
-
return has(m, "FORGE:ARCHITECT") ? { satisfied: true } : { satisfied: false };
|
|
74
|
+
return has(m, "FORGE:ARCHITECT:COMPLETE") ? { satisfied: true } : { satisfied: false };
|
|
73
75
|
},
|
|
74
76
|
async detectOutcome(state, io) {
|
|
75
77
|
const m = await issueMarkers(state.issue, io);
|
|
76
|
-
return has(m, "FORGE:ARCHITECT")
|
|
78
|
+
return has(m, "FORGE:ARCHITECT:COMPLETE")
|
|
77
79
|
? { status: "committed", outputs: {} }
|
|
78
|
-
: { status: "failed", detail: "no FORGE:ARCHITECT" };
|
|
80
|
+
: { status: "failed", detail: "no FORGE:ARCHITECT:COMPLETE" };
|
|
79
81
|
},
|
|
80
82
|
},
|
|
81
83
|
{
|
package/bin/forgedock.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { dirname, join, relative, resolve } from "path";
|
|
|
7
7
|
import { mkdir, lstat, readlink, readdir, unlink, readFile, writeFile } from "fs/promises";
|
|
8
8
|
import {
|
|
9
9
|
existsSync,
|
|
10
|
+
lstatSync,
|
|
10
11
|
readFileSync,
|
|
11
12
|
writeFileSync,
|
|
12
13
|
renameSync,
|
|
@@ -63,7 +64,64 @@ import { buildMinimalForgeYaml } from "./init-detect.mjs";
|
|
|
63
64
|
|
|
64
65
|
const __filename = fileURLToPath(import.meta.url);
|
|
65
66
|
const __dirname = dirname(__filename);
|
|
66
|
-
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the real ForgeDock installation root, guarding against the case
|
|
70
|
+
* where forgedock.mjs is executed from inside a git worktree.
|
|
71
|
+
*
|
|
72
|
+
* A git worktree always has a `.git` FILE (not directory) at its root that
|
|
73
|
+
* points back to the main repository's `.git` directory. When forgedock runs
|
|
74
|
+
* from a worktree, `dirname(__dirname)` resolves to the ephemeral worktree
|
|
75
|
+
* path — causing symlinks in `~/.claude/commands/` to point inside the
|
|
76
|
+
* worktree. After the worktree is cleaned up, those symlinks become dangling
|
|
77
|
+
* refs and all slash commands fail.
|
|
78
|
+
*
|
|
79
|
+
* Detection: if `dir/.git` is a regular file, we are inside a worktree.
|
|
80
|
+
* Resolution: run `git rev-parse --git-common-dir` (cwd: dir) which returns
|
|
81
|
+
* the path to the main `.git` directory; its parent is the stable repo root.
|
|
82
|
+
*
|
|
83
|
+
* Falls back to `dir` unchanged when:
|
|
84
|
+
* - `dir/.git` is a directory (normal git clone — already correct)
|
|
85
|
+
* - `dir/.git` does not exist (npm-installed package — already correct)
|
|
86
|
+
* - Any error occurs (git not available, unexpected structure, etc.)
|
|
87
|
+
*
|
|
88
|
+
* @param {string} dir - Candidate FORGE_HOME (typically dirname(__dirname))
|
|
89
|
+
* @returns {string} Stable repo root, or `dir` if no worktree is detected
|
|
90
|
+
*/
|
|
91
|
+
function resolveRealForgeHome(dir) {
|
|
92
|
+
try {
|
|
93
|
+
const gitEntry = join(dir, ".git");
|
|
94
|
+
let stat;
|
|
95
|
+
try {
|
|
96
|
+
stat = lstatSync(gitEntry);
|
|
97
|
+
} catch {
|
|
98
|
+
// ENOENT → npm-installed package with no .git at all — return as-is
|
|
99
|
+
return dir;
|
|
100
|
+
}
|
|
101
|
+
if (!stat.isFile()) {
|
|
102
|
+
// .git is a directory → normal git clone, already points at the real root
|
|
103
|
+
return dir;
|
|
104
|
+
}
|
|
105
|
+
// .git is a file → we are inside a git worktree.
|
|
106
|
+
// `git rev-parse --git-common-dir` returns the path to the shared .git dir
|
|
107
|
+
// of the main worktree. On some git versions the path is relative to cwd,
|
|
108
|
+
// so resolve it against `dir` before taking dirname.
|
|
109
|
+
const commonDir = execSync("git rev-parse --git-common-dir", {
|
|
110
|
+
cwd: dir,
|
|
111
|
+
encoding: "utf-8",
|
|
112
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
113
|
+
timeout: 5000,
|
|
114
|
+
}).trim();
|
|
115
|
+
// dirname(".git") = "." when commonDir is a bare ".git" string
|
|
116
|
+
const mainRepoRoot = dirname(resolve(dir, commonDir));
|
|
117
|
+
return mainRepoRoot;
|
|
118
|
+
} catch {
|
|
119
|
+
// Any failure (git not found, exit non-zero, etc.) — fail safe
|
|
120
|
+
return dir;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const FORGE_HOME = resolveRealForgeHome(dirname(__dirname));
|
|
67
125
|
const COMMANDS_DIR = join(FORGE_HOME, "commands");
|
|
68
126
|
|
|
69
127
|
// Resolve home cross-platform: HOME on Unix, USERPROFILE on Windows, with
|
|
@@ -929,9 +987,23 @@ async function update() {
|
|
|
929
987
|
cwd: FORGE_HOME,
|
|
930
988
|
encoding: "utf-8",
|
|
931
989
|
}).trim();
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
990
|
+
|
|
991
|
+
// If on a non-main branch, switch to main for the update then restore.
|
|
992
|
+
// Detached HEAD ("HEAD") is treated as non-restorable — we leave on main
|
|
993
|
+
// after the update and warn the user.
|
|
994
|
+
const isDetached = branch === "HEAD";
|
|
995
|
+
const needsCheckout = branch !== "main";
|
|
996
|
+
if (needsCheckout) {
|
|
997
|
+
if (isDetached) {
|
|
998
|
+
console.log(
|
|
999
|
+
` ${YELLOW}Detached HEAD state — switching to main for update.${RESET}`,
|
|
1000
|
+
);
|
|
1001
|
+
} else {
|
|
1002
|
+
console.log(
|
|
1003
|
+
` On branch ${CYAN}${branch}${RESET} — switching to ${CYAN}main${RESET} for update...`,
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
execSync("git checkout main --quiet", { cwd: FORGE_HOME });
|
|
935
1007
|
}
|
|
936
1008
|
|
|
937
1009
|
const before = execSync("git rev-parse HEAD", {
|
|
@@ -953,6 +1025,20 @@ async function update() {
|
|
|
953
1025
|
console.log(` ${GREEN}Updated to latest.${RESET}`);
|
|
954
1026
|
}
|
|
955
1027
|
await relinkAndHint();
|
|
1028
|
+
|
|
1029
|
+
// Restore original branch after a successful update.
|
|
1030
|
+
if (needsCheckout && !isDetached) {
|
|
1031
|
+
try {
|
|
1032
|
+
execSync(`git checkout ${branch} --quiet`, { cwd: FORGE_HOME });
|
|
1033
|
+
console.log(
|
|
1034
|
+
` Restored branch ${CYAN}${branch}${RESET}.`,
|
|
1035
|
+
);
|
|
1036
|
+
} catch {
|
|
1037
|
+
console.log(
|
|
1038
|
+
` ${YELLOW}⚠ Could not restore branch ${branch} — you are now on main.${RESET}`,
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
956
1042
|
} catch (err) {
|
|
957
1043
|
console.log(
|
|
958
1044
|
` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
|
package/bin/journey.mjs
CHANGED
|
@@ -470,6 +470,68 @@ async function saveCopiedManifest(manifestPath, manifest) {
|
|
|
470
470
|
|
|
471
471
|
const isLinkPermissionError = (err) => err.code === "EPERM" || err.code === "EACCES";
|
|
472
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Recursively walk targetDir and remove any symlink whose target begins with
|
|
475
|
+
* commandsDir (i.e. a ForgeDock-managed link) but whose target file no longer
|
|
476
|
+
* exists on disk. These are "orphaned" symlinks left behind when a command is
|
|
477
|
+
* renamed or deleted.
|
|
478
|
+
*
|
|
479
|
+
* Safety invariant: only symlinks whose readlink() result starts with
|
|
480
|
+
* commandsDir + "/" are touched. User-owned symlinks and third-party links are
|
|
481
|
+
* never removed.
|
|
482
|
+
*
|
|
483
|
+
* @param {string} targetDir - ~/.claude/commands (installed commands root)
|
|
484
|
+
* @param {string} commandsDir - FORGE_HOME/commands (source commands root)
|
|
485
|
+
* @returns {Promise<number>} Number of orphaned symlinks removed.
|
|
486
|
+
*/
|
|
487
|
+
async function pruneOrphanedSymlinks(targetDir, commandsDir) {
|
|
488
|
+
const prefix = commandsDir + "/";
|
|
489
|
+
let pruned = 0;
|
|
490
|
+
|
|
491
|
+
async function walk(dir) {
|
|
492
|
+
let entries;
|
|
493
|
+
try {
|
|
494
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
495
|
+
} catch (err) {
|
|
496
|
+
if (err.code === "ENOENT") return; // targetDir doesn't exist yet — nothing to prune
|
|
497
|
+
throw err;
|
|
498
|
+
}
|
|
499
|
+
for (const entry of entries) {
|
|
500
|
+
const full = join(dir, entry.name);
|
|
501
|
+
if (entry.isDirectory()) {
|
|
502
|
+
await walk(full);
|
|
503
|
+
} else if (entry.isSymbolicLink()) {
|
|
504
|
+
let target;
|
|
505
|
+
try {
|
|
506
|
+
target = await readlink(full);
|
|
507
|
+
} catch {
|
|
508
|
+
continue; // can't read link — skip
|
|
509
|
+
}
|
|
510
|
+
// Only manage links that point into our commandsDir
|
|
511
|
+
if (!target.startsWith(prefix)) continue;
|
|
512
|
+
// Check whether the target file still exists
|
|
513
|
+
try {
|
|
514
|
+
await lstat(target);
|
|
515
|
+
// target exists — not orphaned
|
|
516
|
+
} catch (err) {
|
|
517
|
+
if (err.code !== "ENOENT") continue; // unexpected error — skip to be safe
|
|
518
|
+
// target is gone → orphaned symlink
|
|
519
|
+
try {
|
|
520
|
+
await unlink(full);
|
|
521
|
+
pruned++;
|
|
522
|
+
} catch (unlinkErr) {
|
|
523
|
+
if (unlinkErr.code !== "ENOENT") throw unlinkErr;
|
|
524
|
+
// already gone — race condition, that's fine
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
await walk(targetDir);
|
|
532
|
+
return pruned;
|
|
533
|
+
}
|
|
534
|
+
|
|
473
535
|
/**
|
|
474
536
|
* Act II: link commands into ~/.claude/commands with a molten progress line,
|
|
475
537
|
* then register the SessionStart hook.
|
|
@@ -611,6 +673,10 @@ export async function forge(ctx) {
|
|
|
611
673
|
}
|
|
612
674
|
if (barShown) w.write("\x1b[1A\x1b[2K");
|
|
613
675
|
|
|
676
|
+
// Prune orphaned symlinks: links that point into commandsDir but whose
|
|
677
|
+
// target file no longer exists (e.g. after a command is renamed or deleted).
|
|
678
|
+
const pruned = await pruneOrphanedSymlinks(targetDir, commandsDir);
|
|
679
|
+
|
|
614
680
|
const hookScript = join(ctx.forgeHome, "bin", "hooks", "session-start.mjs");
|
|
615
681
|
const settingsPath = join(ctx.home, ".claude", "settings.json");
|
|
616
682
|
const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
|
|
@@ -648,6 +714,9 @@ export async function forge(ctx) {
|
|
|
648
714
|
? `(new ${installed}, copied ${copied}, updated ${updated}, unchanged ${skipped})`
|
|
649
715
|
: `(new ${installed}, updated ${updated}, unchanged ${skipped})`;
|
|
650
716
|
w.write(` ${glyph(true)} ${files.length} slash commands ${headlineVerb} ${dimLine(ctx, headlineDetail)}\n`);
|
|
717
|
+
if (pruned > 0) {
|
|
718
|
+
w.write(` ${glyph(true)} ${pruned} orphaned symlink${pruned === 1 ? "" : "s"} removed ${dimLine(ctx, "(commands deleted or renamed since last install)")}\n`);
|
|
719
|
+
}
|
|
651
720
|
if (copied > 0) {
|
|
652
721
|
w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
|
|
653
722
|
}
|
|
@@ -677,7 +746,7 @@ export async function forge(ctx) {
|
|
|
677
746
|
w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
|
|
678
747
|
}
|
|
679
748
|
|
|
680
|
-
return { installed, updated, skipped, copied, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus };
|
|
749
|
+
return { installed, updated, skipped, copied, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus };
|
|
681
750
|
}
|
|
682
751
|
|
|
683
752
|
// ---------------------------------------------------------------------------
|
package/bin/runner.mjs
CHANGED
|
@@ -759,7 +759,7 @@ export async function runCommand(opts = {}) {
|
|
|
759
759
|
|
|
760
760
|
if (dryRun) {
|
|
761
761
|
logger.log(renderDryRun({ spec, systemPrompt, userMessage, model, maxIterations }));
|
|
762
|
-
return { status: "dry-run", command: spec.name, args, specPath: spec.path };
|
|
762
|
+
return { status: "dry-run", command: spec.name, args, specPath: spec.path, model };
|
|
763
763
|
}
|
|
764
764
|
|
|
765
765
|
if (!apiKey) {
|
|
@@ -857,6 +857,7 @@ export async function runCommand(opts = {}) {
|
|
|
857
857
|
iterations,
|
|
858
858
|
stopReason: response.stop_reason,
|
|
859
859
|
usage,
|
|
860
|
+
model,
|
|
860
861
|
};
|
|
861
862
|
}
|
|
862
863
|
|
|
@@ -892,5 +893,5 @@ export async function runCommand(opts = {}) {
|
|
|
892
893
|
usage,
|
|
893
894
|
}),
|
|
894
895
|
);
|
|
895
|
-
return { status: "max-iterations", command: spec.name, iterations, usage };
|
|
896
|
+
return { status: "max-iterations", command: spec.name, iterations, usage, model };
|
|
896
897
|
}
|